本节目标:把「用哪套 transport」从启动参数变成一次可解释的探测:谁可用、为什么不可用、构件怎么选、失败后回退到哪里。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-transport、netty-transport-classes-epoll、netty-transport-classes-kqueue)。
承接的问题
11.3 把 gRPC 放到了 Netty 上,linkd-v2 的四个端口都有了实现。到这一步,所有 server 仍然用 2.1 的 NioEventLoopGroup 和 NioServerSocketChannel。预发环境是 Linux,开发机是 Apple Silicon 的 macOS;同一份代码在两边都能跑,但底下的系统调用不是同一套。
团队里出现过两个相反的误解。一个说「Netty 有 epoll,直接换 EpollEventLoopGroup 就快」,在本机启动时直接抛 UnsatisfiedLinkError。另一个说「macOS 上 Epoll.isAvailable() 是 false,说明 Netty 装坏了」,于是去重装依赖。两个都不对:前者忽略了构件和平台,后者把平台事实当成了故障。
本节先把三套 transport 的关系摆清楚,再给探测与回退的可运行代码。结论先放在这里:原生传输改的是系统调用入口和可用选项,不保证任何场景都更快;选它的第一目的是平台能力,第二才是开销。
核心机制一:三套 transport,同一组接口
三套实现共享 Channel、ChannelPipeline、EventLoopGroup 这些接口。业务 handler、编解码器、ByteBuf 逻辑一行都不用改;启动代码只换两个类:EventLoopGroup 的实现和 ServerSocketChannel 的实现。
| 维度 | NIO | Epoll | KQueue |
|---|---|---|---|
| 类所在模块 | netty-transport | netty-transport-classes-epoll | netty-transport-classes-kqueue |
| 事件循环 | NioEventLoopGroup | EpollEventLoopGroup | KQueueEventLoopGroup |
| 监听 channel | NioServerSocketChannel | EpollServerSocketChannel | KQueueServerSocketChannel |
| 底层入口 | JDK Selector | epoll_wait | kevent |
| native 库 | 无 | classifier jar 里的 .so | classifier jar 里的 .jnilib |
| 可用平台 | 全平台 | Linux | macOS / BSD |
NioEventLoopGroup 底下是 JDK 的 Selector,也就是卷一 6.2 手写 EventLoop 时用的那套。EpollEventLoopGroup 和 KQueueEventLoopGroup 通过 JNI 直接调 epoll_wait / kevent,native 库放在带 classifier 的 jar 里。JDK 的 selector 在 Linux 上内部也用 epoll,在 macOS 上内部也用 kqueue;原生传输省掉的是 JDK selector 那一层的注册、取消和唤醒簿记,以及拿到了一些 JDK 没有暴露的 socket 选项。
所以三者的关系不是「NIO 旧、原生新」,而是「可移植实现加两个平台特化实现」。这也是为什么不能承诺更快:如果瓶颈在 handler、JSON 解析或下游 RPC,换 transport 不会改变这些成本;只有在连接数多、事件密集,或需要 SO_REUSEPORT、TCP_FASTOPEN 这类选项时,差异才有意义。测量方法放在 14.2,本节不报数字。
核心机制二:探测与 classifier
探测本身只有两个静态方法,但结果要连同原因一起打出来,否则线上只能看到 false:
package com.plume.linkd.v2.server;
import io.netty.channel.epoll.Epoll;
import io.netty.channel.kqueue.KQueue;
public final class TransportProbe {
private TransportProbe() {
}
public static void main(String[] args) {
System.out.println("epoll=" + Epoll.isAvailable());
System.out.println("kqueue=" + KQueue.isAvailable());
System.out.println("epoll cause=" + Epoll.unavailabilityCause());
System.out.println("kqueue cause=" + KQueue.unavailabilityCause());
System.out.println("os.name=" + System.getProperty("os.name"));
System.out.println("os.arch=" + System.getProperty("os.arch"));
}
}
isAvailable() 第一次调用时会触发 native 库加载,结果被缓存;unavailabilityCause() 在可用时返回 null,不可用时返回失败原因。这个原因可能是包装过的初始化错误,排查时要顺着 getCause() 往下看,最内层才是「平台不支持」或「库加载失败」。本机(Apple M1 Pro,macOS 26.3,Temurin 25.0.5+8)的输出是 epoll=false、kqueue=true,完整四行见 12.2。
探测结果只说明「Netty 的原生库能不能加载」,不说明「内核有没有 epoll」。Linux 内核一直有 epoll;在 Linux 上,false 只可能是构件或运行环境的问题。反过来,Epoll.isAvailable() 为 true 也不代表你的 server 一定用了 epoll,还要看 bootstrap 选的 channel 类。
构件按平台和 CPU 两个维度选:
| 平台 | CPU | Maven artifact | classifier |
|---|---|---|---|
| Linux | x86_64 | netty-transport-native-epoll | linux-x86_64 |
| Linux | aarch64 | netty-transport-native-epoll | linux-aarch_64 |
| Linux | RISC-V | netty-transport-native-epoll | linux-riscv64 |
| macOS | Intel | netty-transport-native-kqueue | osx-x86_64 |
| macOS | Apple Silicon | netty-transport-native-kqueue | osx-aarch_64 |
在父 POM 的 dependencyManagement 之外,把需要的 classifier 显式写出来:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-kqueue</artifactId>
<version>${netty.version}</version>
<classifier>osx-aarch_64</classifier>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-epoll</artifactId>
<version>${netty.version}</version>
<classifier>linux-x86_64</classifier>
</dependency>
netty-all 是聚合 POM,能带来 classes jar,但不要假设它一定带了目标平台的 native classifier。显式声明的另一个好处是构建可复现:升级时只改 ${netty.version},classifier 不会跟着漂。两个平台都声明,开发机和 Linux 构建都能跑;生产镜像里只保留目标平台那一份,减少体积。
classifier 必须和 JDK 架构一致。Apple Silicon 上跑 aarch64 的 JDK,os.arch 是 aarch64,要 osx-aarch_64;如果 JDK 本身是 x86_64(包括在 Rosetta 下启动的 x86_64 JDK),os.arch 是 x86_64,要 osx-x86_64。装错的表现不是编译错误,而是 isAvailable() 返回 false,cause 链会指出加载失败或平台不支持。
核心机制三:探测顺序与回退
把判断收在一个类里,启动路径只拿一个 Transport 枚举,避免 group 和 channel 各判断一次、选出不匹配的组合:
package com.plume.linkd.v2.server;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.epoll.Epoll;
import io.netty.channel.epoll.EpollEventLoopGroup;
import io.netty.channel.epoll.EpollServerSocketChannel;
import io.netty.channel.kqueue.KQueue;
import io.netty.channel.kqueue.KQueueEventLoopGroup;
import io.netty.channel.kqueue.KQueueServerSocketChannel;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
public final class NativeEventLoops {
public enum Transport { EPOLL, KQUEUE, NIO }
private NativeEventLoops() {
}
public static Transport detect() {
if (Epoll.isAvailable()) {
return Transport.EPOLL;
}
if (KQueue.isAvailable()) {
return Transport.KQUEUE;
}
return Transport.NIO;
}
public static EventLoopGroup newGroup(Transport transport, int threads) {
return switch (transport) {
case EPOLL -> new EpollEventLoopGroup(threads);
case KQUEUE -> new KQueueEventLoopGroup(threads);
case NIO -> new NioEventLoopGroup(threads);
};
}
public static EventLoopGroup newGroup(Transport transport) {
return newGroup(transport, 0);
}
public static EventLoopGroup newGroup() {
return newGroup(detect());
}
public static void applyChannel(ServerBootstrap bootstrap, Transport transport) {
switch (transport) {
case EPOLL -> bootstrap.channel(EpollServerSocketChannel.class);
case KQUEUE -> bootstrap.channel(KQueueServerSocketChannel.class);
case NIO -> bootstrap.channel(NioServerSocketChannel.class);
}
}
public static String unavailabilityReport() {
return "epoll=" + Epoll.isAvailable() + " cause=" + Epoll.unavailabilityCause()
+ ", kqueue=" + KQueue.isAvailable() + " cause=" + KQueue.unavailabilityCause();
}
}
几个设计点:
- 顺序是 Epoll、KQueue、NIO。macOS 上第一次
Epoll.isAvailable()返回 false 后结果被缓存,后续调用没有额外加载成本;Linux 上 Epoll 命中后不会再碰 KQueue。 threads=0表示用 Netty 的默认线程数(通常是核数乘二);boss 传 1 就够了。不要给 worker 传一个拍脑袋的固定值,容量判断留给 14.1。detect()只在启动时调用一次,结果作为参数传给newGroup和applyChannel。如果在两个地方各自探测,虽然缓存保证结果一致,但代码上无法证明 group 与 channel 属于同一套 transport。- 无参
newGroup()是给探针和单次启动用的便捷方法,内部仍然走detect()的 Epoll、KQueue、NIO 顺序;服务端不要只用它,因为 channel 也必须拿到同一个Transport。 newGroup和applyChannel必须收到同一个Transport。EpollEventLoopGroup搭配NioServerSocketChannel不会在编译期报错,要到 bind 时注册 server channel 才失败;boss/worker 混用时则要到 accept 出子 channel 才失败。排查成本都比编译期高得多。unavailabilityReport()把两个 cause 都带上。线上只打epoll=false,无法区分「非 Linux」「classifier 装错」「glibc 不兼容」三种情况。
这里直接引用了 Epoll 和 KQueue 两个类,意味着两个 classes jar 必须在所有平台的 classpath 上。它们只有几十 KB,可以常驻;真正按平台变化的是 classifier jar 里的 native 库。如果为了省体积把 classes jar 也裁掉,KQueue.isAvailable() 不会返回 false,而是在类加载阶段抛 NoClassDefFoundError。
在 linkd-v2 里落地
LinkdServer 只改 start() 和 bind() 两处。字段加一个 transport,构造器和 shutdown() 保持 2.1 的写法:
package com.plume.linkd.v2.server;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
public final class LinkdServer {
private static final System.Logger LOG = System.getLogger(LinkdServer.class.getName());
private NativeEventLoops.Transport transport;
private EventLoopGroup boss;
private EventLoopGroup worker;
public void start() throws Exception {
transport = NativeEventLoops.detect();
boss = NativeEventLoops.newGroup(transport, 1);
worker = NativeEventLoops.newGroup(transport);
LOG.log(System.Logger.Level.INFO, "transport={0} {1}",
transport, NativeEventLoops.unavailabilityReport());
// 两个端口的 bind 沿用 2.1 的 initializer,这里省略
}
private Channel bind(int port, ChannelHandler childHandler) throws InterruptedException {
ServerBootstrap bootstrap = new ServerBootstrap()
.group(boss, worker)
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childHandler(childHandler);
NativeEventLoops.applyChannel(bootstrap, transport);
Channel channel = bootstrap.bind(port).sync().channel();
LOG.log(System.Logger.Level.INFO, "bound port={0} channel={1} eventLoop={2}",
port, channel.getClass().getName(), channel.eventLoop().getClass().getName());
return channel;
}
}
启动日志是这套机制的自证。macOS 上应该看到(示意输出,实际类名在 12.2 有本机探针):
transport=KQUEUE epoll=false cause=java.lang.ExceptionInInitializerError, kqueue=true cause=null
bound port=9090 channel=io.netty.channel.kqueue.KQueueServerSocketChannel eventLoop=io.netty.channel.kqueue.KQueueEventLoopGroup$KQueueEventLoop
Linux 上对应的是(示意输出,12.3 讲部署前提):
transport=EPOLL epoll=true cause=null, kqueue=false cause=java.lang.ExceptionInInitializerError
bound port=9090 channel=io.netty.channel.epoll.EpollServerSocketChannel eventLoop=io.netty.channel.epoll.EpollEventLoop
这两个输出包含三个可核对的信息:选的 transport、监听 channel 的实际类、以及 event loop 的实际类。任何一个和预期不符,都不需要猜,直接看 cause。日志放在 bind 成功之后,失败的 bind 会在异常栈里带出 channel 初始化错误。
shutdown() 不需要改:关闭两个 server channel 后,依次对 boss 和 worker 调用 shutdownGracefully() 并等待完成。原生 transport 的关闭路径和 NIO 一样,都是先停 accept、再停事件循环。
常见错误与边界
错误一:把 EpollEventLoopGroup 写死在启动代码里。
// 反例:在 macOS 上构造就会失败
EventLoopGroup boss = new EpollEventLoopGroup(1);
EventLoopGroup worker = new EpollEventLoopGroup();
EpollEventLoopGroup 的静态初始化会检查 native 库;本机 Epoll.isAvailable() 为 false,构造时抛出 UnsatisfiedLinkError,cause 链里是 ExceptionInInitializerError 和更内层的平台判断。进程在 bind 之前就退出,日志里只有异常栈,看不到端口。任何跨平台工程都不应该在启动路径直接 new 原生组。
错误二:只判断 isAvailable(),不记录 unavailabilityCause()。
// 反例:线上只知道 false,不知道原因
if (Epoll.isAvailable()) {
bootstrap.channel(EpollServerSocketChannel.class);
}
Linux 生产上返回 false 至少有三种原因:classifier 装成了 macOS 的、镜像里根本没有 native jar、Alpine 的 musl 加载不了 glibc 的 .so。只看布尔值,排查会从「重装依赖」开始,浪费一轮发布。启动时打一次 cause,成本是一条日志。
错误三:认为原生传输一定更快。 原生传输减少的是 selector 层的簿记,并暴露更多 socket 选项;它不会让业务 handler 变快,也不会减少下游 RPC 的等待。单连接 echo 或低并发 HTTP 上,两者的差异可能落在噪声里。要用同一份 pipeline、同一组 payload、同一台机器对比,口径按 14.2;没有测量就不要在评审里写「快 20%」。
错误四:boss 和 worker 用了不同 transport。
// 反例:Epoll 的监听 channel 接受出的连接注册不到 NIO 的 EventLoop
EventLoopGroup boss = new EpollEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
ServerBootstrap.group(boss, worker) 不校验两个组是否同源;错误要到 accept 出第一条连接、子 channel 注册时才会暴露。两个组都由同一个 Transport 创建,是成本最低的约束。
边界一:isAvailable() 是「库可加载」,不是「内核支持」。 Linux 内核一直有 epoll;macOS 内核没有 epoll。把 Epoll.isAvailable() 当内核能力探测,会在非 Linux 上得出错误结论。
边界二:classifier jar 可以跨平台共存,但只有一个会被加载。 同时声明 osx-aarch_64 和 linux-x86_64 不会冲突;各自的 native 库文件名不同,Netty 按当前平台挑。真正会冲突的是同一个 classifier 出现在两个版本里,Maven 会按依赖调解选一个,可能不是你预期的那个。
边界三:native 库加载失败不一定在启动时报错。 如果启动路径没有创建原生组,直到第一次调用 Epoll.isAvailable() 才触发加载;懒加载的探测代码可能把失败推迟到第一个请求。把探测放在 main 的最前面,失败早于端口绑定。
小结
- NIO、Epoll、KQueue 是同一组
Channel/EventLoopGroup接口的三份实现;业务代码不变,只换事件循环和监听 channel 的类。 Epoll.isAvailable()在 macOS 上为 false 是平台事实;Linux 上为 false 则要查 classifier、架构和 libc。- classifier 由平台和 CPU 共同决定:
linux-x86_64、linux-aarch_64、osx-x86_64、osx-aarch_64;classes jar 全平台常驻,native jar 按目标平台选。 NativeEventLoops.detect()按 Epoll、KQueue、NIO 的顺序探测一次,把Transport传给 group 和 channel,保证两者同源。- 原生传输不承诺更快;它改变系统调用入口和可用选项,收益要用同一 pipeline 的对比测量来判断。
- 启动日志同时打印 transport、channel 类、event loop 类和不可用原因,是把「猜」变成「看」的最低成本做法。
选择逻辑定下来之后,下一步是验证它在本机真的生效:12.2 用探针打印 KQueueServerSocketChannel 与 KQueueEventLoop 的实际类名,并处理 JDK 25 对 System.loadLibrary 的受限方法警告。
阅读导航:上一节:11.3 gRPC on Netty 的结构 · 下一节:12.2 KQueue 在 macOS 的实测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。