异步 I/O 的两种范式:Readiness 与 Completion

💡 原文中文,约13700字,阅读约需33分钟。
📝

内容提要

文章梳理异步I/O的两大范式:就绪型(epoll/kqueue,对应Reactor)与完成型(io_uring/IOCP,对应Proactor),并从推进模型与栈模型两个维度分析主流语言:Rust的无栈Future与epoll契合,接入io_uring需转移缓冲区所有权;Go用有栈协程封装epoll;Zig以Fiber原生对齐io_uring;C#的async/await源于IOCP。最后给出选型建议。

🔎

延伸解读

缓冲区所有权:Rust 接入 io_uring 的关键障碍

文章指出,Rust 的 Future 与 epoll 天然契合,因为 read(&mut buf) 的借用语义与就绪后主动读取的流程一致。但 io_uring 要求提交操作时即交出缓冲区指针,若 Future 被取消(如 select! 超时分支),局部 buf 被释放而内核仍持有指针,会导致 Use-After-Free。因此 monoio 等运行时改为所有权转移接口,提交时移交 buf,完成后连同结果归还。这提醒开发者:完成型 I/O 下,缓冲区的生命周期必须与内核操作严格对齐。

Go 与 Zig 的路径差异:封装就绪 vs 原生完成

两者都提供平坦的同步代码风格,但底层机制不同。Go 的 netpoller 将套接字设为非阻塞,遇到 EAGAIN 时挂起 Goroutine 并注册 epoll,就绪后唤醒并重试 read,因此存在两次系统调用和重试循环。Zig 的 std.Io 在 Linux 上直接以 io_uring 为第一公民,提交 SQE 后挂起 Fiber,完成时数据已由内核或 DMA 写入缓冲区,无需再次 read。这一对比说明:有栈协程可以屏蔽两种范式,但运行时开销和系统调用次数仍有实质差别。

选型启示:就绪型与完成型各有适用边界

文章给出的决策路径强调,Readiness-based 适合已有成熟事件循环、需要精细控制读取时机的场景,如 Nginx、Redis 的 Reactor 主循环;Completion-based 则适合希望减少系统调用、让内核直接填充缓冲区的场景,如 io_uring 在 ScyllaDB、Meta 的实践,以及 Windows IOCP 在 IIS 和 SQL Server 中的应用。选型时需同时考虑语言运行时的栈模型与缓冲区管理方式,而非仅看底层 I/O 接口。

❓

Q&A

异步 I/O 的就绪型(Readiness)和完成型(Completion)两种范式有什么区别?

就绪型(如 epoll/kqueue)中,内核在数据可读时通知应用,但数据仍在 socket 缓冲区,应用需主动调用 read 将数据拷贝到用户缓冲区;完成型(如 io_uring/IOCP)中,应用提交操作并交出缓冲区指针,内核在数据就绪后直接写入用户缓冲区,完成后通知应用,应用无需再次调用 read。

为什么 Rust 的 Future 与 epoll 天然契合,但接入 io_uring 时会出现缓冲区所有权问题?

Rust 的 Future 是无栈状态机,通过 poll 轮询就绪事件,与 epoll 的就绪通知模型匹配。但 io_uring 是完成型模型,需要应用提交缓冲区指针并转移所有权给内核;而 Rust 的借用检查要求 &mut buf 在 Future 存活期间有效,若 Future 被取消(如超时),buf 可能被释放,内核仍持有指针,导致 Use-After-Free。因此需要改用所有权转移接口(如 monoio 的 IoBufMut)。

Go 语言如何用同步代码风格实现高并发网络 I/O?

Go 运行时通过 netpoller 将就绪型 I/O(epoll)封装在有栈协程(Goroutine)中。当 Goroutine 调用 Read 时,运行时先尝试非阻塞读,若返回 EAGAIN,则将当前 Goroutine 挂起(gopark)并注册到 epoll,调度器继续运行其他 Goroutine;当 epoll 通知就绪后,唤醒该 Goroutine 重试读取。因此开发者可以像写阻塞代码一样编写高并发程序。

Zig 的异步 I/O 设计有什么特点?与 Go 有何不同?

Zig 在标准库 std.Io 中采用原生轻量纤程(Fiber)并直接对齐完成型模型(Linux 上为 io_uring)。与 Go 用协程包裹 epoll 不同,Zig 提交操作后挂起 Fiber,内核通过 DMA 将数据直接写入用户缓冲区,完成后唤醒 Fiber,无需再次系统调用。两者代码风格都是同步平坦的,但 Zig 底层是完成型,Go 底层是就绪型。

C# 的 async/await 语法与 Windows IOCP 有什么关系?

C# 的 async/await 语法最早源于 C# 5.0,其设计紧密关联 Windows 的 IOCP(完成端口,完成型模型)。编译器将 async 方法改写为实现了 IAsyncStateMachine 的状态机结构体,当异步操作未完成时,将 MoveNext 回调挂载到 IOCP 完成事件上,IOCP 完成包到达后直接驱动状态机推进。无栈状态机与完成型通知天然同构。

在实际系统设计中,如何选择就绪型还是完成型异步 I/O?

文章建议根据场景选择:就绪型(如 epoll/kqueue)适合连接数多但每个连接数据量不大、需要精细控制缓冲区生命周期的场景,典型如 Nginx、Redis;完成型(如 io_uring/IOCP)适合需要减少系统调用、追求极致性能且能管理缓冲区所有权的场景,典型如 ScyllaDB、Meta 的 io_uring 应用。选型需结合语言运行时支持与团队熟悉度。

🏷️

标签

➡️

继续阅读