前言
这篇文章简单探讨一下 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:
内联操作不会阻塞当前线程,也不会产生新的线程。需要注意这即使对于常规文件(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_work
为 tctx_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 就绪通知细节都没有覆盖,更别提其他多到数不清的特性。我也期待大佬们整理一下这方面的资料。
图文无关
《十三机兵防卫圈》游戏截图
按照惯例,文末随机附有内容无关的图。