五种 IO 模型:阻塞、非阻塞、IO 多路复用、信号驱动、异步
凌晨 2 点,你的服务器 CPU 使用率飙到 100%,连接数破万。
日志里全是 too many open files,进程开始疯狂抖动。
你开始怀疑人生:明明代码写得没问题,为什么系统撑不住?
答案可能藏在一个你从没仔细想过的底层问题里——IO 模型。
不了解 IO 模型,就像开车不知道油门和刹车的区别:你以为在踩油门,其实车早就没油了。
一切从一次 read() 说起
当你的程序调用 read() 读取数据时,操作系统在背后做了两件事:
- 等数据就绪:数据可能来自网卡、磁盘,或者另一个进程。这段时间,CPU 什么都不用干。
- 把数据从内核空间拷贝到用户空间:数据就绪后,内核把数据复制到你的进程缓冲区。
这「等」和「拷贝」两步,是理解所有 IO 模型的关键。
现在问题来了:进程在这两步里,是阻塞还是非阻塞?是主动问还是被动等?
不同的选择,造就了五种不同的 IO 模型。
五种 IO 模型详解
模型一:阻塞 IO(BIO)
最传统、最简单、最"老实"的 IO 方式。
进程调用 read()
↓
【进程阻塞】等待数据从磁盘/网络到达内核
↓
数据到达内核,复制到用户空间
↓
【进程解除阻塞】read() 返回进程从调用 read() 的那一刻起,就进入睡眠状态,直到数据就绪并复制完成才醒来。
优点:编程简单,逻辑清晰。 缺点:一个线程只能处理一个 IO 请求,高并发场景下线程数爆炸。
你去餐厅点菜,然后坐在门口等,菜做好了叫你——这就是阻塞 IO。
模型二:非阻塞 IO(NIO)
轮询(polling)模式:进程反复问内核"数据好了没?"
while true:
read() → 返回 EAGAIN,数据还没好
做点其他事
再问一次
...
read() → 返回数据,读取成功数据没就绪时,read() 立即返回错误码 EAGAIN(或 EWOULDBLOCK),进程不被阻塞,继续干别的。
优点:不用阻塞进程,可以做其他事情。 缺点:CPU 空转(busy-waiting),资源浪费。
你每隔 5 分钟去前台问一次"菜好了吗?"——问 100 次,终于好了。累不累?
模型三:IO 多路复用(select/poll/epoll)
这是 Linux 上高性能网络编程的基石。
核心思想:一个线程,同时监听多个文件描述符(fd)。任意一个就绪时,通知进程。
进程调用 select()/poll()/epoll_wait()
↓
【进程阻塞】等待任意 fd 就绪
↓
某个 fd 可读/可写,select() 返回
↓
进程遍历找到就绪的 fd,调用 read() 读取一个线程可以管理成千上万个连接,不用每个连接一个线程。
优点:单线程高效处理高并发。 缺点:编程复杂度高。
你雇了个服务员,他盯着 100 张桌子。哪桌客人举手,他就过去服务——你不用每个客人雇一个服务员。
模型四:信号驱动 IO(SIGIO)
进程告诉内核"数据好了叫我",然后继续干自己的事。
进程注册 SIGIO 信号处理函数
↓
进程继续执行,不阻塞
↓
数据就绪,内核发送 SIGIO 信号
↓
进程被信号唤醒,调用 read() 读取数据优点:全程无阻塞。 缺点:信号处理复杂,而且 sigaction 的标准不统一,Windows 不支持。
你扫码取了号,然后玩手机。广播叫你了,再去窗口——这就是信号驱动。
模型五:异步 IO(POSIX aio)
这是真正的"撒手掌柜":告诉内核"把数据读好放我缓冲区里,读完了叫我"。
进程调用 aio_read()
↓
【全程不阻塞】内核接管,进程继续执行
↓
内核完成所有工作(等数据 + 拷贝数据)
↓
内核发送信号通知进程:读完了
↓
进程直接使用数据前四种模型,进程都需要参与"读数据"这个动作(要么等、要么轮询、要么被通知后自己读)。
异步 IO 把所有工作都交给了内核,进程只管"用数据"。
你在 app 上点外卖,选择"配送到门"。骑手取餐、路上奔波、敲门送餐——全程你不需要做任何事。外卖到了,直接吃。
select/poll/epoll 三兄弟的区别
IO 多路复用有三种实现:select、poll、epoll。它们各有特点。
select
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);特点:
- fd 数量有上限:默认 1024(
FD_SETSIZE) - 每次调用,需要把 fd 集合从用户态拷贝到内核态
- 返回后,需要线性扫描所有 fd,找到就绪的那个
- 支持三种事件类型:读、写、异常
poll
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // 文件描述符
short events; // 关注的事件
short revents; // 实际发生的事件
};改进:
- 用数组代替
fd_set,没有数量限制 - 事件类型更丰富(POLLIN、POLLOUT、POLLERR 等)
不变:
- 依然需要每次把 fd 数组拷贝到内核
- 依然线性扫描
epoll
Linux 2.6 引入,是目前高性能网络编程的首选。
int epoll_create(int size); // 创建 epoll 实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 注册 fd
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待事件三大改进:
| 特性 | select/poll | epoll |
|---|---|---|
| fd 数量 | 受 FD_SETSIZE 限制 | 无限制(受系统文件描述符上限) |
| fd 拷贝 | 每次调用都拷贝 | 首次注册拷贝,增删改不拷贝 |
| 就绪检测 | 线性扫描所有 fd | 只返回就绪的 fd(O(1)) |
两种触发模式:
- 水平触发(LT):只要fd可读/可写,就会一直通知。select/poll 默认是 LT。
- 边缘触发(ET):状态变化时才通知,只通知一次。
LT 模式:服务员看到你杯子里有水,就一直问你"要不要续杯" ET 模式:服务员只在你刚坐下或喝完的时候问你一次
ET 效率更高,但编程复杂度也更高(容易漏掉事件)。
五种 IO 模型对比
| 模型 | 阻塞 | 同步/异步 | 轮询/通知 | 复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 阻塞 IO | 是 | 同步 | - | 低 | 低并发、简单场景 |
| 非阻塞 IO | 否 | 同步 | 轮询 | 中 | 特殊场景 |
| IO 多路复用 | 可选 | 同步 | 通知 | 高 | 高并发服务端 |
| 信号驱动 IO | 否 | 同步 | 信号通知 | 高 | Unix 系统,复杂 |
| 异步 IO | 否 | 异步 | 回调 | 高 | 高性能场景、 Windows |
面试追问方向
追问一:epoll 相比 select 性能优势的本质是什么?
从时间复杂度看:
- select/poll:O(n),每次都要遍历所有 fd
- epoll:O(1),只返回就绪的 fd
但真正性能差异在于系统调用次数和内存拷贝:
- select 每次调用都要把 fd 集合从用户态拷贝到内核态
- epoll 创建后只需要一次拷贝,之后用
epoll_ctl增删 fd
追问二:为什么 Redis 选择 epoll,而不是多线程?
Redis 是单线程 + epoll 的经典模式。原因:
- CPU 不是瓶颈:Redis 是内存数据库,操作本身极快,CPU 不是瓶颈
- 避免锁开销:单线程天然无锁,多线程反而要引入复杂的同步机制
- epoll 高效:单线程监听大量连接,每个连接请求处理极快
面试官可能还会问:Redis 6.0 引入多线程是什么场景? 答:多线程只用于网络 IO 的读写,命令执行依然是单线程——目的是提升多核利用率。
追问三:nginx 和 Node.js 的 IO 模型是什么?
nginx:多进程 + epoll。每个 worker 进程独立用 epoll 处理连接。
Node.js:早期单线程 + libuv(内部用 epoll/kqueue)。V8 引擎单线程,但 libuv 有线程池处理文件 IO。
追问四:Java NIO 用的是哪种 IO 模型?
Java NIO 的 Selector 底层在不同系统上实现不同:
- Linux:epoll(Linux 2.6+)
- macOS:kqueue
- Windows:IOCP
所以 Java NIO 本质上是 IO 多路复用模型。
留给你的思考题
我们讲完了五种 IO 模型,知道 epoll 是 Linux 高性能网络编程的核心。
但还有一个问题:epoll 的红黑树管理 fd,select/poll 的线性扫描,底层都是 kernel 态操作。
你有没有想过:当 epoll_wait 返回就绪的 fd 列表时,这个列表是内核态还是用户态的数据?
如果涉及到数据拷贝,这个开销有多大?
提示:零拷贝(Zero-Copy)技术,就是为了解决这个问题。
