Skip to content

第 2 章 · Linux 五大 I/O 模型

学完本章你将知道:阻塞 I/O、非阻塞 I/O、I/O 多路复用、信号驱动 I/O、异步 I/O 这五个名词分别长什么样、解决了什么问题、以及为什么多路复用是工业界的事实标准。


1. 一句话开场

第 1 章我们说过 read() 有两个阶段:等数据 + 拷数据五大 I/O 模型,就是这两个阶段是「同步阻塞 / 同步非阻塞 / 异步通知」的不同组合


2. 五大 I/O 模型一表览(先有个全局印象)

模型"等数据"阶段"拷数据"阶段谁处理通知谁在工业界主流?
① 阻塞 I/O(Blocking)阻塞阻塞客户端代码
② 非阻塞 I/O(Non-blocking)立即返回 EAGAIN,自己轮询阻塞很少单独用
③ I/O 多路复用(Multiplexing)阻塞在 select/epoll 上等多个 fd阻塞内核统一通知就绪 fd⭐⭐⭐⭐⭐ Nginx / Redis
④ 信号驱动 I/O(Signal-driven)立即返回,数据到了 SIGIO 通知阻塞信号 SIGIO很少用
⑤ 异步 I/O(AIO)立即返回内核完成后通知回调 / 完成事件Linux 上的 io_uring 正崛起

关键洞察:前 4 种都叫「同步 I/O」——因为"拷数据"那一步还是用户线程在等。只有第 5 种"异步 I/O"是真正的"啥都不等"。


3. 模型详解

3.1 ① 阻塞 I/O(Blocking I/O)

工作流程

生活类比

你去水龙头打水,但水龙头还没出水。你就一直站在那里傻等,直到水来——这期间什么都干不了。

代码长什么样

c
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
connect(sockfd, ...);
char buf[1024];
ssize_t n = read(sockfd, buf, 1024);   // ⚠️ 没数据就一直卡这里
printf("got %ld bytes\n", n);

优缺点

✅ 优点❌ 缺点
写法简单,符合直觉一个线程同时只能等一个连接
没有 CPU 浪费高并发时要开几千个线程,线程开销爆炸

典型问题:早期 Tomcat 的 BIO 模式就是阻塞 I/O——每个请求一个线程,到了几千 QPS 直接 OOM。


3.2 ② 非阻塞 I/O(Non-blocking I/O)

工作流程

生活类比

你去打水,水龙头没水你就马上走开干别的,但你每隔几秒就跑回来看一眼——确实没傻等了,但你的脚都跑酸了,效率反而更低。

代码长什么样

c
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);   // 设为非阻塞

while (1) {
    ssize_t n = read(sockfd, buf, 1024);
    if (n > 0) {
        printf("got %ld bytes\n", n);
        break;
    }
    if (n == -1 && errno == EAGAIN) {
        // 数据没准备好,可以做点别的事
        do_other_work();
        continue;
    }
    // 真错误
    perror("read");
    break;
}

优缺点

✅ 优点❌ 缺点
调用立即返回,不挂起用户必须手写轮询,CPU 100% 被吃光
一个线程理论上能"看着"多个 fd一个线程逐个问 fd,效率仍然差

结论:单独用没什么意义,它是为多路复用做准备的。所有 epoll 程序的 socket 都设为非阻塞。


3.3 ③ I/O 多路复用(I/O Multiplexing)⭐⭐⭐⭐⭐

核心思想

不让用户线程自己去问每个 fd,而是让内核帮忙看着一堆 fd——任何一个就绪了,内核就回报一次。

工作流程

生活类比

你去医院看病,护士(内核)让所有病人(fd)都坐在大厅等。

  • 不是每个病人都派一个护士陪着(那是阻塞 I/O,护士不够)
  • 不是病人自己跑来跑去问"轮到我了吗"(那是非阻塞轮询,吵死人)
  • 而是叫号系统——叫到哪个号(fd 就绪)哪个去看医生

一个护士(一个线程)就能管理整个候诊大厅——这就是多路复用。

三代实现

text
       1983              1997               2002
┌─────────────┐    ┌─────────────┐    ┌──────────────────┐
│   select    │ →  │    poll     │ →  │      epoll       │
│ (BSD 起源)  │    │ (System V)  │    │  (Linux 专属)    │
├─────────────┤    ├─────────────┤    ├──────────────────┤
│ 1024 个 fd  │    │ 无上限      │    │ 无上限           │
│ 位图        │    │ 数组        │    │ 红黑树+就绪链表  │
│ O(n) 遍历   │    │ O(n) 遍历   │    │ O(1) 拿就绪      │
│ 全量拷贝     │    │ 全量拷贝     │    │ 注册一次复用    │
└─────────────┘    └─────────────┘    └──────────────────┘

重头戏:第 3、4、5 章会一个一个把它们扒光。

优缺点

✅ 优点❌ 缺点
一个线程管理上万 fd写法比阻塞 I/O 复杂
减少线程数,节省内存与上下文切换需要理解事件回调式编程模型
epoll 性能可达百万 QPSLT/ET 等细节容易踩坑

3.4 ④ 信号驱动 I/O(Signal-driven I/O)

工作流程

生活类比

你给水龙头绑了一个铃铛——平时去做别的事,水一来铃铛就响,你跑回来打水。

为什么没人用?

  • 信号是异步打断,处理器函数里能用的 API 极其受限(只能用 async-signal-safe 的)
  • 多个 fd 共享一个 SIGIO,无法区分是哪个 fd 就绪
  • 信号会丢失(如果同时来好几个,只触发一次)

结论:理论模型,工程上几乎不用。多路复用更优雅。


3.5 ⑤ 异步 I/O(Asynchronous I/O,AIO)

工作流程

生活类比

送水上门:你给水站打电话留地址,水站搞定一切,水送到桌上时你才被叫一声——你两个阶段都没参与。

代码(POSIX AIO)

c
struct aiocb cb = {0};
cb.aio_fildes = fd;
cb.aio_buf = buf;
cb.aio_nbytes = 1024;

aio_read(&cb);   // 立刻返回,内核异步搞定一切

// 用户线程做别的事...

while (aio_error(&cb) == EINPROGRESS) {
    do_other_work();
}
ssize_t n = aio_return(&cb);   // 取结果

现实困境

  • 传统 Linux AIO(libaio)只对直接 I/O(O_DIRECT)的磁盘文件好用,对 socket 几乎没用
  • POSIX AIO 是用线程池模拟的,不是真异步
  • Windows IOCP 是真异步的,做得很好——但跟 Linux 无关

io_uring 改变了一切(2019 起)

text
io_uring:Linux 5.1+ 引入的全新异步 I/O 框架
─────────────────────────────────────────
✅ 用户态 / 内核态共享两个无锁环形队列
✅ 一次 syscall 可以批量提交几千个请求
✅ 真异步,对 socket / 文件 / 时间事件统一支持
✅ 性能在某些场景超过 epoll
🔄 正在被 Nginx、ScyllaDB、PostgreSQL 等逐步引入

结论:未来 5-10 年内 io_uring 可能成为新主流,但当下面试和业务代码 90% 都还是 epoll——这就是本系列重点讲 epoll 的原因。


4. 同步 vs 异步、阻塞 vs 非阻塞:四个词的真正含义

这是面试最常被搞混的概念,必须辨明。

4.1 标准定义

概念定义
同步(Synchronous)调用者自己完成(或等待结果)
异步(Asynchronous)调用者委托别人完成,自己继续干别的
阻塞(Blocking)调用未完成时,线程被挂起
非阻塞(Non-blocking)调用未完成时,立刻返回

4.2 用打电话来理解

  • 同步阻塞:你打电话问朋友答案,电话不挂等他想完(你在等,你自己听)
  • 同步非阻塞:你每分钟打一次电话问"想好了没"(你在多次问,你自己听)
  • 异步阻塞:你说"想好了打回来",然后坐在电话旁边一直等他打来(罕见,奇怪的组合)
  • 异步非阻塞:你说"想好了打回来",挂了电话该干啥干啥(最爽,io_uring)

4.3 五大模型的归类

模型是否同步是否阻塞
阻塞 I/O同步阻塞
非阻塞 I/O同步非阻塞
多路复用同步阻塞(select/epoll_wait 自己阻塞)
信号驱动同步非阻塞
异步 I/O异步非阻塞

重要结论多路复用本质是「同步 + 阻塞」——它阻塞在 select/epoll_wait 上!只是它阻塞着等的是"任意 fd 就绪",而不是"某一个 fd 就绪"。这一点在面试时千万不要搞错!


5. 怎么选模型?决策树

图解说明:99% 的 Linux 后端服务直接选 epoll。跨平台(如 Node.js)通过 libuv 屏蔽 epoll/kqueue/IOCP 的差异;追求极致性能、Linux 5.1+ 可以试试 io_uring。


6. 动手实践

实践:用三种方式写"等输入" 命令

c
// ====== 阻塞版本 ======
// blocking.c
#include <unistd.h>
#include <stdio.h>
int main() {
    char buf[100];
    printf("阻塞读,输入点东西: ");
    fflush(stdout);
    ssize_t n = read(0, buf, 100);   // 卡在这里直到敲键盘
    buf[n] = 0;
    printf("收到: %s", buf);
    return 0;
}

// ====== 非阻塞版本 ======
// nonblock.c
#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
int main() {
    fcntl(0, F_SETFL, fcntl(0, F_GETFL) | O_NONBLOCK);
    char buf[100];
    int tries = 0;
    while (1) {
        ssize_t n = read(0, buf, 100);
        if (n > 0) { buf[n] = 0; printf("\n收到: %s", buf); break; }
        if (errno == EAGAIN) {
            printf("\r%d 次询问,没数据,干别的事...", ++tries);
            fflush(stdout);
            usleep(200000);
        }
    }
    return 0;
}

// ====== 多路复用版本 ======
// muxed.c
#include <unistd.h>
#include <stdio.h>
#include <sys/select.h>
int main() {
    fd_set rfds;
    FD_ZERO(&rfds);
    FD_SET(0, &rfds);   // 监听 stdin
    printf("多路复用读,输入点东西: ");
    fflush(stdout);
    select(1, &rfds, NULL, NULL, NULL);
    char buf[100];
    ssize_t n = read(0, buf, 100);
    buf[n] = 0;
    printf("收到: %s", buf);
    return 0;
}
bash
# 编译运行:
gcc blocking.c -o blocking && ./blocking
gcc nonblock.c -o nonblock && ./nonblock   # 会看到不停打印"没数据"
gcc muxed.c -o muxed && ./muxed

观察重点

  • blocking:什么也不输出,安静地等
  • nonblock:CPU 100%,疯狂打印"没数据"
  • muxed:和 blocking 几乎一样,但 select 可以同时监听多个 fd

7. 本章小结

text
五大 I/O 模型差异本质:
─────────────────────────────────────
阻塞 I/O      → "等数据" 阻塞,"拷数据" 阻塞
非阻塞 I/O    → "等数据" 非阻塞(自己轮询),"拷数据" 阻塞
多路复用       → "等多个 fd" 阻塞在 select/epoll,"拷数据" 阻塞 ⭐
信号驱动 I/O  → "等数据" 通过信号回调,"拷数据" 阻塞
异步 I/O      → "等" 和 "拷" 都不参与 ⭐⭐

8. 真实面试题

Q1:select / epoll 是同步还是异步?

A:同步——它们是"同步阻塞"的多路复用,自己仍然要 read() 把数据从内核拷到用户态。真正的异步 I/O 是 io_uring / Windows IOCP。

Q2:多路复用为什么能比多线程阻塞 I/O 更快?

A:① 省线程开销(一个线程顶上千个);② 避免上下文切换(线程切换约 1-2 μs,epoll_wait 唤醒只需 nanosecond 级);③ Cache 友好(数据热度集中在少数线程)。

Q3:什么是 C10K 问题?

A:1999 年 Dan Kegel 提出的——当单机要承担 10000 个并发连接时,传统"一连接一线程 + 阻塞 I/O"模型会在内存、上下文切换、惊群等多个维度崩溃。多路复用 + 事件驱动是解决方案。今天已发展到 C10M(千万级)。

Q4:epoll 是 select 的替代品吗?为什么 select 还存在?

A:epoll 只在 Linux 上有select 是 POSIX 标准,跨平台——Windows、BSD、macOS 都支持。所以**跨平台库(libuv)**仍然要在 epoll 不可用时退回 select。

Q5:阻塞 I/O 真的一无是处吗?

A:不!① 客户端代码(连接数极少时,简单可靠);② 命令行工具(cat / grep);③ 进程间一对一通信(管道)。多路复用是解决高并发的,不是万能药


下一站第 3 章:select 详解,我们正式打开多路复用三剑客的第一把:select 的位图魔法。

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗