请求线程、异步 Servlet 与响应写出:线程何时真正释放
接口调用了线程池,容器线程却没有释放;请求已经超时,后台任务仍在写响应;异步监听器同时收到 timeout 和 error,清理逻辑执行两次。根因通常是把三个概念混在了一起:把工作交给另一线程、延长 Servlet 请求生命周期、使用非阻塞网络 I/O。只有 startAsync() 改变了容器对本次请求的完成判断;普通 CompletableFuture 不会自动改变 request/response 的所有权。
同步模型的并发上限由驻留时间决定
同步 Servlet 从 Filter 链进入 service(),直到调用栈返回都占用一个执行线程。线程等待数据库、远程接口或锁时不消耗多少 CPU,却仍占线程栈、请求状态和池配额。到达率为 λ、线程平均驻留时间为 W 时,所需并发与 λ × W 同阶;尾延迟上升会先把线程池占满,再让 Connector 队列增长。
增加最大线程只能扩大等待仓库。若下游连接池只有 50 条,把容器线程从 200 调到 2000 会让更多线程排队借连接,增加内存与调度成本,却不增加完成率。应把入口并发与下游容量、deadline 和拒绝策略联动。
startAsync 改变的是请求状态,而不是让代码自动异步
调用 request.startAsync() 后,容器允许初始 dispatch 返回而不完成响应。应用取得 AsyncContext,可 start() 提交任务、dispatch() 发起新的容器派发,或 complete() 终结响应。所有参与链路的 Servlet 与 Filter 都必须支持 async,否则启动会被拒绝。
examples/backend-development/servlet/request-thread-async/AsyncLifecycleDemo.java 把“原线程释放、后台完成”打印为两步:
javac --release 17 -Xlint:all -Werror examples/backend-development/servlet/request-thread-async/AsyncLifecycleDemo.java examples/backend-development/servlet/request-thread-async/AsyncTimeoutRaceDemo.java
java -cp examples/backend-development/servlet/request-thread-async AsyncLifecycleDemorequestThreadReleased=true events=[request-thread:startAsync, worker:complete] completed=true这只是生命周期模型。真实代码必须在后台任务成功、失败与取消的所有分支调用 complete() 或 dispatch();漏掉终止动作会让连接和请求状态一直保留到容器超时。AsyncContext 自带的 start() 由容器管理执行资源,但复杂服务更适合显式、有界、可观测的业务 executor,避免不同任务互相拖垮。
complete、timeout、error 之间存在竞争
后台结果可能与超时定时器同时到达,客户端也可能在写出时断开。若每个回调都无条件写响应并释放资源,会出现 double complete、已提交响应再改状态、计数减两次和连接重复归还。终止路径必须由原子状态或幂等清理统一所有权。
AsyncTimeoutRaceDemo.java 让 complete 与 timeout 竞争同一个终止位:
java -cp examples/backend-development/servlet/request-thread-async AsyncTimeoutRaceDemoterminal=TIMEOUT terminalTransitions=1 loserIgnored=true获胜者可能因调度不同而是 COMPLETE 或 TIMEOUT,稳定不变量是 terminalTransitions=1。生产门禁不应硬编码谁获胜,而应验证只有一个最终状态、一次业务结果和一次资源清理。AsyncListener 的 onTimeout、onError、onComplete 要共享这个终止协议。
超时同样不等于后台执行已停止。Future cancel、线程中断、数据库 statement cancel 和远程 deadline 必须逐层传播。任务若吞掉中断,容器已经返回 503,它仍可能继续占连接池并最终提交事务。写操作必须结合幂等、状态查询或补偿处理结果未知。
非阻塞 I/O 是 readiness 回调,不是后台线程
Servlet 非阻塞读取通过 ReadListener,只有 isReady() 为 true 时读取;isFinished() 表示请求数据结束。写出通过 WriteListener 与 isReady() 控制。回调要求应用在容器通知的就绪窗口内推进状态,不能循环写到阻塞,也不能在 onDataAvailable() 中执行长阻塞业务。
解析器需要保存跨回调状态:已读 Header 字节、帧长度、当前偏移、校验状态和最大限制。一次回调可能只得到半个 UTF-8 字符或 multipart 边界,不能把每块独立解码。写侧则要有有界待发送队列;客户端慢时停止从上游生产,或按业务规则丢弃、合并、断开。
异步 Servlet 与非阻塞 I/O 可以组合,但不是同一件事。startAsync() 允许请求跨越初始线程;ReadListener/WriteListener 避免等待 socket readiness 时占线程。后台数据库驱动仍是阻塞的,网络输出非阻塞也不会让数据库调用变非阻塞。
响应完成包含协议与资源两层
应用调用 complete 只表示不再产生内容,容器仍可能把缓冲排入网络。客户端断开后,业务任务、上传临时文件、订阅句柄和 trace span 都需要结束。流式响应还要区分应用已生成字节、容器已接收字节和对端已收到字节;Servlet API 的 write 成功不能证明客户端消费成功。
错误发生在响应未 committed 时,可以设置状态与错误体;已 committed 后通常只能终止连接或流,并记录已发送字节与失败位置。错误处理不能递归触发同一异常派发。为 ERROR dispatcher 配置的 Filter 应避免重复包装与重复副作用。
生产验收看年龄分布,不只看数量
至少记录同步 active、async active、async age p95/p99/max、timeout、error、complete、double-terminal suppressed、executor active/queue/reject、待写字节和慢客户端断开。异步数量稳定但最大年龄持续上升,说明有请求永远未完成;timeout 增长但后台任务数不降,说明取消没有传播。
停机时先停止新请求,等待同步与异步请求在宽限期内完成,向剩余 AsyncContext 触发取消或错误终止,再关闭业务 executor。先关闭线程池会让在途异步任务永远无法执行清理;先杀 Connector 又会让完成响应无处写出。顺序必须通过带慢依赖和客户端断开的演练验证。
虚拟线程可以降低大量阻塞任务的平台线程成本,却不扩大数据库连接、远程配额和内存容量,也不改变 AsyncContext 终止语义。选择同步虚拟线程还是显式异步,应以编程复杂度、依赖阻塞特征、上下文传播和容器支持为依据,而不是把线程类型当成背压方案。
用竞争测试证明终止协议
异步测试要人为控制时序:结果在 timeout 前到达、timeout 先到、结果与 timeout 同栅栏释放、写出中客户端断开、业务 executor 拒绝、重新 dispatch 后目标抛异常。每轮都断言业务效果次数、终止状态迁移次数、监听器回调、响应提交次数和资源计数。关键是在扩大竞争窗口后仍守住唯一终止不变量。
上下文也要逐项验证。初始线程写入 trace/tenant,异步任务只能看到显式复制的值;任务结束后复用同一 worker 执行无上下文探针,必须读不到前一请求值。性能验收同时比较同步与异步在相同依赖延迟下的完成吞吐、请求年龄、执行线程、连接和内存;异步减少等待线程,却不会缩短依赖耗时。
