凌晨两点半,VPN 还在挂着。刚刚把压测环境的一组存储节点退下来,顺手把排查过程理一理。
事情的起因是存储研发团队在重构底层写引擎,从传统的 AIO 迁移到了 io_uring。理论上,配合 NVMe SSD 和 XFS的 Direct IO,吞吐量应该能实现数量级的跃升。但在进行大并发压测时,现象却让人大跌眼镜:当并发写入请求急剧增加时,磁盘的 IO util 连 30% 都没跑到,IOPS 却出现了断崖式下跌,同时机器的 sys CPU 飙升到了 85% 以上。
引入新技术时,把一切理所当然当成常态,往往就是要交学费的时候。
现场还原与初步定位
登录压测机,第一感觉是系统响应变慢了,但 iostat 显示磁盘毫无压力。
用 top 看了一眼,几个压测进程的 CPU 占用并不高,反而是内核的 kworker 线程和名为 io_wq_manager 的线程把 sys 态 CPU 给吃干抹净了。
遇到内核态 CPU 飙升,最直接的手段就是抓热点。直接跑一把 perf:
perf top -U -F 99 -g
抓了几十秒,展开调用栈,看到了一个非常刺眼的调用链路:
- 81.24% io_wqe_worker
- 79.12% io_issue_sqe
- 78.05% io_write
- 77.81% xfs_file_write_iter
- 75.32% xfs_ilock
- 74.90% down_write
- rwsem_down_write_slowpath
- osq_lock
这说明绝大部分 CPU 周期耗在了自旋锁上(osq_lock 是 qspinlock 的一部分),而锁的源头是 XFS 的 inode lock(xfs_ilock),触发点居然是 io_wqe_worker。
按照 io_uring 的设计理念,它应该是一个极度轻量级的环形队列交互,为什么会突然涌出大量的内核 worker 线程,而且还在疯狂抢夺 XFS 的文件系统锁?
剖析 io_uring 的 NOWAIT 语义退化
要理清这个问题,得深入到 Linux IO 栈的提交流程里。
当我们通过 io_uring 提交一个异步写请求时,如果不做特殊设置,内核底层在解析这个 SQE(Submission Queue Entry)时,会默认给这个 IO 加上 IOCB_NOWAIT 标志。这个标志的含义是告诉底层的文件系统和块设备:这个 IO 必须是非阻塞的,如果你发现当前操作需要睡眠等待(比如等锁、等内存分配),请立刻返回 -EAGAIN,不要阻塞我的提交线程。
我们再来看看 XFS 这一层。压测工具模拟的业务场景是多线程并发对同一个文件进行 Append Write(追加写)。
在 XFS 的实现中,进入 xfs_file_write_iter 时,如果是普通的覆盖写(Overwrite),且不需要分配新的数据块,XFS 只需要获取 inode 的共享锁(XFS_IOLOCK_SHARED),这种情况下并发写入毫无压力。
但是,追加写(Append)需要修改文件的 EOF(End of File),这涉及到了文件大小元数据的变更,甚至可能需要向 Allocation Group(AG)申请分配新的 Block。在这个过程中,XFS 必须获取该文件 inode 的独占锁(排他锁):
// 截取自 fs/xfs/xfs_file.c
STATIC ssize_t
xfs_file_dio_write_aligned(...)
{
...
if (iocb->ki_flags & IOCB_APPEND) {
// 需要独占锁
iolock = XFS_IOLOCK_EXCL;
} else {
iolock = XFS_IOLOCK_SHARED;
}
// 如果带了 NOWAIT 标志,尝试非阻塞获取锁
if ((iocb->ki_flags & IOCB_NOWAIT) && !xfs_ilock_nowait(ip, iolock))
return -EAGAIN;
...
}
看上面这段逻辑就很清晰了。高并发下,第一个线程拿到了 inode 的独占锁开始处理追加写,后续的并发请求到达时,XFS 发现带有 IOCB_NOWAIT 并且无法立刻拿到锁,果断返回 -EAGAIN。
重点来了,io_uring 收到这个 -EAGAIN 后会怎么做?
它当然不能把错误直接抛给应用层(那就破坏了异步 IO 的语意)。io_uring 的内部机制是:既然你在当前的提交上下文无法非阻塞完成,那我就把你扔到后端的 io-wq(内核异步工作队列)里慢慢跑。
于是,原本应该在高速环形队列里完成的极速 IO 提交,变成了一场灾难:
-
提交线程不断遇到
-EAGAIN。 -
任务被海量丢入
io-wq。 -
io-wq线程池迅速扩容(生成大量io_wqe_worker线程)。 -
这些 worker 线程剥离了
NOWAIT标志,再次向 XFS 发起写请求,开始硬扛着去抢那个文件的独占锁(阻塞等待)。 -
几千个内核线程抢一把读写锁的写锁,触发严重的
osq_lock竞争,内核态上下文切换风暴爆发,sys CPU 直接打满,吞吐量断崖式下降。
解决方案与最佳实践
弄懂了底层逻辑,修复方案就不能单纯在 io_uring 层面调参了,必须从文件系统和 IO 模型的结合点入手。
1. 空间预分配(fallocate),化 Append 为 Overwrite
既然罪魁祸首是 Append Write 导致的排他锁和元数据更新,那么最有效的手段就是打破这个条件。通过 posix_fallocate 或者 Linux 特有的 fallocate 系统调用,提前为文件分配好足够的物理空间。
在应用层的逻辑中:
-
先预分配一段空间(比如 1GB)。
-
各个线程不要再用
O_APPEND标志,而是自己维护一个全局递增的offset(可以用原子操作atomic_fetch_add)。 -
每次构建
sqe时,明确指定写入的offset。
这样,XFS 在处理这些 io_uring 请求时,发现空间已经分配,不需要修改 EOF,只需要 XFS_IOLOCK_SHARED 即可。非阻塞拿共享锁几乎不会失败,NOWAIT 语义得以保持,io-wq 的退化灾难直接消失。
// 伪代码示例
int fd = open("test.dat", O_RDWR | O_DIRECT | O_CREAT, 0644);
// 预分配 1G 空间,注意 XFS 建议使用 fallocate 以保持物理连续性
fallocate(fd, 0, 0, 1024 * 1024 * 1024);
// ... 在提交 sqe 时
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, fd, buffer, size, current_offset);
// 更新 offset
atomic_fetch_add(&global_offset, size);
2. XFS Extent Size Hint 的调优
即使做了预分配,如果是针对多个不同文件的大并发写入,依然可能在 XFS 的 AG(Allocation Group)锁上发生竞争。可以通过给目录或文件设置 extsize,让 XFS 在分配数据块时按更大的粒度进行(比如 1MB 到 4MB),减少底层 B+ 树分裂和分配元数据修改的频率。
# 查看当前的 extent size
xfs_io -c "extsize" /data
# 设置当前目录的默认 extent size 为 2MB
xfs_io -c "extsize 2m" /data
3. 谨慎使用 io_uring 的高级特性
很多人觉得配置了 IORING_SETUP_SQPOLL(让内核线程去轮询提交队列)就能解决一切阻塞问题。实际上,SQPOLL 仅仅是把用户态的 io_uring_enter 甚至 syscall 的开销省了,它依然绕不开底层 XFS 的锁机制。如果底层退化成同步抢锁,SQPOLL 的轮询线程一样会被卡死,甚至会导致单核 CPU 100% 的死锁假象。
总结
Linux IO 栈从来不是几个新鲜名词的简单拼凑。io_uring 确实提供了当前 Linux 下最高效的异步原语,但它的性能上限依然受限于底层的块设备调度与文件系统的具体实现。
当你在高频低延迟或者极高并发的场景下使用 Direct IO 时,必须对文件系统(无论是 XFS 还是 ext4)在特定操作(追加、稀疏文件填充、跨 AG 分配)下的锁粒度有绝对的把握。仅仅关注上层调用,只会被深埋在内核态的上下文切换和自旋锁里反复摩擦。