《Netty 高性能网络编程》12.1 原生传输与平台差异

NIO、Epoll、KQueue 三套 transport 不是三个开关,而是同一组接口的三份实现。本节讲清 Epoll.isAvailable() 与 KQueue.isAvailable() 的探测、classifier 构件选择、NativeEventLoops 的回退顺序,以及原生传输为什么不保证更快。

本节目标:把「用哪套 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 的实现。

维度NIOEpollKQueue
类所在模块netty-transportnetty-transport-classes-epollnetty-transport-classes-kqueue
事件循环NioEventLoopGroupEpollEventLoopGroupKQueueEventLoopGroup
监听 channelNioServerSocketChannelEpollServerSocketChannelKQueueServerSocketChannel
底层入口JDK Selectorepoll_waitkevent
native 库无classifier jar 里的 .soclassifier jar 里的 .jnilib
可用平台全平台LinuxmacOS / 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 两个维度选:

平台CPUMaven artifactclassifier
Linuxx86_64netty-transport-native-epolllinux-x86_64
Linuxaarch64netty-transport-native-epolllinux-aarch_64
LinuxRISC-Vnetty-transport-native-epolllinux-riscv64
macOSIntelnetty-transport-native-kqueueosx-x86_64
macOSApple Siliconnetty-transport-native-kqueueosx-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 的实测 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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