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模型的核心区别画出来了。我建议你多看几遍,理解数据流的方向和阻塞点在哪里。
3.6 如何选择?
说了这么多,到底该用哪个?我个人的经验是:
- 连接数少(几百以内):阻塞I/O + 多线程,简单够用
- 连接数中等(几千到几万):epoll(Linux)或IOCP(Windows),这是主流方案
- 超高并发(十万级以上):考虑异步I/O,比如io_uring,但要做好编程复杂度上升的准备
一句话总结:阻塞I/O是“人等数据”,非阻塞I/O是“人反复问数据到了没”,多路复用是“人一次性问哪些数据到了”,异步I/O是“数据到了主动通知人”。
嗯,I/O模型这块内容不少,但理解了这四种模型的核心区别,后面讲网络框架、线程模型的时候,你就能更快上手了。