EventLoopGroup · EventLoop · Channel · Selector · 任务队列 · ChannelPipeline · ChannelHandler · ChannelHandlerContext · 入站 / 出站 · ByteBuf · readerIndex / writerIndex · 引用计数 · 池化 · 堆外内存 · LengthFieldBasedFrameDecoder

Netty:EventLoop、Pipeline、ByteBuf。一条连接一辈子归一个线程,字节沿一串处理器流过,缓冲区用完要自己还

Netty 把 JDK NIO 里最容易写错的三件事做成了固定结构:EventLoop 是一个线程守着一个 Selector,连接一建好就终身绑在某个 EventLoop 上,所以一条连接上的事件不用加锁、天然有序;Pipeline 是每条连接一串处理器,入站从头走到尾,出站从尾走回头,拆帧、解码、业务、编码各占一格;ByteBuf 是读写两个下标的字节缓冲,默认在堆外、从池子里借,用引用计数决定什么时候还。精华是一句话:EventLoop 线程只做快的事。在它上面做一次 200 ms 的查库,同一个线程上的所有连接都陪着等 200 ms;借了 ByteBuf 不还,堆外内存一点点漏光,堆的监控却看不出来。

全景:订单服务调库存服务的一次 RPC · 第 6、8、9 站地图:J04 · Netty:EventLoop、Pipeline、ByteBuf前置:网络 IO 模型(Reactor)· TCP 与粘包

①为什么学:gRPC 底下那一层,出事时你得看得懂

在全景第 6 站,订单服务的下单线程不自己写 socket,而是把两个 HTTP/2 帧交给一个叫 grpc-default-worker-ELG-3-2 的线程;第 8 站,库存服务那边也是这样一个线程被 epoll 叫醒,拆帧、解码,第 9 站再切到业务线程扣库存。这些线程、这串处理器、装字节的缓冲区,都是 Netty。gRPC Java、Dubbo、Spring WebFlux、Lettuce 和 Redisson(Redis 客户端),传输层都是它。线上那几类怪问题:一个慢接口拖慢所有接口、堆外内存涨到被容器杀掉、偶发解析乱码,根子都在这三样东西上。

订单服务 下单线程 stub.reserve(req) 阻塞等结果 交出任务,不碰 socket ⑥ EventLoop 线程 Pipeline 出站:编码成帧 → 放进 ByteBuf → write + flush 库存服务 ⑨ 业务线程池 扣库存(可能等行锁) 慢活儿交出去 ⑧ EventLoop 线程 epoll 叫醒 → 读进 ByteBuf → Pipeline 入站:拆帧、解码 ⑦ TCP:一串没有边界的字节 …0000000c 08b74a 1058 1802 0000000c… 谁来等 IO?→ EventLoop 字节怎么变成一条条消息?→ Pipeline 字节放在哪、谁来回收?→ ByteBuf
全景第 6~9 站放大:两端各有 EventLoop 线程负责所有 IO,业务线程只在边上交接。中间三行就是这一页要回答的三个问题。

需求(假设场景,和全景一致):工作里用 gRPC 调服务,平时不直接写 Netty;但要能看懂线程名、调对线程池、排查堆外内存,偶尔要给内部协议写一个 Netty 服务端。所以⑤给一个能抄的最小服务,⑥先讲最容易踩的两个坑。

②老办法:BIO 撑不住连接数,手写 NIO 又处处是坑

网络 IO 模型那页已经推出来:连接多、大多在发呆时,该让一个线程用 epoll 守着成千上万条连接(Reactor)。JDK 从 1.4 就有 Selector,那为什么还要 Netty?把⑤那个“库存预占”服务用 JDK NIO 手写一遍就知道了。

老办法 A:一条连接一个线程(BIO)

ServerSocket ss = new ServerSocket(9090);
while (true) {
  Socket s = ss.accept();
  pool.submit(() -> serve(s));   // 读、算、写都在这个线程
}
  • 写起来最简单,readInt + readFully 就把帧拆好了
  • 线程大多数时间在等对方发数据。1 万条长连接就要 1 万个线程,每个栈 1 MB 起,切换也贵
  • gRPC、HTTP/2 都是长连接 + 多路复用,连接常驻,正好撞在这个短板上

老办法 B:直接用 JDK NIO 的 Selector

while (true) {
  sel.select();
  Iterator<SelectionKey> it = sel.selectedKeys().iterator();
  while (it.hasNext()) {
    SelectionKey k = it.next(); it.remove();  // 忘了 remove 就重复处理
    ByteBuffer buf = (ByteBuffer) k.attachment();
    int n = ((SocketChannel) k.channel()).read(buf);
    buf.flip();                      // 忘了 flip,一个字节都读不到
    while (buf.remaining() >= 4) { … 自己拼帧、处理半条 … }
    buf.compact();
    reserve(…);                      // 慢的业务也在这?整个 selector 停住
    // 一次没写完:挂 OP_WRITE、自己排队,下次接着写
  }
}
  • 粘包拆包自己拼,ByteBuffer 只有一个 position,读写切换靠 flip / compact,一错就是乱码
  • 写不完要自己注册 OP_WRITE、维护待写队列;对方读得慢时这个队列还会无限长
  • 线程怎么分:几个 Selector?业务放哪个线程?别的线程想往连接里写怎么办?全靠自己设计
  • 还有 JDK 自己的坑:Linux 上 select() 偶尔没有事件也立刻返回(著名的 epoll 空轮询),CPU 打满。Netty 数到连续 512 次空转就重建 Selector 绕开它

不懂精华时的典型误用:把 Netty 当成“更快的 Socket”,在 channelRead 里直接查库、调别的服务、加锁等待。单测一条连接毫无问题,上线后一个慢请求就让同一个 EventLoop 上的几十条连接一起变慢。⑥ 坑 1 实测:一个 200 ms 的慢请求,把另一条连接上 0.1 ms 的快请求拖成了 191 ms。

③选谁:大多数时候你用的是“建在 Netty 上的东西”

同样是“少量线程守很多连接”,Java 里有四条路。它们不是互相替代,而是一层叠一层。

手写 JDK NIO

只有 Selector、ByteBuffer、SocketChannel,其余全自己写。

适合:学原理、极简的单一用途工具。生产协议别这么干。

Netty

线程模型、处理器链、池化缓冲、几十种现成编解码器(长度帧、HTTP/1/2、WebSocket……),Linux 上还能换 epoll 本地传输。

适合:自定义协议、网关、IM、写中间件的客户端或服务端。

Reactor Netty · Vert.x

在 Netty 外面再包一层响应式或事件总线的编程模型。Spring WebFlux、Spring Cloud Gateway 用的是前者。

适合:整个应用都愿意写成非阻塞风格。

gRPC · Dubbo · Tomcat NIO

直接给你 RPC 或 Servlet。gRPC Java 和 Dubbo 的默认传输就是 Netty;Tomcat 的 NIO 连接器是自己写的同类结构。

适合:写业务服务。出事时看到的线程和内存,仍是下面那层的。

选择规则:写业务调用,用 gRPC 或 Dubbo,不要自己拿 Netty 造 RPC;要做的东西本身就是“协议”或“网络组件”,用 Netty。无论哪种,都要懂 Netty 的三样东西,因为 gRPC 的线程名、慢调用、堆外内存问题,全是 Netty 层的现象。

本页学 Netty 4.2(4.2.19.Final,当前主线,最低 JDK 8)。和 4.1 的差别放到 ⑥ 第三圈,API 绝大部分相同。

④原理:三样东西,各管一件事

EventLoop 管“哪个线程干活”,Pipeline 管“字节经过哪些步骤”,ByteBuf 管“字节放在哪、什么时候还”。先看各自怎么工作,再看为什么非这样设计不可。

先知道这些
  • Selector / epoll:一个线程向内核登记“这些连接有数据了叫我”,然后一次等很多条连接。这就是 Reactor,网络 IO 模型讲过。
  • TCP 是字节流:一次 read 可能拿到半条或三条消息,要靠长度前缀切开。TCP 与粘包讲过。
  • 堆外内存(direct memory):不在 Java 堆里、GC 不会挪动的内存。JDK 的 ByteBuffer.allocateDirect 就是它,上限由 -XX:MaxDirectMemorySize 管,不算进 -Xmx。

④-1 EventLoop:一个线程、一个 Selector、一个任务队列,连接终身绑定

bossGroup(1 线程) 只做 accept 新连接交给 worker 注册: group.next() 轮询 挑一个 EventLoop workerGroup:默认 2 × CPU 核数 个 EventLoop(-Dio.netty.eventLoopThreads 可改) EventLoop-1 = 1 个线程 + 1 个 Selector + 1 个任务队列,永远在转这个圈 ① select:等 IO 事件 有任务排着时不睡 ② 处理就绪的连接 读进 ByteBuf,沿 Pipeline 跑处理器 ③ 跑任务队列 别的线程交来的 write、定时任务 连接 A 连接 B 连接 C 这三条连接的所有事件,一辈子只由这个线程处理 EventLoop-2 另一个线程,同样的圈,管另一批连接 连接 D 连接 E 下单线程(不是 EventLoop) chA.writeAndFlush(req) inEventLoop()?否 → 包成任务, 放进 EventLoop-1 的队列,立刻返回 Future
连接一建好就注册到某个 EventLoop,之后读、写、关闭、定时任务全在这个线程上发生。别的线程想写,只能“投递”一个任务,由它在第 ③ 步顺手执行。这就是全景第 6 站看到的:帧日志每一行都出自 grpc-default-worker-ELG-3-2,不是下单线程。

④-2 Pipeline:每条连接一串处理器,入站往尾走,出站往头走

入站 channelRead:head → tail,只停在入站处理器上 跳过出站处理器 没人接的消息 head 连着 socket 读进来、写出去 入站 · frame LengthFieldBased- FrameDecoder:切出整帧 出站 · prepender LengthFieldPrepender 补上 4 字节长度 入站 · reserve ReserveHandler 扣库存,写响应 tail 释放没人处理的 消息,打 debug 日志 ctx.writeAndFlush(resp) 出站:从 reserve 当前位置往 head 走,只停在出站处理器上 ch.writeAndFlush(x):从 tail 开始,经过全部出站处理器 每条连接在 initChannel 里 new 出自己的一串处理器;处理器之间用 ctx.fireXxx(入站)和 ctx.write(出站)把事件交给下一个。
这就是 ⑤ 里库存服务的那条 Pipeline。处理器分两种:入站的(ChannelInboundHandler)处理“读到了、连上了、断开了”,出站的(ChannelOutboundHandler)处理“要写、要关”。位置决定谁能看到谁:prepender 必须放在 reserve 前面(更靠 head),因为 ctx.write 只往 head 方向找。

④-3 ByteBuf:两个下标,引用计数,从池子借

readXxx() 推 readerIndex → writeXxx() 推 writerIndex → 已读,可丢弃 可读 readableBytes() ⑤ 的一帧:readLong(sku) + readInt(count) 可写 writableBytes() 0 readerIndex writerIndex capacity 对比 JDK 的 ByteBuffer:只有一个 position,写完要 flip() 才能读、读完要 compact() 才能接着写,忘一次就读错。 alloc.buffer() 从池子借一块 refCnt = 1 retain() 多一个持有者 refCnt = 2 release() 一个持有者用完 refCnt = 1 release() 最后一个用完 refCnt = 0 → 还回池子 没人 release:GC 回收的只是 Java 里那个小对象,池子里那块堆外内存永远“借出中” 堆的监控一切正常,进程的总内存一直涨(⑥ 坑 2 实测:每 2000 条 1 KB 消息涨 2 MB)
规则只有一句:最后一个用它的人负责 release。入站消息交给下一个处理器(ctx.fireChannelRead(msg)),责任就跟着交出去;自己消化了,就自己 release。SimpleChannelInboundHandler 在 channelRead0 返回后替你 release;走到 tail 都没人处理的消息,tail 也会 release。出站时把 ByteBuf 交给 write,写完由 Netty release。

④-4 为什么这样设计

选中:一条连接终身一个线程

  • 解码器要在两次读之间记住“半条消息”。只有一个线程碰它,不用加锁
  • 同一条连接上的事件严格按到达顺序处理,响应不会乱序
  • 线程数固定(2 × 核),不随连接数涨;数据一直在同一个核的缓存里
  • 代价:这个线程上任何一次慢操作,同一线程上的所有连接一起等。所以“EventLoop 只做快的事”是这个设计的使用条件

放弃:每个 IO 事件扔进一个线程池

  • 连接 A 的第 1 次读和第 2 次读可能落在两个线程上,同时改同一份半包缓存,必须加锁
  • 第 2 个帧可能比第 1 个先处理完,响应顺序乱掉,协议得自己再排序
  • 每个事件都要跨线程交接一次,锁竞争和上下文切换吃掉大部分收益
  • 好处只有一个:慢处理器不会挡住别人。Netty 让你按需把“慢的那一格”单独挪走(⑥ 坑 1),而不是让所有处理器都付这个代价

决定胜负的约束:协议解码是有状态的(半包),而且要有序。有状态加有序,最便宜的做法就是同一时刻只有一个线程碰它,于是一条连接归一个线程。

选中:池化的堆外缓冲 + 引用计数

  • 内核读写 socket 要一个不会被 GC 挪动的地址。堆外内存直接给它,省掉一次拷贝
  • 堆外内存申请、释放都慢,所以从池子里借、用完还,热路径上几乎不分配
  • 还的时机由 release 精确决定,不等 GC。高吞吐下 GC 什么时候来根本没法指望
  • 代价:内存管理回到了“手动”时代,漏一次 release 就漏一块(⑥ 坑 2)

放弃:每次 new byte[],交给 GC

  • JDK 往 socket 写堆内数组时,要先把它拷进一块临时的堆外缓冲(GC 随时可能挪动堆对象)
  • 每秒几十万条消息,就是每秒几十万个短命数组,年轻代 GC 更频繁
  • 好处是不会漏。Netty 也保留了这条路:-Dio.netty.allocator.type=unpooled、或 Unpooled.buffer() 分配堆内缓冲

④-5 最少的 API

Netty 4.2 服务端骨架JDK 8+线程数、水位线、分配器放到 ⑥
EventLoopGroup boss    = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
EventLoopGroup workers = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); // 默认 2 × 核
new ServerBootstrap()
    .group(boss, workers)
    .channel(NioServerSocketChannel.class)
    .childHandler(new ChannelInitializer<SocketChannel>() {   // 每条新连接调一次
        protected void initChannel(SocketChannel ch) {
            ch.pipeline()
              .addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)) // 入站:拆帧
              .addLast(new LengthFieldPrepender(4))                       // 出站:补长度
              .addLast(new ReserveHandler());                             // 入站:业务
        }
    })
    .bind(9090).sync();
// 处理器:继承 SimpleChannelInboundHandler<ByteBuf>,实现 channelRead0,读完自动 release
// 写响应:ctx.writeAndFlush(ctx.alloc().buffer().writeByte(1).writeInt(left));

4.1 的写法是 new NioEventLoopGroup(),在 4.2 里还能用,只是标了 @Deprecated;网上大多数例子还是这种写法,看到别慌。

⑤跑起来:一个能用的库存预占服务

协议照着全景里 gRPC 的思路简化:每帧 4 字节长度 + 内容;请求是 skuId(long) + count(int),响应是 ok(byte) + 剩余库存(int)。客户端故意把 3 个请求塞进一次 write,看服务端能不能切开;顺便把每一行是哪个线程打的印出来。

pom.xmlNetty 4.2.19 · JDK 8+
<dependency>
  <groupId>io.netty</groupId>
  <artifactId>netty-codec-base</artifactId>   <!-- 带上 transport、buffer、common;长度帧编解码器在这里 -->
  <version>4.2.19.Final</version>
</dependency>
InventoryNetty.java(节选:服务端处理器 + 客户端发请求)完整代码 →
// 服务端 Pipeline 见 ④-5:frame → prepender → reserve。worker 线程 2 个。
static class ReserveHandler extends SimpleChannelInboundHandler<ByteBuf> {
    public void channelActive(ChannelHandlerContext ctx) {
        log("reserve", "新连接 " + ctx.channel().remoteAddress() + " 绑定到这个线程");
    }
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf frame) {  // frame 已去掉长度头
        log("reserve", "readerIndex=" + frame.readerIndex() + " writerIndex=" + frame.writerIndex());
        long sku = frame.readLong();
        int count = frame.readInt();
        boolean[] hit = {false};                     // 多条连接可能在不同 worker 上,扣减要原子
        int left = STOCK.compute(sku, (k, v) -> {
            int cur = v == null ? 0 : v;
            if (cur >= count) { hit[0] = true; return cur - count; }
            return cur;
        });
        log("reserve", "预占 sku=" + sku + " x" + count + " → " + (hit[0] ? "成功" : "库存不够"));
        ctx.writeAndFlush(ctx.alloc().buffer(5).writeByte(hit[0] ? 1 : 0).writeInt(left));
    }                                                // 返回后父类 release(frame)
}

// 客户端(订单服务):库存 5 件,连发 3 个“预占 2 件”,塞进同一个 ByteBuf 一次写出
ByteBuf out = ch.alloc().buffer();
for (int count : new int[]{2, 2, 2}) out.writeInt(12).writeLong(88L).writeInt(count);
ch.writeAndFlush(out);    // main 线程调用 → 变成任务交给这条连接的 EventLoop
运行JDK 17(按 JDK 8 编译)
cd study-labs/java
mvn -q -pl network/netty-eventloop-pipeline-bytebuf compile exec:java \
  -Dexec.mainClass=labs.network.netty_eventloop_pipeline_bytebuf.InventoryNetty

实测 本机 2 核 ARM,JDK 17.0.20,Netty 4.2.19,原样输出(第一列是线程名):

main                       main     库存服务监听 9090,worker 线程数 2
multiThreadIoEventLoopGroup-3-1 reserve  新连接 /127.0.0.1:47758 绑定到这个线程
main                       main     一次写出 48 字节(3 个请求),AdaptiveByteBuf direct=true
multiThreadIoEventLoopGroup-3-1 reserve  readerIndex=0 writerIndex=12
multiThreadIoEventLoopGroup-3-1 reserve  预占 sku=88 x2 → 成功
multiThreadIoEventLoopGroup-3-1 reserve  readerIndex=0 writerIndex=12
multiThreadIoEventLoopGroup-3-1 reserve  预占 sku=88 x2 → 成功
multiThreadIoEventLoopGroup-3-1 reserve  readerIndex=0 writerIndex=12
multiThreadIoEventLoopGroup-4-1 client   收到响应 ok=true 剩余=3
multiThreadIoEventLoopGroup-4-1 client   收到响应 ok=true 剩余=1
multiThreadIoEventLoopGroup-3-1 reserve  预占 sku=88 x2 → 库存不够
multiThreadIoEventLoopGroup-4-1 client   收到响应 ok=false 剩余=1

看三件事。① 线程:这条连接的所有事件都在 -3-1 上(④-1 终身绑定),客户端的回调全在自己的 -4-1 上,main 线程只负责交任务。② 拆帧:48 字节一次到达,LengthFieldBasedFrameDecoder 切成 3 帧,每帧去掉长度头后正好 12 字节(④-2)。③ ByteBuf:默认分配器在 4.2 是 AdaptiveByteBufAllocator,给的是堆外缓冲(④-3)。

到这里已经能交差了

一个能处理粘包、能并发接多条连接、内存会自动归还的 TCP 服务。内部协议的小服务,照这个骨架换掉 ReserveHandler 就能用。

还没处理的细节

扣库存要是改成查数据库,会卡住 EventLoop(坑 1);自己继承 ChannelInboundHandlerAdapter 时谁来 release(坑 2);对方读得慢时写出去的数据堆在哪(坑 5);连接空闲多久算死(坑 6);进程退出时怎么关(规范)。

⑥细节:线程别堵、内存要还、帧要切

每个坑旁边的徽标指回 ④ 里推出它的那条原理。标“实测”的是本机跑过的,代码在 study-labs 同一个目录里。

第一圈

注意事项

坑 1一个慢接口,把同一台机器上的其他接口全拖慢← ④-1 连接终身绑一个线程实测
现象
监控里某个接口慢(查库 200 ms),奇怪的是几个本该 1 ms 的接口也时不时 200 ms,而且只有部分连接受影响。BlockingEventLoop:1 个 worker,A 连接发慢请求、10 ms 后 B 连接发快请求,B 的往返中位数 191 ms。
根源
处理器默认跑在 EventLoop 线程上。它在 sleep、等锁、查库时,这个线程上所有连接的读写都停着。
做法
慢的处理器单独放到业务线程上:pipeline.addLast(bizGroup, "biz", handler)(bizGroup = new DefaultEventExecutorGroup(16)),或在 channelRead0 里 pool.submit(...),做完在业务线程直接 ctx.writeAndFlush(Netty 会转交回 EventLoop)。同一实验改成 offload:B 的中位数 1 ms。gRPC 默认就把服务方法放到另一个线程池里跑,这就是全景第 9 站。
坑 2堆监控很平,进程内存一直涨,最后被容器杀掉← ④-3 引用计数实测
现象
LeakDemo leak:处理器继承 ChannelInboundHandlerAdapter,读完不 release。每收 2000 条 1 KB 消息,分配器的堆外用量涨 2 MB(2,048 → 10,112 KB);GC 之后日志里出现 LEAK: ByteBuf.release() was not called before it's garbage-collected,带最后几次访问的调用栈。
根源
池化的堆外缓冲靠 refCnt 归零才还回池子。GC 只回收外面的小对象,池里那块一直“借出中”;它也不在堆里,堆的监控看不见。
做法
入站处理器继承 SimpleChannelInboundHandler(fixed 模式:一直是 128 KB),或在 finally 里 ReferenceCountUtil.release(msg);要往后传就 ctx.fireChannelRead(msg),两者只能选一个。测试环境开 -Dio.netty.leakDetection.level=paranoid;生产默认 SIMPLE,每 128 个缓冲抽 1 个跟踪,漏得多迟早会报。容器内存为什么只看进程总数,见 JVM 在容器里。
坑 3压测时偶发解析乱码,或者一个请求让服务端分配几百 MB← ④-2 第一格是拆帧按源码
现象
本地一切正常,压测时业务处理器偶尔拿到半条或两条拼在一起的数据;或者对端发错一个长度字段,服务端尝试攒满 2 GB。
根源
EventLoop 每次 read 拿到的只是 TCP 字节流的一段(TCP 坑 1)。Pipeline 第一格不是拆帧器,后面每一格都在处理“随机切开的字节”。
做法
第一格放 LengthFieldBasedFrameDecoder(或行分隔、HTTP/2 等现成解码器),maxFrameLength 按协议上限设小,超了它会抛 TooLongFrameException 并丢弃这一帧。⑤ 里设的是 1024。
坑 4写了响应,对面什么都没收到,日志里也没有异常← ④-2 出站只往 head 走按源码
现象
在业务处理器里 ctx.write(pojo),客户端一直等到超时;服务端没有任何报错。
根源
ctx.write 只经过当前位置到 head 之间的出站处理器。编码器排在业务处理器后面(更靠 tail),就根本没经过它;对象一路到 head,被拒成 unsupported message type。这个失败记在返回的 ChannelFuture 上,没人看就没人知道。
做法
编码器放在业务处理器前面;不确定时用 ctx.channel().writeAndFlush(从 tail 走全程)。开发期给写操作挂 addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE),让失败进入 exceptionCaught。
坑 5对方读得慢,这边的堆外内存被写出缓冲撑爆← ④-1 write 只是入队按源码
现象
推送、下载、网关这类“写多读少”的服务,某个慢客户端连上来后内存直线上涨。
根源
write 只是把 ByteBuf 放进这条连接的出站缓冲(ChannelOutboundBuffer),内核发送缓冲满了就一直堆在 Netty 这边,没有上限。
做法
写之前看 channel.isWritable();出站缓冲超过高水位(默认 64 KB)它变成 false,降到低水位(32 KB)以下变回 true 并触发 channelWritabilityChanged,在那里继续写。水位线用 WRITE_BUFFER_WATER_MARK 调。
坑 6长连接放了一夜,第二天第一个请求卡到超时← TCP 坑 5按文档
现象
中间的 NAT、负载均衡器悄悄清掉了空闲连接,两端的 EventLoop 都没收到任何事件。
根源
不是 Netty 的机制问题:连接只是两端内核的账,没有包就没有事件,EventLoop 也就不会被叫醒。
做法
Pipeline 里加 IdleStateHandler(读空闲, 写空闲, 全空闲)(在 netty-handler 里),在 userEventTriggered 收到空闲事件时发心跳或关掉连接。gRPC 对应的是 keepalive 参数,全景第 3 站讲过默认是关的。

实验台:慢请求到底堵住了谁

模型:C 条连接按轮询绑到 E 个 EventLoop;前 k 条连接在 0 ms 各发一个慢请求(处理器阻塞 S ms),其余每条在 10 ms 发一个 0.1 ms 的快请求,另加 0.5 ms 往返。业务处理器放在哪,有三种选法。数字是模拟,第一个预设对上了坑 1 的实测(191 ms / 1 ms)。

先猜:4 个 EventLoop、100 条连接,只有 1 条连接发了 200 ms 的慢请求,业务直接跑在 EventLoop 上。另外 99 个快请求里,有多少个被拖到 50 ms 以上?

业务处理器放在哪

先选一个答案再运行,也可以直接改参数。

快请求 p50–
最慢的快请求–被谁挡住就等多久
被拖到 50 ms 以上–
车道(线程)–

第二圈

使用规范

Pipeline 第一格永远是拆帧器,并设 maxFrameLength

粘包拆包,以及一个错长度字段让服务端攒几百 MB(坑 3)

入站处理器默认继承 SimpleChannelInboundHandler;要往后传就 fireChannelRead,不传就 release,二选一

堆外内存泄漏(坑 2),以及 release 两次的 IllegalReferenceCountException

查库、调别的服务、等锁、同步写日志,都不进 EventLoop;业务线程写回时直接 ctx.writeAndFlush

一个慢请求拖慢一个线程上的所有连接(坑 1)

不在 EventLoop 线程里调 sync() / await()

自己等自己:Netty 直接抛 BlockingOperationException,否则就是死锁

处理器每条连接 new 一个;要共享同一个实例,必须无状态并标 @Sharable

多条连接串用同一份半包状态;没标就复用会报 is not a @Sharable handler

给线程组起名:new MultiThreadIoEventLoopGroup(0, new DefaultThreadFactory("inv-io"), …)

线程 dump 里一屏 multiThreadIoEventLoopGroup-3-1,分不清是哪个组件的

测试和预发环境开 -Dio.netty.leakDetection.level=paranoid

泄漏在上线前就被 LEAK 日志抓到,而不是靠容器 OOM 发现

进程退出时对每个 group 调 shutdownGracefully()(默认静默 2 s、最多等 15 s)

在途的请求和还没写出去的响应被直接丢掉

第三圈

优化和深处

线程数为什么默认 2 × 核,连接为什么会“偏科”

  • EventLoop 本该几乎不阻塞,理论上核数个就够;默认翻倍(MultithreadEventLoopGroup,可用 -Dio.netty.eventLoopThreads 改)是给偶发的短停顿留余量。
  • 新连接靠 group.next() 分配:线程数是 2 的幂时用位与(idx & (n-1)),否则取模,都是注册时的轮询,不看负载。
  • 所以长连接少、流量不均时(比如只有 3 条 gRPC 连接,其中一条特别忙),可能出现一个 EventLoop 打满、其他空闲,而且之后不会自动迁移。解决办法在上层:多建几条连接,或让客户端做连接级负载均衡。

4.1 → 4.2 改了什么

项4.14.2
建线程组NioEventLoopGroupMultiThreadIoEventLoopGroup + NioIoHandler;旧类还在,标 @Deprecated
默认分配器pooled(PooledByteBufAllocator)adaptive(AdaptiveByteBufAllocator);-Dio.netty.allocator.type=pooled 换回
编解码模块一个 netty-codec拆成 netty-codec-base、-compression、-protobuf 等;netty-codec 仍在,依赖这些子模块
io_uring孵化项目 io.netty.incubator正式模块 netty-transport-native-io_uring

依据:4.2.19 源码 ByteBufUtil(默认 "adaptive")、NioEventLoopGroup(@Deprecated)、Maven Central 上 4.2.19 的 pom。

epoll 本地传输:gRPC 默认用的那个

  • 把 NioIoHandler 换成 EpollIoHandler、channel 换成 EpollSocketChannel,就绕过 JDK 的 Selector,直接用 JNI 调 epoll,默认边缘触发,每次唤醒产生的垃圾更少,还能设 TCP_QUICKACK、SO_REUSEPORT 这类 JDK 不暴露的选项。
  • 全景里的线程名 grpc-default-worker-ELG 就是 grpc-netty-shaded 自带的 epoll 版;拿不到本地库时退回 NIO,线程名会变成 grpc-nio-worker-ELG。
  • 线程模型、Pipeline、ByteBuf 完全不变,这一页讲的都照样成立。

少拷贝:slice、CompositeByteBuf、FileRegion

  • LengthFieldBasedFrameDecoder 切出来的帧默认是从累积缓冲里 retainedSlice 出来的:共享底层内存,各有自己的下标和 refCnt 归属,不拷贝。
  • CompositeByteBuf 把“头 + 体”两个缓冲拼成逻辑上的一个,不用先 new 一个大数组再拷进去。
  • 发文件用 DefaultFileRegion,底层是 FileChannel.transferTo(Linux 上是 sendfile),数据不经过用户态。
下一圈

这一页没展开的

↺回到 ⑤:带着 ⑥ 改一遍

三道改造都在 ⑤ 的 InventoryNetty 上做,study-labs 里同目录的 BlockingEventLoop、LeakDemo 就是前两道的参考答案。

1. 把扣库存改成“查库”:在 channelRead0 里 Thread.sleep(200)

worker 改成 1 个线程,再开第二个客户端同时发。先猜第二个客户端多慢,再用 addLast(bizGroup, "reserve", …) 修好,比较前后(坑 1)。

2. 把 ReserveHandler 改成继承 ChannelInboundHandlerAdapter

开 -Dio.netty.leakDetection.level=paranoid 发 1 万个请求,在日志里找 LEAK 和它指向的那一行;再补上 finally 里的 release(坑 2)。

3. 客户端发一帧长度写成 100000 的请求

先猜服务端打印什么、连接会不会断;再把 maxFrameLength 改成 Integer.MAX_VALUE 想想会发生什么(坑 3)。

1. 用一句话说出 Netty 这三样东西的精华。

一条连接终身归一个 EventLoop 线程,所以不用锁、天然有序,但这个线程只能做快的事;字节沿 Pipeline 按“入站往尾、出站往头”流过一串处理器;ByteBuf 是池化的堆外缓冲,靠引用计数精确归还,谁最后用谁 release。

2. 已经有 JDK 的 Selector,为什么还需要 Netty?

Selector 只解决“一个线程等很多连接”。拆帧、读写指针、写不完的排队、线程怎么分工、跨线程写、epoll 空轮询,全要自己写,而且每一项都容易错(②)。Netty 把它们做成了固定结构:EventLoop、Pipeline、ByteBuf,再配上现成的编解码器。

3. “一个慢接口把别的接口也拖慢”,根源是哪条原理?为什么只有一部分连接受影响?

④-1:连接终身绑定一个 EventLoop,处理器默认就跑在这个线程上。慢处理器占着线程,同一个 EventLoop 上的连接都等;别的 EventLoop 上的连接不受影响,所以是“一部分”。4 个 EventLoop 时大约是 1/4(实验台第二个预设:24 个)。

4. 预测:把 ⑤ 里的 addLast 顺序改成 frame → reserve → prepender,客户端会看到什么?

reserve 用的是 ctx.writeAndFlush,只往 head 方向找出站处理器,而 prepender 现在在它后面,经过不到(④-2)。响应 5 字节 01 00 00 00 03 不带长度头直接发出。客户端的拆帧器把前 4 字节 01 00 00 00 当成长度,等于 16777216,超过 1024 的上限,抛 TooLongFrameException,客户端一直等不到响应。把 reserve 里改成 ctx.channel().writeAndFlush 就又好了,因为它从 tail 走全程。

5. 预测:⑤ 的 worker 从 2 个线程改成 1 个,输出会变吗?改成 8 个、同时开 4 个客户端呢?

1 个时不变:⑤ 只有一条连接,本来就只用了 -3-1 一个线程。8 个线程、4 条连接时,按注册轮询,4 条连接分别落在 -3-1 到 -3-4 上,每条连接自己的日志仍然只出自一个线程(④-1)。扣库存因此会真的并发,这也是 ⑤ 里用 compute 做原子扣减的原因。

6. 预测:在 channelRead0 里写 ctx.writeAndFlush(resp).sync(),想等响应真的写出去再继续,会怎样?

当前线程就是 EventLoop,而“写出去”这件事要由同一个线程在后面完成,自己等自己。Netty 检查到在 EventLoop 里阻塞等待,直接抛 BlockingOperationException(DefaultPromise.checkDeadLock)。要知道写完没有,用 addListener。

7. 放回全景:第 6、8、9 站前后各发生了什么?这一层出问题,整条链路会怎样?

第 6 站前是第 5 站:gRPC 把请求装成 HEADERS 和 DATA 两个 HTTP/2 帧;第 6 站下单线程把它们交给连接所属的 EventLoop,经 Pipeline 编码进 ByteBuf,write + flush 给内核(第 7 站 TCP)。第 8 站库存服务的 EventLoop 被 epoll 叫醒,读进 ByteBuf,拆出 HTTP/2 帧和 gRPC 消息;第 9 站交给业务线程池扣库存,响应再由 EventLoop 写回。这一层出事:库存服务若把扣库存直接放在 EventLoop 上做,一个等行锁的请求就让同一线程上所有调用卡住,订单服务那边是成片的 300 ms deadline 超时;自己写的处理器漏 release,库存服务堆外内存慢慢涨到被容器杀掉,订单服务看到的是连接突然断开、重连、一批调用失败。