深入 Spring 31:Async 的提交、执行与失败观察
返回 Future 时,任务可能还没有开始
订单接口调用一个标记 @Async 的方法,获得 CompletableFuture,随即返回成功。这个动作能够证明什么?只能证明提交路径返回了一个句柄。线程池可能正在执行别的任务,新任务仍留在队列里;稍后的业务执行也可能失败。若执行器已饱和,提交本身就可能抛异常,连这个句柄都拿不到。
把异步流程拆成提交、开始和完成三个时点,错误位置就容易定位。提交拒绝发生在调用线程,目标方法异常发生在工作线程,结果观察则由 Future 或异常处理器承担。它们不应共用一条“异步失败”的无阶段日志。
实验基线为 Spring Framework 6.2.11、JDK 21,源码固定到 4c134254642d88e058aa004bdaf44168e1be7bb2。入口 blog.spring.Chapter31 使用真实的 Spring 异步代理、容量有限的 ThreadPoolTaskExecutor 和 PostgreSQL 18.0 事务。没有依赖 Spring Boot 的线程池自动配置。
注解如何变成执行器提交
启用 @EnableAsync 后,基础设施为适用的 Bean 添加异步 Advisor。调用必须到达代理,拦截器才有机会把目标调用包装成任务。标注方法不会因此变成 JVM 的特殊方法,取到原始目标或从目标内部通过 this 调用,仍遵循普通 Java 调用规则。
AnnotationAsyncExecutionInterceptor 负责解释注解上的执行器限定名;公共执行逻辑在 AsyncExecutionInterceptor。后者先定位实际目标方法,再选择执行器,把 invocation.proceed() 放进 Callable,最后根据返回类型提交任务。执行器定位结果按方法缓存,不能把它想成每次请求都重新做一次全容器搜索。固定版本源码
本实验全部异步方法显式使用 @Async("orders")。orders Bean 设置核心线程数1、最大线程数1、队列容量1,线程名前缀为 orders-31-。这种配置把执行路线和容量固定下来,省去了默认执行器搜索规则带来的歧义。
未指定限定名时,配置的默认执行器、唯一的 TaskExecutor 或约定名称 taskExecutor 会影响选择;原生 Framework 还有回退路径。应用中出现多个执行器后,不宜根据某个 Bean 的名称猜测实际使用了哪一个。先确认限定名和返回线程,再分析吞吐。执行器选择实现
外部调用与自调用的可观测差别
Worker.thread() 返回当前线程名。经容器取得的代理调用它,结果以 orders-31- 开头;Worker.self() 在目标内部调用同一个方法,结果等于主调用线程名。这两个断言使用同一个 Bean、同一段方法实现和同一份注解,唯一关键差异是调用有没有重新经过代理。
1 | |
把异步方法移动到另一个受管 Bean,并从外部引用调用,能够形成明确的代理边界。把注解再复制到私有辅助方法上不能修复既有的自调用路径。AspectJ 编织是另一种机制,其行为需要单独配置和验证,不能借用这里的代理实验作证。
异步代理还保留了原方法的返回签名。目标返回临时 CompletableFuture 时,调用方拿到的是基础设施提交任务产生的异步结果句柄。目标方法里的 Future 尚未完成时,工作线程处理它也可能发生等待,因此“返回类型是 Future”不代表目标内部完全没有阻塞。
用门闩固定饱和状态
blocked(entered, release) 一开始就通知 entered,随后等待 release。主线程必须收到开始信号,才提交第二个任务。这样第一条工作线程确定被占用,第二个任务确定进入唯一的队列槽位。
第三次提交在这两个资源仍被占用时发生,实验要求出现 TaskRejectedException。断言并不依赖“休眠100毫秒应该够了”之类时间猜测;两个门闩建立明确的顺序。5秒超时只作为测试失败的上限,不用于制造成功。
1 | |
放开 release 后,正在运行的 Future 得到 done,队列里的 Future 也得到工作线程名。测试同时验证了拒绝以前的两个任务没有丢失。仅仅断言第三次提交抛异常,还不能说明已接受的任务最后完成。
队列容量与最大线程数不能分开阅读。一般的 ThreadPoolExecutor 在核心线程忙碌后先尝试入队,入队失败才考虑增长到最大线程数。很大的队列可能使最大线程数长期不起作用。本实验把核心数与最大数都设成1,刻意排除了扩容,专门观察容量与拒绝。
拒绝策略也是业务语义。若改成 CallerRuns,过载时目标代码可能在提交线程执行,接口延迟、线程上下文与事务观察都会改变。这里实测的是默认拒绝路径;不能把结果推广为所有策略都保持异步线程切换。JDK 21 ThreadPoolExecutor
void 与 Future 使用不同的失败出口
futureFailure() 在目标执行时抛出 IllegalArgumentException。代理调用先返回 Future,主线程通过有超时的 get() 观察到 ExecutionException,其 cause 才是目标异常。代码在调用 worker.futureFailure() 外面加一个只包住提交的 try/catch,无法观察稍后发生的业务错误。
voidFailure() 没有结果句柄。实验配置 AsyncUncaughtExceptionHandler,把方法名与异常信息写入 AtomicReference,再通知门闩。主线程收到通知后要求值为 voidFailure:void。这证明处理器确实被调用,而不只是日志里偶然出现了一段堆栈。
1 | |
两种路径应承担不同的操作约定。可组合的业务结果使用 Future 并规定谁负责终止观察;没有返回值的后台操作需要处理器把失败转成可追踪事件。仅靠异常处理器不会自动重试,也不会补偿已执行的外部副作用。
异步任务取消还需要区分请求取消与目标停止。Future 的取消状态不能证明网络写入已经撤销,也不能证明数据库提交被撤销。本篇没有执行外部请求取消实验;异步完成、取消和资源回收必须分别观察。
调用线程的 JDBC 事务没有跟随任务
实验使用 DataSourceTransactionManager 与 TransactionTemplate 在主线程创建真实 JDBC 事务。回调中首先断言 isActualTransactionActive() 为 true,再调用 worker.transaction()。工作线程返回 false,主线程通过 Future 得到这个结果。
这一差异来自事务资源的线程绑定。执行器转移的是待运行任务,没有把调用线程的连接 holder 和同步回调表一起迁移。给任务参数传入一个业务对象,也不会赋予它原事务的连接身份。
1 | |
如果工作线程再通过事务代理调用另一个方法,可以在那里建立自己的事务,但其提交结果与原事务不再天然原子。原事务随后回滚,不会自动撤销工作线程已经完成的独立提交。需要可靠通知时,应把数据库状态变化与待发送事实放入可恢复的持久化协议,而不是依赖线程切换继承事务。
测试没有把一个物理连接从主线程传给工作线程。连接生命周期、并发使用约束、回调线程以及提交所有权必须一致;复制 holder 只会破坏这些边界。本篇证明的是普通执行器没有自动传播事务,不是证明所有异步业务都不能使用事务。
运行、终态与改动练习
工程从第00篇下载,JDK 21 与数据库启动步骤见实验目录 db/README.md。在工程根目录运行:
1 | |
默认数据库地址为 127.0.0.1:55432/spring_lab,用户 spring_lab。实验记录13条 CHECK PASS,退出码0;日志保存在 evidence/31/local-20261002/run.txt。容器关闭后还检查底层执行器已经 terminated,数据库租约数已回到0。
预测题:把队列容量改成0、核心与最大线程数保持1,在第一条任务被门闩阻塞时,第二次提交会返回 Future 还是抛异常?零容量使用直接交接队列,没有可存放第二条任务的槽位,因此默认策略会拒绝。
改动练习:配置独立的 CallerRuns 对照执行器,重复饱和场景并记录线程名及事务激活标志。不要沿用当前“第三次提交必须拒绝”的断言;新增一组测试,把过载后同步执行作为明确的预期结果,同时在 finally 中释放门闩。
源码对照入口包括 AsyncExecutionTests.asyncMethods() 与 asyncMethodsWithQualifier()。这些上游测试只做了源码阅读,没有运行完整 Framework 构建;本文结果来自上述独立实验,不把上游测试存在视为本地运行证据。

