本节目标:分清服务端与客户端两条启动链,知道每个 setter 作用在监听 channel 还是连接 channel 上,并能写出可关停的
LinkdServer。
适用版本:JDK 25 + Netty 4.2.19.Final。
承接的问题
1.3 已经把 linkd-v2 的 Maven 多模块骨架搭好,pom.xml 里声明了 netty-all:4.2.19.Final,但 LinkdV2Main 里还没有真正监听端口。卷一的 ReactorEchoServer 用 ServerSocketChannel、Selector 和 interestOps 手工完成了 accept、read、write;换成 Netty 之后,这段工作被收进 ServerBootstrap 和 ChannelPipeline。
问题在于,ServerBootstrap 和 Bootstrap 长得很像,方法名又有重叠,很容易被当成同一套 API 的两种包装。最常见的后果是:服务端 bind 成功,日志也打印了端口,但连接进来后业务 handler 一个字节都没收到;客户端 connect 成功,却永远等不到响应。原因通常不是编解码器写错,而是把 handler 和 childHandler、option 和 childOption 用在了错误的对象上。
linkd-v2 要同时开两个服务端端口:9090 走 HTTP/1.1,9093 走 7.2 的自定义帧协议;客户端 RpcClient 连 9093。两条链共用同一套 NioEventLoopGroup 知识,但装配方式不同。这一节先把两条链拆开,再落到 LinkdServer 的完整启动代码。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面没有性能数字,所有代码都是结构示意,运行输出以实际编译结果为准。
核心机制一:两个 Bootstrap 的分工
ServerBootstrap 负责监听和接受连接,Bootstrap 负责发起连接。两者都继承自 AbstractBootstrap,所以 group、channel、option、handler 这些名字看起来一样,但 ServerBootstrap 多了一层「父 channel」和「子 channel」的概念。
一个最小的服务端启动链:
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new LinkdFrameDecoder());
ch.pipeline().addLast(new LinkdFrameEncoder());
}
});
ChannelFuture bindFuture = bootstrap.bind(9093);
bindFuture.sync();
Channel serverChannel = bindFuture.channel();
serverChannel.closeFuture().sync();
这段代码里有三件事要分开看:
group(bossGroup, workerGroup)传入两个EventLoopGroup。第一个给监听 channel,第二个给接受后的连接 channel;只传一个 group 时,两个角色由同一个 group 承担,但职责边界仍然存在。channel(NioServerSocketChannel.class)指定监听 channel 的实现。NioServerSocketChannel包装 JDK 的ServerSocketChannel,真正的 accept 由它完成。bind(9093)返回ChannelFuture,不是Channel。sync()只是在当前线程等待绑定完成;绑定失败时会把原因抛出来,成功后才能从future.channel()拿到监听 channel。
客户端启动链则是另一套:
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(workerGroup)
.channel(NioSocketChannel.class)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new LinkdFrameDecoder());
ch.pipeline().addLast(new LinkdFrameEncoder());
}
});
ChannelFuture connectFuture = bootstrap.connect("127.0.0.1", 9093);
connectFuture.sync();
客户端没有 childHandler,因为客户端只创建一条连接 channel;handler 直接作用于这条 channel。客户端也没有 bind,connect 返回的 ChannelFuture 在 TCP 建连完成或失败时完成。connectFuture.sync() 在 main 线程等待结果,和 bindFuture.sync() 的语义一致,但等待的对象不同。
把差异压成一张表:
| 维度 | ServerBootstrap | Bootstrap |
|---|---|---|
| 角色 | 监听端口、接受连接 | 发起一条连接 |
| group | 父 group + 子 group | 一个 group |
| channel 类型 | NioServerSocketChannel | NioSocketChannel |
| 连接级 handler | childHandler | handler |
| 触发方法 | bind(port) | connect(host, port) |
| 典型失败 | 端口被占用、权限不足 | 拒绝连接、建连超时 |
这张表里最容易被忽略的是「连接级 handler」这一行。ServerBootstrap.handler 也存在,但它作用于监听 channel 自己的 pipeline,不会自动复制到每条新连接上;要让业务 handler 看到请求,必须用 childHandler。
核心机制二:boss 只 accept,worker 管 IO
ServerBootstrap 的两个 group 不是随便拆的。父 group(通常叫 boss)只负责监听 channel 上的 OP_ACCEPT:有新连接进来时,它调用 accept,把得到的 SocketChannel 注册到子 group(worker)里的某个 EventLoop。从注册那一刻起,这条连接的所有读写事件、任务和定时任务都只由那个 EventLoop 线程处理,直到连接关闭。
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childHandler(new CustomProtocolInitializer(registry));
Channel channel = bootstrap.bind(9093).sync().channel();
channel.closeFuture().sync();
} finally {
boss.shutdownGracefully();
worker.shutdownGracefully();
}
new NioEventLoopGroup(1) 把 boss 的线程数显式设为 1。boss 的工作只是 accept,通常不需要更多线程;多个 boss 线程只会让 accept 竞争更复杂。worker 不传参数,线程数由 NioEventLoopGroup 的默认构造器决定,具体值以 4.2 的实现和运行环境为准,不要把它当成固定的「CPU 核数 × 2」。
option 和 childOption 的区别也来自这两个层级:
| 方法 | 作用对象 | 典型参数 |
|---|---|---|
.option(...) | 父 channel,也就是监听 socket | SO_BACKLOG、SO_REUSEADDR |
.childOption(...) | 子 channel,也就是每条已接受的连接 | TCP_NODELAY、SO_KEEPALIVE、AUTO_READ |
.handler(...) | 父 channel 的 pipeline | accept 计数、监听 channel 日志 |
.childHandler(...) | 子 channel 的 pipeline | 编解码器、业务 handler |
把 TCP_NODELAY 写在 .option(...) 里不会报错,但它只作用在监听 socket 上,对每条 accepted SocketChannel 没有影响。这类错误不会在启动时暴露,只会在压测或延迟观察时表现为「参数好像没生效」。
shutdownGracefully() 是异步的,返回一个 future。它在当前线程发起关闭,真正停止事件循环需要时间;如果 main 线程紧接着退出,Netty 的非 daemon 线程会让 JVM 继续存活,直到关闭完成或超时。生产代码通常在 shutdown hook 里先关闭监听 channel,再调用两个 group 的 shutdownGracefully(),需要等待时在主线程 sync(),不要在 EventLoop 线程里 sync()(2.2 会给出反例)。
核心机制三:ChannelInitializer 每个连接执行一次
ChannelInitializer 是一个特殊的入站 handler:它被加入 pipeline 后,等 channel 注册到 EventLoop 时调用一次 initChannel,然后把自身从 pipeline 移除。因为每条 accepted 连接都有独立的 ChannelPipeline,所以同一个 ChannelInitializer 实例被复用时,initChannel 仍然是「每条连接执行一次」。
package com.plume.linkd.v2.server;
import com.plume.linkd.v2.protocol.LinkdFrame;
import com.plume.linkd.v2.protocol.LinkdFrameDecoder;
import com.plume.linkd.v2.protocol.LinkdFrameEncoder;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.socket.SocketChannel;
public final class CustomProtocolInitializer extends ChannelInitializer<SocketChannel> {
private final ConnectionRegistry registry;
public CustomProtocolInitializer(ConnectionRegistry registry) {
this.registry = registry;
}
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new LinkdFrameDecoder());
ch.pipeline().addLast(new LinkdFrameEncoder());
ch.pipeline().addLast(new SimpleChannelInboundHandler<LinkdFrame>() {
@Override
public void channelActive(ChannelHandlerContext ctx) {
registry.add(ctx.channel());
ctx.fireChannelActive();
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
registry.remove(ctx.channel());
ctx.fireChannelInactive();
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, LinkdFrame frame) {
ctx.writeAndFlush(frame);
}
});
}
}
三个行为细节:
- 匿名
SimpleChannelInboundHandler在initChannel里new,所以每条连接拿到自己的实例;channelRead0里的状态不会跨连接共享。 LinkdFrameDecoder和LinkdFrameEncoder也每连接新建。它们不是@Sharable,内部有解码状态;复用同一个实例会让两条连接的半包数据互相污染。ConnectionRegistry是构造参数,属于所有连接共享的组件;它的方法必须线程安全,因为不同的连接可能落在不同的 workerEventLoop上。
ChannelInitializer 自身可以复用,但不要在它的字段里放「当前连接」的可变状态。initChannel 执行完就移除,字段仍然留在 initializer 实例上;如果两条连接同时初始化,字段会被并发读写。需要按连接保存的东西,一律在 initChannel 内创建。
在 linkd-v2 里落地:LinkdServer 完整启动
LinkdServer 负责把 9090 的 HTTP pipeline 和 9093 的自定义协议 pipeline 装配起来。它不处理业务,只持有两个监听 channel、两个 group 和共享的 ConnectionRegistry。
package com.plume.linkd.v2.server;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
public final class LinkdServer {
private static final System.Logger LOG = System.getLogger(LinkdServer.class.getName());
private final int httpPort;
private final int customPort;
private final ConnectionRegistry registry;
private EventLoopGroup boss;
private EventLoopGroup worker;
private Channel httpChannel;
private Channel customChannel;
public LinkdServer(int httpPort, int customPort, ConnectionRegistry registry) {
this.httpPort = httpPort;
this.customPort = customPort;
this.registry = registry;
}
public void start() throws Exception {
boss = new NioEventLoopGroup(1);
worker = new NioEventLoopGroup();
try {
httpChannel = bind(httpPort, new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new HttpServerCodec());
ch.pipeline().addLast(new HttpObjectAggregator(1 << 20));
ch.pipeline().addLast(new LinkdHttpHandler());
}
});
customChannel = bind(customPort, new CustomProtocolInitializer(registry));
LOG.log(System.Logger.Level.INFO,
"linkd-v2 listening http={0} custom={1}", httpPort, customPort);
} catch (Exception e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
shutdown();
throw e;
}
}
private Channel bind(int port, ChannelHandler childHandler) throws InterruptedException {
ServerBootstrap bootstrap = new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childHandler(childHandler);
return bootstrap.bind(port).sync().channel();
}
public void awaitClose() throws InterruptedException {
httpChannel.closeFuture().sync();
customChannel.closeFuture().sync();
}
public void shutdown() {
if (httpChannel != null) {
httpChannel.close().syncUninterruptibly();
}
if (customChannel != null) {
customChannel.close().syncUninterruptibly();
}
if (boss != null) {
boss.shutdownGracefully().syncUninterruptibly();
}
if (worker != null) {
worker.shutdownGracefully().syncUninterruptibly();
}
}
}
装配顺序和边界:
- 一个 boss、一个 worker。 两个端口共用同一对 group,boss 在两次
bind之间串行执行,worker 负责所有 accepted 连接。这样连接数增长时,线程数不会随端口数翻倍。 LinkdHttpHandler的构造参数按 1.3 的骨架来。 本节只关心它被装进 child pipeline;如果骨架给它注入了代理客户端,把new LinkdHttpHandler()换成对应构造即可。HTTP 业务细节在第 10、15 章展开。awaitClose()只等到两个监听 channel 关闭。closeFuture()属于监听 channel,不代表已接受的连接都结束了;已接受连接的关闭由 worker group 和ConnectionRegistry负责。这个区别是 2.2 的主题。shutdown()可以先关监听 channel,再关 group。 先close()监听 channel 能立刻停止 accept,减少关闭过程中新连接进入;随后shutdownGracefully()让 worker 处理剩余任务。需要等待时,shutdownGracefully()返回的 future 可以在 main 或 shutdown hook 里sync()。
启动入口 LinkdV2Main 只做四件事:
LinkdServer server = new LinkdServer(9090, 9093, new ConnectionRegistry());
Runtime.getRuntime().addShutdownHook(new Thread(server::shutdown, "linkd-shutdown"));
server.start();
server.awaitClose();
main 线程停在 awaitClose() 上,start() 和 awaitClose() 里的 sync() 都发生在 main 线程,不在 EventLoop 线程,所以不会触发 Netty 的阻塞检查。shutdown() 由 shutdown hook 调用,它会等待两个监听 channel 和两个 group 关闭完成;因此它不能在 EventLoop 线程里调用。shutdown hook 里等待是安全的,JVM 会先执行完 hook 再退出。
常见错误与边界
错误一:服务端把业务 handler 放进 handler 而不是 childHandler。
// 反例:业务 handler 只挂在监听 channel 上
ServerBootstrap bootstrap = new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class)
.handler(new LinkdHttpHandler());
handler 的 pipeline 属于 NioServerSocketChannel,它只处理 accept 相关事件。每条 accepted SocketChannel 的 pipeline 是新建的,不会继承父 channel 的 handler;业务请求到达时,LinkdHttpHandler 根本不在链路上。
错误二:把 TCP_NODELAY 写在 option 里。
// 反例:只改了监听 socket
.option(ChannelOption.TCP_NODELAY, true)
TCP_NODELAY 是连接级选项,监听 socket 上没有这个概念。正确位置是 .childOption(ChannelOption.TCP_NODELAY, true);客户端则用 .option(...),因为客户端只有一条连接 channel。
错误三:在 ChannelInitializer 字段里保存每连接状态。
// 反例:两条连接共享同一个解码缓冲
public final class BadInitializer extends ChannelInitializer<SocketChannel> {
private final LinkdFrameDecoder decoder = new LinkdFrameDecoder();
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(decoder);
}
}
LinkdFrameDecoder 有半包状态,复用同一个实例会让两条连接的数据混在一起。即使把它改成无状态 handler,只要字段在 initChannel 之外赋值,就可能在多条连接之间共享;每连接对象应该在 initChannel 内创建。
错误四:在 EventLoop 线程里 sync()。
// 反例:在 handler 里等待绑定或写入完成
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ctx.writeAndFlush(msg).sync();
}
writeAndFlush 的完成通知由当前 channel 的 EventLoop 执行,而 sync() 正阻塞这个线程,完成通知永远没有机会运行。Netty 4.2 的 DefaultPromise 会做线程检查,检测到在对应 EventLoop 里等待时抛出 BlockingOperationException(模块 netty-common);如果没有这层检查,或者等待发生在被 EventLoop 依赖的线程上,就会死锁。EventLoop 线程里用 addListener,不要用 sync()。
错误五:bind() 失败后不关闭 group。 bind().sync() 失败会抛异常,但已经创建的 NioEventLoopGroup 不会自动关闭。LinkdServer.start() 用 try/catch 包住两次 bind,失败时调用 shutdown(),就是为了避免端口被占用时留下两组非 daemon 线程。
边界:closeFuture() 不是「所有连接都关闭」。 监听 channel 的 closeFuture() 只在这个监听 socket 关闭时完成。要等待已接受的连接,需要 worker group 的关闭 future,或者在 ConnectionRegistry 里维护在途计数;2.2 会给出 LifecycleLoggingHandler 和 RegistryHandler 的实现。
小结
ServerBootstrap用group(boss, worker)、channel(NioServerSocketChannel.class)、childHandler和bind;Bootstrap用group、channel(NioSocketChannel.class)、handler和connect。- boss 只 accept,worker 处理 accepted 连接的所有读写事件;
option作用于监听 socket,childOption作用于每条连接。 ChannelInitializer.initChannel每条连接执行一次,执行后从 pipeline 移除;每连接状态必须在initChannel内创建,不能放在 initializer 字段里。bind()和connect()都返回ChannelFuture,sync()只能在 main 或普通业务线程等待,不能在 EventLoop 线程等待。LinkdServer用一对 group 同时绑定 9090 与 9093,先关监听 channel 再关 group;closeFuture()只代表监听 socket 关闭,不代表已接受连接结束。- 启动失败时要关闭已经创建的 group,否则非 daemon 线程会让进程无法退出。
下一节沿着一条连接走下去:从注册到失活,ChannelFuture 如何表示完成,closeFuture() 如何参与优雅关闭,以及客户端连接失败时监听器应该做什么。
阅读导航:上一节:1.3 linkd-v2 工程与依赖 · 下一节:2.2 Channel 生命周期与 ChannelFuture 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。