《Netty 高性能网络编程》2.1 ServerBootstrap 与 Bootstrap

ServerBootstrap 与 Bootstrap 是两条启动路径:一条接受连接,一条发起连接。拆开 group、childHandler、option、childOption、bind 与 connect 的分工,说明 boss/worker 职责和 ChannelInitializer 每连接执行一次的语义,给出 linkd-v2 LinkdServer 启动与 sync() 线程边界。

本节目标:分清服务端与客户端两条启动链,知道每个 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() 的语义一致,但等待的对象不同。

把差异压成一张表:

维度ServerBootstrapBootstrap
角色监听端口、接受连接发起一条连接
group父 group + 子 group一个 group
channel 类型NioServerSocketChannelNioSocketChannel
连接级 handlerchildHandlerhandler
触发方法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,也就是监听 socketSO_BACKLOG、SO_REUSEADDR
.childOption(...)子 channel,也就是每条已接受的连接TCP_NODELAY、SO_KEEPALIVE、AUTO_READ
.handler(...)父 channel 的 pipelineaccept 计数、监听 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 是构造参数,属于所有连接共享的组件;它的方法必须线程安全,因为不同的连接可能落在不同的 worker EventLoop 上。

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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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