返回 Mono,为什么仍能阻塞服务器

一个控制器返回 Mono<String>,调用链上没有 Future.get(),响应也能正常到达。这些现象不足以证明请求处理没有阻塞。Mono.fromCallable 可以包装 JDBC 查询、文件读取或者任意同步等待;如果没有安排执行位置,订阅线程仍然执行这段 Callable。包装改变了计算的触发方式,没有改变计算的性质。

判断响应式路径,需要分别回答三个问题:谁发起订阅,谁请求数据,具体工作在哪个线程执行。一个请求里可能有多个 Publisher、多次线程切换和多个资源生命周期。只看方法签名,无法推出这些事实。

实验使用 Spring Framework 6.2.11、Reactor Core 3.7.11、Reactor Netty 1.2.10、Tomcat 10.1.46 和 JDK 21。两个真实 HTTP 服务器返回相同订单文本;独立订阅实验检查需求;受控阻塞实验检查单个事件循环被占用后的行为。入口是下载工程 reactive-lab 中的 blog.spring.reactive.Chapter35,完整源码包含容器启动、HTTP 客户端、断言和关闭路径。

请求分派、订阅及线程边界

相同业务结果,不同调用协议

MVC 控制器直接返回字符串 order=7,total=25.00。DispatcherServlet 调用处理器后,由返回值处理器和消息转换器写出结果。WebFlux 控制器返回一个延迟构造该字符串的 Mono;处理结果随后进入响应式写出链。

相同业务函数只做字符串计算,没有数据库、网络访问或共享可变状态。这样的选择使实验比较的是框架调用协议,而不是不同业务算法。真实 JDK HTTP 客户端分别请求两个端口,检查状态均为 200、响应体逐字相等。这个观察不能转换成吞吐量比较:样本没有负载模型,也没有测量 CPU、队列或尾延迟。

两套服务器使用两个显式配置类,分别启用 @EnableWebMvc 与 @EnableWebFlux。MVC 注册真实 DispatcherServlet;WebFlux 通过 WebHttpHandlerBuilder.applicationContext 构造 HTTP 处理链,再由 ReactorHttpHandlerAdapter 接入 Netty。classpath 同时存在两个模块,不表示一次请求会依次经过两套分派器。

在固定版本的 DispatcherHandler.handle 中,候选 HandlerMapping 经 concatMap 查找处理器,next() 选择第一个结果;匹配失败产生 404。选中的 HandlerAdapter 调用控制器并产出 HandlerResult,支持该返回值的 HandlerResultHandler 负责处理响应。返回 Mono<String> 时,业务 Publisher 的值还要经过适配和消息写出,方法返回不是 HTTP 响应结束。DispatcherHandler 固定源码

构造、订阅与需求是三个时点

Mono.defer 把业务构造动作推迟到订阅时。服务器刚启动、尚未请求 /order 时,计数器为零;真正收到 HTTP 请求后,defer 中的计数器变成一,并记录执行线程。该观察只对应这个冷 Publisher。已经启动的外部任务、缓存结果、热流以及自行调用 subscribe() 的业务不遵守“构造完全没有副作用”的假设。

订阅建立发送信号的关系,需求决定允许发送多少个元素。单独的 BaseSubscriber 在 hookOnSubscribe 中不调用 request,订阅 Flux.range(1, 3) 后结果集合为空。调用 request(1) 后只有元素 1;再调用 request(2) 才得到 2 和 3。断言同时检查需求记录为 [1, 2],避免只检查最终集合而漏掉中间状态。

1
2
3
subscribe       -> onSubscribe,结果集合 []
request(1) -> onNext(1),结果集合 [1]
request(2) -> onNext(2), onNext(3), onComplete

这是直接、同步、无缓存操作符的需求实验。放入 publishOn、flatMap、buffer 等操作符后,预取、并发和分批补充请求都可能改变上游看到的数字。不能把最终订阅者的 request(1) 当成所有上游只能准备一个对象的证明。

HTTP 实验中的 doOnRequest 记录了响应写出链向业务 Publisher 发出的正数需求。该数量属于 Reactor 信号协议;它既不是 TCP 接收窗口,也不是浏览器渲染一行页面的次数。网络缓冲区、消息编码、操作符预取和下游消费共同影响内存占用。背压能协调支持需求协议的生产者和消费者,不能自动限制另一个线程已经开始的阻塞查询。Reactive Streams 协议

受控阻塞比一次响应时间更有解释力

实验把 WebFlux 服务器固定为一个 NIO 事件循环。/block 返回 Mono.fromCallable,Callable 先记录线程,再通知 entered 屏障,随后等待 release。主测试线程确认已经进入等待,向同一个事件循环投递一个标记任务。释放屏障之前,请求没有完成,标记任务也没有执行。

这里没有用“等待 100 毫秒后大概还没返回”判断阻塞。线程身份、已进入的屏障和排队任务共同确定了观察位置。只有主测试线程释放屏障,Callable 才返回业务结果,事件循环才有机会处理标记任务。CountDownLatch 的等待设有 10 秒截止时间,异常路径也释放屏障,避免验证失败后遗留挂起线程。

这一反例说明单个事件循环正在执行的用户代码可以阻止该循环上的其他任务前进。它没有证明整台服务器所有连接都会同时停止:生产配置通常有多个事件循环,一个连接的任务与其他连接的调度关系还受绑定方式影响。因此程序明确固定循环数与 NIO 实现,正文结论也限制在被占用的循环。

一个更隐蔽的错误是 Mono.just(blockingCall())。Java 先求参数值,再调用 just,阻塞发生在 Publisher 构造之前;之后再增加 subscribeOn,也不能把已经执行完的调用移走。对比 Mono.fromCallable 的价值在于保留延迟执行的机会,而不是赋予 Callable 非阻塞能力。

subscribeOn 改变执行位置,不改变资源成本

/offload 使用 Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())。返回文本相同,记录的业务线程由服务器事件循环变成 boundedElastic 线程。验证关注的是这个 Callable 的订阅执行位置,不把线程池名称当作整个请求链都运行于同一线程的证据。

publishOn 主要影响其下游信号处理位置;subscribeOn 安排订阅及上游执行。位置选择要结合操作符链分析。在阻塞源外层错误地放置 publishOn,可能只移动阻塞完成后的转换工作,源仍然占用原线程。把线程切换符号放进方法并不构成隔离证明,记录阻塞调用实际发生的线程才构成证据。

把阻塞工作移动到适合的执行器后,仍然存在连接数、排队长度、任务执行时间和取消响应能力。一个 JDBC 调用即使运行在 boundedElastic,仍然占用数据库连接;下游取消也不必然立即终止驱动中的系统调用。本实验不执行 JDBC,也不验证中断传播,不能据此声称阻塞调用已经能随请求取消。

在设计取舍上,MVC 加同步驱动、WebFlux 加响应式驱动,以及 WebFlux 对少量阻塞边界做隔离都可能成立。选择依据应包含服务的等待比例、连接预算、依赖支持和维护成本。当前实验证明调用语义,不提供哪一种架构普遍更快的排名。WebFlux 模型与并发说明

运行与观察

从第 00 篇下载完整实验工程,使用 JDK 21。在工程根目录执行:

1
2
3
./mvnw -f reactive-lab/pom.xml -q compile dependency:build-classpath \
-Dmdep.outputFile=target/classpath.txt
python3 reactive-lab/verify.py 35

验证脚本要求 JAVA_HOME 指向 JDK 21,使用直接 Java 进程运行入口,不依赖 IDE 配置。该篇不需要数据库。HTTP 服务绑定 127.0.0.1 的随机端口,脚本为整个进程设 90 秒上限。

实际执行通过 16 条断言。日志位于 evidence/35/local-20261002/run.txt,退出码为 0;同目录 manifest.json 保存调用命令、JDK 版本以及 Java 与 POM 的 SHA-256。典型线程记录如下,数字后缀不作为契约:

1
2
3
4
5
TRACE subscribe-order thread=spring35-nio-1
TRACE mvc-handler thread=http-nio-auto-1-exec-1
TRACE blocking-enter thread=spring35-nio-1
TRACE offloaded-callable thread=boundedElastic-1
SUMMARY chapter=35 checks=16

最后一个断言检查 HTTP 服务器、事件循环和应用上下文已关闭;Tomcat 执行 stop/destroy,JDK HTTP 客户端退出 try-with-resources。临时 Tomcat 工作目录仅保存运行文件,不修改博客源码或数据库。

预测题与改动练习

把 Mono.fromCallable 改成 Mono.just(order()),订阅计数还能证明业务计算延迟吗?不能。order() 在 just 调用前执行;需要把记录点移到业务函数内部,观察它与 HTTP 处理、订阅的先后关系。

把 subscribeOn(boundedElastic()) 换成尾部 publishOn(boundedElastic()),记录业务函数内部线程,再记录最后一个 map 的线程。预期两个记录可能不同;不能仅凭最后一个 map 已换线程就认定阻塞源完成隔离。

保留阻塞屏障,将事件循环从一个增加到两个,同时发起多个连接的请求。记录每个连接的处理线程、标记任务和完成次序。该练习检验“一个循环被阻塞”与“所有请求都被阻塞”的区别,不以一次总耗时估算容量。

需求实验中加入 buffer(2),让最终订阅者只请求一个缓冲区。预测上游需求数量及集合内容,再检查 doOnRequest。只有把观察位置写清楚,背压数字才有解释价值。

参考资料

完整环境与下载入口:深入 Spring(00):从手动组装到可验证的容器实验。