前言

这篇文章简单探讨一下 Linux 内核中 io_uring 的一些关于调度上的细节,基于内核版本 v6.4.8。

「任务调度」在这里大概意思是指两个方面:

  • 一是 IO 提交时,io_uring 会把请求移交到哪个上下文;
  • 二是 IO 就绪时,io_uring 会以怎样的方式去通知上层。

在开始前先做一点说明,以免浪费你的时间:

  • 如果你知道 io-wq、SQPOLL 和 task work,那么大概不用看这篇文章。
  • 实现细节已经放在超链接里面,建议点进去看一下。
  • 这里说的任务通常是以块设备 IO 栈为前提。
  • io_uring 的实现是真的复杂,我在抓取 trace 过程中对它理解得很肤浅,如有错误还请指出。

内联操作

在提交时,io_uring 可以做到在不使用额外线程的前提下去处理 IO,即内联(inline)操作。

// SQE 提交的调用栈
io_issue_defs[req->opcode]->issue(req, issue_flags)
io_issue_sqe(req, issue_flags = IO_URING_F_NONBLOCK | ...)
io_queue_sqe
io_submit_sqe
io_submit_sqes
io_uring_enter

当一个 SQE 被提交时,issue_flags 会使用 NONBLOCK 标记,首先执行非阻塞式的 IO。以其中一个 issue 回调 io_read() 为例,它会使得流程满足 force_nonblock 条件,也就是 kiocb 控制结构体会添加上 IOCB_NOWAIT 标记。当 IO 栈处理过程中需要引起阻塞的时候(比如对应的 page cache 不存在,要等待 disk IO 的结果做映射),将直接以 -EAGAIN 的结果返回。

NOTES:

  • 大多数文件系统默认会在文件打开时加上 FMODE_NOWAIT 标记,表示支持内联(例:f2fs)。
  • IOCB_NOWAIT 仍然允许同步的预读(readahead)行为,因为它也是非阻塞的。

内联操作不会阻塞当前线程,也不会产生新的线程。需要注意这即使对于常规文件(regular file)来说也是成立的,用户态通常做不到非阻塞的常规文件 IO 操作(preadv2() 除外),而 io_uring 可以轻易搞定。

异步缓冲操作

当非阻塞的内联操作失败后,io_uring 会再次尝试异步缓冲(async buffered)操作。

// 实现依赖于 VFS 的接口
io_rw_should_retry
io_read

异步缓冲操作会在 io_rw_should_retry() 时给 kiocb 打上 IOCB_WAITQ 标记,并且注册 page unlock 回调(在这个场景下为 io_async_buf_func)。回调的注册时机在 __folio_lock_async(),从调用可以看出,如果此时 folio trylock 失败,内部将返回 -EIOCBQUEUED,并在异步解锁成功后再执行回调。之所以本地持锁失败,必有其它的上下文在争(使)用,也就是说回调的执行相对于本地是异步的。

NOTES:

  • async buffered read 可以参考 LWN 的介绍。
  • 对于 -EIOCBQUEUED 的情况,io_read() 会结束重试
  • io_uring 处理部分读的做法就是再次(基于新的偏移量)重试。
  • 内核 v6.4.8 版本的文件系统中,只有 ext4、btrfs 和 xfs 支持异步缓冲操作。

io-wq

对于常规文件 IO 来说,如果上述的内联操作和异步缓冲操作均失败(-EAGAIN)时,请求将会转发到 io-wq 再异步处理。

io_wq_enqueue
io_queue_iowq
io_queue_async
io_submit_sqe

NOTE: 异步缓冲操作启动(返回 -EIOCBQUEUED)不会立刻派发到 io-wq。而是从 io_read() 经过 io_req_defer_failed(),使得 req 请求挂入到 submit state 链表中延迟处理(submit state 可理解为用于批处理的结构体,具体见 io_submit_sqes());异步缓冲操作也存在 返回 -EAGAIN 的可能性。

NOTE: 由于常规文件对应的 file_operation 文件操作并不支持 poll 接口,也不存在对应的 POLLIN 和 POLLOUT 事件,所以 io_arm_poll_handler() 的 switch-case 必匹配 IO_APOLL_ABORTED

io_wq_create(concurrency, data)
io_init_wq_offload(...)
io_uring_alloc_task_context
__io_uring_add_tctx_node
io_uring_install_fd
io_uring_create
io_uring_setup

create_io_thread
create_io_worker
io_wq_create_worker
io_wq_enqueue
io_queue_iowq
io_submit_sqes
io_uring_enter

io-wq 在 io_uring_setup() 阶段只做必要的初始化而不会提前启动线程池,工作线程是延迟到 io_uring_enter() 阶段再通过 create_io_thread() 创建和 io_init_new_worker() 运行。在早期的 io_uring 实现中,工作线程使用的是 kthread 机制,而现在使用定制的 IO thread(还是 clone 的做法)。作者在公开演讲提到使用 kthread 是有风险的,我也不太懂啥意思 ╮(╯_╰)╭

NOTE: IO thread 创建失败的话会退一步使用内核已有的 workqueue 机制。

io-wq 的线程模型选用了线程池的实现,线程数则依据任务的类型区分 BOUND(有界)和 UNBOUND(无界)的场合。io-wq 的有界并发(线程)数取决于 setup 阶段的 concurrency 变量的计算:四倍的 CPU 数量,或者是 SQE 队列深度,取两者的最小值。无界并发数RLIMIT_NPROC,基本是一个相当大的数目。任务的类型区分取决于 req->work.flags 是否包含 IO_WQ_WORK_UNBOUND 标记,从 io_prep_async_work() 函数里判断,常规文件和块设备 IO 是有界的,而网络 IO 是无界的。

io_issue_sqe
wq->do_work(work) = io_wq_submit_work
io_worker_handle_work
io_wq_worker

io-wq 具体的工作任务仍然是 issue,不过因为已经是异步的线程了,所以可以在里面循环重试 issue 也不需担心阻塞问题,并且处理 poll/iopoll 等细节。

NOTE: 如果 SQE 设置了 IOSQE_ASYNC,将无条件进入 io-wq,见 io_queue_sqe_fallback()

SQPOLL

io_uring 提供 SQPOLL 特性,使得 IO 提交可以交由 iou-sqp 内核线程去收集(以轮询的方式)和处理 SQE,从而进一步避免系统调用。

create_io_thread(io_sq_thread, sqd, NUMA_NO_NODE)
io_sq_offload_create
io_uring_create
io_uring_setup

和 io-wq 一样,sq 内核线程的创建使用了 io_uring 定制的 create_io_thread(),而在旧版本是直接使用 kthread 机制。

__io_sq_thread() 可以看出,它的任务同样是 io_submit_sqes(),这些前面都提过了。

NOTE: SQPOLL 这个特性有很复杂的使用说明(举个例子,sq 线程闲置超过 sq_thread_idle 毫秒后需要主动设置 IORING_ENTER_SQ_WAKEUP 标记),建议看下 man 2 io_uring_setup。我选择把琐事交给 liburing,只需无条件地执行 io_uring_submit(),liburing 会聪明地避开系统调用 (ゝ∀・)

内联完成

io_uring 的内联操作如果能成功,那么可以直接在当前调用栈完成并生成 CQE。

// SQE 提交的调用栈,注意之前隐藏的 issue_flags 其实不只是 NONBLOCK
io_issue_sqe(req, issue_flags = IO_URING_F_NONBLOCK | IO_URING_F_COMPLETE_DEFER)
io_queue_sqe
io_submit_sqe
io_submit_sqes
io_uring_enter

// 继续展开如下
io_submit_sqes(SQEs)
    io_submit_state_start(state)
    for sqe in SQEs:
        io_queue_sqe
            io_issue_sqe(req, issue_flags = ... | IO_URING_F_COMPLETE_DEFER)
                io_issue_defs[req->opcode]->issue(req, issue_flags)
                    return kiocb_done() = IOU_OK
                if IOU_OK and IO_URING_F_COMPLETE_DEFER:
                    io_req_complete_defer(req)
                        wq_list_add_tail(req->comp_list, state->compl_reqs)
    io_submit_state_end
        io_submit_flush_completions
            __io_submit_flush_completions
                for req in state->compl_reqs:
                    __io_fill_cqe_req(ctx, req)
                        填充 CQE
                        这里就是完成事件的跟踪点(trace_io_uring_complete())
    commit SQ ring head

从上面内联操作的调用栈继续展开,由于 issue_flag 添加了 IO_URING_F_COMPLETE_DEFER 标记,已经内联 IO 执行成功的 req 请求结构体会添加到 submit state 批处理数据结构的内部链表,后续批量填充 CQE 即可完成。

task work

io_uring 使用 task work 机制作为除内联以外的 IO 完成辅助手段。task work 简单来说就是一个(伪)信号到来后的回调。在这里 task 就是指 task struct 结构体。

task_work_add(req->task, tctx->task_work, ...)
__io_req_task_work_add(req)
io_req_task_work_add(req)
io_req_task_queue(req) #备注
io_async_buf_func

// #备注:该函数有一条关键代码如下
req->io_task_work.func = io_req_task_submit

// 继续展开
// 这是一个回调,不是直接调用
// FORCE_ASYNC 包括 IOSQE_ASYNC 标记等任一条件
io_req_task_submit
    if FORCE_ASYNC:
        io_queue_iowq
    else:
        io_queue_sqe

以前面的异步缓冲操作为例,某个任务(task struct)在满足条件后异步地执行 io_async_buf_func() 回调时,会添加一个 task work 回调到指定的 req->task 任务当中。而指定的任务其实就是提交时的任务(current 宏定义),见 io_init_req()req->task 任务除了指代用户自身进程以外,也允许是 sq 内核线程。从 task work 的 io_req_task_submit() 注册结合前面章节的内容可以知道,这相当于是一个重试流程。

NOTE: 需要额外说明一下,「添加一个 task work 回调」指的是 tctx->task_work 而不是 req->io_task_work.func。它们可以理解为嵌套关系,在 setup 阶段的 io_uring_alloc_task_context() 初始化 tctx->task_worktctx_task_work(),流程是逐一处理此前插入io_task_work.func,具体见 handle_tw_list()

task_work_run
get_signal
arch_do_signal_or_restart
exit_to_user_mode_loop
exit_to_user_mode_prepare
__syscall_exit_to_user_mode_work
syscall_exit_to_user_mode_work
syscall_exit_to_user_mode

一般来说,task work 的执行有两条路径:

NOTES:

  • 这里系统调用不仅限于 io_uring_enter()
  • 如果 req->task 是一个(不退出到用户态的)sq 内核线程,将会在主循环中处理掉 task work。
  • 如果 arm poll(一个 VFS poll 的封装)能检测出网络 IO 就绪,也会 使用 task work 去完成 IO。

协作式调度

io_uring 针对 task work 机制提供了协作式的调度方式:IORING_SETUP_COOP_TASKRUN。它假定用户肯定会在后续有执行系统调用,从而不再是收到伪信号后强制陷入内核态,以提高吞吐性能。

标记影响到非 SQPOLL 模式下的 io_uring 的通知方法(notify method):

  • 默认(未使用标记)情况为 TWA_SIGNAL
  • 使能标记的情况为 TWA_SIGNAL_NO_IPI
task_work_add(task, work, notify_method)
    add work to task->task_works
    switch notify_method:
        case TWA_NONE          -> nop
        case TWA_RESUME        -> set_notify_resume(task)
        case TWA_SIGNAL        -> set_notify_signal(task)
        case TWA_SIGNAL_NO_IPI -> __set_notify_signal(task)

set_notify_signal(task)
    __set_notify_signal(task)
        thread info 设置 TIF_NOTIFY_SIGNAL,作为 signal_pending() 的判断依据
        task 放入 runqueue
    kick_process(task)
        如果 task 不在当前的 CPU,将会产生 IPI 中断让目标 CPU 立刻处理

将前面章节的 task_work_add() 函数展开(记得看下里面的注释!实现我是看不懂了),可以看到通知方法影响了后两条匹配。set_notify_signal()__set_notify_signal() 的唯一区别就是前者还会发出 IPI 核间中断(kick process)。既然不发出 IPI,彼此隔离的 CPU 就不会立刻中断、陷入内核态和 exit to user mode,从而做到避免打断用户态当前任务的效果。

对于用户还有 liburing 来说,还可以设置 IORING_SETUP_TASKRUN_FLAG 标记。因为上述 IORING_SETUP_COOP_TASKRUN 的场合下不能立刻陷入内核态处理 task work,所以我们需要一种能让上层感知的办法。这里的办法就是在 SQ-ring 打上 IORING_SQ_TASKRUN 标记,使得用户在用户态忙轮询也可以得知存在 task work 待处理。liburing 也使用这个标记去判断是否需要进入内核态。

用户也可以使用更加激进的 IORING_SETUP_DEFER_TASKRUN 标记,不仅要求主动进入系统调用,还必须是 io_uring_enter() 带上 IORING_ENTER_GETEVENTS 才会处理 task work。具体做法就是 io_uring__io_req_task_work_add() 中不再实际生成 task work,而是 local work。这对于同时高频交织产生 task work 和执行系统调用的场合能提供优化潜能,意味着有更好的批处理机会。

总结

io_uring 的任务调度直观来看不算复杂,但是实现就很让人掉头发。(还好我的头发又多又长)

总而言之,io_uring 在 IO 提交时会尽量使用内联和异步缓冲操作从而避开额外线程的开销,失败后再考虑使用 io-wq 线程池去执行,或者从一开始就使用 SQPOLL 去代替用户提交;在内联 IO 就绪时,无需额外操作;其他情况(非内联的就绪,比如 arm poll;或者可能的就绪,比如 folio unlock)会使用 task work 去通知提交方进行重试,并且还提供了协作式调度的优化方法来提高性能。

需要注意文章本身并不能代表 io_uring 的所有情况,比如对于支持 iopoll() 接口的块设备 IOPOLL 模式,又或是网络 IO 使用 internal poll(arm poll 和 fast poll)结合 task work 的 IO 就绪通知细节都没有覆盖,更别提其他多到数不清的特性。我也期待大佬们整理一下这方面的资料。

图文无关

13-sentinels 《十三机兵防卫圈》游戏截图

按照惯例,文末随机附有内容无关的图。