3. 操作系统I/O模型:从阻塞到异步的进化之路

聊到高并发,I/O模型是个绕不开的话题。我记得刚入行那会儿,写个简单的网络服务,用阻塞I/O一把梭,单机撑个几百连接就卡得不行。后来慢慢踩坑,才搞明白这几种模型的区别。今天咱们就把阻塞I/O、非阻塞I/O、I/O多路复用和异步I/O这四兄弟,掰开揉碎了讲清楚。

3.1 阻塞I/O:最朴素的“等”

阻塞I/O,说白了就是“你等着,我去拿数据”。应用程序调用recvfrom()时,内核开始准备数据,这个过程里,整个线程就挂在那了,啥也干不了。

核心流程:

  • 应用进程调用recvfrom → 系统调用进入内核
  • 内核等待数据包到达(比如网卡收到数据)
  • 数据从内核缓冲区拷贝到用户缓冲区
  • recvfrom返回,应用继续执行

我在项目中遇到过,早期用Java BIO写聊天服务器,每个连接一个线程。连接数一上千,线程上下文切换就把CPU吃光了。嗯,这模型简单是简单,但并发能力确实堪忧。

// 阻塞I/O的典型写法(伪代码)
int sock = socket(AF_INET, SOCK_STREAM, 0);
bind(sock, ...);
listen(sock, 5);

while (1) {
    int client = accept(sock, NULL, NULL);  // 阻塞在这里
    char buf[1024];
    int n = recv(client, buf, sizeof(buf), 0);  // 又阻塞在这里
    // 处理数据...
}

避坑指南:我曾经在压测时发现,阻塞I/O下每个连接至少占用1MB的线程栈空间。1000个连接就是1GB内存,这还没算业务逻辑的开销。所以,别指望用阻塞I/O撑高并发。

3.2 非阻塞I/O:轮询的代价

非阻塞I/O的思路是:调用recvfrom时,如果数据没准备好,内核直接返回一个EWOULDBLOCK错误,而不是挂起线程。应用可以继续干别的事,过一会儿再来问。

你想想看,这听起来不错对吧?但实际用起来,问题就来了——你得不停地轮询。每次轮询都是一次系统调用,CPU空转得厉害。

// 非阻塞I/O的轮询写法
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);

while (1) {
    char buf[1024];
    int n = recv(sock, buf, sizeof(buf), 0);
    if (n == -1 && errno == EWOULDBLOCK) {
        // 数据还没到,干点别的
        do_something_else();
        continue;
    }
    // 处理数据...
}

个人经验:我试过用非阻塞I/O配合while循环处理1000个连接,CPU直接飙到90%以上。说白了,轮询就是拿CPU换响应速度,得不偿失。

3.3 I/O多路复用:select/poll/epoll的进化

I/O多路复用的核心思想是:用一个线程同时监控多个文件描述符,哪个准备好了就处理哪个。这就像你同时盯着好几个水壶,哪个响了就去关哪个,而不是每个水壶配一个人盯着。

3.3.1 select:老当益壮

select是最早的多路复用方案。它把所有要监控的fd塞到一个集合里,然后调用select()。内核遍历这个集合,看看哪些fd有事件发生。

// select的典型用法
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock1, &readfds);
FD_SET(sock2, &readfds);

struct timeval tv = {5, 0};  // 5秒超时
int ret = select(max_fd + 1, &readfds, NULL, NULL, &tv);

if (ret > 0) {
    for (int i = 0; i <= max_fd; i++) {
        if (FD_ISSET(i, &readfds)) {
            // 处理这个fd的数据
        }
    }
}

select的坑:我曾经在生产环境吃过select的亏——它最多只能监控1024个fd。而且每次调用都要把整个fd集合从用户态拷贝到内核态,效率很低。更坑的是,select返回后,内核会修改fd集合,下次用之前得重新设置。

3.3.2 poll:改进有限

poll用pollfd结构体数组代替了select的位图,突破了1024的限制。但本质上还是遍历所有fd,性能瓶颈没解决。

struct pollfd fds[2];
fds[0].fd = sock1;
fds[0].events = POLLIN;
fds[1].fd = sock2;
fds[1].events = POLLIN;

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

3.3.3 epoll:Linux下的王者

epoll是Linux 2.6引入的,它解决了select/poll的两个核心问题:

  • 事件驱动:只返回有事件发生的fd,不用遍历全部
  • 零拷贝:通过mmap在内核和用户空间共享内存,减少数据拷贝
// epoll的典型用法
int epfd = epoll_create(1);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);

struct epoll_event events[1024];
while (1) {
    int n = epoll_wait(epfd, events, 1024, -1);
    for (int i = 0; i < n; i++) {
        if (events[i].events & EPOLLIN) {
            // 处理就绪的fd
        }
    }
}

性能对比:

模型 时间复杂度 最大fd数 数据拷贝
select O(n) 1024 每次全量拷贝
poll O(n) 无限制 每次全量拷贝
epoll O(1) 无限制 mmap共享内存

我个人习惯,在Linux下做高并发网络服务,首选epoll。Nginx、Redis这些高性能组件,底层用的都是它。

3.4 异步I/O:真正的“回调”

前面说的几种模型,不管是阻塞还是非阻塞,数据从内核拷贝到用户缓冲区这一步,都是同步的。异步I/O不一样——你发起一个aio_read(),内核帮你把数据准备好,甚至拷贝到你的缓冲区,然后通知你。

你想想看,这期间你的线程完全不用管,可以处理其他请求。这才是真正的“非阻塞”。

// Linux AIO的简单示例
struct aiocb cb;
memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd;
cb.aio_buf = buffer;
cb.aio_nbytes = 1024;
cb.aio_offset = 0;

// 发起异步读
aio_read(&cb);

// 干点别的事...
do_something_else();

// 检查是否完成
while (aio_error(&cb) == EINPROGRESS) {
    // 继续干别的事
}

// 获取结果
int ret = aio_return(&cb);

实际应用:Windows的IOCP和Linux的io_uring是异步I/O的典型代表。我在做文件服务器时用过io_uring,磁盘I/O的吞吐量提升了3倍以上。不过要注意,异步I/O的编程模型比较复杂,回调地狱不是闹着玩的。

3.5 知识体系总览

下面这张图,把四种I/O模型的核心区别画出来了。我建议你多看几遍,理解数据流的方向和阻塞点在哪里。

I/O模型核心对比 应用进程 内核 硬件设备 recvfrom(阻塞) 等待数据 recvfrom(立即返回) 轮询检查 select/poll/epoll 批量监控 事件通知 异步I/O aio_read(立即返回) 回调通知 图例: 同步调用 非阻塞调用 异步回调 阻塞I/O:应用等待内核完成 → 非阻塞I/O:轮询检查 → 多路复用:批量监控事件 → 异步I/O:回调通知

3.6 如何选择?

说了这么多,到底该用哪个?我个人的经验是:

  • 连接数少(几百以内):阻塞I/O + 多线程,简单够用
  • 连接数中等(几千到几万):epoll(Linux)或IOCP(Windows),这是主流方案
  • 超高并发(十万级以上):考虑异步I/O,比如io_uring,但要做好编程复杂度上升的准备

一句话总结:阻塞I/O是“人等数据”,非阻塞I/O是“人反复问数据到了没”,多路复用是“人一次性问哪些数据到了”,异步I/O是“数据到了主动通知人”。

嗯,I/O模型这块内容不少,但理解了这四种模型的核心区别,后面讲网络框架、线程模型的时候,你就能更快上手了。


可可精品资料库