14、异步 I/O 基础:阻塞与非阻塞 I/O,I/O 多路复用

聊到协程,绕不开 I/O。说实话,很多同学学协程学到一半卡住了,不是因为协程本身难,而是对底层的 I/O 模型一知半解。你想想看,协程要解决的核心问题是什么?就是让程序在等待 I/O 的时候不傻等,能去干点别的。所以,搞懂 I/O 模型,是理解协程的必修课。

我刚开始做嵌入式开发那会儿,写过一个串口通信的程序。用的是最朴素的阻塞读,结果程序一跑起来,CPU 占用率倒是低,但整个系统就像死了一样——因为主循环卡在 read() 上,按键按了没反应,屏幕也不刷新。后来我才意识到,I/O 模型选错了,再好的业务逻辑也白搭。

阻塞 I/O:最直观,也最坑

阻塞 I/O 是什么?说白了就是:你调用 read(),数据没来,线程就挂在那等。内核把线程塞进等待队列,直到数据到了才唤醒你。

代码长这样:

int fd = open("/dev/ttyS0", O_RDWR);
char buf[128];
// 这一行下去,线程就睡了,直到有数据
int n = read(fd, buf, sizeof(buf));
// 醒来继续干活
printf("读到 %d 字节\n", n);

这种模型的好处是编程简单——你不需要操心数据什么时候到,内核帮你管了。但坏处也很明显:一个线程只能伺候一个 I/O。如果你有 1000 个连接,就得开 1000 个线程。线程多了,上下文切换的开销能把 CPU 吃死。

注意: 我在项目中见过有人用阻塞 I/O 做高并发服务器,结果线程数一上去,系统响应时间反而变慢了。这不是代码写得不好,是模型选错了。

非阻塞 I/O:不等待,但得轮询

非阻塞 I/O 就聪明一点了。你调用 read(),不管数据有没有,函数立刻返回。有数据就返回数据长度,没数据就返回 -1,并设置 errno 为 EAGAIN。

// 设置非阻塞
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

char buf[128];
int n = read(fd, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) {
    // 没数据,干点别的
    do_other_work();
} else if (n > 0) {
    // 有数据,处理
    process_data(buf, n);
}

这样线程就不会被卡死了。但问题来了——你怎么知道什么时候该去读?最简单的办法就是轮询:每隔几毫秒去 read 一次。这就像你每隔几分钟去检查一下邮箱,效率可想而知。

轮询的坏处有两个:一是浪费 CPU,二是响应有延迟。你轮询间隔设短了,CPU 空转;设长了,数据来了不能及时处理。

我的经验: 非阻塞 I/O 配合轮询,只适合 I/O 频率很低、对延迟不敏感的场景。比如读取温度传感器,每秒一次就够了,轮询没问题。但如果是网络服务器,每秒几千个请求,轮询就是灾难。

I/O 多路复用:一个线程管一堆 I/O

那有没有办法让一个线程同时监视多个文件描述符,哪个有数据就处理哪个?有,这就是 I/O 多路复用。

Linux 下主要有三种:select、poll、epoll。它们的核心思想是一样的:你告诉内核「我关心这些 fd 上的读/写事件」,内核帮你盯着,有事件了通知你。

select:老前辈,但有限制

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd1, &readfds);
FD_SET(fd2, &readfds);

struct timeval tv = {5, 0}; // 5秒超时
int ret = select(fd2 + 1, &readfds, NULL, NULL, &tv);
if (ret > 0) {
    if (FD_ISSET(fd1, &readfds)) handle(fd1);
    if (FD_ISSET(fd2, &readfds)) handle(fd2);
}

select 的问题很明显:

  • 能监视的 fd 数量有限,默认 1024
  • 每次调用都要把整个 fd_set 从用户态拷贝到内核态
  • 返回后要遍历所有 fd 才能知道哪些就绪了

我曾经在一个项目中用 select 管理 500 个连接,每次 select 返回后遍历一遍,CPU 占用率直接飙到 30%。后来换成 epoll,同样的负载,CPU 降到 5%。

poll:改进有限

poll 解决了 select 的 1024 限制,用链表管理 fd,数量只受内存限制。但其他问题还在——每次调用还是要传全部 fd,返回后还是要遍历。

struct pollfd fds[2];
fds[0].fd = fd1; fds[0].events = POLLIN;
fds[1].fd = fd2; fds[1].events = POLLIN;

int ret = poll(fds, 2, 5000); // 5秒超时
for (int i = 0; i < 2; i++) {
    if (fds[i].revents & POLLIN) {
        handle(fds[i].fd);
    }
}

epoll:Linux 下的王者

epoll 是 Linux 2.6 引入的,专门为高并发设计。它解决了 select/poll 的两个核心痛点:

  1. 不再重复传 fd:用 epoll_ctl 注册一次,内核就记住了
  2. 只返回就绪的 fd:不用遍历全部,直接拿到就绪列表
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = fd1;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev);

struct epoll_event events[128];
int n = epoll_wait(epfd, events, 128, -1);
for (int i = 0; i < n; i++) {
    handle(events[i].data.fd);
}

epoll 的高效在于两点:

  • 内核用红黑树管理注册的 fd,增删改查都是 O(log n)
  • 就绪的 fd 通过链表返回,应用程序直接取,不用遍历

核心区别: select/poll 是「你告诉我你关心谁,然后我告诉你谁就绪了,但你要自己找」。epoll 是「你告诉我你关心谁,然后我直接告诉你谁就绪了」。

三种模型对比

特性 阻塞 I/O 非阻塞 I/O + 轮询 I/O 多路复用
编程复杂度
CPU 利用率 低(线程挂起) 高(轮询空转) 低(事件驱动)
并发能力 差(1 线程/1 I/O) 中(1 线程/N I/O) 强(1 线程/万级 I/O)
响应延迟 低(数据到即唤醒) 高(取决于轮询间隔) 低(事件触发)
适用场景 低并发、简单应用 低频 I/O、嵌入式 高并发服务器

I/O 多路复用与协程的关系

到这里你可能会问:I/O 多路复用和协程有什么关系?关系大了。

协程的本质是「让出执行权,等待 I/O 就绪后再回来」。但协程自己不会监视 I/O,它需要有人告诉它「数据到了」。这个人就是 I/O 多路复用机制。

典型的协程调度器是这样工作的:

  1. 协程 A 发起 read(),发现数据没到
  2. 调度器把协程 A 挂起,并把 fd 注册到 epoll
  3. 调度器切换到协程 B 执行
  4. epoll 通知 fd 就绪
  5. 调度器把协程 A 重新加入就绪队列
  6. 协程 A 从上次挂起的地方继续执行

你看,epoll 在这里扮演了「事件通知者」的角色。没有它,协程就不知道什么时候该被唤醒。

我的建议: 如果你想自己实现一个协程库,第一步不是写协程切换代码,而是先把 epoll 封装好。事件循环是协程调度的心脏,epoll 就是心脏起搏器。

一张图看懂 I/O 模型演进

下面这张图展示了从阻塞 I/O 到 I/O 多路复用的演进过程。每个阶段解决了一个核心问题,但也带来了新的挑战。

I/O 模型演进 阻塞 I/O 线程挂起等待数据 1 线程 = 1 I/O 问题:并发能力差 改进 非阻塞 I/O + 轮询 立即返回,轮询检查 1 线程 = N I/O 问题:CPU 空转 改进 I/O 多路复用 事件驱动,内核通知 1 线程 = 万级 I/O 优势:高效并发 三种 I/O 多路复用实现 select fd_set 位图 最大 1024 个 fd 每次全量拷贝 O(n) 遍历 poll pollfd 链表 无数量限制 每次全量拷贝 O(n) 遍历 epoll 红黑树 + 就绪链表 无数量限制 只传就绪 fd O(1) 获取就绪 从阻塞到多路复用,核心思路是「把等待交给内核,让应用程序做更有价值的事」

避坑指南

最后分享几个我踩过的坑:

  • 不要混用阻塞和非阻塞:同一个 fd 上,要么全阻塞,要么全非阻塞。混用会导致不可预期的行为。我曾经在一个 fd 上同时用阻塞 read 和非阻塞 write,结果 read 卡住的时候,write 也受影响。
  • epoll 的边沿触发 vs 水平触发:水平触发(LT)是默认模式,只要 fd 还有数据可读,每次 epoll_wait 都会返回。边沿触发(ET)只在状态变化时通知一次。ET 模式效率更高,但必须用非阻塞 I/O,并且要一次性读完所有数据,否则会丢事件。我建议新手先用 LT,稳定后再尝试 ET。
  • 信号打断:epoll_wait 可能会被信号打断,返回 -1 并设置 errno 为 EINTR。代码里一定要处理这种情况,否则会出现「明明没超时,却提前返回」的诡异问题。

一句话总结: 阻塞 I/O 简单但低效,非阻塞 I/O + 轮询灵活但浪费 CPU,I/O 多路复用才是高并发的基石。而 epoll,是 Linux 下实现 I/O 多路复用的最佳选择。协程调度器,本质上就是一个封装了 epoll 的事件循环。


公众号:蓝海资料掘金营,微信deep3321