程序活在自己的世界里——内存里的变量、寄存器里的值,一切都快得可以忽略延迟。但只要程序需要和外部世界打交道,就必须越界。越界就意味着等待。

速度鸿沟是所有 I/O 复杂性的根源:

CPU 指令       ~1ns      ▏
内存访问      ~100ns      ████
SSD 读取     ~100µs      ████████████████████
网络往返       ~1ms      ████████████████████████████████████████
HDD 读取      ~10ms      ████████████████████████████████████████████████████████████████

差距跨越六个数量级。所有 I/O 模型设计,本质上都是在回答同一个问题:CPU 在等待外部世界响应的这段时间里,应该做什么?

一、I/O 是什么?(本体论)

I/O 是程序的越界行为——从自己可控的内存世界,穿越到不可控的外部设备。每次越界包含三个动作:发出请求、等待响应、取回数据。

四类越界,形式不同,本质相同:

类型越界目标典型延迟
文件 I/O磁盘(操作系统缓存后面)µs ~ ms
网络 I/O远端服务(穿越协议栈)ms ~ s
设备 I/O外设(键盘、摄像头、GPU)µs ~ ms
进程间通信另一个进程(管道、socket、共享内存)ns ~ µs

关键认知:I/O 不是”数据传输问题”,是等待协调问题。数据怎么移动是次要的,困难在于:程序要不要停下来等?等的过程中 CPU 交给谁?等完了怎么知道?

二、谁来等待?(责任论)

这是阻塞 / 非阻塞 / 异步的哲学本质——等待的责任归属

graph LR
    subgraph "阻塞 I/O"
        A1["运行"] --> A2["挂起等待"] --> A3["数据就绪,恢复"]
    end
    
    subgraph "非阻塞 I/O"
        B1["运行"] --> B2["检查"] --> B3["未就绪"] --> B1
        B2 --> B4["就绪,读取"]
    end
    
    subgraph "异步 I/O"
        C1["运行"] --> C2["提交请求"] --> C3["继续运行"]
        C3 --> C4["内核完成,通知"]
    end
    
    style A2 fill:#ff6b6b,color:#fff
    style B3 fill:#ffa94d,color:#fff
    style C3 fill:#69db7c,color:#fff

阻塞 I/O:调用 read() 后,线程被操作系统挂起(进入 TASK_WAITING),直到数据就绪才唤醒。哲学立场是坦诚的——我确实在等,就老实让出 CPU。代价是线程资源被占用,1000 个并发连接就需要 1000 个线程。

非阻塞 I/O:调用立即返回 EAGAIN,线程自己反复轮询。乐观轮询——先问问,没准好了。但没好的时候 CPU 在空转,频率低则延迟高,频率高则 CPU 浪费。

I/O 多路复用:把等待外包给内核——“帮我盯着这些 fd,有动静通知我”。线程不轮询,内核通知。这是 select / poll / epoll 的本质。

真异步 I/O:不只委托”就绪通知”,而是委托整个 I/O 操作——“帮我读,读完了通知我”。内核完成数据拷贝后才通知应用,应用只处理结果。这是 io_uring(Linux)、IOCP(Windows)的模式。

W. Richard Stevens 在《Unix Network Programming》中的经典判断:前四种模型(阻塞/非阻塞/多路复用/信号驱动)本质上都是同步的——无论哪种,应用都要参与”数据从内核搬到用户空间”这一步。只有真正的异步 I/O,内核全权处理全程,应用只取结果。Linux 直到 io_uring(2019)才实现了高性能的真异步 I/O。

三、如何协调大量等待?(协调论)

单个 I/O 等一等无所谓。当程序要同时管理数万个连接,协调本身成了问题。

一个 HTTP 服务器同时处理 10000 个连接,如果每个连接用一个阻塞线程,就需要 10000 个线程——每个线程默认栈 1MB,光栈内存就 10GB,上下文切换开销不计其数。这是 C10K 问题(Dan Kegel, 1999)的核心。

多路复用的进化

graph LR
    A["select<br/>O(n) / 1024 限制<br/>1983"] --> B["poll<br/>O(n) / 无限制<br/>1997"]
    B --> C["epoll<br/>O(1) / Linux<br/>2002"]
    B --> D["kqueue<br/>O(1) / BSD/macOS<br/>2000"]
    C --> E["io_uring<br/>批量提交 / 零拷贝通信<br/>2019"]
    
    style A fill:#ff6b6b,color:#fff
    style C fill:#69db7c,color:#fff
    style D fill:#69db7c,color:#fff
    style E fill:#4dabf7,color:#fff

select:告诉内核”盯着这些 fd”,内核返回后应用遍历整个集合找就绪项。O(n) 扫描,且硬限制 1024 个 fd——因为用固定大小的位图(FD_SETSIZE)表示 fd 集合。

poll:用动态数组突破 1024 限制,但扫描仍然 O(n)。当有 10000 个连接但只有 10 个活跃时,每次都要扫描 10000 项——大部分时间在做无用功。

epoll(Linux 2002)/ kqueue(macOS/BSD 2000):关键创新——内核用红黑树管理 fd 集合,用回调链表收集就绪项,epoll_wait 只返回有活动的 fd。O(1) 就绪通知,fd 集合只注册一次不需要每次拷贝。

epoll 的两种触发模式:水平触发(LT) 持续通知直到数据被读完(编程简单);边缘触发(ET) 只在状态变化时通知一次(性能更好,但必须一次读完所有数据)。

io_uring(Linux 5.1, 2019):彻底的架构革命——提交队列(SQ)和完成队列(CQ)通过共享内存在用户态和内核态间通信,无需系统调用。一次 io_uring_submit() 可以批量提交数百个请求,内核合并处理。这不只是更快的 epoll,是从 Reactor 到 Proactor 的范式转变。

机制就绪发现fd 数限制系统调用开销模型
selectO(n) 扫描1024每次拷贝整个集合Reactor
pollO(n) 扫描无限制每次拷贝整个数组Reactor
epoll / kqueueO(1) 通知百万级仅注册时拷贝Reactor
io_uringO(1) + 批量无限制共享内存,近零Proactor

Reactor vs Proactor:责任边界划分

graph LR
    subgraph "Reactor(通知就绪)"
        R1["epoll_wait"] --> R2["fd 可读"]
        R2 --> R3["应用执行 read()"]
        R3 --> R4["处理数据"]
        R4 --> R1
    end
    
    subgraph "Proactor(通知完成)"
        P1["提交 io_uring 请求"] --> P2["继续处理其他"]
        P2 --> P3["内核完成读取"]
        P3 --> P4["处理数据"]
        P4 --> P1
    end
    
    style R3 fill:#ffa94d,color:#fff
    style P3 fill:#69db7c,color:#fff

Reactor:内核告诉你”就绪了”,你来执行实际的读写。应用仍然负责缓冲区管理、处理部分读写(一次没读完要循环)。Node.js / nginx / Redis 都是 Reactor 架构。

Proactor:内核帮你执行完读写,告诉你”完成了”,数据已经在你指定的缓冲区里。应用只处理结果,缓冲区管理由内核负责。Windows IOCP 是经典 Proactor;io_uring 是 Linux 的 Proactor 答案。

C10K 的哲学意义

C10K 不只是一个并发数字,它是”等待数量超过线程可承载时的历史拐点”。解法演化:

时代方案代表瓶颈
2000s 前每连接一线程Apache prefork内存、上下文切换
2000s事件驱动 + 非阻塞 I/ONginx、Node.js编程复杂度
2010s协程 + 事件循环Go goroutine、Kotlin coroutines调度开销
2020sio_uring + 批量异步Tokio-uring、glommio内核版本要求

四、数据如何穿越边界?(物理论)

等待协调解决了,数据本身的移动也有成本。每次越界,数据都要穿越多道物理边界。

传统路径:四次拷贝

graph LR
    A["磁盘"] -->|"① DMA"| B["内核<br/>Page Cache"]
    B -->|"② CPU 拷贝"| C["用户缓冲区"]
    C -->|"③ CPU 拷贝"| D["Socket<br/>缓冲区"]
    D -->|"④ DMA"| E["网卡"]
    
    B -.->|"sendfile 零拷贝"| D
    
    style C fill:#ff6b6b,color:#fff
    style B fill:#ffa94d,color:#fff
    style D fill:#ffa94d,color:#fff

一次文件发送:4 次拷贝(2 次 DMA + 2 次 CPU)、4 次上下文切换(用户态/内核态)。对于视频流、文件下载服务,这成为真实瓶颈。

两种零拷贝的本质区别

这是最常被混淆的地方——零拷贝指的是两件不同的事:

DMA(Direct Memory Access):硬件层,设备直接读写内存,绕过 CPU。这是真正意义上的”不经过 CPU”——CPU 只负责发出 DMA 指令,数据移动由 DMA 控制器完成。每次磁盘读取和网卡发送都在用 DMA,它是所有 I/O 的物理基础。

sendfile / splice:应用层,消除内核→用户空间的冗余中转。数据从内核 Page Cache 直接传到 Socket 缓冲区,不经过用户空间。CPU 仍然参与(发送 sendfile 指令,管理内核内部传输),但消除了两次无意义的拷贝和两次上下文切换。

核心区别:DMA 是”数据移动不需要 CPU”,sendfile 是”数据不需要到用户空间转一圈”。nginx 发送静态文件用的是 sendfile——不是说 CPU 不工作,是说数据不需要经过你的代码。

什么时候不能用零拷贝:一旦你需要处理数据(加密、压缩、解析),就必须把数据拷到用户空间。零拷贝只适用于”读来转发”的场景,这是物理约束,不是技术限制。

mmap:模糊边界

mmap 把文件直接映射到进程虚拟地址空间,内核 Page Cache 和用户空间共享同一物理页——读文件 = 访问内存,内核按需换页,省去一次拷贝。

适合随机访问大文件(数据库引擎、内存映射文件)。对于顺序读写,read() + 内核预读往往更快,因为 mmap 有页错误(page fault)开销。

io_uring 的真零拷贝发送

IORING_OP_SEND_ZC:用户缓冲区通过 DMA 直接发送到网卡,不经过内核 Socket 缓冲区。这是真正意义上连内核中间缓冲区都省掉的零拷贝,在高带宽网络服务场景下效果显著。

五、异步的传染性:函数颜色问题

异步不只是 API 选择,它会向上感染整个调用链。

Bob Nystrom 在 2015 年的文章《What Color is Your Function?》中描述了这个现象:在有 async/await 的语言中,函数分为两种”颜色”——同步和异步。异步函数可以调用同步函数,但同步函数无法调用异步函数(不加 .await)。颜色向上传染:底层一旦异步,上层全部必须跟着异步。

async fn handle_request()        ← 必须是 async
    async fn fetch_user()        ← 必须是 async
        async fn query_db()      ← 必须是 async(因为 I/O)

Python 的生态分裂正是颜色问题的工程后果:requests(同步)和 aiohttp(异步)是两套独立的库,在异步函数里调用同步 I/O 会阻塞整个事件循环。迁移到异步不只是换 API,是重写整个调用链。

三种答案

Go 的 goroutine:写阻塞语法,运行时偷偷做非阻塞 I/O。goroutine 在 I/O 时让出调度,调度器切换到别的 goroutine,I/O 就绪后恢复——从程序员视角,颜色问题不存在。代价是运行时不透明,调度行为需要理解 M:N 线程模型。

Java 虚拟线程(Java 21):Project Loom 的答案,和 Go 思路相同——阻塞语法,JVM 内部用非阻塞 I/O 实现。现有同步代码不需要改写就能获得高并发能力。这是 Java 对 Go 思路的借鉴。

结构化并发:Python 3.11 的 TaskGroup、Kotlin 的 coroutineScope、Swift 的 TaskGroup——不解决颜色传染,但给异步代码加入结构,让 Task 生命周期和父作用域绑定,异常处理更可预测。把无结构的”回调地狱”变成有结构的”作用域异步”。

六、多语对照

语言 / 运行时I/O 哲学底层机制颜色问题
Node.js / libuv事件循环是语言 DNA,同步 I/O 是异常epoll / kqueue / IOCP天然异步,无颜色问题
Go阻塞语法 + 异步运行时,调度器吸收复杂性netpoller(epoll)+ M:N 调度运行时隐藏,无颜色问题
Java(虚拟线程)阻塞语法 + JVM 虚拟线程,存量代码无需改写NIO + JVM 调度同 Go,JVM 隐藏
Python asyncio同步优先,异步后发追赶;生态分裂是代价selectors → epoll严重,两套生态
Rust / Tokio显式双模型,编译器保证不混用mio → epoll / kqueue存在,但编译器报错而非运行时崩溃
维度Python (CPython)TypeScript (Node.js)Rust (Tokio)
默认模型同步阻塞异步非阻塞无默认,显式选择
异步实现asyncio(事件循环)内置事件循环(libuv)Tokio(运行时库)
底层多路复用selectors → epollepoll / kqueue / IOCPmio → epoll / kqueue
零拷贝os.sendfile()response.pipe()io::copy() / sendfile
io_uring 支持无官方支持Deno 实验性tokio-uring(成熟)
颜色问题严重性高(两套生态)低(天然异步)中(编译期保证一致性)
生态分裂严重轻(Tokio 统一)

Node.js 天然异步,是因为 1995 年的浏览器是单线程的——JavaScript 设计之初就没有选择同步 I/O 的余地,这个历史约束最终成了最大优势。Python 走了相反的路,同步优先是 Guido van Rossum 的主动选择,可读性优先于吞吐量,asyncio 是对高并发需求的后发追赶。

三语的 I/O 模型差异,本质上是「谁来等待」与「等待如何协调」两个哲学问题的不同答案——也是 05 并发模型 中共享状态管理问题在 I/O 维度的延伸。


延伸阅读

理论

  • W. Richard Stevens《Unix Network Programming Vol.1》— I/O 模型五种分类的经典定义
  • Bob Nystrom《What Color is Your Function?》(2015) — 函数颜色问题
  • Douglas Schmidt《Reactor Pattern》(1995) — Reactor 架构形式化

实践


关联 meta 维度

  • 05 并发模型 — 异步 I/O 驱动事件循环,事件循环调度协程;I/O 等待是并发的主要动因
  • 06 内存管理 — 零拷贝 I/O(sendfile、mmap、splice)涉及内核缓冲区与用户空间的内存映射策略
  • 07 编译与执行 — 协程的 async/await 语法是编译器的变换(async fn → 状态机),属于编译模型范畴