《Netty 高性能网络编程》3.1 EventLoopGroup 与线程归属

本节回答 linkd-v2 上线前的三个问题:boss 与 worker 各开多少线程、一个 Channel 为什么终身只属于一个 EventLoop、group.next() 如何分配连接。用探针打印 channel.eventLoop() 与线程名,给出 10 核机器的真实分配、io.netty.eventLoopThreads 的效果,以及多连接共享 EventLoop 的延迟代价。

本节目标:把 linkd-v2 的 boss/worker 线程数、Channel 归属和 next() 分配讲成可验证的规则。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-transport 模块)。

承接的问题

第 2 章的 ServerBootstrap 示例里,两组线程是这样创建的:

EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();

代码能跑,但上线前有三个问题必须回答清楚。第一,worker 不传参数时到底开多少线程,和机器核数是什么关系。第二,一条连接从 accept 到关闭,它的读事件、写事件、定时任务是不是永远在同一条线程上执行,如果是,为什么可以不对连接状态加锁。第三,group.next() 看起来像「随机拿一个线程」,它和 Channel 的绑定发生在什么时候,多个 Channel 共享同一个 EventLoop 会付出什么代价。

这三个问题不搞清楚,后面会以一种很难排查的方式暴露:心跳偶尔延迟几百毫秒、日志里同一个连接的处理线程变来变去、某个 handler 里加了一个 Thread.sleep(50) 就在压测时把整条链路拖垮。linkd-v2 的 9090 HTTP 端口和 9093 自定义协议端口共用一组 worker,任何一条连接上的阻塞都会影响同一 EventLoop 上的其他连接。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面标注「本机输出」的内容都来自这台机器,单次运行,未做统计。

boss 与 worker 是两个不同的池

ServerBootstrap.group(boss, worker) 的两个参数职责不同。boss 组只负责 accept:监听端口、把新连接交给 worker。worker 组负责已建立连接上的所有 IO 事件:读、写、连接关闭,以及 connect 发起的客户端连接。

EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
ServerBootstrap bootstrap = new ServerBootstrap()
        .group(boss, worker)
        .channel(NioServerSocketChannel.class)
        .childHandler(new ChannelInitializer<SocketChannel>() {
            @Override
            protected void initChannel(SocketChannel ch) {
                ch.pipeline().addLast(new LinkdFrameDecoder());
                ch.pipeline().addLast(new LinkdFrameEncoder());
            }
        });

boss 只开一个线程通常够用。accept 本身很轻,真正的成本在后面的 TLS 握手、协议解析和业务处理,这些都已经在 worker 上。只有当同一个进程里绑定了大量监听端口,并且 accept 速率高到单线程处理不过来时,才需要考虑多个 boss 线程;linkd-v2 只监听 9090 和 9093,一个 boss 线程足够。

worker 的默认线程数不是「核数」,而是 availableProcessors() * 2。用下面这段探针可以直接看到数量,不需要读源码:

EventLoopGroup group = new NioEventLoopGroup();
int count = 0;
for (var it = group.iterator(); it.hasNext(); ) {
    it.next();
    count++;
}
System.out.println("executorCount=" + count);
group.shutdownGracefully().sync();

在本机运行两次,第二次通过系统属性指定 4:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
CP=$(find ~/.m2/repository/io/netty -path "*4.2.19.Final*" -name "*.jar" | tr '\n' ':')
java -cp "out:$CP" demo.DefaultThreadsProbe
java -Dio.netty.eventLoopThreads=4 -cp "out:$CP" demo.DefaultThreadsProbe

本机输出:

io.netty.eventLoopThreads=null
executorCount=20
io.netty.eventLoopThreads=4
executorCount=4

10 核机器得到 20,正好是核数的两倍。io.netty.eventLoopThreads 只在类初始化时读一次,必须在第一次创建 NioEventLoopGroup 之前设置;运行中修改系统属性不会改变已经创建的线程池。

20 是 Netty 的默认值,不是 linkd-v2 的目标值。worker 线程数要按 handler 的行为定:如果 handler 只做解码、查表、入队,4 到 8 条线程通常就能压满一个 10 核节点;如果 handler 里有少量无法避免的阻塞,线程数需要覆盖阻塞比例,或者先把阻塞移出去(3.3)。线程开得过多,上下文切换和缓存失效会吃掉收益;开得过少,一条慢连接会拖住同一 EventLoop 上的其他连接。没有压测数据时,先用默认值跑基线,再按 14.1 的方法调整,不要凭感觉写死一个数字。

一个 Channel 终身绑定一个 EventLoop

Netty 的 Channel 在注册到 EventLoopGroup 时,会从组里选一个 EventLoop,之后这个 Channel 的所有事件都在那条线程上执行。channel.eventLoop() 返回的就是它,ctx.executor() 在没有额外指定执行器时也返回同一个对象。

下面这段探针在服务端每条连接建立时记录 EventLoop 的身份和当前线程名,再用四个客户端连上来:

.childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) {
        ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
            @Override
            public void channelActive(ChannelHandlerContext ctx) {
                System.out.println(ctx.channel().remoteAddress()
                        + " -> " + Thread.currentThread().getName()
                        + " loop=" + Integer.toHexString(
                                System.identityHashCode(ctx.channel().eventLoop())));
            }
        });
    }
});

boss 和 worker 分别用 1 和 2 条线程,四个客户端依次连接,本机输出:

worker executors = 2
NioEventLoop@12bc6874 <- [/127.0.0.1:63192 -> nioEventLoopGroup-3-1, /127.0.0.1:63194 -> nioEventLoopGroup-3-1]
NioEventLoop@de0a01f <- [/127.0.0.1:63193 -> nioEventLoopGroup-3-2, /127.0.0.1:63195 -> nioEventLoopGroup-3-2]
group.next() x8 = 12bc6874 de0a01f 12bc6874 de0a01f 12bc6874 de0a01f 12bc6874 de0a01f

两条连接落在 nioEventLoopGroup-3-1,两条落在 -3-2,每个 Channel 只出现一次,没有连接在两个 EventLoop 之间跳动。这意味着连接级别的状态——半包缓冲、协议解析状态、会话属性、写队列——只被一条线程访问,普通字段不需要 volatile,HashMap 也不需要换成 ConcurrentHashMap。这就是 4.2 会展开的 handler 状态模型的基础。

绑定发生在 register 阶段,不是 bind 或 connect 返回之后。服务端连接由 boss 线程 accept 出来后,在 ServerBootstrapAcceptor 里调用 childGroup.register(child),register 内部通过 next() 选出 EventLoop 并写入 Channel。客户端在 Bootstrap.connect() 时完成同样的注册。绑定之后不会因为一次读写失败、一次 close() 或一次重连而改变;重连创建的是新的 Channel,会重新选一次 EventLoop。

next() 的分配与线程命名

EventLoopGroup.next() 按轮询选择执行器。上面输出里的 group.next() x8 是同一个 worker 组连续调用八次的结果,两个身份哈希交替出现,说明选择器在两个 EventLoop 之间轮流分配。

服务端连接的分配看起来是「一条连接一个线程」,实际由 accept 顺序决定:boss 线程每接受一条连接就调用一次 next(),所以连续连接会均匀落到不同 worker 上。客户端并发发起大量连接时,next() 仍按调用顺序轮询,但多个线程同时调用会让顺序交织,不能假设第 N 条连接一定落在第 N mod 线程数 个 EventLoop 上。

线程名的格式是 nioEventLoopGroup-<poolId>-<index>。本机输出里的 nioEventLoopGroup-3-1 和 -3-2,中间的 3 不是 worker 线程序号,而是进程内第 3 个创建的 EventLoopGroup:boss 是第 2 个,客户端组是第 4 个。这个编号随启动顺序变化,每次进程重启都可能不同,日志里可以用来区分线程,但不能用来做路由、限流或监控判断。

EventLoopGroup 的线程数在创建时固定,运行中不能扩缩容。需要调整只能重建整个组,而重建意味着关闭所有注册在上面的 Channel。因此容量规划要在启动参数里完成,不要在运行中尝试动态改线程数。

多个 Channel 共享 EventLoop 的后果

一个 EventLoop 可以服务成百上千条连接,这不是异常,而是设计目标。共享的代价是串行:同一个 EventLoop 上的所有 Channel 共享一条线程,任何一条连接上的 handler 阻塞,其他连接的读写和定时任务都要排队。

下面这个探针让两条连接分别发送 slow 和 fast,服务端收到 slow 后睡眠 300 毫秒再回复,fast 立即回复。先用 1 条 worker 线程,再用 2 条:

if (text.equals("slow")) {
    Thread.sleep(300);
}
ctx.writeAndFlush(Unpooled.copiedBuffer("ok:" + text, CharsetUtil.UTF_8));

本机输出,workers=1:

 254 ms  server read slow on nioEventLoopGroup-3-1
 569 ms  server read fast on nioEventLoopGroup-3-1
 570 ms  client slow got response on nioEventLoopGroup-4-1
 570 ms  client fast got response on nioEventLoopGroup-4-1

两条连接落在同一个 EventLoop 上,fast 在客户端只晚了 20 毫秒发出,服务端却直到 569 毫秒才读到它,比 slow 晚了 315 毫秒。fast 的响应必须等 slow 的 300 毫秒睡眠结束,因为两者在同一条线程上排队。

同样代码换成 workers=2:

 227 ms  server read slow on nioEventLoopGroup-3-1
 227 ms  server read fast on nioEventLoopGroup-3-2
 236 ms  client fast got response on nioEventLoopGroup-4-1
 541 ms  client slow got response on nioEventLoopGroup-4-1

fast 落在另一条 EventLoop 上,236 毫秒就拿到响应,不再等 slow 的 300 毫秒。两条连接是否互相影响,取决于它们是否落在同一个 EventLoop,而不是取决于它们是否在同一个进程。

这解释了 3.1 标题里的「线程归属」为什么重要:连接的状态不需要锁,代价是所有操作串行。短任务下串行是优势,省掉了锁和线程切换;长任务下串行变成队头阻塞。判断一条连接是否会影响另一条,看 channel.eventLoop() 是否相同,而不是看端口或业务类型。

在 linkd-v2 里落地

LinkdV2Main 持有两个组,启动时绑定 9090 和 9093,关闭时先关监听 Channel,再关组:

package com.plume.linkd.v2.app;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import com.plume.linkd.v2.server.ConnectionRegistry;
import com.plume.linkd.v2.server.LinkdHttpHandler;

import java.util.ArrayList;
import java.util.List;

public final class LinkdV2Main {
    private final EventLoopGroup boss = new NioEventLoopGroup(1);
    private final EventLoopGroup worker = new NioEventLoopGroup(
            Integer.getInteger("linkd.ioThreads", 4));
    private final ConnectionRegistry registry = new ConnectionRegistry();
    private final List<Channel> servers = new ArrayList<>();

    public void start() throws InterruptedException {
        servers.add(bind(9090));
        servers.add(bind(9093));
    }

    private Channel bind(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 LinkdHttpHandler(registry));
                    }
                })
                .bind(port).sync().channel();
    }

    public void shutdown() {
        for (Channel server : servers) {
            server.close();
        }
        boss.shutdownGracefully();
        worker.shutdownGracefully();
    }
}

Integer.getInteger("linkd.ioThreads", 4) 把 worker 线程数做成启动参数,默认 4。每绑定一个端口都会创建一个 server Channel,它注册在 boss 组上;每个新连接创建一个子 Channel,注册在 worker 组上。两个端口共用同一组 worker,所以 9090 上的一条慢请求可能影响 9093 上一条连接,前提是它们落在同一个 EventLoop。要彻底隔离两个端口,可以给 9093 单独建一组 worker,代价是线程数增加、跨组共享状态需要额外同步。

ConnectionRegistry 的 add 和 remove 都在 Channel 的 EventLoop 上被调用,成员表本身仍然需要并发安全,因为不同连接的 EventLoop 不同。广播时把写操作交回每个 Channel 自己的 EventLoop,避免在调用线程上直接碰 Channel:

public void broadcast(ByteBuf frame) {
    for (Channel ch : channels) {
        ByteBuf copy = frame.retainedDuplicate();
        ch.eventLoop().execute(() -> ch.writeAndFlush(copy));
    }
}

这里 frame 的引用计数由调用方管理,每个 Channel 拿一份 retainedDuplicate(),写失败时由写操作释放。第 5 章会展开引用计数;本节只关心线程:writeAndFlush 本身是线程安全的,但把写操作排到目标 EventLoop 上,能让「谁拥有这条连接的状态」保持清晰,也方便在任务里做限流和顺序控制。

关闭顺序也有讲究。server.close() 先停止接收新连接,shutdownGracefully() 再让两个组进入关闭流程。默认的 quiet period 是 2 秒,期间不再接受新任务;从 main 线程调用时可以 sync() 等待,但在 EventLoop 线程里不能等,原因见下一节。

常见错误与边界

错误一:不设线程数,直接 new NioEventLoopGroup()。 在 10 核机器上是 20 条线程,在 64 核机器上是 128 条。线程本身不贵,但每个 EventLoop 都有自己的 selector、任务队列和定时器堆,线程过多会让缓存局部性变差,也让线程 dump 难以阅读。linkd-v2 用 linkd.ioThreads 显式控制,默认 4。

错误二:以为调用 next() 会把 Channel 固定到某个 EventLoop。 next() 只是从组里选一个执行器,真正绑定发生在 register。注册之后再次调用 next() 得到的是下一个执行器,不会影响已经注册的 Channel。判断归属用 channel.eventLoop(),不要自己保存 next() 的返回值当映射。

错误三:在 EventLoop 线程里调用 group.shutdownGracefully().sync()。 本机探针在事件循环任务里执行这句,进程一直不退出,只能强制结束。原因是组的终止 Future 不属于当前 EventLoop,Netty 的死锁检测看不到「我在等一个需要我自己推进的关闭」,于是 sync() 把线程挂住,关闭流程永远等不到这条线程退出。关闭要从 main 线程发起,或者用 addListener 异步收尾。

错误四:在业务线程里直接调用 handler 的实例方法。

// 反例:业务线程直接修改连接状态
sessionHandler.touch(channel);   // 与 EventLoop 线程并发修改同一个 Map

handler 的字段默认只在它所属的 EventLoop 上被访问,跨线程调用就破坏了这个前提。正确做法是把动作提交回去:

channel.eventLoop().execute(() -> sessionHandler.touch(channel));

错误五:解析线程名做业务判断。 nioEventLoopGroup-3-1 里的 3 是组创建序号,测试环境、生产环境、重启前后都可能不同。线程名适合放进日志帮助定位,不适合作为分片键或监控维度。

边界:boss 线程不处理业务。 它只负责 accept,连接建立后的所有事件都在 worker 上。不要在 boss 的 handler 里做任何与连接内容相关的工作,ServerBootstrap 也没有给 boss 配置子 pipeline 的入口。

小结

  • boss 只负责 accept,通常 1 条线程;worker 负责已建立连接的全部 IO,默认线程数是 availableProcessors() * 2,本机 10 核为 20。
  • io.netty.eventLoopThreads 在类初始化时读取,必须在创建第一个 EventLoopGroup 之前设置;运行中修改无效。
  • 一个 Channel 注册后终身绑定一个 EventLoop,连接的半包缓冲、会话状态和写队列只被一条线程访问,因此不需要额外加锁。
  • next() 按轮询分配执行器,绑定发生在 register;线程名里的组序号随创建顺序变化,不能用于业务判断。
  • 多个 Channel 共享 EventLoop 时,一条连接上的阻塞会推迟同一 EventLoop 上其他连接的读写和定时任务;本机 1 条 worker 线程下,300 毫秒的 handler 让另一条连接的读事件晚了 315 毫秒。
  • 关闭 EventLoopGroup 要从外部线程发起;在 EventLoop 线程里 sync() 等待组关闭会挂住线程。

线程归属解决了「谁来跑」,但没有回答「跑的任务从哪来、排在哪、会不会挡住定时器」。下一节把 execute、schedule 和 scheduleAtFixedRate 放到同一条 EventLoop 上,看任务顺序、阻塞代价,以及重连和心跳应该怎么写。

阅读导航:上一节:2.3 ChannelOption 与 Socket 参数 · 下一节:3.2 任务队列与定时任务 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发