4、I/O多路复用入门:什么是I/O多路复用、select/poll/epoll发展史、适用场景对比

聊到高性能网络编程,有个概念你肯定绕不开——I/O多路复用。

说白了,就是让一个线程能同时盯着成百上千个socket。哪个socket有数据来了,就去处理哪个。不用再给每个连接都配一个线程,也不用傻乎乎地轮询。

我记得刚入行那会儿,接手一个老项目,用的是最原始的阻塞式模型。来一个连接就fork一个进程。连接数一上到几百,服务器直接卡死。后来换成select,才算是打开了新世界的大门。

4.1 什么是I/O多路复用?

先看个最朴素的场景。

你写了一个服务端,需要同时处理多个客户端的数据。最直接的做法是:

  • 多线程/多进程:每个连接一个线程。连接多了,线程切换开销巨大。
  • 非阻塞轮询:用一个循环,挨个检查每个socket。CPU空转,浪费严重。

这两种方式,说白了都不够优雅。

I/O多路复用的思路是:交给内核去帮我们盯着这些socket。你告诉内核:“帮我看着这100个socket,哪个可读/可写了,通知我一声。”然后你阻塞在系统调用上。内核一旦发现有socket就绪,就返回给你。你再挨个处理就绪的socket。

这样,一个线程就能管理成千上万个连接。效率高得多。

核心本质:I/O多路复用是一种同步I/O模型。它只是帮我们“监听”多个文件描述符的状态,实际的读写操作还是由应用程序自己完成。别跟异步I/O搞混了。

为了让你更直观地理解,我画了一张图:

I/O多路复用核心模型 应用进程 select/poll/epoll 内核 就绪队列(可读/可写/异常) Socket 1 (就绪) Socket 2 (等待) Socket 3 (就绪) 应用进程阻塞在系统调用上,内核返回就绪的socket列表,应用再逐个处理

4.2 发展史:从select到poll再到epoll

这东西不是一蹴而就的。我经历过从select到epoll的迁移,感触很深。

4.2.1 select:老前辈,但问题不少

select是最早出现在POSIX标准里的。它的工作方式很简单:

  • 把要监听的fd集合传给内核。
  • 内核遍历所有fd,检查状态。
  • 返回时,把就绪的fd集合返回给用户。

听着还行?但坑很多。

我曾经踩过的坑:select默认最大监听1024个fd。有一次线上服务连接数刚过1000,新连接死活连不上。查了半天,才发现是FD_SETSIZE的限制。后来改源码重新编译才解决。

select的主要问题:

  • fd数量限制:默认1024,虽然能改,但本质是硬伤。
  • 重复拷贝:每次调用都要把整个fd集合从用户态拷贝到内核态。连接多了,拷贝开销巨大。
  • 全量遍历:内核不知道哪些fd是活跃的,只能全部遍历一遍。O(n)复杂度。
  • 修改fd集合:内核会修改传入的fd集合,下次调用前必须重新初始化。

4.2.2 poll:解决了上限,但没解决本质

poll的出现,算是解决了select的“1024魔咒”。它用链表来存储fd,理论上没有上限。

但其他问题还在:

  • 依然要全量拷贝fd列表到内核。
  • 内核依然要全量遍历所有fd。
  • 依然是O(n)的复杂度。

说白了,poll只是把select的“数组”换成了“链表”,治标不治本。

4.2.3 epoll:Linux下的王者

epoll是Linux 2.6内核引入的。它彻底改变了游戏规则。

它的核心改进有三点:

特性 select/poll epoll
fd数量 有限制(select 1024) 无上限(受系统内存限制)
拷贝方式 每次调用全量拷贝 通过epoll_create创建红黑树,增量注册
就绪通知 全量遍历,O(n) 回调机制,只返回就绪fd,O(1)
工作模式 仅LT(水平触发) 支持LT和ET(边缘触发)

epoll的API只有三个函数:

// 1. 创建一个epoll实例,返回一个文件描述符
int epoll_create(int size);  // size参数在Linux 2.6.8后已废弃,但必须大于0

// 2. 注册/修改/删除要监听的fd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

// 3. 等待事件发生
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

我个人习惯:用epoll时,epoll_create的size参数我习惯传1。因为内核已经不再依赖这个参数了。但为了兼容老版本,传个大于0的值就行。

4.3 适用场景对比

没有银弹。每种模型都有它最舒服的战场。

4.3.1 什么时候用select?

  • 跨平台需求(Windows、Linux、macOS都支持)。
  • 监听的fd数量很少(几十个以内)。
  • 对性能要求不高,快速实现原型。

4.3.2 什么时候用poll?

  • 需要突破1024限制,但又不想用epoll(比如某些嵌入式环境)。
  • fd数量在几千以内,且对性能不敏感。

说实话,我在生产环境中已经很少用poll了。它比select好不了多少。

4.3.3 什么时候用epoll?

  • 高并发网络服务:比如Web服务器、消息队列、游戏服务器。连接数动辄上万甚至几十万。
  • 需要高性能I/O:比如Redis、Nginx、Node.js的底层都依赖epoll。
  • Linux平台:epoll是Linux专属。macOS用kqueue,Windows用IOCP。

避坑指南:我曾经在一个项目里,把epoll用在了一个只有几十个连接的管理后台。结果代码复杂度上去了,性能提升几乎感觉不到。后来换回select,代码清爽多了。所以,别为了用而用。选型要看场景。

4.4 一张图总结

我把三者的核心差异整理成了一张对比图:

select / poll / epoll 核心差异对比 select fd上限:1024 遍历方式:全量O(n) 拷贝:每次全量 适用:小规模 跨平台原型 poll fd上限:无限制 遍历方式:全量O(n) 拷贝:每次全量 适用:中等规模 突破1024限制 epoll fd上限:无限制 遍历方式:回调O(1) 拷贝:增量注册 适用:高并发 高性能Linux服务 选型建议 连接数 < 1000,跨平台 → select 连接数 1000~5000,非Linux → poll 连接数 > 5000,Linux平台 → epoll