Skip to content

五种 IO 模型:阻塞、非阻塞、IO 多路复用、信号驱动、异步

凌晨 2 点,你的服务器 CPU 使用率飙到 100%,连接数破万。

日志里全是 too many open files,进程开始疯狂抖动。

你开始怀疑人生:明明代码写得没问题,为什么系统撑不住?

答案可能藏在一个你从没仔细想过的底层问题里——IO 模型

不了解 IO 模型,就像开车不知道油门和刹车的区别:你以为在踩油门,其实车早就没油了。


一切从一次 read() 说起

当你的程序调用 read() 读取数据时,操作系统在背后做了两件事:

  1. 等数据就绪:数据可能来自网卡、磁盘,或者另一个进程。这段时间,CPU 什么都不用干。
  2. 把数据从内核空间拷贝到用户空间:数据就绪后,内核把数据复制到你的进程缓冲区。

这「等」和「拷贝」两步,是理解所有 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

c
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

特点

  • fd 数量有上限:默认 1024(FD_SETSIZE
  • 每次调用,需要把 fd 集合从用户态拷贝到内核态
  • 返回后,需要线性扫描所有 fd,找到就绪的那个
  • 支持三种事件类型:读、写、异常

poll

c
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 引入,是目前高性能网络编程的首选。

c
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/pollepoll
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

但真正性能差异在于系统调用次数和内存拷贝

  1. select 每次调用都要把 fd 集合从用户态拷贝到内核态
  2. epoll 创建后只需要一次拷贝,之后用 epoll_ctl 增删 fd

追问二:为什么 Redis 选择 epoll,而不是多线程?

Redis 是单线程 + epoll 的经典模式。原因:

  1. CPU 不是瓶颈:Redis 是内存数据库,操作本身极快,CPU 不是瓶颈
  2. 避免锁开销:单线程天然无锁,多线程反而要引入复杂的同步机制
  3. 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)技术,就是为了解决这个问题。

基于 VitePress 构建