资讯详情

资讯详情

io_uring实战:用Linux C实现高性能异步文件写入

之前维护的一个常驻采集模块数据从内存丢到本地磁盘这个动作一度是整个服务的最大瓶颈。单看业务逻辑其实很简单业务线程把数据写入环形缓冲落盘线程定时把缓冲里的数据刷到文件。但流量一上来最普通的pwrite系统调用开销、上下文切换、页缓存回写之间的互相拉扯导致CPU占用一路飙升落盘速度反而越来越慢。后面我把这一整条链路换成了io_uring异步提交整个落盘路径的CPU占用直接降了一个量级数据积压问题也彻底解决。这篇文章就围绕“用Linux C io_uring把内存中的数据异步写到本地文件”这件事把从提交请求、内核处理、数据落盘到完成事件处理的完整链路讲清楚。会给出可直接运行的代码骨架也会把我在实操中踩过的坑、调过的参数一并交代。适合已经会用read/write、想了解异步IO怎么落地的人也适合准备在项目里引入io_uring但还没决定怎么设计缓冲区和提交策略的人。1. 传统写路径卡在哪一次采集模块的压测复盘1.1 我先说当时为什么非要换模块的运行逻辑很简单采集线程把数据塞进一块共享内存环形缓冲落盘线程再把这些数据搬进磁盘文件。最初版本用的是阻塞式pwrite每次写入都陷入内核把一个4KB的块搬到页缓存再返回。单看一次操作延迟也就几十微秒pwrite本身似乎不慢。但问题在于每秒有上万次写入的时候每一次写入都要经历完整的用户态→内核态→用户态切换加上频繁的上下文切换和CPU缓存失效实际吞吐和延迟都不是线性叠加而是指数级恶化。压测数据很直观当写入频率从每秒5千次提高到每秒2万次时CPU占用从30%直接冲到85%但磁盘吞吐并没有跟着翻四倍反而因为CPU被系统调用吃满开始卡住业务侧的内存积压。这其实不是磁盘慢是CPU在无谓的内核态进出中烧掉了。1.2 传统write调用在大流量下的三个硬伤第一个硬伤是每次write都要做一次系统调用而系统调用的开销不仅仅是一条syscall指令还包括进程陷入内核、参数拷贝、返回时的上下文恢复。高频小IO的时候这些固定开销会占据可观的CPU周期。第二个硬伤是每次IO都要把用户态缓冲区映射到内核。内核在接收pwrite传来的地址时需要判断这个缓冲区是否合法、是否驻留在内存中这背后涉及get_user_pages、页表锁定、引用计数维护等一整套操作。对于一次性的、高频重复提交的小块写入这些页管理操作的成本甚至比实际搬数据还高。第三个硬伤是完成通知机制。传统的同步IO只能阻塞等待返回aiolibaio虽然能异步提交但完成事件的收割要么靠io_getevents轮询要么靠信号前者在高频场景下仍然有较高的用户态轮询成本后者的信号处理开销更大生产环境基本没人这么用。1.3 io_uring的破局思路共享环少打扰io_uring是Linux 5.1内核引入的异步IO框架核心思路一句话就能讲明白内核和用户态通过共享内存里的一对环形队列通信一个提交队列SQ和一个完成队列CQ。要发起IO就往SQ里塞一个请求描述符SQE内核处理完之后把结果写进CQ的一个完成事件CQE里。关键点在于普通的提交路径完全不需要每次write都陷入内核。你可以把一批请求先填进SQ然后调用一次io_uring_enter批量提交甚至开启SQPOLL模式后连这一次系统调用都可以省掉由内核线程自动从SQ里捞请求。完成事件的收割也一样直接读共享内存里的CQ即可省掉了传统模型里频繁的上下文切换和页管理。这套设计对“内存数据落盘”这个场景天然合适。IO模式高度重复、缓冲区生命周期可控、提交可以批量这些特征都能在io_uring里发挥得很充分。2. 一条写请求从内存到磁盘的完整旅程SQE/CQE/页缓存/O_DIRECT2.1 提交描述数据地址、长度、偏移量一次异步写请求从构造SQE开始。最核心的接口是io_uring_prep_writevoid io_uring_prep_write(struct io_uring_sqe *sqe, int fd, const void *buf, unsigned nbytes, off_t offset);这个调用只是把写操作的必要信息填进结构体并不会真正发起IO。字段含义和pwrite完全一致fd是目标文件描述符buf是用户态内存起始地址nbytes是写入长度offset是文件内的偏移量。写完SQE之后还必须通过io_uring_sqe_set_data挂上一个自定义的用户态指针用于在完成事件里识别这个请求io_uring_sqe_set_data(sqe, my_ctx);这块用户数据可以是任何东西缓冲区结构体指针、请求序号、业务指针。等到CQE返回时通过io_uring_cqe_get_data把它取回来就能知道哪个请求完成了。2.2 完成事件什么时候才算写完提交完成后内核处理写请求处理完毕后会在CQ里生成一个CQE。用户态通过io_uring_wait_cqe或io_uring_peek_cqe来拿完成事件struct io_uring_cqe *cqe; int ret io_uring_wait_cqe(ring, cqe); if (ret 0) { // 处理错误 } if (cqe-res 0) { // cqe-res 是负的错误码说明写入失败 } else { // cqe-res 是实际写入的字节数 } io_uring_cqe_seen(ring, cqe);这里有个容易忽略的细节cqe-res必须做双重判断。小于0是错误码比如-EINVAL、-ENOSPC大于等于0时它表示实际写入的字节数。对于普通文件返回值理应等于你请求的长度但如果出现磁盘满、文件系统错误等情况可能出现部分写入所以严谨的做法是比较返回值和请求长度是否一致。io_uring_cqe_seen这个调用也别忘了。它告诉内核“这个CQE我已经消费掉了CQ空间可以回收”。如果一直不调用CQ队列满了之后内核就无法再写入新的完成事件程序会卡死在等待上。2.3 绕不开的落盘判定page cache与O_DIRECT说到“写数据到本地”大多数人会默认写入完成数据已经在磁盘上。这个认知在普通文件IO下是错的。默认情况下pwrite或io_uring的写操作把数据从用户态缓冲区拷贝到内核的页缓存page cache之后请求就算完成了。数据只是标记为脏页真正刷到磁盘是后续由内核回写线程pdflush/wb_workfn在某个时间点完成的。如果你的场景要求严格的数据落盘保证比如数据库的redo log、订单流水这样的关键数据就必须用O_DIRECT打开文件。O_DIRECT绕过页缓存让内核直接在你的用户态缓冲区和块设备之间搬运数据写请求的完成意味着数据已经抵达设备层。用O_DIRECT是有代价的缓冲区的起始地址、写入长度、文件偏移量都必须按设备扇区大小对齐通常至少是512字节现代存储普遍要求4KB对齐。如果对齐不满足write会直接返回-EINVAL。这个坑我踩过后面专门说。2.4 一个可直接运行的写入骨架下面给一个最小可运行示例初始化ring注册一块缓冲区提交一个固定缓冲区写请求等待完成。如果要用非固定缓冲区版本把io_uring_prep_write_fixed换成io_uring_prep_write并去掉注册缓冲区那一步即可。#include liburing.h #include fcntl.h #include string.h #include unistd.h #include stdlib.h #include stdio.h #define QD 64 #define BLOCK_SIZE 4096 int main(int argc, char *argv[]) { struct io_uring ring; struct io_uring_sqe *sqe; struct io_uring_cqe *cqe; struct iovec *iovecs; char *buf; int fd, ret; if (argc 2) { fprintf(stderr, usage: %s file\n, argv[0]); return 1; } // O_DIRECT 要求内存对齐这里用 aligned_alloc 保证4096字节对齐 buf aligned_alloc(4096, BLOCK_SIZE); memset(buf, A, BLOCK_SIZE); fd open(argv[1], O_WRONLY | O_CREAT | O_DIRECT, 0644); if (fd 0) { perror(open); return 1; } ret io_uring_queue_init(QD, ring, 0); if (ret) { fprintf(stderr, queue_init failed: %d\n, ret); return 1; } // 注册一块固定缓冲区 iovecs malloc(sizeof(struct iovec)); iovecs[0].iov_base buf; iovecs[0].iov_len BLOCK_SIZE; ret io_uring_register_buffers(ring, iovecs, 1); if (ret) { fprintf(stderr, register_buffers failed: %d\n, ret); return 1; } // 构造写请求fd、内存地址、长度、文件偏移、固定缓冲区索引0 sqe io_uring_get_sqe(ring); io_uring_prep_write_fixed(sqe, fd, buf, BLOCK_SIZE, 0, 0); io_uring_sqe_set_data(sqe, buf); io_uring_submit(ring); ret io_uring_wait_cqe(ring, cqe); if (ret 0) { fprintf(stderr, wait_cqe failed: %d\n, ret); return 1; } if (cqe-res ! BLOCK_SIZE) { fprintf(stderr, write failed or short write: %d\n, cqe-res); } else { printf(write ok\n); } io_uring_cqe_seen(ring, cqe); io_uring_unregister_buffers(ring); io_uring_queue_exit(ring); close(fd); free(buf); free(iovecs); return 0; }编译命令gcc -O2 -o iouring_write iouring_write.c -luring3. 内存这边怎么配合固定缓冲区、生命周期管理和对齐3.1 普通缓冲区的页面锁定开销最开始我用的就是普通io_uring_prep_write。它和pwrite一样每次提交请求时内核都需要检查用户缓冲区把相关的物理页面锁定get_user_pages保证在异步IO过程中这些页面不会被换出——因为IO操作是异步的内核可能在你提交后的某个任意时刻才真正访问这块内存。问题在于如果你的业务模式是高频地、反复地把同一块或几块固定缓冲区写进文件那么get_user_pages和随后对应的put_page操作会重复执行无数次。每次提交都重复锁页、解锁这部分开销在CPU profile里非常显眼。压测时我观察到在大部分时间内内核里消耗CPU最高的函数之一是get_user_pages_fast。3.2 固定缓冲区用一次注册省下每轮开销io_uring提供了一套“注册缓冲区”机制。你可以通过io_uring_register_buffers预先向内核提交一组struct iovec数组内核提前完成对所有缓冲区的页锁定和映射并返回一个缓冲区索引列表。之后提交写请求时使用io_uring_prep_write_fixed并指定缓冲区索引内核就不再重复执行get_user_pages而是直接从已注册的缓冲区池里定位内存。这非常契合“从内存到本地”的常规模式业务侧的内存缓冲区本来就是预先分配的、生命周期长的、反复使用的。一次注册多次提交IOPS提升非常明显。注册上限要注意io_uring_register_buffers注册的缓冲区总大小受RLIMIT_MEMLOCK限制。如果注册几GB内存可能需要调高这个rlimit。使用方式很简单核心代码就是上文示例里io_uring_register_buffers那一段。3.3 缓冲区生命周期CQE没回来看之前不能动这是一条血的教训。io_uring是异步提交io_uring_submit返回后内核还没有真正处理完你的写请求。所以只要CQE没有返回你提交时引用的用户态缓冲区就不能释放、不能修改、不能被其他线程写入新数据。否则内核可能读到的是一半旧数据一半新数据的混合体甚至如果缓冲区已经释放还会读到野指针导致的段错误或脏数据。我在实际项目里碰到过一次极其隐蔽的问题一个线程池里多个线程各自负责自己的缓冲但一个工作线程处理完业务后习惯性地把缓冲区清零紧接着就把同一个缓冲区指针交给io_uring去写。正常逻辑里清零和提交之间是有同步的。但异步IO的完成时间是不确定的某个瞬间内核可能正在读这块缓冲区清零操作就会破坏内核正在读取的数据。后来排查出来的结果就是文件里大量写入了0x00块。从此之后项目里明确了一条规矩缓冲区所有权从“提交请求”那一刻就转移给IO引擎直到对应的CQE被处理完缓冲区才重新归业务线程所有。这个所有权转移没法通过加锁解决只能靠严格的流程控制。3.4 对齐问题为什么总在O_DIRECT下爆发O_DIRECT触发-EINVAL是异步IO最常见的失败之一。这里把对齐规则彻底讲清楚省得大家反复试错对齐项要求缓冲区起始地址必须对齐到设备逻辑块大小常见为512字节或4096字节单次写入长度必须是设备逻辑块大小的整数倍文件偏移量必须对齐到设备逻辑块大小固定缓冲区注册时的起始地址同样需满足对齐要求我用aligned_alloc(4096, size)分配缓冲区并保证写入长度是4096的倍数文件偏移量也从4096的边界开始。如果坚持用malloc分配的缓冲区哪怕内容完全一样O_DIRECT下也会失败因为malloc默认只保证16字节对齐。另一个坑是文件系统本身的块大小可能和你以为的不一样。比如mkfs.ext4默认块大小可能是4096字节但如果你在设置了更小块大小的文件系统上跑实际对齐粒度会不同。稳妥的办法是启动时用stat或ioctl查询设备的实际逻辑块大小不要硬编码512。4. 数据真正落盘的保证异步fsync、半写与异常处理4.1 IORING_OP_FSYNC怎么和写请求排在同一队列如果你的模块使用普通文件IO不带O_DIRECT写完数据后还需要主动刷盘才能保证数据真正落盘。传统做法是在所有write之后调用fsync或fdatasync。但在异步IO里简单的做法是用io_uring本身提交一个FSYNC请求并且把它排到相关写请求之后。示例struct io_uring_sqe *fsync_sqe io_uring_get_sqe(ring); io_uring_prep_fsync(fsync_sqe, fd, IORING_FSYNC_DATASYNC); io_uring_sqe_set_data(fsync_sqe, fsync_ctx); io_uring_submit(ring);IORING_FSYNC_DATASYNC只刷数据和必要的元数据效果等同fdatasync如果要保证文件大小、权限等所有元数据都落盘用0即可等同完整fsync。两者都受CQ顺序约束由于SQ是FIFO内核按顺序处理队列里的请求FSYNC排在同一队列中且在写请求之后提交就能保证它刷的是前面几个写请求的数据。4.2 fsync也是异步的也需要等待CQE很多人会误以为把FSYNC通过io_uring提交之后就万事大吉了。不对。FSYNC请求本身也是异步的它提交之后你同样需要等待对应的CQE返回才能确认数据已经刷入磁盘。而且FSYNC的CQE返回也不能保证数据已经到达磁盘介质它只代表内核已经把数据交给设备层设备写缓存策略仍然会影响最终落盘。不过对绝大多数业务场景来说等待FSYNC的CQE已经足够。所以完整的可靠落盘路径是提交一批写请求写请求A、写请求B、写请求C提交一个FSYNC请求排在后面等待所有写请求的CQE返回确认写入长度正确等待FSYNC的CQE返回确认刷盘完成这四步走完数据才算真正“从内存到了本地”。4.3 异常返回与半写问题cqe-res如果返回了负值比如-ENOSPC、-EIO、-EINVAL整个请求是失败的数据没有写进去。但有一种更隐蔽的情况是部分写入cqe-res返回一个正数但小于请求长度。这在普通文件里不常见但在特殊情况下确实会发生。我建议统一做这样一个检查if (cqe-res ! expected_len) { // 记录错误标记该缓冲区的数据需要重写或丢弃 // 不要在这一点上直接复用缓冲区因为可能部分数据已经写出去了 }如果检测到部分写入后续怎么处理取决于业务能够容忍丢数据就跳过不能容忍就需要记录偏移量和已写长度重新安排一次写入。但注意重写操作必须使用另一个缓冲区不能复用当前这个因为本次请求的CQE都还没处理完缓冲区生命周期问题依然存在。4.4 常见错误码的排查方向错误码含义排查方向-EINVAL参数无效检查O_DIRECT对齐、固定缓冲区索引是否越界-EFAULT缓冲区不可访问检查缓冲区是否被释放或内存越界-ENOSPC磁盘空间不足检查目标磁盘剩余空间-EIO设备IO错误检查磁盘健康状态、dmesg输出-EBADF文件描述符无效检查fd是否被别的线程关闭-EAGAIN资源暂时不可用如果是固定文件或缓冲注册尝试重新注册5. 调优实测与踩坑记录队列深度、SQPOLL、多线程竞争5.1 队列深度、IOPOLL、SQPOLL怎么配合队列深度QD指ring里可以同时挂起的请求数量。QD太小时一次提交能批量处理的请求太少无法发挥io_uring的批量优势QD太大时内存占用增加且单个请求的排队延迟可能变高。我实际测下来日志落盘这类写场景QD设在128到512之间比较合适。小QD适合追求低延迟大QD适合追求高吞吐。IORING_SETUP_IOPOLL标记要求内核以轮询方式检查块设备的IO完成状态。它适用于块设备直接IO场景能减少中断开销但会增加CPU占用。普通文件系统上用IOPOLL收益不大。IORING_SETUP_SQPOLL则会启动一个内核线程主动轮询SQ应用只需要写入SQ tail并更新内存屏障连io_uring_enter都不需要调。这个模式能把系统调用次数降到零但会增加一个内核线程的CPU占用。我实际组合配置是io_uring_queue_init(QD, ring, IORING_SETUP_SQPOLL)配合固定缓冲区和O_DIRECT。压测效果相当好单线程写4KB块IOPS从21万左右提升到33万业务侧CPU占用下降约35%。线程的延迟始终在几十微秒量级系统的整体吞吐上了一个台阶。5.2 多线程提交时我遇到过的竞争与解决办法io_uring的SQ和CQ是共享数据结构多线程同时调用io_uring_submit时会竞争SQ tail的更新。liburing在内部会保护这个操作吗看一下liburing的源码io_uring_submit内部会加锁但锁的粒度比较粗多线程高频提交时锁竞争会拖累吞吐。我踩过这样一个坑8个线程同时往一个ring里提交写请求吞吐非但没有线性增长反而比单线程提交还低。原因就是SQ tail更新这把大锁成了瓶颈。解决办法有几种每个线程独立的ring。这是最简单的做法隔离彻底不需要跨线程同步。缺点是每个ring都要占用内存和fd。多线程共享一个ring但用业务层的批处理机制减少提交频率。比如每个线程先把请求攒到自己的本地数组攒满32个再统一调用一次io_uring_submit。明确用io_uring_submit_and_wait来做批量提交加等待一次调用同时完成提交和等待完成事件。我最终选了第二种把提交频率降到原来的四分之一锁竞争明显下降线程扩展性也正常了。5.3 我在实际项目中踩过的坑第一个坑是忘记io_uring_cqe_seen。在某个初版代码里我拿到CQE之后检查了res值但忘了调用io_uring_cqe_seen。程序跑了一段时间后CQ队列满了io_uring_wait_cqe永远不返回。这个问题排查了很久最后看liburing样例代码才发现少了一行。CQ队列里的条目必须手动标记已消费否则内核没法回收位置。第二个坑是SQPOLL模式下进程退出前的资源清理顺序。如果先关闭了文件描述符再退出ringSQPOLL内核线程可能还在引用这个fd导致数据丢失或者内核报userfaultfd相关错误。正确的顺序是先退出SQPOLL线程通过io_uring_unregister或io_uring_queue_exit再关闭文件描述符。第三个坑和固定缓冲区有关。io_uring_register_buffers注册的缓冲区如果其内存块既被业务线程使用又被IO请求使用会产生和第三节一样的所有权问题。一个隐藏隐患是注册缓冲区不能是栈变量或临时内存因为它必须保持有效直到注销缓冲区。如果注册的是临时堆对象即使CQE返回了注销之前这块内存也不能释放。5.4 最终项目效果总结改造完成后的模块最终运行状态环形缓冲里的数据以固定缓冲区注册的方式批量提交到io_uring队列写请求走O_DIRECT写完后排一个IORING_FSYNC_DATASYNC请求完成事件由独立的收割线程统一处理。业务侧提交线程只负责填SQ、更新tail不参与完成事件处理把提交和完成分离整体的并发模型清爽了很多。压测数据不再像开头那么难看同样2万次/秒的写入频率CPU占用从85%降到20%出头数据积压从持续上涨变成接近零。后来我又把队列深度调大、把批量提交数调到64峰值吞吐提升了接近40%。如果现在有人问我Linux C里做内存数据落盘用什么方案最顺手我的回答就是io_uring加liburing没有悬念。传统同步IO在高频写入面前的上限太明显而io_uring的批量提交和固定缓冲区机制简直就是为这种场景设计的。实际操作中能优化的点还很多但我上面讲到的这套组合拳已经足够让一个普通的内存落盘模块跑出很好的性能了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →