本节目标:把卷一手写的五个组件逐个对上 Netty 的类型,并用一个真正跑通的 Echo 证明映射成立。
适用版本:JDK 25(Temurin 25.0.5+8)+ Netty 4.2.19.Final。
承接的问题
卷一 6.2 的 EventLoop 里有 Selector、任务队列和 processSelectedKeys;Connection 里有 SelectionKey、outbound 队列和 interestOps;Handler 只有 onReadable、onWritable、onClosed 三个回调;BufferPool 管直接缓冲区的借用与归还;6.3 之后还有一个手写的帧解码器负责半包。
这五个东西不是五个 API,而是五种责任。换成 Netty 后,责任还在,承载它们的类型变了。如果只记 ServerBootstrap 的写法,不知道 Connection.flush() 去了哪里,读到 9.1 的水位线时会接不上。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面的 Echo 输出来自这台机器的单次运行,未做统计。
一张对照表
| 责任 | 卷一 linkd | Netty | linkd-v2 里的落点 |
|---|---|---|---|
| 事件循环 | EventLoop(Selector + 任务队列) | EventLoopGroup / EventLoop | 共享的 NioEventLoopGroup |
| 连接 | Connection(key + outbound) | Channel(NioSocketChannel) | ConnectionRegistry 存 Channel |
| 读回调 | Handler.onReadable | ChannelInboundHandlerAdapter.channelRead | 编解码器与业务 handler |
| 写队列 | Connection.outbound + OP_WRITE | Channel 的出站缓冲 + ChannelFuture | ctx.writeAndFlush |
| 缓冲区 | BufferPool.acquire/release | ByteBufAllocator / ByteBuf | 默认 PooledByteBufAllocator |
| 半包累积 | 手写 FrameDecoder | ByteToMessageDecoder | LinkdFrameDecoder |
| 关闭通知 | closeHook / onClosed | channelInactive / closeFuture | 从 registry 移除连接 |
| 跨线程任务 | EventLoop.execute | EventLoop.execute / ctx.executor() | 业务线程提交写回 |
| 监听 | listen(port, acceptor) | ServerBootstrap.bind | LinkdV2Main |
| 线程归属检查 | register 里手写判断 | Channel 终身绑定一个 EventLoop | 不再需要手写 |
这张表不是严格的一对一。Connection 在 Netty 里被拆成了 Channel、ChannelPipeline、ChannelConfig 和它所属的 EventLoop;BufferPool 则被藏进了 ByteBufAllocator 的池化实现。找不到同名类不代表机制消失,只代表职责被重新分配了。
EventLoopGroup 是「一组 EventLoop」,EventLoop 是其中一个单线程执行器。一个 Channel 注册后终身绑定同一个 EventLoop,所以同一个连接上的事件天然串行,卷一在 register 里手写的线程检查也就不需要了。
三个关键替换
第一,Connection 变成 Channel。 卷一把通道、key、缓冲池和写队列塞进一个对象,应用只通过 Connection 入队和关闭。Netty 把这几件事拆开:Channel 表示连接,ChannelPipeline 表示处理链,ChannelConfig 表示 socket 参数,出站缓冲由 Channel 内部管理。linkd-v2 的 ConnectionRegistry 因此不再存 Connection,而是存 Channel,size() 和 broadcast(ByteBuf) 的语义不变。
第二,Handler 变成 ChannelPipeline 里的一节。 卷一的 handler 是一个接口,事件循环直接调用它;Netty 的 ChannelHandler 挂在 pipeline 上,入站事件从 head 走向 tail,出站事件从 tail 走向 head。ctx.writeAndFlush() 从当前 handler 往回找出站 handler,ctx.channel().writeAndFlush() 从 tail 开始。这个方向差异在 4.1 会展开,现在先记住:handler 的位置会影响它能不能看到某次写。
第三,BufferPool 变成 ByteBufAllocator。 卷一的池是一个 ConcurrentLinkedQueue,借还都由应用显式调用;Netty 默认用 PooledByteBufAllocator,按线程和 arena 分片,应用通常只接触 ByteBuf 的引用计数。ByteBuf 用 readerIndex / writerIndex 两个指针,读和写互不干扰,不需要像 ByteBuffer 那样 flip();5.1 会讲索引和视图。
第四,enqueue 的 void 返回值变成 ChannelFuture。 卷一调用 Connection.enqueue 后只能等 onWritable 回调;Netty 的每次写都返回 ChannelFuture,可以 addListener 在完成或失败时执行动作。linkd-v2 的 ConnectionRegistry.broadcast 不会同步等待每条写完成,但会给每个 ChannelFuture 挂监听,记录失败的连接,避免一个慢客户端拖住整个广播循环。
为什么 worker 默认是 20 个线程
new NioEventLoopGroup() 不传参数时,线程数是 max(1, CPU 核数 * 2),本机 10 核就是 20 个。这个默认值假设 handler 都是非阻塞的:线程多一点,能分摊 I/O 和任务队列的压力。
如果 LinkdHttpHandler 里同步调用 libraryd,20 个线程也只会变成 20 个一起阻塞的位置,连接继续排队。这时要调的不是 worker 数量,而是把阻塞调用移出 EventLoop,3.3 会给出隔离方案。反过来,如果连接数很少、handler 很轻,20 个线程会浪费栈和调度开销,可以显式传一个更小的值。线程数与业务模型绑定,没有通用最优值。
最小 Echo:ServerBootstrap 与 NioEventLoopGroup
下面这个类就是卷一 Echo 的 Netty 版本,完整可运行:
package com.plume.linkd.v2.server;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
public final class EchoServer {
private EchoServer() {
}
public static void main(String[] args) throws InterruptedException {
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new EchoHandler());
}
});
Channel server = bootstrap.bind(9093).sync().channel();
System.out.println("echo listening on 9093");
server.closeFuture().sync();
} finally {
boss.shutdownGracefully();
worker.shutdownGracefully();
}
}
private static final class EchoHandler extends SimpleChannelInboundHandler<ByteBuf> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
ctx.writeAndFlush(msg.retainedDuplicate());
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
}
对着卷一的结构看这段代码:
boss只有一个线程,只负责 accept;worker默认取2 * CPU个线程(本机 10 核即 20 个),负责所有已建立连接的读写。这对应卷一 6.3 的「一个 accept 线程 + 多个 I/O 线程」。.channel(NioServerSocketChannel.class)指定监听通道的实现;.childHandler(...)里的ChannelInitializer每接受一条连接执行一次,给这条连接装配独立的 pipeline。bind(9093).sync().channel()在主线程阻塞到端口绑定完成;sync()这里安全,因为 main 不是 EventLoop 线程。closeFuture().sync()让主线程停住,否则main会立刻走到finally,把刚绑定好的 EventLoopGroup 关掉。finally里的shutdownGracefully()关闭两组 EventLoop。生产环境还要等返回的Future,并给一个关闭期限,15.3 会做。
handler 只有一行核心逻辑:ctx.writeAndFlush(msg.retainedDuplicate())。SimpleChannelInboundHandler 在 channelRead0 返回后会自动 release msg;retainedDuplicate() 产生一个共享同一块内存、引用计数加一的新 ByteBuf,写出去的那份由 Netty 在写完后 release。如果不加 retain,handler 释放后写队列里的引用就悬空了,写出去时可能抛 IllegalReferenceCountException(io.netty.util,在 netty-common 里),也可能读到已经被复用的内存。
运行与真实往返
先按 1.3 的父 POM 建好工程,把类放进 linkd-server/src/main/java/com/plume/linkd/v2/server/,编译:
mvn -q -DskipTests package
为了一次性验证,直接用本地仓库里的 Netty jar 拼 classpath 启动。正式打包在 15 章,这里不引入额外插件:
CP="linkd-server/target/classes:$(find ~/.m2/repository/io/netty -name '*-4.2.19.Final.jar' | tr '\n' ':')"
java -cp "$CP" com.plume.linkd.v2.server.EchoServer
本机输出(服务端):
echo listening on 9093
另开一个终端,用 Python 做两次往返再半关闭写方向:
import socket
s = socket.create_connection(("127.0.0.1", 9093), timeout=3)
s.sendall(b"hello netty\n")
print("recv=" + s.recv(64).decode().strip())
s.sendall(b"second line\n")
print("recv=" + s.recv(64).decode().strip())
s.shutdown(socket.SHUT_WR)
print("eof=" + str(s.recv(1) == b""))
s.close()
本机输出(客户端):
recv=hello netty
recv=second line
eof=True
第二条消息证明连接没有在第一次写后被关闭;eof=True 证明半关闭后服务端正确关闭了连接。这里没有压测,也没有吞吐数字,只验证读写链路和关闭语义。
卷一哪些手写逻辑消失了
逐项对照,消失的是模板代码:
processSelectedKeys和selectedKeys()的迭代没了,EventLoop内部处理。interestOps的OP_WRITE开关没了,出站缓冲在写不出去时自己登记可写事件。Connection.flush()的部分写循环没了,Channel的写操作返回ChannelFuture,写完或不写完都由内部缓冲推进。BufferPool.acquire/release的池对象没了,分配器内部池化;但ByteBuf的引用计数仍在,谁 retain 谁 release 的规则没变。register里的线程归属检查没了,Channel注册时就绑定了EventLoop,后续事件只在那一个线程执行。- 手写帧解码器里的累积缓冲区没了,
ByteToMessageDecoder用cumulation保存跨次读的剩余字节。
但「消失」不等于「不用管」。ByteToMessageDecoder 的累积缓冲区仍然需要上限,否则对端一直发半截帧会把内存撑大;TooLongFrameException 就是为此准备的。写队列仍然需要水位线,只是从 Connection.outbound.size() 换成了 Channel.isWritable()。9.1 和 9.2 会把这些策略补上。
还有一处不能照搬:卷一的 ReactorEchoHandler 把读到的字节复制到新的直接缓冲区,因为 inbound 会被下一次读覆盖。Netty 里 msg 在 channelRead0 返回后就被释放,但底层内存还在,retainedDuplicate() 让写出的那份共享同一块内存,不需要复制。这是引用计数带来的差别,不是 API 糖。
映射到 linkd-v2 的类
四个共享类型的父类或容器换成了 Netty 的对应物:
public final class LinkdFrameDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
// 6.1/7.1 实现:读 16 字节头,校验 magic 与 length,切出 payload
// 不要 release in,累积缓冲区由父类管理
}
}
LinkdFrameDecoder 对应卷一 6.3 的手写帧解码器;LinkdFrameEncoder extends MessageToByteEncoder<LinkdFrame> 对应卷一的出站编码;LinkdHttpHandler extends SimpleChannelInboundHandler<FullHttpRequest> 对应卷一的业务 handler;ConnectionRegistry 用 Channel 取代 Connection;WebSocketHub 用 ChannelGroup 管理一组连接。
两条 pipeline 的形状:
9090: HttpServerCodec → HttpObjectAggregator → LinkdHttpHandler
9093: LinkdFrameDecoder → LinkdFrameEncoder → 业务 handler(第 7 章)
9093 的解码器在前、编码器在后,是因为入站和出站方向相反:读进来的字节先过 LinkdFrameDecoder,写出去的 LinkdFrame 最后过 LinkdFrameEncoder。把编码器放在解码器前面也能工作,但事件传播方向会让阅读顺序变得别扭,linkd-v2 统一按「入站在前、出站在后」排。
常见错误与边界
错误一:在 channelRead0 里手动 release 再写。 SimpleChannelInboundHandler 已经负责释放;再调一次 ReferenceCountUtil.release(msg),随后 writeAndFlush 就会碰到引用计数为 0 的缓冲区,抛 IllegalReferenceCountException。要么用 retainedDuplicate() 转移一份引用,要么改用 ChannelInboundHandlerAdapter 自己管理。
错误二:以为 ctx.channel().writeAndFlush() 会经过当前 handler 之后的出站 handler。 它从 tail 开始;ctx.writeAndFlush() 才从当前 handler 往前找。如果一个编码器挂在当前 handler 前面,两种写法结果不同。4.1 会用事件传播图说明。
错误三:在 EventLoop 线程里 sync()。 bind().sync() 在 main 里没问题;在 handler 里对写操作 sync() 会阻塞当前 EventLoop,Netty 会抛 BlockingOperationException(io.netty.util.concurrent,在 netty-common 里),严重时整个循环停摆。EventLoop 线程里只能用 addListener。
错误四:给有状态的 handler 加 @Sharable。 卷一的 handler 按连接创建,字段天然隔离;Netty 里如果标了 @Sharable,同一个实例会被多条连接共用,计数器、ByteBuf 成员都会并发访问。无状态才能共享,4.3 会讲判据。
连接关闭时发生了什么
客户端 shutdown(SHUT_WR) 后,服务端会读到 EOF。SimpleChannelInboundHandler 的 channelRead0 不会被调用,Netty 改为触发 channelInactive,然后关闭这条 Channel。卷一的 Connection.close() 里那串动作——取消 key、关闭通道、释放未写出的缓冲区、执行 close hook——在 Netty 里由 Channel 的关闭流程接管;linkd-v2 只需要在 channelInactive 里把连接从 ConnectionRegistry 移除。
要注意 server.closeFuture() 等的是监听通道的关闭,不是某条客户端连接的关闭。客户端断开不会让 main 退出,这正是 Echo 服务能连续接受多次连接的原因。如果想让进程在收到 SIGINT 时退出,需要单独注册关闭钩子,关闭监听通道并停止 EventLoopGroup,15.3 会给出完整顺序。
关闭顺序也有坑:shutdownGracefully() 只是发起关闭,返回的 Future 完成前 EventLoop 仍在处理任务。直接 System.exit() 会跳过未完成的写操作;正确做法是等两组 EventLoop 的 Future,或者给一个明确的期限。
边界:映射不是改名。 Connection 没有单一对应物,BufferPool 也没有;把卷一的对象图硬套到 Netty 上,会在「写队列在哪」「谁触发 flush」这类问题上卡住。按责任找,不按类名找。
小结
EventLoop对应EventLoopGroup/EventLoop,Connection对应Channel,Handler对应 pipeline 中的ChannelHandler。BufferPool对应ByteBufAllocator,FrameDecoder对应ByteToMessageDecoder;引用计数规则仍然由应用负责。- 最小 Echo 由
ServerBootstrap、NioEventLoopGroup、NioServerSocketChannel和ChannelInitializer组成,bind(9093)后由closeFuture()维持进程。 SimpleChannelInboundHandler自动释放入站消息;要写出同一块内存时必须retainedDuplicate(),否则引用计数会归零。- Netty 接管的是 accept、事件分发、部分写和累积缓冲;水位线、帧上限、业务线程隔离仍要 linkd-v2 自己定义。
- 每次写返回
ChannelFuture,用监听器处理完成与失败;不要在 EventLoop 线程里等它完成。 - 连接关闭走
channelInactive,server.closeFuture()等的是监听通道,不是某条客户端连接。 - 默认 worker 线程数是 CPU 核数的两倍,它按非阻塞 handler 设计,不能替代业务线程隔离。
映射清楚了,下一步是把这些类放进一个能构建、能启动的工程。下一节落地四模块 Maven 工程,固定 netty.version、maven.compiler.release=25 和模块依赖方向。
阅读导航:上一节:1.1 Netty 在网络栈中的位置 · 下一节:1.3 linkd-v2 工程与依赖 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。