io_uring 异步写如何让 Netty HTTP/2 把大 body 切成 1KB 碎片

note: 本文记录的是 Claude Opus 5.5 与我一起排查 Netty io_uring 在 HTTP/2 场景下 CPU 异常的过程。文中 Netty 代码均基于 4.2@006a2f1d92。

TL;DR

  • 把 Netty 的 HTTP/2 服务从 NIO(epoll)切到 io_uring,event loop 的 CPU 反而虚高了。问题不在 io_uring 慢,而在于它的写是异步完成的。
  • NIO 是同步写,flush 时字节立刻离开 ChannelOutboundBuffer,写水位马上回落。io_uring 要等 CQE 到来才 removeBytes,在这之前水位一直被占着。
  • h2 的 flow controller 按 channel 剩余的可写字节分配写入预算。在途字节一旦越过高水位(默认 64KB),后续响应就会积压在 flow controller 里。积压解除时,多个 stream 同时待发,默认的 WeightedFairQueueByteDistributor 以 allocationQuantum = 1KB 轮转,把大 body 切成一堆 1KB 的 DATA frame。ByteBuf、slice、promise 的数量膨胀近 10 倍,zero-copy 的阈值也永远达不到。
  • 即使是 HttpToHttp2ConnectionHandler + 每个响应各自 writeAndFlush,在 io_uring 下也会中招;在 NIO 下不会。
  • 参考 grpc-java,改用 UniformStreamByteDistributor + minAllocationChunk = 16KB 后,io_uring 的 event loop CPU 比 WFQ 默认下降 33% 到 63%,延迟也更低。HTTP/2 的优先级权重已在 RFC 9113 中被废弃,1KB 的轮转粒度在内网带宽下也换不来可感知的公平性。Vert.x 5 同样默认改用了 Uniform。
  • 核心教训:io_uring 的异步写改变了"一次写什么时候算完成"的语义,所有隐含"写是同步完成"这个假设的上层机制(写水位、流控预算),都可能出现意料之外的行为。

1. 问题:命中不了的 SENDMSG_ZC,和比 NIO 还高的 CPU

我在研究 Netty io_uring 的 SENDMSG_ZC 在 h2 上的优化时,碰到一件很奇怪的事:向同一个连接的多个 stream 写入远超 zero-copy 阈值的大 body,后续的 DATA frame 竟然没有一个达到 SENDMSG_ZC 的阈值。用 bpftrace 统计 io_uring:io_uring_submit_req,16KB body、5000 rps 下,每个请求的 SEND_ZC/SENDMSG_ZC 都是 0,只有零星的 WRITEV(每个请求 0.36 次,即一次 writev 带走约 3 个响应)。

退回到普通 writev 后,更怪的事情来了:io_uring 的 event loop CPU 比 NIO 实现还高。换成线上的写法(HttpToHttp2ConnectionHandler,每个响应各自 writeAndFlush(FullHttpResponse)),64KB body、每个连接并发 4 个请求时,NIO 每个请求写出 12 个 ByteBuf、event loop 耗时 11.27 µs;io_uring 每个请求写出 43.7 个 ByteBuf、耗时 17.36 µs,多了 54%(详见第 3 节)。同样的服务换成 HTTP/1.1 的 FullHttpResponse,两个 transport 基本没有差别(云上 64KB 时是 15.5 对 15.7 µs),差距只出现在 h2 上。

排查下来,罪魁祸首是 WeightedFairQueueByteDistributor:它把大 body 切成了大量 1KB 的 DATA frame。为什么只有 io_uring 会这样?

2. 原因:写水位的变化方式不同

2.1 h2 的写入预算跟着 channel 的可写状态走

h2 写 DATA 分两步。第一步,DefaultHttp2RemoteFlowController.writePendingBytes() 算出这一轮能写多少,再交给 StreamByteDistributor 分给各个 stream(DefaultHttp2RemoteFlowController.java#L611-L634):

int bytesToWrite = writableBytes();
for (;;) {
    if (!streamByteDistributor.distribute(bytesToWrite, this) ||
        (bytesToWrite = writableBytes()) <= 0 ||
        !isChannelWritable0()) {
        break;
    }
}

预算取决于 channel 还能写多少字节(#L237-L262):

private int maxUsableChannelBytes() {
    // If the channel isWritable, allow at least minUsableChannelBytes.
    int channelWritableBytes = (int) min(Integer.MAX_VALUE, ctx.channel().bytesBeforeUnwritable());
    int usableBytes = channelWritableBytes > 0 ? max(channelWritableBytes, minUsableChannelBytes()) : 0;
    return min(connectionState.windowSize(), usableBytes);
}

bytesBeforeUnwritable() 是写高水位减去 ChannelOutboundBuffer 中还没写完的字节(ChannelOutboundBuffer.java#L787)。channel 不可写时预算是 0,数据只能留在 flow controller 里排队。

第二步,FlowControlledData.write() 只写出本轮分到的 allowedBytes(DefaultHttp2ConnectionEncoder.java#L526),然后由 DefaultHttp2FrameWriter.writeData() 按 maxFrameSize 切帧。每个帧都会单独分配一个 9 字节的帧头 buffer,再加上一个 payload 的 retained slice(DefaultHttp2FrameWriter.java#L162-L183)。

所以实际 DATA frame 的大小 = min(本轮分到的字节数, maxFrameSize)。SETTINGS_MAX_FRAME_SIZE(默认 16KB)只是上限,多个 stream 并发时,真正起作用的是 distributor 每轮分出去的字节数。

2.2 WFQ 默认 1KB 轮转

WeightedFairQueueByteDistributor 的默认 quantum 是 1KB(Http2CodecUtil.java#L119、WeightedFairQueueByteDistributor.java#L90)。多个 stream 同时待发时,每个 stream 每轮分到的字节数是(#L313-L339):

int nsent = distribute(nextChildState == null ? maxBytes :
                min(maxBytes, (int) min((nextChildState.pseudoTimeToWrite - childState.pseudoTimeToWrite) *
                                   childState.weight / oldTotalQueuedWeights + allocationQuantum, MAX_VALUE)),
                writer, childState);

几个 stream 刚开始时伪时间相同,所以每轮只能分到约 allocationQuantum = 1KB,然后就切到下一个 stream。只要 flush 时有两个以上的 stream 同时待发,大 body 就会被切成一串约 1KB 的 DATA frame,交错写出:[9B][~1KB][9B][~1KB]...。只有一个 stream 待发时,它能拿到全部预算,不会被切成 1KB。

关键问题因此变成:什么时候会有多个 stream 同时待发?

2.3 NIO:同步写,水位立刻回落

NIO 的 doWrite() 在 flush 里直接 writev,写多少就立刻 removeBytes 多少(NioSocketChannel.java#L423-L431):

final long localWrittenBytes = ch.write(nioBuffers, 0, nioBufferCnt);
...
in.removeBytes(localWrittenBytes);

内网场景下 socket 发送缓冲区几乎总有空间,所以一个大 DATA frame flush 完,outbound buffer 就清空了,水位马上回落。下一个 stream 写入时看到的预算是充足的,flow controller 里只有它自己,于是整个 body 以满帧(16KB)发出。

2.4 io_uring:异步写,水位要等 CQE

io_uring 的 flush 只是提交一个 WRITEV SQE(AbstractIoUringStreamChannel.java#L265-L291),字节要等下一轮 event loop 处理 CQE 时才移除(#L690-L720):

boolean writeComplete0(byte op, int res, int flags, long data, int outstanding) {
    ...
    if (res >= 0) {
        channelOutboundBuffer.removeBytes(res);
    }
    ...
}

而且每个 channel 同一时刻只有一个写在飞,在途期间到来的 flush 只能往 outbound buffer 里追加。于是:

  1. 同一个 read loop 里处理了同一连接上的多个请求,每个响应 writeAndFlush 后,字节都还留在 outbound buffer 里,水位只升不降。
  2. 积累几个响应后就越过了 64KB 高水位(ChannelOutboundBuffer.incrementPendingOutboundBytes → setUnwritable,#L198-L205),channel 变成不可写,预算变成 0,后续响应只能积压在 flow controller 里。
  3. CQE 回来后 removeBytes,水位降到低水位以下,触发 channelWritabilityChanged,进而 Http2ConnectionHandler.flush() 和 writePendingBytes()(Http2ConnectionHandler.java#L195-L207)。这时 flow controller 里同时有多个 stream 待发,WFQ 开始按 1KB 轮转。

一句话总结:NIO 和 io_uring 的写水位曲线不一样。NIO 每写完一次就回落,WFQ 永远只看到一个 stream;io_uring 的水位要等 CQE 才回落,积压让 WFQ 进入多 stream 轮转,把大 body 切成了 1KB 的碎片。

2.5 HttpToHttp2ConnectionHandler 也逃不掉,Http2MultiplexHandler 更糟

我们线上用的是 HttpToHttp2ConnectionHandler:每个 FullHttpResponse 直接 writeAndFlush,整个 body 交给 encoder(HttpToHttp2ConnectionHandler.java#L135)。在 NIO 下,每个响应 flush 时只有自己一个 stream 待发,确实不会交错;但在 io_uring 下,一旦积压,同样会触发上面的流程。

Http2MultiplexHandler 的情况更糟:子 channel 的 flush 在父 channel 的 read loop 期间是空操作(AbstractHttp2StreamChannel.java#L1209-L1222),要等到 read complete 时统一 flush 一次(Http2MultiplexHandler.java#L391-L406):

public void flush() {
    // If we are currently in the parent channel's read loop we should just ignore the flush.
    // We will ensure we trigger ctx.flush() after we processed all Channels later on and
    // so aggregate the flushes.
    if (!writeDoneAndNoFlush || isParentReadInProgress()) {
        return;
    }
    ...
}

所以同一批请求的响应天然就会同时待发,即使在 NIO 下也会被 WFQ 切碎,在子 channel 里调用 writeAndFlush 也没用。第 1 节中 16KB 一次 zero-copy 都没命中,就是这个原因(压测工具 h2load 的 --rps 每 10ms 成批发请求,会让同一连接上的多个请求落进同一次读)。

2.6 除了切碎,异步完成本身也有代价

即使没有多个 stream 同时待发,io_uring 在 h2 上也比 NIO 贵。云上两台 4 vCPU 虚拟机走内网压测,h2c,Http2MultiplexHandler,固定速率,event loop 线程每个请求的 CPU:

body NIO io_uring(未开 zero-copy)
16KB @ 5000 rps 14.4 µs 16.9 µs
64KB @ 1500 rps 28.3 µs 32.4 µs
256KB @ 400 rps 79.1 µs 85.6 µs

其中 64KB @ 1500 rps 时,每个连接平均约 10ms 才来一个请求,几乎不会出现多个 stream 同时待发。这时的差距分成两部分。第一部分是多一轮 writev:一个 64KB 的 h2 响应(数据 + 帧头 + HEADERS)刚好超过 64KB 高水位,NIO 能在同一次 flush 里写完,io_uring 要等 CQE 回来释放预算后,在下一轮 event loop 再提交一次。把高水位调到 1MB 就能消除:

配置(64KB @ 1500 rps) event loop µs/req 每个请求的 WRITEV 每个请求的发包次数
NIO 25.7 — 1.96
io_uring,默认 64KB 高水位 31.1 1.92 2.58
io_uring,1MB 高水位 29.1 0.96 1.95

第二部分是剩下的约 3.4 µs。用 perf stat -t 只统计 event loop 线程(20 秒,约 1490 rps,两轮):两边的指令数完全相同(2.58/2.53 G 对 2.53/2.56 G),但 io_uring 的周期数多 8% 到 10%,cache miss 多 35%(33.9/32.5 M 对 44.8/45.2 M),IPC 从 0.87 降到 0.80。我的推测是,NIO 在 flush 里同步完成写入,buffer、promise、outbound entry 在被访问时都还在缓存里;io_uring 要到下一轮处理 CQE 时才回头释放 buffer、完成 promise、清理 entry,这时这些数据已经被其他工作挤出了缓存。

3. 测试

3.1 环境

  • 本地:i5-13600KF,loopback。服务端 2 个 event loop,绑在 P 核 0 和 2;压测端绑在 P 核 4/6/8/10。
  • 云上:两台腾讯云 SA9.LARGE8(4 vCPU,内核 7.0,virtio_net,iommu=pt),走内网,带宽被限在约 2.25 Gbps,所以统一用固定速率比较每个请求的 CPU。注意:主机安全 agent 在网卡上挂的 ETH_P_ALL 抓包 socket 会让所有 zero-copy 悄悄退化成 copy(发送路径上 dev_queue_xmit_nit 会调用 skb_copy_ubufs),测试前必须停掉。
  • 基线代码:Netty 4.2@006a2f1d92,JDK 25。

3.2 服务端:模拟线上的 HttpToHttp2ConnectionHandler

请求经 InboundHttp2ToHttpAdapter 聚合成 FullHttpRequest,handler 直接 writeAndFlush(FullHttpResponse)。body 在启动时构造一次,每次写出 retainedDuplicate(),相当于一份缓存好的内容:

DefaultHttp2Connection connection = new DefaultHttp2Connection(true);
installDistributor(connection, distributor, quantum);
pipeline.addLast(new HttpToHttp2ConnectionHandlerBuilder()
                .connection(connection)
                .initialSettings(Http2Settings.defaultSettings().maxConcurrentStreams(1024))
                .frameListener(new InboundHttp2ToHttpAdapterBuilder(connection)
                        .maxContentLength(1 << 20)
                        .propagateSettings(false)
                        .validateHttpHeaders(false)
                        .build())
                .build(),
        new AdaptedH2Handler(body));

// AdaptedH2Handler
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
    DefaultFullHttpResponse res = new DefaultFullHttpResponse(HTTP_1_1, OK, body.retainedDuplicate());
    res.headers().setInt(CONTENT_LENGTH, body.readableBytes());
    res.headers().set(HttpConversionUtil.ExtensionHeaderNames.STREAM_ID.text(),
                      req.headers().get(HttpConversionUtil.ExtensionHeaderNames.STREAM_ID.text()));
    ctx.writeAndFlush(res, ctx.voidPromise());
}

切换 distributor 的方式:

private static void installDistributor(DefaultHttp2Connection connection, String distributor, int quantum) {
    StreamByteDistributor d;
    if ("uniform".equals(distributor)) {
        UniformStreamByteDistributor u = new UniformStreamByteDistributor(connection);
        if (quantum > 0) {
            u.minAllocationChunk(quantum);
        }
        d = u;
    } else {
        WeightedFairQueueByteDistributor w = new WeightedFairQueueByteDistributor(connection);
        if (quantum > 0) {
            w.allocationQuantum(quantum);
        }
        d = w;
    }
    connection.remote().flowController(new DefaultHttp2RemoteFlowController(connection, d));
}

为了看清 transport 实际收到了什么,pipeline 最前面挂了一个出站 handler,统计每个写出的 ByteBuf 的大小分布。

3.3 压测端:同一连接上成批并发

压测端是一个基于 Netty 的 h2c 客户端:16 个连接,每个连接每 10ms 一次性打开 N 个 stream(开环),模拟浏览器或 gRPC 在一个连接上的并发请求。每个 stream 记录完成时间;测量窗口前后各请求一次 /stats,拿到服务端 event loop 线程的累计 CPU(ThreadMXBean.getThreadCpuTime)和 buffer 大小直方图,相减得到严格对齐测量窗口的数据:

loop.scheduleAtFixedRate(() -> {
    for (int k = 0; k < burst; k++) {
        request(ch, reqHeaders, rec);   // Http2StreamChannelBootstrap(ch).open() -> HEADERS(endStream)
    }
}, initialDelayMicros, interval * 1000L, TimeUnit.MICROSECONDS);

3.4 结果一:NIO 不切,io_uring 切

HttpToHttp2ConnectionHandler + writeAndFlush,WFQ(Netty 默认),默认 64KB 高水位:

body 每个连接的并发 transport 每个请求的 ByteBuf 数 其中 1-2KB 小块 event loop µs/req
16KB 4 NIO 4.0 0 8.22
16KB 4 io_uring 4.0 0 8.09
16KB 16 NIO 4.0 0 6.02
16KB 16 io_uring 16.4 2.5 7.93(+32%)
32KB 4 NIO 6.0 0 8.07
32KB 4 io_uring 8.4 1.3 8.23
64KB 4 NIO 12.0 0 11.27
64KB 4 io_uring 43.7 16.2 17.36(+54%)
64KB 4 io_uring,1MB 高水位 10.0 0 10.21

NIO 在所有组合下都保持满帧。io_uring 只要在途字节越过高水位(body 越大、并发越高越容易发生)就开始切分;把高水位调到 1MB 后切分消失。

3.5 结果二:换 distributor

io_uring,HttpToHttp2ConnectionHandler + writeAndFlush,默认 64KB 高水位:

body 并发 distributor 每个请求的 ByteBuf 数 16KB 帧 event loop µs/req 完成时间 p50
64KB 4 WFQ(Netty 默认) 43.8 1.9 18.0 58 µs
64KB 4 Uniform(Vert.x 5 默认) 13.9 3.8 12.0(−33%) 40 µs
64KB 4 Uniform + 16KB(grpc-java) 13.9 3.7 12.1 40 µs
64KB 4 WFQ + quantum 16KB 12.5 4.0 11.2 39 µs
64KB 16 WFQ(Netty 默认) 109.5 0.5 28.9 423 µs
64KB 16 Uniform(Vert.x 5 默认) 49.5 0.6 17.0(−41%) 240 µs
64KB 16 Uniform + 16KB(grpc-java) 12.6 4.0 10.6(−63%) 144 µs
16KB 16 WFQ(Netty 默认) 16.4 0.5 7.93 62 µs
16KB 16 Uniform + 16KB 4.3 1.0 5.25(−34%) 49 µs

UniformStreamByteDistributor 每次给每个 stream 分 max(minAllocationChunk, 预算 / 待发 stream 数)(UniformStreamByteDistributor.java#L96):

final int chunkSize = max(minAllocationChunk, maxBytes / size);
  • 并发 4 时,预算平分后每个 stream 有 8KB 到 16KB,Uniform 默认配置就基本不切了。
  • 并发 16 时,平分后只剩 2KB 到 4KB,Uniform 默认配置又开始退化(每个请求 49.5 个 ByteBuf)。
  • minAllocationChunk = 16KB 让每次分配至少一个满帧,在所有并发度下都不切分。16KB 并发 16 时,io_uring 的 event loop CPU(5.25 µs)甚至低于 NIO(6.02 µs)。

更粗的粒度没有损害延迟,反而因为 CPU 开销下降,所有 stream 都更快完成了。

4. 别人也踩过:grpc-java 和 Vert.x

grpc-java 改了两次:

Vert.x:

5. 结论

5.1 优先级已废除,1KB 的粒度也没有意义了

"加权"已经没有输入。 RFC 9113 §5.3 废弃了 RFC 7540 的优先级方案,新的客户端不再发送依赖关系和权重,所有 stream 权重相同。WFQ 的 CFS 式伪时间计算退化成了带额外开销的轮转,还会造成 grpc-java #11659 那种乱序。

轮转粒度还有意义,但 1KB 太小。 它控制的是并发时每个 stream 一次能连续写多少,本质是首字节公平性和帧数/CPU 之间的取舍。按 12 Gbps 内网粗算:

粒度 线上发送时间 每多一个竞争 stream,最多多等
1KB 约 0.7 µs 0.7 µs
16KB 约 11 µs 11 µs
64KB 约 44 µs 44 µs
  • 内网 RTT 在几十到几百微秒,单个请求在 event loop 上的处理时间也有几十微秒,16KB 粒度带来的约 11 µs 等待根本测不出来;1KB 换来的那点公平性被完全淹没,代价(约 10 倍的帧数和明显的 CPU 开销)却是实实在在的。
  • 更关键的是,Netty 的调度只决定字节进入 socket 的顺序。数据一旦进入内核发送缓冲区(自动调整后可达数 MB),就是 FIFO 发出。Netty 每次只调度高水位范围内的几十 KB,再精细的轮转,在数 MB 的内核队列面前也没有意义。
  • 只有在低带宽链路上(比如面向公网的移动端,10 Mbps 下 16KB 要约 13 ms),小粒度才可能对首字节时间有一点帮助,而且也会被内核发送缓冲区稀释。

5.2 我们的做法

  1. 改用 UniformStreamByteDistributor + minAllocationChunk(16 * 1024),和 grpc-java 一致:

    DefaultHttp2Connection connection = new DefaultHttp2Connection(true);
    UniformStreamByteDistributor distributor = new UniformStreamByteDistributor(connection);
    distributor.minAllocationChunk(16 * 1024);
    connection.remote().flowController(new DefaultHttp2RemoteFlowController(connection, distributor));
    HttpToHttp2ConnectionHandler handler = new HttpToHttp2ConnectionHandlerBuilder()
            .connection(connection)
            .frameListener(...)
            .build();
    

    我们的带宽大到几乎打不满,优先级也已经被废弃,所以这个修改对我们是纯收益。

  2. 用 io_uring 时,同时调大 WRITE_BUFFER_WATER_MARK(比如高水位 1MB),从源头上减少"异步完成导致预算被冻结、多个 stream 积压"的情况。

  3. 可以给 Netty 提的改进:h2 默认使用 Uniform,且 chunk 至少等于 SETTINGS_MAX_FRAME_SIZE;io_uring transport 在计算可写字节时考虑"已提交、未完成"的写,或者让 h2 的 flush 在 CQE 回来后继续,避免异步写把写入预算冻结一整轮。

5.3 教训

io_uring 的异步写不只是"更快的系统调用"。它改变了"一次写什么时候算完成"的语义。 Netty 的写水位、h2 的流控预算,以及流控之上的 stream 调度,都隐含了"flush 返回时字节已经交给内核"的假设。NIO 满足这个假设,io_uring 不满足。从 epoll 迁移到 io_uring 时,除了 transport 本身,还要回头检查这些建立在同步写语义上的上层机制。

results matching ""

    No results matching ""