深入 Spring 30:MVC 异步超时取消与资源回收
响应已经超时,后台为何还在工作
客户端收到 503,服务器的一项外部生产任务仍停在等待中。稍后任务结束,尝试把结果写回 DeferredResult,却得到 false。请求超时只结束了可向客户端交付结果的窗口,没有自动取得并终止应用自行创建的任务。
相似的误判也发生在断连场景:客户端关闭连接后,服务器不一定立即知道;通常要到读写触及网络错误时才能发现。即使发现了断连,也需要业务代码释放持有的资源。Servlet 异步把请求线程释放出来,并没有把这些生命周期合并成一个自动完成的对象。
本篇使用 Spring Framework 6.2.11,固定源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2,JDK 21 与 Tomcat 10.1.46。完整实验 MvcLab 30 运行真实容器,分别观察 HTTP 响应、重新分派、任务状态及资源计数。断连负例使用真实 Socket 发送 RST,不以取消模拟 Future 代替网络故障。
Callable 与 DeferredResult 的任务归属
Callable 返回值把任务交给 MVC 的 AsyncTaskExecutor。实验显式配置两个工作线程、有限队列与 mvc-task- 线程名前缀,避免把默认执行器行为当作并发容量设计。控制器返回 Callable 后,WebAsyncManager 启动异步请求,再向执行器提交任务。
DeferredResult 则提供一个可由其他线程完成的结果对象。它本身没有要求 MVC 创建某个业务任务;结果可能来自消息订阅、外部回调、已有 Future 或另一套执行器。本例由独立 PRODUCER 线程池提交任务,MVC 并不持有这个生产任务的 Future,除非应用主动把取消关系连接起来。
两种入口都能在结果产生后触发 ASYNC 分派,沿 MVC 返回值或异常解析路径完成响应。但“结果如何返回 MVC”相同,不代表“谁拥有生产任务”相同。取消策略首先取决于这个所有权,不能因为两者都是异步返回值就假定具有相同终止行为。
/async 的 Callable 返回 async-ok,/deferred 从外部线程设置 deferred-ok。两项真实请求都得到 200,日志显示初次 REQUEST 退出后重新进入 ASYNC。成功场景用于确认任务提交、结果接受与响应写出整条链路都已接通。
结果重新进入 DispatcherServlet
WebAsyncManager 接收并发结果后,保存结果及处理上下文,再通过 AsyncWebRequest 发起 dispatch。RequestMappingHandlerAdapter 在重新分派时识别已有结果,包装成方法返回值继续处理,原控制器不会再次创建生产任务。WebAsyncManager 固定源码
Callable 抛出的异常也作为并发结果进入这条路径。/async-fail 在任务线程抛出 Conflict,重新分派后由已有异常处理器转成 409 与 ORDER_CONFLICT。异常不是在最初调用控制器时同步抛出,但仍可以进入 MVC 的异常解析机制。
这不代表任意后台线程异常都会自动变成 HTTP 响应。若应用启动任务后既不返回关联的异步结果,也不将异常传给 DeferredResult,MVC 就没有这条因果联系。只把任务扔进执行器而让控制器立即返回 200,随后任务失败属于另一个业务交付问题。
状态转换需要防止多个结果竞争。正常完成、超时、网络错误及应用回调可能同时发生,框架只接受符合当前状态的结果。应用应检查 setResult 的布尔返回值,而不能因为调用过这个方法就记录“响应已发送成功”。接受结果也不等于客户端已经完整读取正文,后续转换和网络写入仍可能失败。
超时不等于外部任务终止
/timeout 创建超时时间为 100ms 的 DeferredResult,独立生产任务增加 ACTIVE 计数后等待 CountDownLatch。超时回调只记录状态,没有取消任务。Tomcat 实际发现超时并重新分派后,默认处理产生 503;此时 ACTIVE 仍为 1。
测试不会用固定 sleep 猜测任务是否结束,而是在客户端收到 503 后直接断言 ACTIVE 等于 1。随后释放 latch,等待计数回到 0,再检查迟到的 setResult("late") 返回 false。三个观察分别证明响应窗口已结束、生产任务曾继续存活,以及迟到结果没有重新开启响应。
100ms 是配置值,不是本实验承诺的精准响应延迟。容器的异步超时检查、线程调度和网络读取会影响实际触发时点。测试给整体 HTTP 操作设置更宽的上限,并以状态结果验收;没有据此提供性能或实时性结论。
这个反例针对应用自行管理的 DeferredResult 生产任务。不能泛化为“Spring MVC 超时从不取消任务”。固定源码中的 Callable 处理链保留了提交任务的 Future,上游 startCallableProcessingTimeoutAndCheckThreadInterrupted 用例明确验证超时路径调用 future.cancel(true)。该上游测试源码已阅读,未运行其 Gradle 套件。超时测试
即使调用 cancel(true),线程中断也不是任意任务的强制停止。阻塞点是否响应中断、驱动是否支持取消、远端操作是否已经提交,必须分别检查。应用没有权限把“发出取消信号”直接写成“业务没有发生”。
显式取消与 finally 清理
/timeout?cancel=true 使用同样的任务,但在 onTimeout 中调用生产 Future 的 cancel(true)。CountDownLatch.await 响应中断,任务记录 producer-interrupted,并在 finally 中减少 ACTIVE。客户端仍得到 503,资源计数则回到 0。
这组对照只改变取消连接,不改变请求类型和生产工作,因此可以把资源终态差异归因到显式取消。onCompletion 也记录当时 ACTIVE 值,但它本身没有等待任务终止;测试另行等待并断言资源为零。响应完成回调被调用,不足以证明后台资源已经释放。
真实任务可能同时拥有 HTTP 请求、数据库连接、订阅、文件句柄等资源。每一种都应有明确关闭责任,且取消回调和正常完成可能竞争。常见做法是让任务的 finally 负责释放自身资源,响应回调负责发出取消或解绑信号,再用幂等关闭策略避免重复释放。仅依赖线程池 shutdown 并不能解决长时间运行中的逐请求泄漏。
本实验的 ACTIVE 是资源占用标记,用于验证任务进入和 finally 退出;它不冒充真实 JDBC 连接。数据库和远端 HTTP 的取消行为必须在对应篇章中使用实际资源单独实验,不能从一个 latch 的中断结果推断驱动能力。
客户端断开什么时候可见
/disconnect 返回 StreamingResponseBody。任务持续写固定大小块并 flush,客户端用原始 Socket 收到首字节后设置 SO_LINGER 为 0 并关闭,主动产生 RST。服务器后续写入抛出 IOException 的子类,本次具体记录为 AsyncRequestNotUsableException,finally 执行后释放计数并通知完成 latch。
验收要求客户端确实先收到字节、服务器确实观测到写入异常、清理 latch 在上限内完成、ACTIVE 回到零。只检查客户端关闭成功不能证明服务器已知道断连;只检查异常产生不能证明业务资源释放。四个观察串联起来,才覆盖本例的断连与回收路径。
MVC 的流式写入仍可能是阻塞写入,使用独立线程并不自动成为非阻塞网络模型。一个很慢的客户端可能长期占用写入任务,执行器容量、缓冲和超时都需要单独设计。实验没有做负载比较,也没有推导 MVC、虚拟线程或 WebFlux 的普遍性能排名。
直连 RST 是可控故障。经过反向代理、负载均衡和缓冲后,客户端断开不一定立即向应用传播,服务端可能继续写入代理缓冲区。对于长时间没有输出的任务,网络错误也可能迟迟不被触发。生产取消设计不宜仅靠下一次 IOException,应结合业务截止时间和上游取消协议。MVC 异步与流式处理文档
关闭整个实验进程
一次请求清理完成后,还要关闭应用拥有的执行器和端口。程序 finally 先解除可能仍在等待的 latch,停止外部生产执行器并等待终止,再停止 Tomcat、销毁 Spring 上下文、等待 MVC 执行器结束。随后连接原监听端口,必须得到拒绝连接,最后删除本次独占的临时容器目录。
这些断言排除了“测试通过但 JVM 留下工作线程”的一类问题。它们仍不等于完整的泄漏检测工具:未对堆对象或所有 ThreadLocal 进行长时间检测,Tomcat 在未开启部分模块访问时也会打印检测能力提示。本文对资源关闭的结论限定在显式执行器、活动任务标记、应用上下文与监听端口。
复现与练习
在下载工程执行:
1 | |
本章 17 条断言的原始记录位于 evidence/30/local-20261002/。成功、任务异常、两种超时策略、RST 和最终关闭均在同一真实容器中执行,源文件哈希和命令保存于 manifest.json。
推导题:把生产任务换成捕获 InterruptedException 后继续无限等待,保留 cancel(true),资源计数应当自动归零吗?不应。取消信号已发送与代码合作退出是两件事,现有资源断言应失败。
改动练习:给 DeferredResult 增加正常完成与超时同时竞争的两个生产者,只允许一次结果成功接受。记录两次 setResult 的布尔值,同时保证两个任务都执行 finally。不要把失败的第二次设置改成抛异常重试;响应状态一旦终止,重新提交结果不会恢复原 HTTP 交换。
完整工程下载见 深入 Spring(00):从手动组装到可验证的容器实验。六章共用独立的 mvc-lab 模块,单章参数决定执行场景;正文中的改动练习未计入已通过断言。
