本节目标:把阻塞调用从 EventLoop 上移走,选对隔离方式,并知道虚拟线程的边界。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-transport、netty-codec-http模块)。
承接的问题
3.2 的结论是「任务要短」。但 linkd-v2 的 GatewayHandler 必须访问 libraryd:收到 GET /books/{id} 后,它要请求 http://127.0.0.1:8080/books/{id},拿到响应再写回客户端。这个请求可能 5 毫秒返回,也可能因为下游 GC、慢查询或网络重传卡住 2 秒。类似的还有:
- 认证 handler 要查一次数据库或 Redis;
- 上传接口要落盘,文件可能几十 MB;
- 某些接口要调用一个只提供同步 SDK 的第三方服务。
这些操作都有同一个特征:调用线程在等待外部资源,CPU 在此期间什么也不做。如果调用发生在 EventLoop 上,等待的就不只是这一条连接,而是同一 EventLoop 上所有连接的读写、心跳和超时。3.1 的探针已经量过:300 毫秒的 handler 让同一 EventLoop 上另一条连接的读事件晚了 315 毫秒。2 秒的下游超时会把这段延迟放大到秒级,并且让写缓冲持续增长(第 9 章)。
这一节先说明阻塞的代价怎么被放大,再比较三种隔离方式:DefaultEventExecutorGroup、独立线程池、JDK 25 虚拟线程。最后用一个完整的 GatewayHandler 说明「提交到虚拟线程、回写 Channel」的写法,以及虚拟线程解决不了的两类问题:CPU 密集和下游容量。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面标注「本机输出」的内容都来自这台机器,单次运行,未做统计。
为什么不能在 EventLoop 做阻塞调用
EventLoop 是单线程执行器,所有回调都是 run-to-completion:一个 channelRead 返回之前,同一条线程不会去处理下一个事件、下一个任务或到期的定时器。阻塞调用不会让出线程,它只是让这条线程停在系统调用或锁上。
本机探针使用 1 条 worker 线程、两条连接,服务端 handler 对每个请求睡眠 200 毫秒,同时在这条 EventLoop 上挂一个 50 毫秒周期的心跳任务。本机输出(节选):
254 ms heartbeat tick
269 ms io read a1a2 on nioEventLoopGroup-4-1
269 ms work a1a2 on Thread[#28,nioEventLoopGroup-4-1,10,main]
473 ms heartbeat tick
474 ms io read b1b2 on nioEventLoopGroup-4-1
474 ms work b1b2 on Thread[#28,nioEventLoopGroup-4-1,10,main]
474 ms client got ok:a1a2
679 ms heartbeat tick
679 ms heartbeat tick
679 ms heartbeat tick
三个后果同时出现。第一,第二条连接的读事件在 474 毫秒才被处理,比第一条晚了约 205 毫秒,尽管它早就到达了内核缓冲区。第二,50 毫秒周期的心跳在 254 到 473 毫秒之间出现了一个 219 毫秒的空档,阻塞结束后又在 679 毫秒连续补跑。第三,第一条连接的响应也要等睡眠结束才写出,因为读和写在同一条线程上。
如果把 200 毫秒换成 HttpClient.send 的 2 秒超时,这段时间内同一 EventLoop 上的所有连接都停止读写。客户端的请求超时先触发,连接被关闭;服务端的心跳任务积压,超时检测失效;写队列里的响应越堆越多,水位线被突破。第 9 章会讲写缓冲,但根因在这里:线程被占住了。
阻塞的来源比想象中多:JDBC 查询、Files.readAllBytes、同步 HTTP 客户端、InetAddress.getByName 的 DNS 解析、数据库连接池借连接的等待、synchronized 锁竞争、大对象的 JSON 序列化、日志同步刷盘。它们不一定慢,但只要在 EventLoop 上,就会把延迟转嫁给同一条线程上的其他连接。
检测这类问题不能只靠代码评审。可用的手段有三个:在 handler 里给回调计时,超过阈值记 WARN;用 jstack 看 nioEventLoopGroup-* 线程是否停在 socketRead、read0 或 JDBC 的栈帧上;用 JFR 的阻塞事件统计。13.3 会给出具体命令。
三种隔离方式
隔离的本质是把「等待」移到另一条线程,让 EventLoop 只做内存操作和事件分发。三种方式的差别在于线程从哪来、顺序怎么维持、容量怎么控制。
DefaultEventExecutorGroup
DefaultEventExecutorGroup 是 Netty 提供的业务线程组。把 handler 添加时指定这个组,handler 的所有回调都会在组内的线程上执行,而不是 IO 线程:
DefaultEventExecutorGroup business = new DefaultEventExecutorGroup(2);
channel.pipeline().addLast(business, new SlowHandler());
本机探针在 4 条连接上各发一个请求,业务组开 2 条线程,每个请求睡眠 200 毫秒。本机输出(节选):
266 ms work c3* on Thread[#30,defaultEventExecutorGroup-2-2,5,main]
266 ms work c0* on Thread[#29,defaultEventExecutorGroup-2-1,5,main]
471 ms work c2* on Thread[#29,defaultEventExecutorGroup-2-1,5,main]
472 ms work c1* on Thread[#30,defaultEventExecutorGroup-2-2,5,main]
474 ms client got ok:c0*
474 ms client got ok:c3*
684 ms client got ok:c2*
684 ms client got ok:c1*
4 个阻塞任务分两波完成:第一波 266 毫秒开始,第二波 471 毫秒开始,相差约 205 毫秒,正好是一个任务的处理时间。DefaultEventExecutorGroup(2) 的并发上限就是 2,第 3、4 个任务在队列里等。
它的优点是顺序:同一个 handler 固定绑定组内的一条线程,channelRead 之间保持提交顺序,连接状态仍然只被一条线程访问。缺点是容量固定、队列默认没有明显上限,任务堆积时只能靠监控发现。它适合「阻塞时间可控、并发不高」的场景,不适合把整个网关的转发都塞进去。
独立线程池
用一个普通的 ExecutorService 接收任务,handler 只负责提交,结果通过 ctx.writeAndFlush 回写:
ExecutorService pool = Executors.newFixedThreadPool(8);
pool.execute(() -> {
String result = blockingLookup(id);
ctx.writeAndFlush(response(result));
});
它和 DefaultEventExecutorGroup 的区别是:线程池不参与 pipeline 的事件顺序,多个请求可能并发完成、乱序回写;线程池可以被多个 handler 和端口共享,容量按整个进程规划;队列和拒绝策略由创建者决定。适合无状态、可以乱序的查询类操作,例如按 ID 查缓存。
需要自己处理提交失败(RejectedExecutionException)和关闭顺序。线程池的线程是平台线程,8 条线程就是 8 个并发阻塞任务,超过就排队。
虚拟线程
JDK 21 起有虚拟线程,JDK 25 上可以直接用 Executors.newVirtualThreadPerTaskExecutor()。每个任务一条虚拟线程,阻塞时卸载载体线程,载体线程去跑别的虚拟线程:
ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();
virtual.execute(() -> {
String body = blockingHttpCall(uri);
ctx.writeAndFlush(response(body));
});
同样的探针换成虚拟线程,本机输出(节选):
312 ms io read a1a2 on nioEventLoopGroup-4-1
317 ms io read b1b2 on nioEventLoopGroup-4-1
317 ms work a1a2 on VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1
318 ms work b1b2 on VirtualThread[#32]/runnable@ForkJoinPool-1-worker-2
343 ms heartbeat tick
393 ms heartbeat tick
443 ms heartbeat tick
493 ms heartbeat tick
527 ms client got ok:a1a2
527 ms client got ok:b1b2
两条连接的读事件在 312 和 317 毫秒被处理,两个 200 毫秒的等待同时进行,响应都在 527 毫秒返回。心跳保持 50 毫秒的节奏,343、393、443、493 毫秒连续出现,没有再出现 219 毫秒的空档。工作线程是 VirtualThread[#30] 和 VirtualThread[#32],载体是 ForkJoinPool-1-worker-1 和 -2。
三种方式的对比:
| 方式 | 工作线程 | 顺序 | 并发上限 | 适合 |
|---|---|---|---|---|
| EventLoop | nioEventLoopGroup-* | 天然串行 | 1 条 IO 线程 | 纯内存、亚毫秒操作 |
| DefaultEventExecutorGroup | defaultEventExecutorGroup-* | 每 handler 有序 | 构造时线程数 | 有状态、需要顺序的阻塞 |
| 独立线程池 | 自定义 | 无序 | 池大小 + 队列 | 无状态查询、共享容量 |
| 虚拟线程 | VirtualThread | 无序 | 受下游和信号量约束 | 大量等待型 IO |
虚拟线程看起来最优,但它只是把「线程很贵」这个约束去掉了,没有把「下游很慢」和「CPU 只有 10 核」这两个约束去掉。
ChannelHandler 里提交虚拟线程的完整示例
GatewayHandler 是 linkd-v2 里把请求转发到 libraryd 的 handler。它继承 SimpleChannelInboundHandler<FullHttpRequest>,入站消息在 channelRead0 返回后由父类自动释放,所以不能把 request 本身交给虚拟线程;只提取需要的字符串,再提交任务。
package com.plume.linkd.v2.server;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.util.CharsetUtil;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.Semaphore;
@ChannelHandler.Sharable
public final class GatewayHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
private final ExecutorService virtual;
private final Semaphore inFlight = new Semaphore(256);
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(1))
.build();
public GatewayHandler(ExecutorService virtual) {
this.virtual = virtual;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
String uri = request.uri();
if (!inFlight.tryAcquire()) {
write(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE, "busy");
return;
}
try {
virtual.execute(() -> {
try {
HttpRequest outbound = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:8080" + uri))
.timeout(Duration.ofSeconds(2))
.GET()
.build();
HttpResponse<String> response = client.send(
outbound, HttpResponse.BodyHandlers.ofString());
write(ctx, HttpResponseStatus.valueOf(response.statusCode()),
response.body());
} catch (Exception e) {
write(ctx, HttpResponseStatus.BAD_GATEWAY, "proxy failed");
} finally {
inFlight.release();
}
});
} catch (RejectedExecutionException e) {
inFlight.release();
write(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE, "shutting down");
}
}
private void write(ChannelHandlerContext ctx, HttpResponseStatus status, String body) {
if (!ctx.channel().isActive()) {
return;
}
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, status,
Unpooled.copiedBuffer(body, CharsetUtil.UTF_8));
response.headers().set(HttpHeaderNames.CONTENT_LENGTH,
response.content().readableBytes());
ctx.writeAndFlush(response);
}
}
HttpVersion 是 io.netty.handler.codec.http 包里的类型,位于 netty-codec-http 模块,构造 DefaultFullHttpResponse 时必须显式给出。
几个关键点。
第一,request.uri() 返回一个普通字符串,channelRead0 返回后 request 被释放,但字符串不受影响。任何需要读取 request.content() 的逻辑都必须在提交前把字节复制出来;把 request 或 content() 的 ByteBuf 捕获进 lambda 会在虚拟线程里访问已释放内存。第 5 章会详细讲引用计数。
第二,ctx.writeAndFlush 可以从任意线程调用。Netty 会把写操作排到 Channel 自己的 EventLoop,不需要额外加锁。ctx 本身在 handler 生命周期内有效,但 Channel 可能已经关闭,所以写之前检查 isActive()。
第三,inFlight 信号量把并发请求限制在 256。虚拟线程创建很便宜,不代表下游能承受无限并发;没有这个上限,10 万个虚拟线程会同时打向 libraryd,把它的连接池和数据库压垮。信号量满时立即返回 503,比排队到超时更好。提交本身也可能因为执行器已关闭被拒绝,RejectedExecutionException 分支要释放刚拿到的配额,否则关闭窗口里会白白漏掉许可。
第四,两处超时都要设。connectTimeout 限制建连,HttpRequest.timeout 限制整个请求。虚拟线程不会让超时自动生效,等待仍然是等待,必须有明确的截止时间。
第五,GatewayHandler 标了 @ChannelHandler.Sharable,所有连接共享一个实例,因此 inFlight 和 client 必须是线程安全的;HttpClient 本身线程安全,Semaphore 也是。带可变实例字段的 handler 不能这样共享,4.3 会展开。
第六,HTTP/1.1 的响应顺序。虚拟线程完成顺序不确定,如果客户端使用 pipelining 连续发多个请求,响应可能乱序。linkd-v2 的网关默认一个连接一次只处理一个请求;如果允许多个在途请求,需要按请求序号排队回写,或者升级到 HTTP/2(第 11 章)。
虚拟线程不能解决什么
CPU 密集任务。 虚拟线程的载体线程数量默认等于 availableProcessors(),本机是 10。1000 条虚拟线程同时做 JSON 序列化或加密,仍然只有 10 条线程在跑,其余在等 CPU 时间片。虚拟线程适合「等待多、计算少」的任务,把 CPU 密集任务放进去不会变快,反而多了一层调度开销。计算任务应该用固定大小的平台线程池,配合 ForkJoin 或并行流。
下游容量。 虚拟线程让「同时发起 10 万个请求」变得容易,但下游的连接池、数据库连接数、磁盘 IOPS 不会跟着变大。虚拟线程把排队从本进程的线程池转移到了下游,超时和雪崩的风险更高。必须用信号量、连接池上限或限流器把并发压回下游能承受的范围。
内存。 虚拟线程比平台线程轻,但不是零成本。每条虚拟线程仍然有对象头和按需增长的栈块,捕获的闭包、ThreadLocal 也会占内存。10 万条同时存活是可以接受的量级,100 万条就要重新算账。不要因为「虚拟线程很便宜」就取消所有背压。
固定载体线程的阻塞。 JDK 24 的 JEP 491 让 synchronized 不再钉住载体线程,但 native 调用和部分类初始化仍然可能占用载体线程。JDBC 驱动如果是纯 Java 实现,阻塞时通常能卸载;如果走 JNI,载体线程会被占住。判断方法是 JFR 里的虚拟线程固定事件,而不是假设。
调试与上下文。 线程 dump 里会出现大量虚拟线程,按名字找不到业务含义;MDC、安全上下文、事务上下文也不会自动传播到新线程。需要在线程工厂里命名,或者在提交任务时显式复制上下文。
极短任务。 一个 10 微秒的内存查表,提交到虚拟线程的调度成本可能比直接执行还高。判断标准是「阻塞时间是否远大于调度成本」,不是「有没有阻塞」。
在 linkd-v2 里落地
LinkdV2Main 创建虚拟线程执行器,注入 GatewayHandler,关闭时先停服务端再停执行器:
private final ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();
private Channel bindHttp(int port) throws InterruptedException {
return new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new GatewayHandler(virtual));
}
})
.bind(port).sync().channel();
}
public void shutdown() {
for (Channel server : servers) {
server.close();
}
virtual.shutdown();
try {
virtual.awaitTermination(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
boss.shutdownGracefully();
worker.shutdownGracefully();
}
这里只装配代理分支;/healthz、WebSocket 和 h2c 的 pipeline 在后续章节分别装配,不能把多个消费同一消息的 handler 串在一条链上。ChannelPool.acquire() 返回的是 Future<Channel>,所以用监听器拿连接,不要在 EventLoop 上 sync()。
关闭顺序是「停止接收新连接、停止提交新任务、等待在途任务、关闭 IO 线程」。virtual.shutdown() 允许已提交的任务执行完,awaitTermination(5, SECONDS) 给它们 5 秒;超过时间还在跑的任务要么被下游超时结束,要么只能放弃。注意不要在 EventLoop 线程里调用 awaitTermination,它和 sync() 一样会阻塞线程。
RpcClient 是另一类例子。它基于 Netty 客户端,call 返回 CompletableFuture<LinkdFrame>,本身就不应该阻塞。下面的片段假设已导入 io.netty.channel.Channel、io.netty.util.concurrent.Future 和 com.plume.linkd.v2.protocol.LinkdFrame:
public CompletableFuture<LinkdFrame> call(LinkdFrame request, Duration timeout) {
CompletableFuture<LinkdFrame> result = new CompletableFuture<>();
pool.acquire().addListener((Future<Channel> acquired) -> {
if (!acquired.isSuccess()) {
result.completeExceptionally(acquired.cause());
return;
}
Channel channel = acquired.getNow();
var timer = channel.eventLoop().schedule(
() -> result.completeExceptionally(new TimeoutException("rpc timeout")),
timeout.toMillis(), TimeUnit.MILLISECONDS);
result.whenComplete((frame, error) -> timer.cancel(false));
channel.writeAndFlush(request).addListener(written -> {
if (!written.isSuccess()) {
result.completeExceptionally(written.cause());
}
});
});
return result;
}
超时用 channel.eventLoop().schedule 实现,回调在 EventLoop 上完成 Future;调用方拿到 Future 后可以在任何线程组合,不需要占用 EventLoop 等待。这里没有虚拟线程,因为 Netty 客户端本身就是异步的;虚拟线程只在「必须调用阻塞 API」时才有价值。把异步客户端包成阻塞调用再丢给虚拟线程,等于多绕一圈,还丢掉了背压。
常见错误与边界
错误一:把 FullHttpRequest 或它的 ByteBuf 捕获进虚拟线程。 SimpleChannelInboundHandler 在 channelRead0 返回后自动释放消息。虚拟线程稍后执行时读到的可能是已释放或已被复用的内存,表现为 IllegalReferenceCountException、乱码或偶发崩溃。提交前只复制普通 Java 值,或者用 retainedDuplicate() 并明确由谁释放。
错误二:认为 ctx.writeAndFlush 只能在 EventLoop 上调用。 它可以从任意线程调用,内部会排队;但正因为它异步,调用返回不代表数据已经写出。需要知道写结果时用返回的 ChannelFuture 加监听器,不要在虚拟线程里 sync() 等。
错误三:无上限地提交虚拟线程任务。 每个请求一条虚拟线程,连接数一万就是一万条,下游只有 50 个数据库连接。没有信号量或 limiter 时,所有请求一起超时,重试再放大流量。虚拟线程解决的是线程成本,不是容量。
错误四:在 EventLoop 线程里 sync()。 本机在 EventLoop 里发起连接并等待,得到:
io.netty.util.concurrent.BlockingOperationException: DefaultChannelPromise@74640e71(incomplete)
这个异常只覆盖「Future 属于当前 EventLoop」的情况。等待线程池任务、等待 EventLoopGroup 关闭都不在检测范围内,会直接挂住线程(3.1 已实测)。EventLoop 上只用 addListener。
错误五:用虚拟线程跑 CPU 密集任务。 10 条载体线程不会因为虚拟线程变多而增加。CPU 密集任务用固定大小的平台线程池,并且把并行度控制在核数附近,避免上下文切换。
边界:虚拟线程不是「无限并发」的许可证。 它的收益来自阻塞时释放载体线程,前提是阻塞点能被 JDK 识别并卸载。下游容量、内存、文件描述符、数据库连接数仍然是硬约束,必须单独做限流和超时。
边界:关闭时在途任务可能被丢弃。 shutdown() 之后新提交会抛 RejectedExecutionException,shutdownNow() 会中断正在运行的任务。网关关闭时先关监听 Channel,再关执行器,能减少「请求已接收但任务没跑」的窗口,但仍有残留窗口;需要更强保障时,要用请求级别的优雅关闭协议。
小结
- EventLoop 上任何阻塞都会推迟同一线程上的所有连接、任务和定时器;本机 200 毫秒的 handler 让心跳出现 219 毫秒空档。
DefaultEventExecutorGroup把 handler 回调移出 IO 线程并保持每 handler 顺序,但线程数就是阻塞并发上限,本机 2 条线程处理 4 个请求分成两波,相差约 205 毫秒。- 独立线程池适合无状态、可乱序的查询,容量和拒绝策略由创建者控制;虚拟线程适合大量等待型 IO,每条任务一条线程,阻塞时卸载载体。
GatewayHandler只把 URI 字符串带进虚拟线程,FullHttpRequest由父类释放;写回用ctx.writeAndFlush,并用信号量限制在途请求数。- 虚拟线程不增加 CPU 核数,也不增加下游连接池容量;CPU 密集任务和无限并发提交都不适合直接丢给虚拟线程。
- 超时要用
HttpRequest.timeout或EventLoop.schedule显式设置;虚拟线程不会让等待自动结束。
阻塞隔离解决了「在哪里等」,接下来要解决「事件在 handler 链上怎么走」。下一节从 ChannelPipeline 的结构开始,看入站和出站事件的方向、ctx 的起点,以及 handler 的添加顺序为什么会影响结果。
阅读导航:上一节:3.2 任务队列与定时任务 · 下一节:4.1 Pipeline 结构与事件传播 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。