深入 io_uring 延迟雪崩排查:O_DIRECT 缺失引发的 io-wq 线程池打满与 XFS 阻塞实战

io_uring 并非异步 IO 银弹。在缺失 O_DIRECT 或执行 Append 写时,XFS 元数据锁会迫使 io_uring 降级至内核 io-wq 线程池。一旦线程池耗尽,主提交线程将陷入 D 状态,p99 延迟暴涨。核心解法:强制 Direct IO 并结合 fallocate 预分配文件块,彻底绕过元数据锁争用。

某次排查一个基于 io_uring 重构的高并发存储网关(C++ 编写,运行于 Ubuntu 22.04,Kernel 5.15.0-82-generic,底层为 XFS v5 挂载)。该网关在压测阶段初期表现极佳,但当并发写入量达到 5000 QPS 时,系统 Load Average 瞬间飙升至 200+,p99 延迟从 2ms 劣化至惊人的 800ms。

现场取证与指标异动

首先看基础 IO 指标。通过 iostat 观察,磁盘的 %util 达到了 100%,但实际写入吞吐量(wMB/s)仅有可怜的 40MB/s,远未达到 NVMe SSD 的瓶颈。

# iostat -x -d 1
Device            r/s     w/s     rMB/s     wMB/s   rrqm/s   wrqm/s  %rrqm  %wrqm r_await w_await aqu-sz rareq-sz wareq-sz  svctm  %util
nvme1n1          0.00 4820.00      0.00     38.50     0.00     0.00   0.00   0.00    0.00  182.50 142.10     0.00     8.18   0.21 100.00

接着查看 CPU 状态,发现 iowait 极高,且存在大量的上下文切换(CS)。直接拉取进程状态,发现核心网关进程及其派生的内核线程大面积处于 D 状态(Uninterruptible Sleep)。

# 查看 D 状态进程堆栈
$ for pid in $(ps -eo pid,state | awk '$2=="D"{print $1}'); do echo "PID: $pid"; cat /proc/$pid/stack; done

PID: 14205 (网关主线程)
[<0>] io_sq_thread+0x28a/0x560
[<0>] ret_from_fork+0x22/0x30

PID: 14221 (io_wqe_worker_0)
[<0>] xfs_ilock+0x105/0x220
[<0>] xfs_file_buffered_aio_write+0x142/0x3a0
[<0>] xfs_file_write_iter+0x7b/0xc0
[<0>] io_write+0xe4/0x310
[<0>] io_issue_sqe+0x39a/0x1e30
[<0>] io_wq_submit_work+0x12d/0x3b0
[<0>] io_worker_handle_work+0x153/0x290
[<0>] io_wqe_worker+0x2cd/0x350
[<0>] ret_from_fork+0x22/0x30

内核堆栈直接暴露了致命问题:大量的 io_wqe_worker 线程阻塞在 xfs_ilock 上,且调用链明确显示走的是 xfs_file_buffered_aio_write(Buffered IO 路径)。

为什么 io_uring 会在这个场景下退化为同步阻塞?

很多研发对 io_uring 有个致命的误解,认为只要把 IO 丢进 SQE(Submission Queue Entry),内核就会纯异步处理。然而在 Linux VFS/文件系统层,真正的“非阻塞”是非常严苛的。

io_uring 提交一个写请求时,它会默认带上 IOCB_NOWAIT 标志尝试“内联”(Inline)执行。

  1. 如果是 Direct IO (O_DIRECT) 且不改变文件大小(已分配块):XFS 能够无锁直接下发 BIO,请求立即返回 EIOCBQUEUED,这是最完美的 Fast Path。

  2. 如果是 Buffered IO 或者需要改变文件大小(Append 写)

  3. Buffered IO 需要分配 Page Cache,甚至触发内存回收,这在内核中是无法完全非阻塞的。
  4. Append 写需要分配新的磁盘 Block 并更新 Inode metadata,XFS 必须获取独占的 IOLOCK_EXCL 锁(即堆栈中的 xfs_ilock)。

如果 XFS 发现无法 NOWAIT 完成,会向 io_uring 返回 -EAGAINio_uring 捕获到 -EAGAIN 后,会将这个 IO 任务打包,丢进内核后台的 io-wq 线程池(Slow Path)。

在我们的高并发网关中,由于未设置 O_DIRECT,且业务在不断 Append 写新日志文件,导致:

  1. 所有的写请求都在 Fast Path 返回 -EAGAIN

  2. io_uring 疯狂创建 io_wqe_worker 线程来接管任务。

  3. 这些 Worker 线程在执行 XFS 元数据更新时,由于抢占同一个 Inode 的 xfs_ilock,发生严重的锁排队。

  4. 内核 io-wq 线程池有并发上限(受限于 RLIMIT_NPROC 和内部调度机制),当线程池被打满后,io_uring 的主提交线程(如果启用了 SQPOLL,则是 io_sq_thread,否则是用户态的 io_uring_enter 系统调用)也会被迫阻塞。

这就形成了经典的雪崩链路:Buffered IO/元数据写 -> EAGAIN -> io-wq 线程池爆炸 -> XFS 锁争用打满 IO 栈 -> 核心线程 D 状态阻塞。

源码级溯源:XFS 与 io-wq 的死亡缠绕

翻开 Kernel 5.15 的源码,我们可以清晰地看到这个降级逻辑:

// fs/io_uring.c
static int io_issue_sqe(struct io_kiocb *req, unsigned int issue_flags)
{
    // ...
    // 尝试执行写操作,带有 IOCB_NOWAIT
    ret = io_write(req, issue_flags); 

    // 如果底层文件系统返回 -EAGAIN,说明无法非阻塞完成
    if (ret == -EAGAIN && !(req->flags & REQ_F_NOWAIT)) {
        // 降级:将任务丢入 io-wq 后台线程池
        return io_queue_async_work(req, NULL);
    }
    // ...
}

而在 XFS 层:

// fs/xfs/xfs_file.c
STATIC ssize_t
xfs_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
    // 如果是 NOWAIT 且需要更新元数据/加锁失败,直接返回 -EAGAIN
    if (iocb->ki_flags & IOCB_NOWAIT) {
        if (!xfs_ilock_nowait(ip, XFS_IOLOCK_EXCL))
            return -EAGAIN;
    }
    // ...
}

破局之道:防御性 IO 架构改造

要让 io_uring 发挥真正的 100K+ IOPS 威力,必须严防死守 Slow Path 降级。针对该网关,我们实施了以下三板斧改造:

1. 强制启用 O_DIRECT 并对齐内存

修改文件打开标志,彻底绕过 Page Cache。注意,使用 O_DIRECT 要求用户态 buffer 的内存地址和写入长度必须是块设备逻辑扇区(通常是 512 或 4096 字节)的整数倍。可以使用 posix_memalign 分配内存。

// 改造前
int fd = open("data.log", O_WRONLY | O_CREAT | O_APPEND, 0644);

// 改造后 (移除 O_APPEND,加入 O_DIRECT)
int fd = open("data.log", O_WRONLY | O_CREAT | O_DIRECT, 0644);

2. 利用 fallocate 预分配击穿 XFS 元数据锁

由于去掉了 O_APPEND,我们要自己维护写入 Offset。更重要的是,为了避免每次写入都触发 XFS 的块分配(Block Allocation)导致获取 IOLOCK_EXCL,必须在文件创建时预分配足够大的空间。

// 预分配 1GB 空间,保持文件 size 不变 (FALLOC_FL_KEEP_SIZE)
// 这样后续的 IO 都是纯数据覆写 (Overwrite),XFS 只需要 IOLOCK_SHARED 甚至无锁下发
if (fallocate(fd, FALLOC_FL_KEEP_SIZE, 0, 1024 * 1024 * 1024) != 0) {
    perror("fallocate failed");
}

3. 约束 io_uring 的退化行为 (非必须,但推荐)

在初始化 io_uring 时,可以在 SQE 中显式设置 IOSQE_ASYNC,但这会强制走 io-wq,并非我们想要的。正确做法是依赖系统的默认行为,但通过上述 1 和 2 的改造,确保所有的 IO 都能在 Fast Path 成功,彻底饿死 io_wqe_worker 线程。

改造后再次压测,5000 QPS 下 Load Average 降至 2.5,iowait 趋近于 0,p99 延迟稳定在 1.2ms,通过 ps 命令再也看不到海量的 io_wqe_worker 线程,系统恢复丝滑。

常见问题

Q1:除了 XFS,在 ext4 上使用 io_uring 也会遇到这个问题吗? 会。无论 ext4 还是 btrfs,只要是 Buffered IO,或者涉及到文件 Append 写、打洞(Punch hole)、文件扩容,VFS 层都会面临元数据更新的锁保护。io_uring 遇到无法立即拿到的锁,统统会返回 -EAGAIN 并回退到 io-wq 线程池。这也是为什么高性能存储引擎(如 SPDK, ScyllaDB)坚决只用 O_DIRECT | O_DSYNC + AIO/io_uring 的原因。

Q2:如何监控系统中 io_uring 的 io-wq 线程数量及退化情况? 可以通过 eBPF 挂载内核探测点。一个简单的 bpftrace 脚本可以统计每秒降级到 io-wq 的请求数:

bpftrace -e 'kprobe:io_queue_async_work { @[comm] = count(); } interval:s:1 { time("%H:%M:%S\n"); print(@); clear(@); }'

如果看到你的核心业务进程疯狂触发该探针,说明你的 IO 栈配置存在严重问题,正在大量退化。

Q3:我使用了 O_DIRECT,为什么 io_uring 的 p99 延迟偶尔还是会抖动? 即使是 Direct IO,如果底层 NVMe 硬件队列打满,或者发生了 PCIe 链路层重传,依然会导致延迟上升。此外,XFS 默认开启了 speculative preallocation(推测性预分配),在某些碎片化严重的文件系统上,即便是对齐的覆写,也可能偶尔触发元数据刷新(Journaling),可以通过挂载参数 allocsize 进行微调,或者定期进行 xfs_fsr 碎片整理。

Q4:启用 IORING_SETUP_SQPOLL 轮询模式能解决阻塞问题吗? 不能。SQPOLL 只是内核启动一个专门的 io_sq_thread 去轮询你的 SQ 队列,省去了你发起 io_uring_enter 系统调用的开销(减少 syscall 上下文切换)。但如果底层的 XFS 依然因为锁争用返回 -EAGAINio_sq_thread 同样会将任务丢给 io-wq,甚至如果 io-wq 阻塞,io_sq_thread 自身也会陷入 D 状态,导致整个提交队列停摆。架构设计不能用并发去掩盖底层的串行锁。