操作系统IO模型:阻塞IO、非阻塞IO、IO多路复用、异步IO
说到IO模型,很多同学第一反应就是背概念。但说实话,背概念解决不了实际问题。我在做高并发系统时,踩过最多的坑就是IO模型选型不当。今天咱们把这四种模型彻底聊透。
一、阻塞IO:最朴素的模型
阻塞IO,说白了就是「等」。你调用read(),内核没有数据,你就一直等着。线程挂起,啥也不干。
我刚开始写网络程序时,用的就是这种模型。一个线程处理一个连接,简单粗暴。但问题来了——如果1000个连接,就得开1000个线程。线程切换的开销,你想想看,系统很快就扛不住了。
核心特征:
- 调用方发起系统调用后,进程/线程进入睡眠状态
- 内核准备数据期间,调用方完全阻塞
- 数据从内核拷贝到用户空间后,调用才返回
// 典型的阻塞IO示例
int sock = socket(AF_INET, SOCK_STREAM, 0);
connect(sock, ...);
// 这里会阻塞,直到有数据到达
char buf[1024];
int n = read(sock, buf, sizeof(buf)); // 阻塞点
printf("received: %s\n", buf);
嗯,这里要注意:阻塞IO在并发量低时其实很稳定。我早期做的一个内部管理系统,就几十个用户,用阻塞IO完全没问题。但一旦上了量,就得换思路了。
二、非阻塞IO:轮询的代价
非阻塞IO解决了一个问题:调用不会挂起线程。你调用read(),如果没数据,立即返回-1,设置errno为EAGAIN。线程可以继续干别的事。
但代价是什么?你得不停地轮询。我曾经在一个项目中用过非阻塞IO+轮询,CPU直接飙到90%以上。为什么?因为while循环里一直在问「有数据了吗?有数据了吗?」——这就像你每分钟给快递员打一次电话问包裹到了没,快递员烦,你也累。
我的经验:非阻塞IO很少单独使用。它通常和IO多路复用配合,或者用在某些特殊场景,比如你需要同时处理多个fd,但又不想用多线程。
// 非阻塞IO示例
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);
char buf[1024];
int n;
while (1) {
n = read(sock, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) {
// 没数据,干点别的
do_something_else();
continue;
}
break;
}
三、IO多路复用:高并发的基石
这才是真正解决高并发问题的模型。select、poll、epoll,它们的作用是:用一个线程监视多个fd。
我记得2015年做一个IM系统,单机要支持5万长连接。如果用阻塞IO,5万个线程直接炸了。用非阻塞IO轮询,CPU也扛不住。最后选了epoll,一个线程搞定所有连接。
IO多路复用的核心思想:你告诉内核「帮我盯着这些fd,哪个有动静了通知我」。然后你阻塞在select/epoll_wait上。内核发现有数据了,返回给你一个列表,你挨个处理就行。
三种多路复用的对比:
| 模型 | 时间复杂度 | 最大fd数 | 工作模式 |
|---|---|---|---|
| select | O(n) | 1024(默认) | 水平触发 |
| poll | O(n) | 无限制 | 水平触发 |
| epoll | O(1) | 无限制 | 水平+边缘触发 |
// epoll 核心用法
int epfd = epoll_create(1);
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 边缘触发
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++) {
handle_event(events[i].data.fd);
}
}
避坑指南:我曾经在边缘触发模式下没处理好数据读取,导致数据没读完就返回了。边缘触发必须用循环读,直到EAGAIN。水平触发则不用,但效率稍低。选哪个?看你的业务场景。
四、异步IO:终极方案?
异步IO是最高级的模型。你发起一个IO操作,然后立刻返回。内核帮你把数据准备好,甚至拷贝到用户空间,然后通知你「搞定了」。
Linux的AIO(POSIX AIO)和Windows的IOCP是典型代表。Java里的NIO 2.0也支持异步通道。
说实话,我在实际项目中使用异步IO的次数并不多。为什么?因为它的复杂度太高了。回调地狱、内存管理、异常处理,都是坑。而且对于大多数业务场景,epoll已经够用了。
异步IO vs IO多路复用:
- IO多路复用:你告诉内核「有数据了通知我」,然后你自己去读
- 异步IO:你告诉内核「帮我读数据,读完了通知我」,内核帮你读完
// Linux AIO 示例(简化)
struct aiocb cb;
cb.aio_fildes = fd;
cb.aio_buf = buf;
cb.aio_nbytes = 1024;
cb.aio_offset = 0;
aio_read(&cb); // 立即返回
// 干点别的事...
do_something();
// 检查是否完成
while (aio_error(&cb) == EINPROGRESS) {
// 等待或干别的
}
int ret = aio_return(&cb);
五、四种模型的对比总结
我画了一张图,帮你理清这四种模型的关系:
我的建议:对于99%的后端服务,IO多路复用(尤其是epoll)是最佳选择。异步IO虽然听起来高大上,但除非你做的真的是极致性能的系统,否则别轻易碰。我曾经在一个项目里强行上异步IO,结果调试了整整两周,最后换回epoll,世界清净了。
六、实际选型建议
说了这么多,到底怎么选?我总结几条经验:
- 连接数 < 100:阻塞IO + 多线程,简单够用
- 连接数 100-1000:IO多路复用,select或poll都行
- 连接数 > 1000:必须上epoll,这是Linux下唯一的选择
- 需要极致性能:考虑异步IO,但要做好心理准备
避坑指南:我曾经见过一个团队,用select处理1万个连接,结果每次select都要遍历整个fd集合,CPU直接打满。后来换成epoll,CPU降到5%。选对模型,比什么都重要。
好了,四种IO模型就聊到这里。记住一句话:没有最好的模型,只有最合适的模型。理解它们的本质,你就能在合适的场景做出正确的选择。