2230 字
约 7 分钟
17
S02-X02 · Netty 与 Reactor:主从多线程模型怎么跑起来的

一个 Netty 服务端,bossGroup 给 1 个线程、workerGroup 给 CPU 核数 × 2,几万连接就能稳住。这不是玄学,是 Reactor 主从模型在 IO 多路复用上的固定收益:accept 与读写分离,每个 Channel 的事件永远在同一线程排队。

下面按"模型 → 线程绑定 → 数据流 → 编解码 → 线程安全 → 内存"拆,最后给骨架与泄漏排查手法。


1. BIO / NIO / AIO 与 Reactor 三种形态

BIO 的 accept 与 read 都阻塞,一条连接占一个线程;NIO 把等待交给 selector,一条连接只是一个 Channel 对象;AIO 由内核完成回调,Netty 4 虽有 AioEventLoopGroup 但极少用。Reactor 是在 NIO 之上回答"谁等 IO、谁干活",三种形态:

形态 结构 瓶颈
单线程 Reactor 一个线程做 accept + 读写 + 业务 业务一慢,全卡
多线程 Reactor 一个线程 accept,一组线程读写 accept 线程仍可能成瓶颈
主从 Reactor 一组线程 accept,另一组线程读写 Netty 的默认形态

Netty 用 EventLoopGroup 表达"一组 Reactor 线程",ServerBootstrap.group(boss, worker) 就是主从形态的直接映射。


2. EventLoopGroup:boss 只 accept,worker 只读写

完整的主从配置在第 7 节的骨架里:boss 给 1 个线程、worker 给默认的 2 × 核数,b.group(boss, worker) 这一行就是主从形态。

角色 负责的事件 线程数建议
boss OP_ACCEPT,拿到 SocketChannel 后注册到 worker 1
worker OP_READ / OP_WRITE / 连接关闭 CPU 核数 × 2 起步
业务线程池 耗时逻辑(DB、RPC) 单独一组,不占 EventLoop

new NioEventLoopGroup() 默认线程数是 max(1, 可用核数 × 2):EventLoop 既跑 IO 又跑任务队列,留一倍冗余。boss 给多个线程不会更快——bind 之后只有一个 ServerSocketChannel,抢同一个 selector 的 accept 事件收益为零。


3. NioEventLoop:一个线程绑多个 Channel,天然无锁

NioEventLoopGroup 内部是一个 EventLoop 数组,next() 按轮询挑一个(EventExecutorChooser,2 的幂时用位运算)。Channel 在 register 时绑定某个 EventLoop,此后整个生命周期都不换。

三条推论:① 同一 Channel 的读、写、关闭事件在同一线程串行执行,Handler 内部读写 Channel 状态不用加锁;② 不同 Channel 可能落在同一 EventLoop 上,一个慢 Handler 会拖累同组连接;③ 因此不要在 EventLoop 里做阻塞调用(JDBC、Thread.sleep、同步 HTTP),要阻塞就扔业务线程池。

EventLoop 的循环体固定三段:select → processSelectedKeys → runAllTasks。第三段正是 eventLoop().execute(...) 能生效的原因。


4. ChannelPipeline:入站正着走,出站倒着走

pipeline 是一条双向链表,每个节点是 ChannelHandlerContext,包着一个 Handler。

方向 触发源 传播顺序 典型 Handler
入站(inbound) 对端发来数据、连接建立/断开 head → tail,即 addLast 的添加顺序 解码器、业务处理器
出站(outbound) 本地调用 write / flush / close tail → head,与添加顺序相反 编码器、超时、流量整形

关键区别在这一行:

ctx.write(msg);              // 从当前 handler 往前(向 head)找下一个 outbound handler
ctx.channel().write(msg);    // 从 tail 开始,走完整条出站链

自定义 Handler 里用 ctx.write 是常见做法,但链上位于它之前(更靠 tail)的编码器会被跳过——"数据没编码就发出去"的 bug 都出在这里。addLast 的顺序要同时满足两个方向:decoder → business → encoder。


5. 粘包与半包:ByteToMessageDecoder 的三种解法

TCP 是字节流,一次 read 可能拿到半个包,也可能三个包黏在一起。ByteToMessageDecoder 内部维护 cumulation buffer,不够一个完整消息就等下次。

方案 类 适用 关键参数
定长 FixedLengthFrameDecoder 协议帧长度固定 帧长
长度字段 LengthFieldBasedFrameDecoder 自定义二进制协议 偏移、长度字段宽度、修正值、剥离字节数
分隔符 DelimiterBasedFrameDecoder / LineBasedFrameDecoder 文本协议、JSON 一行一条 最大帧长、分隔符

长度字段方案最常用,五个参数必须一次配对:

// 协议:[4 字节长度][body],长度字段值 = body 长度
new LengthFieldBasedFrameDecoder(
        1 << 20, 0, 4, 0, 4);
//  maxFrameLength, lengthFieldOffset, lengthFieldLength,
//  lengthAdjustment, initialBytesToStrip

协议若是 [4 字节长度(含头)][body],lengthAdjustment 写 -4;不想在业务层看到长度头,initialBytesToStrip 写 4。参数配错的表现只有两种:一直等不到完整帧,或 body 错位一个包。

文本协议更简单:new DelimiterBasedFrameDecoder(8192, Delimiters.lineDelimiter())。

ByteToMessageDecoder 有两个约束:不能标 @Sharable(持有累积缓冲与解析状态,共享必然串数据);子类 decode 往 out 里 add 的 ByteBuf 由框架释放,但你 readBytes 出来的副本要自己 release。


6. @Sharable 与线程安全:什么时候能共享

Handler 类型 能否 @Sharable 原因
无状态编解码器(如 StringEncoder) 可以 不持有每连接状态
ByteToMessageDecoder 子类 不可以 持有累积缓冲
IdleStateHandler 可以 状态在 Channel 属性里
标了 @Sharable 却持有可变状态,症状是"偶发串包、A 的响应发给了 B",排查时在 channelRead 里断言 Channel 等于 ctx.channel()。

channel.writeAndFlush() 本身线程安全,内部把任务投递到该 Channel 的 EventLoop;但组合操作(先判断再写、先计数再写)必须整体放进 eventLoop().execute(...):

channel.eventLoop().execute(() -> {
    if (channel.isActive()) {          // 判断与写在同一个线程,无竞态
        counter++;
        channel.writeAndFlush(resp);
    }
});

7. 骨架:一份能跑的 ServerBootstrap

public final class NettyServer {
    public static void main(String[] args) throws InterruptedException {
        EventLoopGroup boss = new NioEventLoopGroup(1);
        EventLoopGroup worker = new NioEventLoopGroup();
        ServerBootstrap b = new ServerBootstrap();
        b.group(boss, worker)
         .channel(NioServerSocketChannel.class)
         .option(ChannelOption.SO_BACKLOG, 1024)
         .childOption(ChannelOption.TCP_NODELAY, true)
         .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
         .childHandler(new ChannelInitializer<SocketChannel>() {
             @Override protected void initChannel(SocketChannel ch) {
                 ch.pipeline()
                   .addLast(new LengthFieldBasedFrameDecoder(1 << 20, 0, 4, 0, 4))
                   .addLast(new LengthFieldPrepender(4))     // 出站:补长度头
                   .addLast(new BusinessHandler());
             }
         });
        try {
            b.bind(8080).sync().channel().closeFuture().sync();
        } finally {
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }
}

option 作用在 accept 队列,childOption 作用在每条 SocketChannel;shutdownGracefully() 要留两秒静默期。Linux 上换 EpollEventLoopGroup / EpollServerSocketChannel 能拿到边缘触发与更低的 syscall 开销,前提是 native epoll 包的 classifier 与架构匹配,否则报 UnsatisfiedLinkError。


8. 为什么用 FastThreadLocal 与池化 ByteBuf

FastThreadLocal:ThreadLocal 用哈希表,有冲突退化与 WeakReference 的 GC 压力;FastThreadLocal 给每个变量分配稳定下标,FastThreadLocalThread 上挂 Object[],取值就是一次数组访问。EventLoop 线程正是 FastThreadLocalThread,这条优化在 IO 线程上免费。

池化 ByteBuf:PooledByteBufAllocator 是 4.1 默认分配器,按 arena + chunk + page 复用内存,避免每次读写都 allocate。池化带来引用计数,规则是"谁 retain 谁 release"。

泄漏信号 含义 处理
LEAK: ByteBuf.release() was not called before it's garbage-collected 有 buf 没释放 看堆栈里的 addLast 位置,定位到 Handler
IllegalReferenceCountException: refCnt: 0 释放了两次,或释放后还在用 检查 retain 配对
内存持续上涨但堆 dump 正常 泄漏在堆外(direct memory) 用 -Dio.netty.leakDetection.level=paranoid 复现

排查命令与开关:

java -Dio.netty.leakDetection.level=paranoid \
     -Dio.netty.leakDetection.samplingInterval=1 -jar app.jar
jcmd <pid> VM.native_memory summary   # 需 -XX:NativeMemoryTracking=summary

级别 DISABLED / SIMPLE(默认,约 1% 采样)/ ADVANCED / PARANOID:前两级只报"哪里分配",PARANOID 每次分配都记录才能追到 channelRead 行号。生产先 SIMPLE。


9. 排错表

现象 原因 处理
业务收到半截 JSON 没加帧解码器,直接读裸流 加 LineBasedFrameDecoder 或长度字段方案
偶发"响应发错人" Handler 标了 @Sharable 却持有每连接状态 去掉注解,状态挪到 Channel.attr()
写出去的数据没编码 用了 ctx.write 跳过了编码器 改 ctx.channel().write 或调整链顺序
LEAK: ByteBuf.release() 报警 手动 readBytes 的副本没释放 配对 release,或用 SimpleChannelInboundHandler

10. 边界:什么时候不该用 Netty

场景 更合适的选择 理由
纯 HTTP/1.1 + JSON 的常规服务 Spring MVC / WebFlux Netty 的收益在长连接与二进制协议
协议极简、连接数很少 原生 SocketChannel 引入框架只增加调试成本

一句话:Netty 换的不是"更快",是"用固定线程数扛更多连接"。


11. 自测

  1. boss 组给 4 个线程为什么不会提升 accept 吞吐?
  2. 同一个 Channel 的两个 Handler 之间共享一个计数器,需要加锁吗,为什么?
  3. ctx.write 与 ctx.channel().write 在出站链上分别从哪里开始传播?
  4. LengthFieldBasedFrameDecoder 的 lengthAdjustment 在什么协议下要写负数?
  5. ByteToMessageDecoder 为什么不能标 @Sharable?
  6. SIMPLE 与 PARANOID 泄漏检测级别的差别在哪,生产上该先用哪个?
S02-X02 · Netty 与 Reactor:主从多线程模型怎么跑起来的
https://www.clxhxhhr.top/posts/5676/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。