操作系统学习笔记(一):I/O 模型全解

写在前面

本文是操作系统底层系列第一篇,系统讲清楚 5 种 I/O 模型:阻塞、非阻塞、多路复用、信号驱动、异步。理解这些是理解 Nginx、Redis、Netty、Kestrel 为什么快的根基。

之前在 Nginx 系列聊过 epoll,这里从操作系统层面把整个 I/O 模型体系讲全。


一、I/O 的本质

1.1 为什么要懂 I/O 模型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
程序 99% 的时间在等 I/O(网络、磁盘)
CPU 处理只是零头

I/O 慢的根本:
  CPU:纳秒级
  内存:百纳秒级
  网络/磁盘:毫秒级(比 CPU 慢 100 万倍)

所以高效程序的核心:不让 CPU 陪 I/O 等
不同 I/O 模型 = 不同的"等待方式"

1.2 用户空间与内核空间

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
操作系统分两层:
  用户空间(User Space)— 应用程序运行的地方
  内核空间(Kernel Space)— 操作系统内核,能直接碰硬件

应用不能直接读写硬件(网卡、磁盘),必须通过「系统调用」请求内核代劳。

一次 read() 的流程:
  1. 应用调用 read()(系统调用)
  2. 切换到内核态
  3. 内核等数据到达网卡/磁盘
  4. 内核把数据从内核缓冲区拷贝到用户缓冲区
  5. 切换回用户态,应用拿到数据

两个阶段:
  阶段1:等待数据就绪(数据从硬件到内核缓冲区)—— 慢,主要耗时
  阶段2:数据拷贝(内核缓冲区 → 用户缓冲区)—— 快
1
2
3
所有 I/O 模型的区别,就在于「应用如何处理这两个阶段」:
  阶段1(等数据):阻塞?轮询?被通知?
  阶段2(拷贝):谁拷贝?什么时候拷?

二、阻塞 I/O(Blocking I/O)

最传统的模型。应用调用 read() 后,一直阻塞到数据读完

1
2
// 应用代码
int n = read(fd, buf, size);   // 阻塞在这,直到数据读完
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
时间线:
  应用  read() ──阻塞─────────────────────── 拿到数据
  内核            等数据就绪 ──── 拷贝数据 ─→
                  (阶段1)      (阶段2)

  阶段1、阶段2 都阻塞应用线程

问题:一个线程只能等一个 fd
      1万个连接 = 1万个线程傻等
      这就是 C10K 问题的根源

三、非阻塞 I/O(Non-blocking I/O)

应用调用 read(),如果数据没就绪,立即返回错误(EAGAIN),不阻塞。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 设置 fd 为非阻塞
fcntl(fd, F_SETFL, O_NONBLOCK);

// 循环轮询
while (1) {
    int n = read(fd, buf, size);
    if (n == -1 && errno == EAGAIN) {
        // 数据没就绪,干点别的,待会儿再问
        continue;
    }
    // 有数据了
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
时间线:
  应用  read()→EAGAIN  read()→EAGAIN  read()→数据  拿到数据
         (立即返回)   (立即返回)     (拷贝)
  内核              等数据就绪 ──── 拷贝 ─→

  阶段1 不阻塞(轮询),但阶段2(拷贝)仍阻塞

问题:轮询浪费 CPU(空转)
      不停 read 大部分返回 EAGAIN,CPU 全耗在询问上
      很少单独用,通常配合多路复用

四、I/O 多路复用(select / poll / epoll)

一个线程同时监控多个 fd,谁有数据就处理谁。这是高并发的核心。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// epoll 用法(简化)
int epfd = epoll_create1(0);

// 注册关心的 fd
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &event);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &event);

// 等待(阻塞,直到有 fd 就绪)
int n = epoll_wait(epfd, events, max, -1);
// n = 就绪的 fd 数量,只返回有数据的
for (i = 0; i < n; i++)
    handle(events[i]);   // 处理(read 时阶段2仍阻塞)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
时间线:
  应用  epoll_wait() ──阻塞(等任意fd就绪)──→ 返回就绪 → read(拷贝)
  内核             监听fd1,fd2... fd2数据到 → 通知 → 拷贝

  阶段1:交给内核多路复用,应用只等通知(不轮询)
  阶段2:read 拷贝时仍阻塞(但很快)

  1个线程管理上万 fd,谁就绪处理谁
  Nginx / Redis 的核心

select / poll / epoll 对比

1
2
3
4
5
6
7
8
              select              poll              epoll
────────────────────────────────────────────────────────────────
连接数限制     1024(FD_SETSIZE)  无限制             无限制
每次调用       传全部 fd           传全部 fd          只 epoll_wait
内核检查       遍历全部 fd O(n)    遍历全部 O(n)      就绪回调 O(1)
返回结果       全部 fd(再遍历)   全部(再遍历)      只返回就绪的
数据结构       位图                数组               红黑树+就绪链表
适用           连接少              连接多             连接多+活跃少(最佳)

(epoll 细节见 Nginx 系列第二篇,这里不重复。)


五、信号驱动 I/O(Signal-driven)

应用告诉内核"fd 有数据时给我发信号",然后去干别的。数据就绪时内核发 SIGIO 信号。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 开启信号驱动
fcntl(fd, F_SETFL, O_ASYNC);
fcntl(fd, F_SETOWN, getpid());   // 信号发给当前进程

// 注册信号处理函数
signal(SIGIO, handler);

// 应用继续干别的,数据就绪时 handler 被调用
void handler(int sig) {
    read(fd, buf, size);   // 此时数据已就绪,拷贝很快
}
1
2
3
4
5
6
7
8
9
时间线:
  应用  注册信号 → 干别的 ... 被信号打断 → read(拷贝)
                              ↑SIGIO
  内核            等数据就绪 ─── 发信号 ─→ 拷贝

  阶段1:不阻塞,靠信号通知
  阶段2:read 仍阻塞

  用得少(信号处理复杂、队列溢出问题),UDP 场景偶有使用

六、异步 I/O(AIO,真正的异步)

前 4 种模型,阶段2(数据拷贝)都还是要应用自己 read。异步 I/O 让内核连拷贝都做完,再通知应用

1
2
3
4
5
6
// POSIX AIO(Linux)/ IOCP(Windows)
struct aiocb cb = { .aio_fildes = fd, .aio_buf = buf, .aio_nbytes = size };
aio_read(&cb);   // 立即返回,内核自己去等+拷贝

// 应用继续干别的
// 内核完成(等数据 + 拷贝)后,发信号/回调通知
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
时间线:
  应用  aio_read() → 立即返回,干别的 ... → 通知(数据已在 buf)
  内核            等数据就绪 ─── 拷贝到 buf ─→ 通知

  阶段1、阶段2 都由内核完成,应用完全不阻塞
  这是真正的异步(前面 4 种都是"同步 I/O"的不同等待方式)

Linux 的 AIO:
  - POSIX aio:用户态模拟,性能一般
  - io_uring(Linux 5.1+):新一代高性能异步 I/O,接近 Windows IOCP
  Windows IOCP:成熟的高性能异步 I/O

七、五种模型对比

1
2
3
4
5
6
7
              阶段1(等数据)   阶段2(拷贝)   是否真异步
──────────────────────────────────────────────────────
阻塞 I/O       阻塞            阻塞          否
非阻塞 I/O     轮询(不阻塞)    阻塞          否
I/O 多路复用    阻塞(等通知)    阻塞          否
信号驱动 I/O    信号通知        阻塞          否
异步 I/O        内核完成        内核完成       是 ✓
1
2
3
4
5
6
前 4 种本质都是「同步 I/O」——阶段2(拷贝)都要应用自己干
只有第 5 种是「真正的异步」——内核把活全干了

  Reactor 模式(Nginx/Redis/Netty)= 用 I/O 多路复用 + 同步处理
  Proactor 模式(Windows IOCP)   = 用异步 I/O
  (详见本系列 Reactor/Proactor 篇)

八、概念澄清:同步/异步 vs 阻塞/非阻塞

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
这两个维度容易混,区分清楚:

阻塞 vs 非阻塞(调用方的行为):
  阻塞   — 调用后线程挂起,直到有结果
  非阻塞 — 调用立即返回(没有结果就返回错误)

同步 vs 异步(结果如何获取):
  同步 — 调用方主动去拿结果(read 自己拷贝数据)
  异步 — 被调用方做完后通知调用方(内核拷贝完通知)

组合:
  同步阻塞     = read()(最传统)
  同步非阻塞   = 非阻塞 read() 轮询
  异步         = aio_read()(被通知)

  「异步」必然涉及「通知」,调用方不等,被通知后结果已就绪

九、小结

  • I/O 两阶段:等数据就绪(慢)+ 数据拷贝(快)
  • 5 种模型
    • 阻塞:简单,一连接一线程
    • 非阻塞:轮询,浪费 CPU
    • 多路复用:一线程管多 fd,高并发核心(select/poll/epoll)
    • 信号驱动:用得少
    • 异步 I/O:内核全包,真正异步(io_uring/IOCP)
  • 关键认知:前 4 种都是同步 I/O(拷贝阶段应用自己干),只有异步 I/O 是真异步
  • 概念:阻塞/非阻塞是调用行为,同步/异步是结果获取方式

下一篇讲进程、线程、协程的本质与调度。