io_uring internals · Linux 5.7 → 7.2

一次 recv
如何最终变成 CQE

从 FAST_POLL 的唤醒回调出发,追踪 work 挂在哪里、谁来执行, 直到 DEFER_TASKRUN 与新的 MPSC FIFO。

socket readypoll callbacktask_workCQE
SPACE / → 逐拍播放 · 虚线下划线可悬停查看术语
01 · before fast poll

没有 FAST_POLL:recv 由 io-wq worker 阻塞等待

submitter T submit recv socket recv → -EAGAIN io-wq worker blocking recv fallback CQE
触发:recv 返回 -EAGAIN 承载:io-wq work item 运行:独立 io-wq worker task 完成:worker 发布 CQE

recv 返回 -EAGAIN,请求进入等待路径。

io-wq work item 保存请求;独立 worker task 调用阻塞式 recv,睡在 socket 等待队列上。

每个正在等待的请求都占用 worker 的调度实体和内核栈。FAST_POLL 用 socket wait entry 保存等待状态。

01 → 02

命中 io-wq 后,阻塞只是从用户线程转移给内核 worker,仍会占用调度实体和内核栈,真异步的优势没有发挥出来。倘若能像 epoll 那样 arm poll 呢?

02 · IORING_FEAT_FAST_POLL

FAST_POLL:request 挂入 socket wait queue

submitter T io_uring_enter try recv -EAGAIN socket wait queue arm poll + park req poll callback socket ready queue task_work 挂回 submitter T retry recv fill CQE network wakeup req poll callback:排入 task_work,CQE 尚未发布 submitter T:执行 callback → retry recv → 发布 CQE
task_work callback 节点挂到目标 task;目标 task 进入内核后调用节点中的 func(req)
02 → 03

那么,是谁来执行这些填充 CQE 的回调?

03 · execution ownership

task_work:把回调挂到一个具体的 task

mm / files / signal …
task_works
callback_head → func(req)
执行者:这个 task 自己

触发、承载与运行

触发 / 承载 poll callback 调用入队函数;节点链接到 T.task_works
运行 T 到达内核返回点后调用 task_work_run(),逐项调用 func(req)

task_work 是目标 task 上的回调队列。 “消费队列”指摘下待执行节点并逐个调用,直到队列为空。

03 → 04

callback 已经挂到 T.task_works。T 仍在远端 CPU 的用户态运行时,内核怎样提醒它回来执行?

04 · Linux 5.7 · T is running

默认 task_work:远端完成通知正在运行的提交者

completion CPU poll callback T.task_works f1 f2 f3 CPU 1 submitter T running in user set_notify_resume → kick_process reschedule IPI T in kernel task_work_run() call each callback work T CQE

poll callback 把 callback 节点链接到 T.task_works。此时 work 已经入队,尚未执行。

task_work_add(..., true) 设置 notify-resume 标志。T 正在远端 CPU 运行时,kick_process(T) 发送 reschedule IPI;IPI 本身不调用 callback。

T 进入内核并经过返回路径,随后调用 task_work_run():摘下待执行链表,逐个调用 callback。

T 正在运行时,通知路径包含 reschedule IPI。T 已经阻塞时,callback 队列保持不变,通知动作改为显式唤醒。

04 → 05

如果 task 此时处于挂起状态呢?

05 · Linux 5.7 · T is sleeping

目标 T 处于可中断睡眠:先唤醒,再由 T 执行

completion CPU queue callback T.task_works callback queued wake_up_process(T) io_uring explicit wakeup blocked → runnable T scheduled task_work_run() execute callback T CQE

callback 的承载位置仍为 T.task_works

notify-resume 标志已经设置。阻塞中的 T 不满足 task_curr(T),因此 kick_process 不发送 reschedule IPI;io_uring 随后调用 wake_up_process(T)

T 变为 runnable。调度器若要唤醒空闲 CPU,可能发送调度器自己的 wakeup IPI;这条 IPI 用于安排调度,不是强制打断正在运行的 T。

Linux 5.7 的两个分支都会尽快调度 T。Linux 5.19 的 COOP_TASKRUN 保留 T.task_works,同时跳过强制 reschedule IPI。

05 → 06

默认 task_work 总想尽快唤回 T。完成频繁时,running T 会反复收到 reschedule IPI,额外的 CPU 打断也会成为开销。

06 · Linux 5.19

COOP_TASKRUN:保留 T.task_works,跳过 kick_process

completion CPU poll callback T.task_works callback queued same carrier as default notify flag only TWA_SIGNAL_NO_IPI skip kick_process submitter T next kernel entry run callbacks → CQE

callback 继续由 T.task_works 承载,执行者仍为 T。

TWA_SIGNAL_NO_IPI 设置通知标志并跳过 kick_process。远端完成不会为这批 callback 强制打断 T。

T 下一次进入内核时消费 callback 队列。T 停留在用户态的时间会直接计入完成延迟。

06 → 07

COOP_TASKRUN 仍依赖 task_work 的运行时机:callback 要等 T 下一次进入内核才能执行,这段等待缺乏可预测性。

07 · Linux 6.1

DEFER_TASKRUN:callback 归 ringsingle issuer 消费

completion CPU produce callback io_ring_ctx · ring-local task_list work 1 work 2 work 3 IORING_SQ_TASKRUN = 1 userspace io_uring_enter(GETEVENTS) work issuer T pop work → call func(req) → next CQE 1 CQE 2 CQE 3 task_list empty · SQ_TASKRUN cleared

completion CPU 把 callback 节点追加到 io_ring_ctx 的本地队列。

更多完成可以继续入队;ring 设置 IORING_SQ_TASKRUN,期间不触发 task_work 通知或 reschedule IPI。

single issuer 观察到标志并调用 io_uring_enter(GETEVENTS),随后进入 ring 的消费路径。

issuer T 依次取出 work 1、2、3,在自己的内核上下文中调用 callback,并发布对应 CQE。

队列消费完毕后清除 TASKRUN 状态。callback 的承载者是 ring,运行者仍为 single issuer T。

08 · Linux 6.1 wait loop

submit + wait:睡前 drain,醒后再 drain

enter submit + wait drain local work before sleep check CQ CQE ≥ min_complete? arm + schedule cq_wait_nr = nr_wait not enough TASK_RUNNING wake claimed once drain local work after schedule recheck CQ enough? return userspace consume CQE completion CPU queue task_work ϟ still short → recompute nr_wait and loop T

进入 io_cqring_wait() 后,先消费已经挂在 ring 上的 local task_work。

drain 完才读取 CQ:已经达到 min_complete 就直接返回,不进入睡眠。

仍不足时计算 nr_wait,写入 cq_wait_nr,把当前 task 设为 TASK_INTERRUPTIBLE 后 schedule。

外部完成上下文只负责入队;lazy 计数归零或 non-lazy 抢到本轮 wake 权后,调用 wake_up_state()

schedule 返回后先恢复 TASK_RUNNING,解除本轮计数,再次 drain ring-local task_work。

最后重新检查真实 CQ 数量:够了返回 userspace;不够则更新剩余数量并进入下一轮。

08.1 · cq_wait_nr countdown

普通 one-shot read/write:一次 lazy task_work,只扣 1

cq_wait_nr · lazy adds still needed 3 2 1 0 min_complete = 4 · CQ already has 1 → nr_wait = 3 completion CPU one-shot read / write ring-local work_list read #1 write #2 read #3 single issuer TASK_INTERRUPTIBLE T 0 → wake once CQE +1 CQE +1 CQE +1

read #1 的 completion 入队,atomic_dec_and_test(3) 得到 2:还不 wake。

write #2 再扣一次,2 → 1:issuer 继续睡,producer 立即返回自己的完成路径。

read #3 令 1 → 0:这个 producer 赢得本轮唯一一次 wake,唤醒 single issuer。

issuer 醒来后 drain 三个 callback,各发布一个 CQE;连同原有 1 个,真实 CQ 数达到 4。

08.2 · wake policy

何时立刻 wake,何时等待凑够 min_complete

普通 CQE · one-shot read/write

凑满后 wake

IOU_F_TWQ_LAZY_WAKE
cq_wait_nr--;只有减到 0 才 wake

multishot accept

立刻 wake

poll_exclusive → EPOLLEXCLUSIVE
REQ_F_POLL_NO_LAZY

multishot recv

凑满后 wake

普通 poll wake 仍带 LAZY_WAKE
一次 task_work add 只扣 1

linked request

立刻 wake

IO_REQ_LINK_FLAGS
入队前主动 clear LAZY_WAKE

cq_wait_nr 初值 = 欠缺 CQE 数;每个 lazy task_work add 扣 1。 立刻 wake 只叫醒 issuer;producer 不跑 callback。
08.2 → 09

wait 策略解决“何时叫醒 consumer”;接下来再看多个 completion CPU 如何把 task_work 并发写入同一个 FIFO。

09 · Linux 7.2 queue rewrite

从 llist + reverse 到直接保持 FIFO 的 MPSC

BEFORE

生产者压入 LIFO

CBA
push: cmpxchg(head, node) consumer: detach_all() reverse(list) // O(n)

锁少,但消费者每批都要反转链表恢复 FIFO。

LINUX 7.2

生产者直接在 tail 追加

stubAB
prev = xchg(&tail, node) store_release(&prev->next, node) consumer: load_acquire(head->next)

生产者争用一次 tail;消费者按 FIFO 前进,不再聚合后 reverse

算法把固定的 O(n) 反转成本拿掉,但交换 tail 与发布 prev->next 之间出现一个短暂窗口。

09 → 10

MPSC 拿掉了 reverse,但更新 tail 和发布 prev->next 分成两步。consumer 恰好撞进这两步之间,会把队列误判为空吗?

10 · the publication gap

MPSC 的关键:暂时 pop 不到,不等于队列为空

stub sentinel node A A.next = NULL node B B.next = NULL atomic tail consumer head store_release publication gap tail = B, but A.next = NULL pop A then head → B

① producer 准备 B:先令 B.next = NULL

xchg(tail, B) 已完成;consumer 此刻看到 A.next == NULL,但 head != tail

③ producer 用 release-store 发布 A.next = B;consumer 的 acquire-load 现在能看见 B。

④ consumer 弹出 A 并前移 head。发布窗口只会短暂重试,不能误判 empty。

11 · summary

io_uring 完成路径的三个组成部分

等待位置FAST_POLL 把 request 挂入 socket wait queue
执行位置task_work 挂 task;DEFER_TASKRUN 挂 ring,由 issuer 消费
队列实现Linux 7.2 MPSC 直接维持 FIFO,省去批量 reverse
01 / 12
io_uring · execution ownership