尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

面试宝典(十六):io_uring 真异步实战

面试宝典(十六):io_uring 真异步实战 0. 为什么需要 io_uring传统 Linux 异步 I/Oio_submit/aio只能用于直接 I/OO_DIRECT的文件且 API 丑陋、对网络支持差。网络高并发长期依赖 epoll但 epoll 有两个本质痛点每次 I/O 仍要一次系统调用。epoll_wait告诉你可读你还是要recv要写又得send。一次请求往往伴随多次陷入内核。中断驱动。epoll_wait默认阻塞在中断上高吞吐下中断开销明显。2019 年内核 5.1 由 Jens Axboe 引入io_uring核心思想是用两个内核与用户态共享内存的环形队列ring buffer彻底消灭每 I/O 一次 syscall 的开销Submission Queue (SQ)用户态生产、sqesubmission queue entry描述一个 I/O 请求内核消费。Completion Queue (CQ)内核生产、cqecompletion queue entry描述完成结果用户态消费。因为 SQ/CQ 是mmap出来的共享内存用户态填 sqe、读 cqe 都不需要系统调用只有在需要让内核注意到新请求、或等待完成时才调用一次io_uring_enterliburing 封装为io_uring_submit/io_uring_wait_cqe。1. 核心数据结构用户态 内核态 ┌──────────────┐ ┌──────────────────┐ │ sqe (请求) │ 提交环(SQ) │ 内核处理 │ │ → SQ ring │ ──────────▶ │ │ │ (下标数组) │ │ │ │ │ │ │ │ cqe (结果) │ 完成环(CQ) │ │ │ ← CQ ring │ ◀────────── │ 内核写入结果 │ └──────────────┘ └──────────────────┘sqe一次 I/O 操作的完整描述包含opcode操作类型、fd、buf、len、off、user_data用户自定义令牌用来把完成和提交对应起来、flags等。SQ ring一个下标数组里面存的是哪些 sqe 槽位被使用了。这样 sqe 本身可以非连续存放SQ ring 只是索引。cqeuser_datares结果类似 read 的返回值负数表示 errnoflags。user_data 是关键你提交 N 个请求完成时顺序不一定和提交顺序一致尤其异步、乱序设备必须靠user_data把 cqe 关联回你的连接/请求上下文。liburing 帮我们封装了底层io_uring_setupmmap暴露简洁 API。2. 最小上手搭建 ring#includeliburing.h#includestdio.hintmain(){structio_uringring;// 第二个参数: SQ 队列深度可同时提交的请求数// 0: 默认 flagsintretio_uring_queue_init(32,ring,0);if(ret0){fprintf(stderr,io_uring_queue_init: %s\n,strerror(-ret));return1;}// ... 提交 I/O ...io_uring_queue_exit(ring);// 释放return0;}io_uring_queue_init(entries, ring, flags)内部调用io_uring_setup并mmap出 SQ/CQ。返回负值绝对值是 errno表示失败常见于内核过旧 5.1或io_uring_setup被 seccomp/容器限制。3. 一次完整读提交与收割#includeliburing.h#includeunistd.h#includestring.h// 提交一个异步读staticvoidsubmit_read(structio_uring*ring,intfd,char*buf,size_t len,void*ud){structio_uring_sqe*sqeio_uring_get_sqe(ring);// 取一个空闲 sqeif(!sqe){/* SQ 满了需先收割 */}io_uring_prep_read(sqe,fd,buf,len,0);// 准备 read 操作io_uring_sqe_set_data(sqe,ud);// 绑定 user_dataio_uring_submit(ring);// 提交必要时 io_uring_enter}// 收割完成staticvoidreap(structio_uring*ring){structio_uring_cqe*cqe;// io_uring_wait_cqe: 阻塞等至少一个完成io_uring_wait_cqe(ring,cqe);// 批量收割所有已就绪 cqe更高效unsignedhead;io_uring_for_each_cqe(ring,head,cqe){void*udio_uring_cqe_get_data(cqe);intrescqe-res;// 读到的字节数负数错误printf(完成 fd 相关%p 读到%d 字节\n,ud,res);}io_uring_cq_advance(ring,/*已处理数量*/0);// 见下方注意}两个极易踩的坑io_uring_get_sqe返回 NULL 表示 SQ 满了。生产代码必须先io_uring_submit或io_uring_peek_cqe收割腾出空间再取 sqe。收割批量 cqe 后必须用io_uring_cq_advance(ring, count)告诉内核这 count 个 cqe 我处理完了内核才能复用 CQ 槽位。io_uring_for_each_cqe本身不消费只遍历。若只处理一个可用io_uring_cqe_seen(ring, cqe)。4. 模式SQPOLL 与 IOPOLLio_uring 的性能主要来自三种io_uring_setupflag传给io_uring_queue_init的第三个参数flag作用代价IORING_SETUP_IOPOLL让内核轮询设备完成而不是等中断。仅对支持轮询的文件如 NVMe、O_DIRECT有效占 CPU但延迟极低IORING_SETUP_SQPOLL内核起一个内核线程轮询 SQ用户态提交后连io_uring_enter都不用调用需要CAP_SYS_NICE或合适权限空闲会睡眠IORING_SETUP_SQ_AFF配合 SQPOLL绑定轮询线程到某 CPU—structio_uringring;unsignedflagsIORING_SETUP_SQPOLL;// 提交零 syscallio_uring_queue_init(256,ring,flags);// 之后 io_uring_submit 在 SQPOLL 模式下可省去 enter面试要点IOPOLL 针对完成侧不要中断SQPOLL 针对提交侧不要 enter syscall。两者叠加可做到提交与收割都几乎零系统调用这是 io_uring 碾压 epoll 的根本原因。但 CPU 占用高适合追求极限延迟、设备支持轮询的场景。普通网络场景用默认模式 批量收割已收益显著。5. 操作码opcode全景liburing 提供io_uring_prep_*系列为常见操作填 sqeio_uring_prep_accept—— 异步 acceptio_uring_prep_recv/io_uring_prep_send—— 网络收/发支持MSG_WAITALL等 flagio_uring_prep_read/io_uring_prep_write—— 文件/管道io_uring_prep_readv/io_uring_prep_writevio_uring_prep_timeout—— 异步超时用来实现定时器io_uring_prep_close—— 异步关闭 fdio_uring_prep_nop—— 空操作测试/占位io_uring_prep_connect/io_uring_prep_poll_add/io_uring_prep_fsync/io_uring_prep_openat等关键能力io_uring 把 accept、recv、send、甚至文件 open、fsync、timeout 都统一成提交一个 sqe用同一套完成机制处理。这就是它统一事件源的魅力——网络连接、定时器、磁盘 I/O 全在一个 ring 里调度。6. 链式请求linked sqe有时需要先 recv 拿到数据再 send 同样的数据回去两步必须顺序。用sqe-flags | IOSQE_IO_LINK把多个 sqe 链起来前一个完成才提交下一个前一个失败则后续整条取消。structio_uring_sqe*sqe1io_uring_get_sqe(ring);io_uring_prep_recv(sqe1,fd,buf,len,0);sqe1-flags|IOSQE_IO_LINK;// 链接到下一条structio_uring_sqe*sqe2io_uring_get_sqe(ring);io_uring_prep_send(sqe2,fd,buf,len,0);// sqe2 会等 sqe1 完成才执行io_uring_submit(ring);链式请求可减少收割→再提交的往返特别适合 echo 这类收完即发的场景。7. 注册缓冲区与文件registered buffers / files默认每次 I/O内核要把用户态 buf 锁定pin进内核地址空间这本身有开销。io_uring 允许预先注册一组 buffer 和一组 fdio_uring_register_buffers(ring, iovecs, n)—— 内核一次性 pin 住这些 buffer之后 I/O 用时指定buf_index免去每次 pin。io_uring_register_files(ring, fds, n)—— 把 fd 在内核里固定编号sqe 用sqe-fd 固定编号并设IOSQE_FIXED_FILE省去 fd 查找。这进一步优化了极限吞吐。生产服务器如现代 Redis/Velocity 类会结合注册 buffers 提升性能。8. 实战io_uring 高并发回声服务器完整可编译需要g -stdc17 server.cpp -luring内核 5.1#includeliburing.h#includesys/socket.h#includenetinet/in.h#includearpa/inet.h#includeunistd.h#includestring.h#includestdio.h#includestdlib.h#defineENTRIES1024#defineBUF_SIZE4096// 每个连接的上下文buffer fdstructConn{intfd;charbuf[BUF_SIZE];};staticvoidsubmit_accept(structio_uring*ring,intlisten_fd);staticvoidsubmit_recv(structio_uring*ring,Conn*c);intmain(){intlisten_fdsocket(AF_INET,SOCK_STREAM,0);intone1;setsockopt(listen_fd,SOL_SOCKET,SO_REUSEADDR,one,sizeof(one));structsockaddr_inaddr{};addr.sin_familyAF_INET;addr.sin_porthtons(8080);addr.sin_addr.s_addrINADDR_ANY;bind(listen_fd,(sockaddr*)addr,sizeof(addr));listen(listen_fd,1024);structio_uringring;io_uring_queue_init(ENTRIES,ring,0);submit_accept(ring,listen_fd);// 先挂一个 acceptstructio_uring_cqe*cqe;while(true){io_uring_wait_cqe(ring,cqe);// 等完成void*udio_uring_cqe_get_data(cqe);if(udnullptr){// 这是我们约定的user_datanullptr 表示 accept 完成intconn_fdcqe-res;// accept 的返回值就是新 fdif(conn_fd0){Conn*cnewConn{conn_fd,{}};submit_recv(ring,c);// 立即为新连接提交 recv}submit_accept(ring,listen_fd);// 再挂下一个 accept}else{Conn*c(Conn*)ud;intrescqe-res;// recv 读到的字节数if(res0){// 回显把刚读到的内容 send 回去structio_uring_sqe*sqeio_uring_get_sqe(ring);io_uring_prep_send(sqe,c-fd,c-buf,res,0);io_uring_sqe_set_data(sqe,c);io_uring_submit(ring);submit_recv(ring,c);// 继续等下一次 recv}else{// res0 对端关闭 / 负数出错清理close(c-fd);deletec;}}io_uring_cqe_seen(ring,cqe);// 标记该 cqe 已处理}io_uring_queue_exit(ring);return0;}staticvoidsubmit_accept(structio_uring*ring,intlisten_fd){structio_uring_sqe*sqeio_uring_get_sqe(ring);io_uring_prep_accept(sqe,listen_fd,nullptr,nullptr,0);io_uring_sqe_set_data(sqe,nullptr);// nullptr 代表 acceptio_uring_submit(ring);}staticvoidsubmit_recv(structio_uring*ring,Conn*c){structio_uring_sqe*sqeio_uring_get_sqe(ring);io_uring_prep_recv(sqe,c-fd,c-buf,BUF_SIZE,0);io_uring_sqe_set_data(sqe,c);// 绑定连接上下文io_uring_submit(ring);}注意这是教学骨架。生产级需要处理io_uring_get_sqe返回 NULL满了先收割、 SQ 满时背压、优雅退出信号、链接 recv→send 用IOSQE_IO_LINK、错误处理cqe-res 0。但核心套路已经完整一个 ring所有 accept/recv/send 都是 sqe一个循环收割 cqe 并据user_data分发。9. io_uring vs epoll面试怎么答维度epollio_uring模型就绪通知reactor异步完成proactor 风格每次 I/O 系统调用需要recv/send不需要提交即走完成入队提交侧开销无但需 waitSQPOLL 下零 syscall磁盘 I/O不支持 AIO 文件原生支持且统一定时器/信号处理需 timerfd/signalfdIORING_OP_TIMEOUT原生内核版本2.5.44 全平台5.1新特性逐版增强复杂度心智简单API/概念更多一句话总结epoll 是我告诉你 fd 就绪了你自己去读io_uring 是你把读请求交给我读完了我通知你结果。前者是 reactor后者更接近 proactor且把网络、磁盘、定时器统一进同一个事件循环这是它架构上的最大优势。10. 速答 12 题临场背诵io_uring 两个环分别叫什么、谁生产谁消费SQ用户生产、内核消费放请求CQ内核生产、用户消费放结果。为什么 io_uring 比 epoll 快SQ/CQ 是共享内存提交与完成都可免去每 I/O 一次 syscallSQPOLL/IOPOLL 进一步消除 enter 与中断。sqe 和 cqe 区别sqe 描述要做什么 I/Ocqe 描述做完了结果如何。user_data 干嘛用把完成结果关联回提交时的连接/请求上下文因为异步完成顺序不保证。IORING_SETUP_SQPOLL 作用内核起线程轮询 SQ用户态提交无需io_uring_entersyscall。IORING_SETUP_IOPOLL 作用内核轮询设备完成代替中断降低设备 I/O 延迟需设备支持。sqe 返回 NULL 怎么办SQ 满了先收割/提交已有 sqe 腾空间再取。为什么要 io_uring_cq_advance告诉内核这批 cqe 已处理内核才能复用 CQ 槽位。IOSQE_IO_LINK 是什么把多个 sqe 链接成顺序链前一完成才做下一失败则整链取消。注册 buffer/file 有何用预先 pin 住内存/固定 fd免去每次 I/O 的 pin 与查找开销极限优化。io_uring 能做定时器吗能IORING_OP_TIMEOUT原生支持无需 timerfd。最低内核版本5.12019后续版本不断增强 opcode 与特性。11. 临场口诀SQ 交请求CQ 收结果user_data 定归属cq_advance 别忘还SQPOLL 免 enterIOPOLL 免中断epoll 是就绪通知io_uring 是真异步。12. 实战踩坑清单高频sqe 用尽io_uring_get_sqe返回 NULL 时若继续写会段错误。正确做法是在主循环里先收割再取 sqe或预留 back-pressure 机制。生产服务器常把SQ 满了当作需要放慢提交的信号。忘记cqe_seen/cq_advanceCQ 槽位永不回收跑一会儿 CQ 满io_uring_wait_cqe无限阻塞。务必每处理完一个 cqe 就seen批量就cq_advance。user_data 悬空指针cqe-user_data指向你在 sqe 提交时绑的对象。如果该对象在 I/O 未完成前被delete完成回来访问的是野指针。务必保证上下文生命周期覆盖整个异步周期常见做法引用计数或完成时才释放。SQPOLL 权限问题容器里常因缺CAP_SYS_NICE导致io_uring_queue_init失败返回-EPERM。此时退化到默认模式即可不要硬编码 SQPOLL。内核版本特性差异IORING_OP_ACCEPT在 5.5IORING_OP_RECV/SEND在 5.6IORING_OP_TIMEOUT在 5.11。编译期用io_uring_opcode_supported探测更稳。buffer 生命周期sqe 里的buf/iovec在 I/O 完成前必须一直有效不能提交后立即释放局部数组。13. 真实落地与版本演进io_uring 已被众多高性能项目采用或试验数据库/存储RocksDB、TiKV 等引入 io_uring 后端提升写吞吐PostgreSQL 社区也有 io_uring 补丁讨论。网络/代理现代高性能代理、gRPC 实验性 io_uring 传输、Envoy 相关探索。Redis 7引入 io_uring 线程池后端选项io_threads 的替代/补充。fioJens Axboe 自己的工具最早吃螃蟹io_uring 引擎性能远超 libaio。演进方向io_uring 正逐步统一 Linux 异步 I/O把网络、磁盘、定时器、信号、文件操作收进一个原语。C 生态里liburing是事实标准封装Boost.Asio 也通过boost::asio::io_uring实验性支持。面试若被问未来异步 I/O 趋势答 io_uring 的统一事件源 真异步 共享内存零 syscall即可体现视野。14. 一个可亲手跑的 perf 对照思路想直观感受 io_uring 收益用 S03 的 perf 方法对 epoll 回声服务器与 io_uring 回声服务器各跑perf stat -e syscalls:sys_enter_*压测你会看到 io_uring默认模式的 syscall 次数明显少于 epoll因为少了 recv/send 陷入若开 SQPOLL 则io_uring_enter也大幅下降。这种用工具量化差异的思路正是中高级工程师区别于初学者的地方。15. io_uring 与 Reactor / Proactor 模型计算机网络里两种经典异步模型Reactor反应堆你注册我对 fd 可读感兴趣事件循环通知你现在可读了然后你自己去读。epoll 是典型的 Reactor。好处是心智简单、控制力强坏处是读写仍占用户态 CPU 且要 syscall。Proactor前摄器你提交帮我读完这块缓冲区事件循环在操作真正完成后通知你数据已经在你的 buffer 里了。io_uring 天然是 Proactor 风格——你提交 sqe内核把数据搬好cqe 带着结果回来。但注意一个细节io_uring 也可以模拟 Reactor——用IORING_OP_POLL_ADD提交一个等 fd 可读的请求完成时再提交真正的 recv。所以 io_uring 既能 Proactor 也能 Reactor灵活性极高。这也是为什么很多框架如 tokio、glommio选择 io_uring 作为统一后端。16. io_uring 线程池混合架构单线程跑 ring 的事件循环主 Reactor负责 accept/recv/send 等 I/O当某请求需要重计算如压缩、序列化、业务规则时把该任务submit进线程池见 S06算完再把结果通过另一个 sqe 发回客户端。这样 I/O 不被 CPU 密集任务阻塞是典型的高性能服务器架构。伪流程主循环wait_cqe→ 若是计算完成的 cqe用独立user_data标记取出线程池结果 → 提交IORING_OP_SEND把结果写回若是收到请求的 cqe则把解析后的任务丢进线程池。这种事件循环 线程池卸载组合在 S01epoll、S05io_uring、S06线程池三篇里形成闭环。17. 内核参数与线上限制/proc/sys/fs/aio-max-nr虽然 io_uring 不用老 aio但某些发行版仍相关io_uring 自身限制由io_uring_setup的IORING_FEAT_*特性位决定。SQPOLL 内核线程会常驻一个内核线程轮询空闲一段时间默认会自行休眠省 CPU可通过IORING_SETUP_SQ_AFF绑核。容器/受限环境可能禁止需 try 默认模式 fallback。每进程 ring 数量理论上限受RLIMIT_MEMLOCK/mmap配额影响因为 SQ/CQ 是锁页内存。大量短命 ring 会触发内存上限应复用长生命周期 ring。提交深度entries太小会频繁 SQ 满太大浪费内存根据并发连接数与 I/O 在途量评估常见 1024~4096。理解这些限制才能在面试里把io_uring 很快讲成在哪些条件下、受到哪些约束、如何调优而不是一句空话。18. 速查io_uring opcode 与 flag 全表常用 opcode提交请求的动作opcode含义典型场景IORING_OP_NOP空操作测试、占位IORING_OP_READ/WRITE文件读/写磁盘 I/OIORING_OP_READV/WRITEV分散/聚集读写多段缓冲区IORING_OP_RECV/SEND网络收/发TCP 回声IORING_OP_ACCEPT接受连接服务器监听IORING_OP_CONNECT发起连接客户端IORING_OP_POLL_ADD注册 fd 等待事件模拟 ReactorIORING_OP_TIMEOUT异步超时定时器IORING_OP_CLOSE异步关闭 fd清理连接IORING_OP_FSYNC/FSYNC刷盘持久化IORING_OP_OPENAT打开文件文件服务IORING_OP_SPLICE/LINKAT零拷贝拼接代理转发常用 setup flag队列创建选项flag作用适用IORING_SETUP_SQPOLL内核线程轮询 SQ免 enter syscall极限提交吞吐IORING_SETUP_IOPOLL内核轮询设备完成免中断NVMe/DIRECTIORING_SETUP_SQ_AFFSQPOLL 绑核稳定延迟IORING_SETUP_CQSIZE自定义 CQ 大小深度调优IORING_SETUP_CLAMP限制内核参数上限安全约束环境把这张表记熟面试官随便问一个 opcode 你都能接住并且能讲清每个 flag 的代价与适用条件——这才是深度的体现而非只背过io_uring 很快。19. 临场自检三问 io_uring自测你是否真懂 io_uring能答出以下三点即可过关“io_uring 怎么做到比 epoll 少系统调用”—— SQ/CQ 是内核用户共享内存提交填 sqe、收割读 cqe 都不 syscallSQPOLL 进一步免去 enterIOPOLL 免去中断所以提交零 syscall、完成零中断。“异步完成顺序乱了怎么办”—— 靠user_data把 cqe 关联回提交时的连接上下文完成顺序不保证绝不能用数组下标假设对应。“为什么 io_uring 能统一网络、磁盘、定时器”—— 所有操作都被抽象成 sqeaccept/recv/send/read/write/timeout/close…统一由 ring 调度完成这就是统一事件源架构优势而 epoll 只管网络就绪。能流畅讲清这三点io_uring 这一题基本拿下。本篇所有代码均建议在你本地内核 5.1 以上的机器上用g -stdc17 server.cpp -luring实际编译运行并用perf trace观察io_uring_enter的调用频次把看懂真正变成会写、会调、会量化这才是中高级面试想要的真实动手能力。
返回列表