🤖
AI审核中

从“等就绪”到“收结果”:Netty4.2如何用io_uring重构网络 I/O

Java 29分钟 139浏览 1评论

你好呀,我是小邹。

在 Java 高性能网络编程中,epoll 几乎已经成为 Linux 服务端的标准答案。无论是网关、RPC 框架、消息中间件,还是基于 Netty 构建的长连接服务,底层通常都绕不开事件循环与就绪通知。

但随着 Linux io_uring 持续演进,一个新的问题出现了:

已经使用 epoll 的 Java 服务,还有必要切换到 io_uring 吗?

很多文章会直接给出“io_uring 比 epoll 更快”“io_uring 没有系统调用”“io_uring 天然零拷贝”之类的结论。实际上,这些说法都过度简化了问题。

截至 2026 年 9 月,Netty 官方当前最新的 4.2 版本是 4.2.17.Final,发布于 2026 年 8 月 4 日。早在 Netty 4.2.0.Final 中,io_uring 就已经结束孵化状态,成为正式支持的一等传输模块。本文以 4.2.17.Final 为例,系统讲清它与 epoll、虚拟线程之间的关系,以及如何安全地落地到生产环境。(Netty)

一、epoll 的核心不是“异步完成”,而是“就绪通知”

先回顾一下 epoll 的工作方式。

Linux 内核中的 epoll 实例主要维护两类集合:

  • 应用关注的文件描述符集合;
  • 当前已经具备 I/O 条件的就绪集合。

应用通过 epoll_wait 获取已经就绪的文件描述符,然后再对这些描述符执行 acceptreadwrite 等操作。换句话说,epoll 告诉应用的是:

这个连接现在应该可以读了。

而不是:

读取操作已经完成,结果就在这里。

Linux 手册也将 epoll 定义为一种 I/O 事件通知机制:它监视多个文件描述符,并通过就绪列表告诉用户态哪些描述符当前可以执行 I/O。(Man7)

flowchart LR
    A[Netty EventLoop] --> B[调用 epoll_wait]
    B --> C[内核返回就绪 FD]
    C --> D[应用调用 accept 或 read]
    D --> E[获取并处理数据]
    E --> F[触发 ChannelPipeline]
    F --> A

这种模型非常成熟,也足够高效。它让少量 EventLoop 线程能够管理大量网络连接,是 Netty 能支撑高并发的重要基础。

但它仍然存在一层天然的分离:

  1. 先等待“就绪事件”;
  2. 收到事件后,再发起真正的 I/O 操作;
  3. 用户态继续管理读取循环、缓冲区和写出状态。

在连接数量非常多、请求非常短、单次 I/O 很小的场景中,事件通知、实际 I/O、状态切换和系统调用边界所产生的成本就会逐渐显现。

二、io_uring 从“等待就绪”转向“提交操作”

io_uring 的核心变化,不是把 epoll 的速度再提升一点,而是重新设计了用户态与内核之间交付 I/O 的方式。

它主要包含两个共享环形队列:

队列 全称 作用
SQ Submission Queue 用户态向内核提交 I/O 请求
CQ Completion Queue 内核向用户态返回 I/O 完成结果

应用不再只是注册“我关注这个连接是否可读”,而是直接描述需要执行的操作,例如:

  • 接收一个新连接;
  • 从某个 Socket 读取数据;
  • 向某个 Socket 发送数据;
  • 注册超时;
  • 取消尚未完成的请求。

Linux 官方手册明确说明,SQ 和 CQ 会映射为用户态与内核共享的环形缓冲区。应用把请求放入 SQ,内核完成操作后,再把结果放入 CQ。(Man7)

flowchart LR
    A[Netty EventLoop] --> B[构造 SQE]
    B --> C[SQ 提交队列]
    C --> D[Linux 内核执行 I/O]
    D --> E[CQ 完成队列]
    E --> F[EventLoop 读取 CQE]
    F --> G[触发 ChannelPipeline]
    G --> A

其中:

  • SQE 是 Submission Queue Entry,描述一次待执行操作;
  • CQE 是 Completion Queue Entry,记录操作结果;
  • CQE 中通常包含成功读取的字节数,或者以负数形式表示的错误码。

多个 SQE 可以先写入提交队列,再通过一次 io_uring_enter 交给内核,因此系统调用成本可以被多个 I/O 操作共同摊薄。多个请求完成的顺序不一定等于提交顺序,框架必须根据请求标识将 CQE 映射回正确的连接和操作。(Man7)

三、epoll 与 io_uring 的差异到底在哪里

两者并不是简单的“旧接口”和“新接口”关系。

对比维度 epoll io_uring
核心语义 文件描述符是否就绪 具体 I/O 操作是否完成
内核返回内容 可读、可写等事件 操作结果、字节数或错误码
I/O 发起方式 就绪后再调用 read、write 直接提交 read、send、accept 等操作
批处理方式 一次返回多个就绪事件,但后续 I/O 通常仍需分别执行 多个 SQE 可以一次提交
完成顺序 应用按事件循环处理 多个请求可能乱序完成
用户态职责 管理就绪状态和读写循环 管理提交项和完成项
平台范围 Linux Linux
成熟程度 非常成熟、行为稳定 功能持续演进,受内核版本影响明显

对于普通业务服务,epoll 的性能可能已经远高于真正需要的水平。

只有当网络 I/O 本身成为瓶颈时,io_uring 的完成模型、批处理和缓冲区能力才更可能体现价值。假如服务的大部分时间都消耗在数据库、远程接口、JSON 序列化或者业务计算上,仅仅更换网络传输实现,整体吞吐量通常不会发生质变。

四、io_uring 并不等于“零系统调用”和“零拷贝”

这是理解 io_uring 时最容易出现的两个误区。

1. 默认情况下仍然存在系统调用

应用将 SQE 写入共享队列后,通常仍然需要调用 io_uring_enter,通知内核开始处理请求。

io_uring 的优势在于:

可以先写入多个 SQE,再通过一次系统调用批量提交,而不是每个操作都单独跨越一次用户态和内核态边界。

只有在使用 Submission Queue Polling 等特定模式时,才有可能进一步减少提交时的系统调用。但这需要内核线程持续轮询提交队列,会带来额外 CPU 消耗,也不能简单理解为免费的性能提升。(Man7)

2. 共享环不代表业务数据天然零拷贝

SQ 和 CQ 共享的是提交信息与完成信息。这样能够减少 I/O 描述信息在用户态和内核态之间的复制,但并不代表 Socket 中的业务数据一定不会复制。

真正减少数据复制还需要依赖:

  • 已注册缓冲区;
  • Buffer Ring;
  • send_zc 等特定操作;
  • 内核版本支持;
  • 上层框架是否真正启用对应能力。

因此,更准确的说法是:

io_uring 为高效批处理、缓冲区注册和特定零拷贝能力提供了基础,但使用 io_uring 并不自动意味着整个数据链路零拷贝。

五、multishot 和 Buffer Ring 为什么重要

如果只把普通的 readwrite 换成 SQE,io_uring 的优势可能并没有想象中明显。真正值得关注的是它逐步加入的一系列批量化机制。

1. multishot

普通模式下一次 SQE 通常对应一次完成事件。操作完成后,应用若还想继续接收数据,就需要再次提交新的 SQE。

multishot 允许一个 SQE 连续产生多个 CQE。例如:

  • 一个 multishot accept 持续接收多个新连接;
  • 一个 multishot recv 持续产生多次接收结果;
  • 一个 multishot poll 持续返回多个状态变化。

这样可以减少重复构造和提交同类请求的成本。

2. Buffer Ring

接收数据时,内核需要知道数据应该写入哪个用户态缓冲区。

Buffer Ring 允许应用提前向内核注册一组可用缓冲区。数据到达后,内核可以直接从这组缓冲区中选择合适的空间,并在 CQE 中告诉应用最终使用了哪个缓冲区。

这能够减少高频接收场景中的缓冲区注册、选择和管理成本。

Netty 4.2 已经提供了 multishot accept、multishot recv、Buffer Ring、splice 和 TCP Fast Open 等能力的运行时检测接口,而不是要求开发者仅根据内核版本猜测功能是否可用。(Netty)

可以在启动时输出当前机器实际启用的能力:

if (IoUring.isAvailable()) {
    System.out.println("io_uring features: " + IoUring.featureString());
    System.out.println("accept multishot: "
            + IoUring.isAcceptMultishotEnabled());
    System.out.println("recv multishot: "
            + IoUring.isRecvMultishotEnabled());
    System.out.println("buffer ring: "
            + IoUring.isRegisterBufferRingSupported());
}

这里需要注意,内核“支持某个功能”和 Netty“当前已经启用某个功能”并不完全是同一个概念。因此,生产诊断时应优先查看 Netty 自己的运行时检测结果。

六、Netty 4.2 对传输层做了什么重构

在 Netty 4.1 时代,EventLoopGroup 与具体传输实现耦合得比较紧。

开发者通常需要使用不同的类型:

new NioEventLoopGroup();
new EpollEventLoopGroup();
new KQueueEventLoopGroup();

Netty 4.2 引入了统一的 MultiThreadIoEventLoopGroup,并将具体 I/O 处理逻辑抽象为 IoHandlerFactory

新的结构是:

EventLoopGroup group = new MultiThreadIoEventLoopGroup(
        IoUringIoHandler.newFactory()
);

更换底层传输时,只需要更换工厂:

NioIoHandler.newFactory();
EpollIoHandler.newFactory();
KQueueIoHandler.newFactory();
IoUringIoHandler.newFactory();

需要注意的是,ServerBootstrapBootstrap 中仍然要配置与传输实现相匹配的 Channel,例如 IoUringServerSocketChannel。Netty 官方迁移指南已经将传统的传输专用 EventLoopGroup 标记为弃用方向,并推荐通过 IoHandlerFactory 注入具体传输实现。(GitHub)

这个调整带来的价值不仅是少写几个类名,而是把两层职责真正拆开了:

flowchart LR
    A[MultiThreadIoEventLoopGroup] --> B[IoHandlerFactory]
    B --> C[NIO]
    B --> D[epoll]
    B --> E[kqueue]
    B --> F[io_uring]
    A --> G[任务调度与事件循环]
    C --> H[具体 I/O 多路复用]
    D --> H
    E --> H
    F --> H

EventLoopGroup 负责线程、任务调度和事件循环,IoHandler 负责与特定操作系统 I/O 机制交互。

这使得运行时探测、指标采集、传输回退以及后续扩展都更加容易。

七、从孵化版迁移到 Netty 4.2 要改什么

Netty 4.2 中的 io_uring 并不是旧孵化模块简单改了一个版本号。

旧孵化模块 Netty 4.2 正式模块
netty-incubator-transport-io_uring netty-transport-native-io_uring
io.netty.incubator.channel.uring io.netty.channel.uring
IOUringServerSocketChannel IoUringServerSocketChannel
IOUringEventLoopGroup MultiThreadIoEventLoopGroup
EventLoopGroup 内置传输逻辑 注入 IoUringIoHandler
孵化状态 正式的一等传输模块

类名前缀也从全大写风格的 IOUring 改成了 IoUring

Netty 官方明确指出,旧孵化模块与 4.2 中的新实现并不兼容;同时,虽然 Netty 4.2 整体仍可运行在 Java 8 上,但 io_uring 传输要求 Java 9 或更高版本。(GitHub)

八、添加 Maven 依赖

下面以 JDK 17 和 Netty 4.2.17.Final 为例,同时引入 io_uring 和 epoll,以便在 io_uring 不可用时自动降级。

<properties>
    <maven.compiler.release>17</maven.compiler.release>
    <netty.version>4.2.17.Final</netty.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-bom</artifactId>
            <version>${netty.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-codec-http</artifactId>
    </dependency>

    <!-- io_uring Java API -->
    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-transport-classes-io_uring</artifactId>
    </dependency>

    <!-- io_uring Linux x86_64 原生库 -->
    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-transport-native-io_uring</artifactId>
        <classifier>linux-x86_64</classifier>
        <scope>runtime</scope>
    </dependency>

    <!-- epoll Java API,用于降级 -->
    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-transport-classes-epoll</artifactId>
    </dependency>

    <!-- epoll Linux x86_64 原生库 -->
    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-transport-native-epoll</artifactId>
        <classifier>linux-x86_64</classifier>
        <scope>runtime</scope>
    </dependency>
</dependencies>

这里的 linux-x86_64 只是示例。如果服务器使用 ARM64,应选择与实际架构匹配的分类器,例如 linux-aarch_64

Netty 官方原生传输文档强调,依赖必须使用正确的 classifier。官方 Linux 原生库基于 glibc 构建,Alpine Linux 默认使用的 musl 并不在官方原生构建支持范围内;使用 Alpine 时,要么自行编译原生库,要么更换基础镜像,要么回退到 NIO。(Netty)

九、实现 io_uring、epoll、NIO 三级回退

生产环境不应该假定 io_uring 一定可用。

即使服务器运行的是 Linux,也可能因为以下原因加载失败:

  • 内核版本过低;
  • 必要的 io_uring 操作不完整;
  • 原生库架构不匹配;
  • 系统使用 musl;
  • 宿主机禁用了 io_uring;
  • 容器 seccomp 策略禁止相关系统调用;
  • 原生库加载失败。

因此,更稳妥的方案是:

flowchart LR
    A[应用启动] --> B{io_uring 可用}
    B -->|是| C[使用 io_uring]
    B -->|否| D{epoll 可用}
    D -->|是| E[使用 epoll]
    D -->|否| F[使用 Java NIO]

下面是一份可以直接参考的完整 HTTP 服务示例:

import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.IoHandlerFactory;
import io.netty.channel.MultiThreadIoEventLoopGroup;
import io.netty.channel.ServerChannel;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.epoll.Epoll;
import io.netty.channel.epoll.EpollIoHandler;
import io.netty.channel.epoll.EpollServerSocketChannel;
import io.netty.channel.nio.NioIoHandler;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.uring.IoUring;
import io.netty.channel.uring.IoUringIoHandler;
import io.netty.channel.uring.IoUringServerSocketChannel;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.HttpUtil;
import io.netty.handler.codec.http.HttpVersion;

import java.nio.charset.StandardCharsets;

public final class NettyTransportServer {

    public static void main(String[] args) throws InterruptedException {
        Transport transport = Transport.detect();

        EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(
                1,
                transport.ioHandlerFactory()
        );

        EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(
                transport.ioHandlerFactory()
        );

        try {
            Channel serverChannel = new ServerBootstrap()
                    .group(bossGroup, workerGroup)
                    .channel(transport.serverChannelClass())
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel channel) {
                            channel.pipeline()
                                    .addLast(new HttpServerCodec())
                                    .addLast(new HttpObjectAggregator(64 * 1024))
                                    .addLast(new HelloHandler());
                        }
                    })
                    .bind(8080)
                    .sync()
                    .channel();

            System.out.printf(
                    "Server started at http://127.0.0.1:8080, transport=%s%n",
                    transport.name()
            );

            serverChannel.closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully().syncUninterruptibly();
            workerGroup.shutdownGracefully().syncUninterruptibly();
        }
    }

    private static final class HelloHandler
            extends SimpleChannelInboundHandler<FullHttpRequest> {

        @Override
        protected void channelRead0(
                ChannelHandlerContext context,
                FullHttpRequest request
        ) {
            FullHttpResponse response = new DefaultFullHttpResponse(
                    HttpVersion.HTTP_1_1,
                    HttpResponseStatus.OK,
                    Unpooled.copiedBuffer(
                            "Hello from Netty " + context.channel()
                                    .eventLoop()
                                    .getClass()
                                    .getSimpleName(),
                            StandardCharsets.UTF_8
                    )
            );

            response.headers().set(
                    HttpHeaderNames.CONTENT_TYPE,
                    "text/plain; charset=UTF-8"
            );

            response.headers().setInt(
                    HttpHeaderNames.CONTENT_LENGTH,
                    response.content().readableBytes()
            );

            boolean keepAlive = HttpUtil.isKeepAlive(request);
            HttpUtil.setKeepAlive(response, keepAlive);

            ChannelFuture future = context.writeAndFlush(response);
            if (!keepAlive) {
                future.addListener(ChannelFutureListener.CLOSE);
            }
        }

        @Override
        public void exceptionCaught(
                ChannelHandlerContext context,
                Throwable cause
        ) {
            cause.printStackTrace();
            context.close();
        }
    }

    private record Transport(
            String name,
            IoHandlerFactory ioHandlerFactory,
            Class<? extends ServerChannel> serverChannelClass
    ) {

        private static Transport detect() {
            if (IoUring.isAvailable()) {
                System.out.println(
                        "io_uring available: " + IoUring.featureString()
                );

                return new Transport(
                        "io_uring",
                        IoUringIoHandler.newFactory(),
                        IoUringServerSocketChannel.class
                );
            }

            System.err.println(
                    "io_uring unavailable: "
                            + IoUring.unavailabilityCause()
            );

            if (Epoll.isAvailable()) {
                return new Transport(
                        "epoll",
                        EpollIoHandler.newFactory(),
                        EpollServerSocketChannel.class
                );
            }

            System.err.println(
                    "epoll unavailable: " + Epoll.unavailabilityCause()
            );

            return new Transport(
                    "nio",
                    NioIoHandler.newFactory(),
                    NioServerSocketChannel.class
            );
        }
    }
}

这个方案有三个重要特点。

第一,开发环境即使是 Windows 或 macOS,也可以自动使用 Java NIO,不必为本地运行维护另一套代码。

第二,Linux 服务器不满足 io_uring 条件时,会优先回退到成熟的 epoll,而不是直接退回普通 NIO。

第三,降级原因会被输出。生产环境还应把最终使用的传输类型注册为监控指标或服务标签,避免应用已经悄悄降级,团队却仍然按照 io_uring 的性能预期分析问题。

Netty 提供的 IoUring.isAvailable()unavailabilityCause()featureString() 正是为运行时检测与诊断准备的。(Netty)

十、生产环境需要检查哪些条件

1. 内核版本

Netty 4.2.17 的 io_uring 实现默认要求 Linux 内核至少为 5.9。源码中会检查内核版本,同时还会探测 Netty 所需的具体操作是否可用。(Netty)

可以先检查:

uname -r

不过,不建议只通过版本号决定是否启用。

同一个内核大版本在不同发行版中可能包含不同的补丁、回移功能和安全策略。最终仍应以 IoUring.isAvailable() 的运行结果为准。

2. Java 版本

java -version

Netty 4.2 的 io_uring 传输要求 Java 9 或更高版本。生产环境更适合使用仍在维护周期内的 LTS JDK。

3. glibc 和 CPU 架构

uname -m
ldd --version | head -n 1

要确保 Maven classifier 与服务器实际架构一致:

服务器架构 常用 classifier
x86-64 linux-x86_64
ARM64 linux-aarch_64
RISC-V 64 linux-riscv64

Netty 4.2 的迁移指南还将官方原生传输所需的最低 glibc 版本提升到了 2.17。(GitHub)

4. 宿主机是否禁用了 io_uring

cat /proc/sys/kernel/io_uring_disabled

这个配置值的含义如下:

含义
0 所有进程可以正常创建 io_uring,默认值
1 普通非特权进程默认不能创建,需要指定组或相应权限
2 所有进程都不能创建新的 io_uring 实例

当值为 12 时,io_uring_setup 可能返回 EPERM。Linux 内核文档也说明,该开关主要用于缩小内核攻击面。(Linux内核文档)

不要在应用启动脚本中擅自修改这个系统级配置。它属于宿主机安全策略,应由运维和安全团队统一评估。

5. 容器 seccomp 策略

这是容器部署中最容易遗漏的一项。

即使宿主机支持 io_uring,容器运行时也可能阻止:

io_uring_setup
io_uring_enter
io_uring_register

典型表现是应用在宿主机直接运行正常,放进容器后 IoUring.isAvailable() 返回 false,异常原因中出现:

Operation not permitted

Moby 官方问题记录显示,在 Docker 25.0.4 的默认 seccomp 配置下,io_uring 初始化会因为权限限制而失败。Docker 的 seccomp 本身采用允许列表机制,未被允许的系统调用会受到限制。(GitHub)

生产环境不建议为了启用 io_uring 直接使用:

--security-opt seccomp=unconfined

更合理的方式是基于组织现有的默认安全配置,经过安全评审后,仅开放业务所需的 io_uring 系统调用,并保留其他限制。

十一、如何配置 Ring Size 和 CQ Size

Netty 提供了 IoUringIoHandlerConfig,可以配置:

  • Submission Queue 大小;
  • Completion Queue 大小;
  • bounded worker 数量;
  • unbounded worker 数量;
  • Buffer Ring;
  • Single Issuer。

默认 Ring Size 是 4096,默认 CQ Size 是 Ring Size 的两倍。Netty 官方说明,这组默认值足以覆盖大多数场景。(Netty)

确实经过压测并发现队列容量不足时,可以进行显式调整:

import io.netty.channel.IoHandlerFactory;
import io.netty.channel.uring.IoUringIoHandler;
import io.netty.channel.uring.IoUringIoHandlerConfig;

IoUringIoHandlerConfig config = new IoUringIoHandlerConfig()
        .setRingSize(8192)
        .setCqSize(16384)
        .setSingleIssuer(true);

IoHandlerFactory factory = IoUringIoHandler.newFactory(config);

这里的 819216384 只是演示值,不是推荐所有服务直接照搬。

setCqSize 还有两个约束:

  1. CQ Size 不能小于 Ring Size;
  2. CQ Size 必须是 2 的幂。

否则 Netty 会抛出 IllegalArgumentException。(Netty)

Ring Size 不是越大越好

增大 Ring Size 可以让单个 EventLoop 同时容纳更多待提交操作,但也会带来:

  • 更多原生内存占用;
  • 更大的队列扫描范围;
  • 更高的 CPU 缓存压力;
  • 更多尚未完成的请求;
  • 更复杂的尾延迟表现。

每个 EventLoop 都会通过工厂创建自己的 IoHandler,因此 EventLoop 线程越多,Ring 实例以及相关原生资源也会随之增加。

真正需要关注的不是“队列最大能配多大”,而是:

在目标负载下,当前队列是否频繁达到压力边界。

十二、几个关键调优参数应该怎么理解

参数 主要作用 调整建议
ringSize 控制 SQ 可容纳的提交项规模 默认开始,仅在提交压力明确时增加
cqSize 控制 CQ 可容纳的完成项规模 multishot 或完成突发明显时重点观察
singleIssuer 限制由同一线程驱动 Ring 一般保持默认开启
Buffer Ring 提前注册接收缓冲区 接收密集且内核支持时再评估
bounded worker 控制可能阻塞的受限 io-wq 工作线程 普通网络服务通常保持内核默认
unbounded worker 控制可能长时间阻塞的 io-wq 工作线程 涉及文件 I/O 时重点评估
EventLoop 数量 决定并行处理 I/O 的线程规模 根据 CPU 与实际利用率配置,不按连接数线性增加

singleIssuer 在 Netty 中默认开启,主要是为了获得更好的性能,但这也意味着负责驱动该 IoHandler 的线程不能随意更换。只有确实需要动态改变 EventLoop 驱动线程时,才应考虑关闭。(Netty)

十三、io_uring 与虚拟线程并不冲突

有一种常见观点认为:

Java 有了虚拟线程,就不再需要 Netty,也不再需要 io_uring。

这个结论同样把不同层次的问题混到了一起。

io_uring 解决的是内核 I/O 交付问题

它关注:

  • 用户态怎样向内核提交 I/O;
  • 怎样批量提交;
  • 内核怎样返回完成结果;
  • 怎样减少重复注册与系统调用成本。

Netty 解决的是网络框架问题

它关注:

  • 连接生命周期;
  • EventLoop;
  • 协议编解码;
  • ChannelPipeline;
  • 背压;
  • 内存管理;
  • 超时和连接状态。

虚拟线程解决的是 Java 并发编程问题

它让开发者可以使用同步、阻塞式的代码结构表达高并发任务。

Oracle 文档说明,虚拟线程执行阻塞 I/O 时,Java 运行时可以挂起虚拟线程并释放其载体线程,让少量平台线程承载大量虚拟线程。虚拟线程适合大量时间都在等待 I/O 的任务,但并不适合长时间运行的 CPU 密集任务;它的主要目标是扩展吞吐能力,而不是让单次任务执行得更快。(Oracle 文档)

三者所处的位置可以这样理解:

flowchart LR
    A[业务请求] --> B[虚拟线程或异步任务]
    B --> C[Netty ChannelPipeline]
    C --> D[Netty EventLoop]
    D --> E[io_uring]
    E --> F[Linux 内核网络栈]

在实际项目中,它们完全可以组合使用:

  • Netty EventLoop 负责网络收发和协议处理;
  • io_uring 负责与 Linux 内核高效交互;
  • 虚拟线程负责不得不使用阻塞式接口的业务逻辑,例如传统 JDBC、同步 SDK 或文件操作。

不过需要特别注意:

把业务代码放在 Netty Handler 中,并不会自动让它运行在虚拟线程上。

如果 Handler 当前运行在 Netty EventLoop 中,那么直接执行阻塞数据库查询、Thread.sleep 或同步远程调用,仍然会阻塞整个 EventLoop。

虚拟线程的引入必须通过独立执行器或清晰的任务边界完成,同时还要正确处理 Netty 引用计数对象的生命周期。

十四、怎样正确压测 io_uring

最容易得到错误结论的方式,是拿 Netty 4.1 的 epoll 服务与 Netty 4.2 的 io_uring 服务直接对比。

因为 Netty 4.2 除了传输层,还修改了其他默认行为。例如默认内存分配器从 pooled 调整为 adaptive,TLS 客户端的主机名验证策略也发生了变化。这样得到的性能差异,不能全部归因于 io_uring。(GitHub)

正确的对比方式应该是:

使用同一个 Netty 4.2.17.Final 应用,只切换 IoHandlerFactory 和 Channel 类型。

例如分别启动:

IoUringIoHandler.newFactory();
EpollIoHandler.newFactory();
NioIoHandler.newFactory();

同时固定以下变量:

变量 要求
JDK 完全相同
JVM 参数 堆内存、直接内存和 GC 相同
Netty 版本 完全相同
内存分配器 显式固定
EventLoop 数量 相同
请求内容 相同
Keep-Alive 相同
TLS 同开或同关
CPU 亲和性 保持一致
宿主机 尽量使用同一台机器

为了隔离内存分配器变量,可以在测试阶段显式使用:

-Dio.netty.allocator.type=pooled

基础 HTTP 压测

wrk -t8 -c512 -d60s --latency \
  http://127.0.0.1:8080/

不要只看每秒请求数,还应关注:

  • P50、P95、P99 和 P999 延迟;
  • 单位请求 CPU 消耗;
  • 上下文切换;
  • 系统调用次数;
  • JVM 堆内存;
  • Netty 直接内存;
  • GC 暂停;
  • 超时和连接重置;
  • 长时间运行后的稳定性。

使用 perf 观察 CPU 行为

perf stat -p "$PID" \
  -e task-clock,cycles,instructions,context-switches,cpu-migrations

使用 strace 检查系统调用

strace -f -c -p "$PID" \
  -e trace=io_uring_setup,io_uring_enter,io_uring_register,epoll_wait,recvfrom,sendto

strace 会明显干扰应用性能,所以它只能用于诊断系统调用路径,不能把开启 strace 时的吞吐量作为正式性能结果。

不要只测试“健康的本机短请求”

真实生产场景还应该覆盖:

  • 大量长连接;
  • 客户端慢读;
  • 服务端慢写;
  • 上游接口变慢;
  • 请求大小不均匀;
  • 突发流量;
  • 连接频繁建立和关闭;
  • TLS 加解密;
  • 24 小时以上稳定性测试。

io_uring 在低并发、纯本机回环、业务处理为空的基准测试中表现很好,并不代表它在真实业务链路中一定能带来相同收益。

十五、监控时还要注意 iowait 指标变化

Netty 4.2 提供了:

IoUring.isIoringEnterNoIoWaitEnabled();

当内核支持并启用 IORING_ENTER_NO_IOWAIT 时,空闲状态下等待 io_uring 完成事件的时间不会再被统计为传统 iowait。

这会让服务器侧 CPU 指标更加符合网络服务的实际情况,但也意味着:

从 epoll 切换到 io_uring 后,iowait 指标可能发生变化,不能直接将前后数值机械对比。

Netty 官方 API 还指出,该能力可能影响 CPU 频率调节器基于 iowait 的加速行为。因此,在延迟敏感的服务上,除了观察 CPU 使用率,还应同时观察 CPU 频率、尾延迟和单位请求周期数。(Netty)

十六、哪些场景更值得尝试 io_uring

场景 建议
高并发 API 网关、反向代理 值得压测
大量长连接和频繁小包收发 值得压测
自研 RPC、消息系统、网络中间件 值得深入评估
连接建立和关闭非常频繁 可重点观察 multishot accept
网络 I/O 已经成为 CPU 瓶颈 更可能获得收益
普通数据库 CRUD 服务 通常先优化数据库和业务链路
请求量较低的内部管理系统 没有必要为了技术新颖度迁移
Windows、macOS 混合部署 保留 NIO 或增加 kqueue 适配
Alpine、musl 环境 官方原生库兼容性需要额外处理
强安全隔离且禁止 io_uring 使用 epoll 或 NIO
追求简单同步代码结构 优先评估虚拟线程

判断是否迁移,不应该只看峰值 QPS。

更合理的生产准入条件是:

  1. 在相同负载下,P99 或单位请求 CPU 有明确改善;
  2. 直接内存和原生资源没有异常增长;
  3. 慢客户端和突发流量下没有新的尾延迟问题;
  4. 长时间稳定性测试通过;
  5. io_uring、epoll、NIO 回退路径都经过验证;
  6. 容器与宿主机安全策略已经评审;
  7. 线上指标能够明确显示当前使用的传输类型。

十七、几个常见的错误做法

1. 看到 Linux 就强制使用 io_uring

Linux 只是最基础的条件。内核、原生库、glibc、CPU 架构、必要操作、sysctl 和容器权限都可能导致它不可用。

正确做法是运行时探测,并准备回退方案。

2. 无限制增大 Ring Size

队列更大不等于吞吐量一定更高。过大的队列可能增加内存、缓存和在途请求压力,并让尾延迟更加难以控制。

正确做法是从默认值开始,根据队列压力和压测结果调整。

3. 为了启用 io_uring 关闭全部 seccomp

这相当于为了一个性能特性,移除容器的重要安全边界。

正确做法是最小权限开放,并经过安全评审。

4. 在 EventLoop 中执行阻塞业务

底层换成 io_uring,也无法抵消 EventLoop 被数据库查询、同步 HTTP 调用或磁盘操作长期阻塞的问题。

正确做法是保持 EventLoop 非阻塞,把不可避免的阻塞任务转移到独立线程池或虚拟线程。

5. 只看平均延迟

平均延迟可能下降,但 P999、超时率和直接内存却可能恶化。

正确做法是同时观察吞吐量、尾延迟、CPU、上下文切换、系统调用、直接内存和稳定性。

6. 强行覆盖上层框架的 Netty 版本

使用 Reactor Netty、Vert.x、gRPC 或其他框架时,不能只在 Maven 中强制替换 Netty 版本,还需要确认上层框架对 Netty 4.2 的兼容范围。

否则即使项目能够编译,也可能在运行期遇到方法缺失、行为改变或原生传输初始化问题。

十八、总结

epoll 的思路是:

告诉我哪些文件描述符已经就绪,我再执行 I/O。

io_uring 的思路是:

我把需要执行的 I/O 操作提交给内核,完成后把结果返回给我。

这不仅是 API 写法变化,更是从“就绪驱动”向“完成驱动”的模型变化。

Netty 4.2 将 io_uring 从孵化项目纳入正式传输体系,又通过 MultiThreadIoEventLoopGroupIoHandlerFactory 解耦事件循环与底层 I/O,让 io_uring、epoll 和 NIO 可以使用统一的组织方式。

但 io_uring 不是一个打开就能免费获得性能的开关。

它更适合网络 I/O 已经成为瓶颈、能够统一控制 Linux 环境、愿意进行系统压测与安全评审的服务。对于普通业务系统,成熟稳定的 epoll 仍然是非常优秀的选择;对于追求简单同步开发体验的应用,虚拟线程可能比更换底层传输更值得优先考虑。

真正合理的技术决策,不是“新技术一定替代旧技术”,而是:

在同样的业务负载下,用吞吐量、尾延迟、资源消耗和稳定性证明它值得迁移。

1 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  1 条评论
伴我   湖南省衡阳市