几种IO方式
本文最后更新于 2026年3月3日 下午
面试问到了,重新梳理一下
一次 IO 到底发生了什么?
以读 socket 数据为例。
一次 read 实际上包含两个阶段:
等待数据准备好(内核态)
- 数据从网卡 → DMA → 内核缓冲区
- 这个过程应用程序完全无法参与
- 只能等
数据从内核拷贝到用户空间
- 内核 buffer → 用户 buffer
- 这是一次内存拷贝
一次 IO = 等待数据 + 拷贝数据
所有 IO 模型的区别,都来自:
- 等待阶段谁在等?
- 拷贝阶段谁在等?
- 是否阻塞线程?
- 是否需要主动轮询?
阻塞 IO(Blocking IO)
这是最传统的模型。
1 | |
如果数据没到:
- 线程直接睡眠
- 内核等数据
- 数据到达后拷贝
- read 返回
特点
- 等待阶段:阻塞
- 拷贝阶段:阻塞
- 整个线程完全卡住
优点
- 简单
- 易写
- 易理解
缺点
- 1个线程只能处理1个连接
- 高并发会炸线程
▶
示例代码
1 | |
非阻塞 IO(Non-blocking IO)
设置描述符为非阻塞
1 | |
如果数据没到:
- 立刻返回 -1
- errno = EAGAIN
不会睡眠
但它只是不等待数据,它没有解决什么时候知道数据来了的问题
所以必须轮询,这就导致了:
- CPU 空转
- 效率低
- 不能规模化
▶
示例代码
1 | |
IO 多路复用(select / poll / epoll)
它解决的是如何高效等待多个 fd
不对每个 fd 调 read
而是先问内核:哪些 fd 可以读?
内核来等。
时间线
1 | |
关键理解
IO多路复用:
- 等待阶段:由内核统一等待
- 拷贝阶段:仍然是同步的
所以它本质是同步 IO,只是等待更高效
Select / Poll / Epoll
select(最原始,效率最低)
- 原理:传给内核一个固定大小的位图,里面存了所有要监控的 FD。
- 痛点 1(限制):位图长度有限,默认只能监控 1024 个 FD。
- 痛点 2(拷贝):每次调用都要把这个位图从用户态全量拷贝到内核态。
- 痛点 3(遍历):内核发现有数据了,它不告诉你是哪个,而是把位图还给你。你得在用户态亲自写个循环从 1 扫到 1024,看看到底是谁有数。
poll(select 的小幅改良版)
- 原理:不用位图了,改用结构体数组(链表实现)。
- 进步:没有了 1024 的数量限制。
- 原地踏步:它依然需要全量拷贝数组,内核依然不告诉你是谁有数,你还是得手动 O(n) 遍历整个数组。
epoll(Linux 的终极方案,性能王者)
它之所以快,是因为在内核里做了三件大事:
- 红黑树:在内核里开了一棵树,想监控哪个 FD 就往树上挂。这样不用每次调用都拷贝全量列表,只需告诉内核变动了哪一个。
- 回调机制:内核给每个 FD 挂了钩子。一旦数据到了,内核自动执行回调,把这个 FD 塞进一个就绪链表。
- 就绪链表:当你调用
wait时,内核直接把这个“有数”的链表丢给你。复杂度是 O(1),不需要去遍历那些没动静的死链接。
▶
示例代码
1 | |
信号驱动 IO(Signal-driven IO)
1 | |
调用后:
- 立刻返回
- 内核负责等待
- 完成后通知(信号)
- 数据拷贝阶段仍然是同步的
▶
示例代码
1 | |
同步 IO vs 异步 IO
这是最容易混淆的地方。
区别标准:数据拷贝阶段是否由用户线程等待
同步 IO(Synchronous IO)
特点:
调用 read 数据拷贝完成前线程不能继续
包括:
- 阻塞 IO
- 非阻塞 IO
- IO 多路复用
它们都是同步 IO。
异步 IO(Asynchronous IO)
1 | |
调用后:
- 立刻返回
- 内核负责等待
- 内核负责拷贝
- 完成后通知(信号 / 回调 / 事件)
从头到尾都没有阻塞
▶
示例代码
1 | |
IO_uring
io_uring 是 Linux 5.1 引入的一个全新的异步 IO 框架,旨在提供更高效、更灵活的异步 IO 处理方式。它通过使用共享内存环形缓冲区来减少系统调用的开销,实现了真正的零拷贝异步 IO.
▶
示例代码
1 | |
整体对比与总结
| 模型 | 等待阶段 | 拷贝阶段 | 是否阻塞线程 |
|---|---|---|---|
| 阻塞 IO | 阻塞 | 阻塞 | 是 |
| 非阻塞 IO | 不阻塞 | 阻塞 | 部分 |
| IO 多路复用 | 阻塞在select | 阻塞 | 是 |
| 信号驱动 IO | 内核通知 | 阻塞 | 部分 |
| 异步 IO | 内核处理 | 内核处理 | 否 |
几种IO方式
https://www.harkerhand.cn/io/