返回 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
2
CHECK PASS 31 qualified executor
CHECK PASS 31 self invocation stays caller

把异步方法移动到另一个受管 Bean,并从外部引用调用,能够形成明确的代理边界。把注解再复制到私有辅助方法上不能修复既有的自调用路径。AspectJ 编织是另一种机制,其行为需要单独配置和验证,不能借用这里的代理实验作证。

异步代理还保留了原方法的返回签名。目标返回临时 CompletableFuture 时,调用方拿到的是基础设施提交任务产生的异步结果句柄。目标方法里的 Future 尚未完成时,工作线程处理它也可能发生等待,因此“返回类型是 Future”不代表目标内部完全没有阻塞。

用门闩固定饱和状态

blocked(entered, release) 一开始就通知 entered,随后等待 release。主线程必须收到开始信号,才提交第二个任务。这样第一条工作线程确定被占用,第二个任务确定进入唯一的队列槽位。

第三次提交在这两个资源仍被占用时发生,实验要求出现 TaskRejectedException。断言并不依赖“休眠100毫秒应该够了”之类时间猜测;两个门闩建立明确的顺序。5秒超时只作为测试失败的上限,不用于制造成功。

1
2
3
4
5
CHECK PASS 31 submitted is not completed
CHECK PASS 31 one queued task
CHECK PASS 31 rejection on caller
CHECK PASS 31 running task completes
CHECK PASS 31 queued task executes

放开 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
2
CHECK PASS 31 Future exposes target failure
CHECK PASS 31 void handler observes method and cause

两种路径应承担不同的操作约定。可组合的业务结果使用 Future 并规定谁负责终止观察;没有返回值的后台操作需要处理器把失败转成可追踪事件。仅靠异常处理器不会自动重试,也不会补偿已执行的外部副作用。

异步任务取消还需要区分请求取消与目标停止。Future 的取消状态不能证明网络写入已经撤销,也不能证明数据库提交被撤销。本篇没有执行外部请求取消实验;异步完成、取消和资源回收必须分别观察。

调用线程的 JDBC 事务没有跟随任务

实验使用 DataSourceTransactionManager 与 TransactionTemplate 在主线程创建真实 JDBC 事务。回调中首先断言 isActualTransactionActive() 为 true,再调用 worker.transaction()。工作线程返回 false,主线程通过 Future 得到这个结果。

这一差异来自事务资源的线程绑定。执行器转移的是待运行任务,没有把调用线程的连接 holder 和同步回调表一起迁移。给任务参数传入一个业务对象,也不会赋予它原事务的连接身份。

1
2
3
CHECK PASS 31 caller real JDBC transaction active
CHECK PASS 31 transaction absent on worker
CHECK PASS 31 JDBC lease returned

如果工作线程再通过事务代理调用另一个方法,可以在那里建立自己的事务,但其提交结果与原事务不再天然原子。原事务随后回滚,不会自动撤销工作线程已经完成的独立提交。需要可靠通知时,应把数据库状态变化与待发送事实放入可恢复的持久化协议,而不是依赖线程切换继承事务。

测试没有把一个物理连接从主线程传给工作线程。连接生命周期、并发使用约束、回调线程以及提交所有权必须一致;复制 holder 只会破坏这些边界。本篇证明的是普通执行器没有自动传播事务,不是证明所有异步业务都不能使用事务。

运行、终态与改动练习

工程从第00篇下载,JDK 21 与数据库启动步骤见实验目录 db/README.md。在工程根目录运行:

1
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter31

默认数据库地址为 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 构建;本文结果来自上述独立实验,不把上游测试存在视为本地运行证据。

参考资料

系列入口与完整实验工程