4、I/O多路复用:select、poll、epoll原理与对比

说到网络编程,I/O多路复用是个绕不开的话题。我记得刚入行那会儿,写一个简单的聊天服务器,用的是多线程——每个客户端来一个线程。结果呢?客户端一多,服务器直接卡死。后来才知道,原来有select、poll、epoll这些好东西。

说白了,I/O多路复用的核心思想就一句话:一个线程同时监控多个文件描述符。你想想看,如果每个连接都要一个线程去伺候,那系统开销得多大?

核心概念:I/O多路复用允许单个进程同时监视多个文件描述符,一旦某个描述符就绪(可读/可写/异常),就能通知程序进行相应的读写操作。

4.1 select:老当益壮

select是最早出现的I/O多路复用函数。我在项目中遇到过一些老系统,还在用select。它的原理其实挺直观的:

  • 把所有要监控的fd(文件描述符)放到一个集合里
  • 调用select,内核帮你检查这些fd有没有就绪
  • 返回时,告诉你哪些fd可以读了、哪些可以写了
// select 的基本用法
fd_set readfds;
struct timeval tv;

FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);

tv.tv_sec = 5;
tv.tv_usec = 0;

int ret = select(sockfd + 1, &readfds, NULL, NULL, &tv);
if (ret == -1) {
    perror("select error");
} else if (ret == 0) {
    printf("超时了,没有数据\n");
} else {
    if (FD_ISSET(sockfd, &readfds)) {
        // 可以读数据了
    }
}

select的坑:我曾经在一个高并发项目里用select,结果发现它只能监控1024个fd。为什么?因为FD_SETSIZE默认就是1024。而且每次调用都要把整个fd集合从用户态拷贝到内核态,效率很低。

select的缺点很明显:

  • fd数量有限制(默认1024)
  • 每次调用都要重新设置fd集合
  • 返回后需要遍历所有fd才能找到就绪的
  • 用户态和内核态之间频繁拷贝数据

4.2 poll:改进版select

poll的出现,说白了就是解决select的fd数量限制问题。它用链表来存储fd,理论上没有上限。

// poll 的基本用法
struct pollfd fds[2];

fds[0].fd = sockfd;
fds[0].events = POLLIN;

fds[1].fd = listenfd;
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) {
            // fd[i] 可读
        }
    }
}

嗯,这里要注意:poll虽然解决了fd数量问题,但性能问题还在。每次调用还是要遍历所有fd,而且用户态和内核态的数据拷贝也没少。

我的经验:poll适合连接数不多(几百个以内)的场景。如果连接数上万,还是老老实实用epoll吧。

4.3 epoll:Linux下的王者

epoll是Linux特有的I/O多路复用机制。我第一次用epoll的时候,感觉就像打开了新世界的大门。它解决了select和poll的所有痛点:

  • 没有fd数量限制(只受系统内存限制)
  • 不需要每次都拷贝fd集合
  • 只返回就绪的fd,不用遍历所有
  • 支持边缘触发(ET)和水平触发(LT)两种模式
// epoll 的基本用法
int epfd = epoll_create(1);
struct epoll_event ev, events[1024];

ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

while (1) {
    int nfds = epoll_wait(epfd, events, 1024, -1);
    for (int i = 0; i < nfds; i++) {
        if (events[i].events & EPOLLIN) {
            // 处理可读事件
        }
    }
}

关键区别:select和poll是「主动问」——每次都要把所有fd传给内核,让内核帮你查。epoll是「被动等」——先把fd注册到内核,内核有事件了主动通知你。

4.4 三种机制的对比

特性 select poll epoll
最大fd数 1024(可改) 无限制 无限制
数据结构 位图 链表 红黑树+链表
用户态拷贝 每次全部拷贝 每次全部拷贝 只拷贝一次(epoll_ctl时)
返回结果 所有fd(需遍历) 所有fd(需遍历) 仅就绪的fd
触发模式 水平触发 水平触发 水平+边缘触发
跨平台 几乎所有平台 几乎所有平台 仅Linux

避坑指南:我曾经在边缘触发模式下吃过亏。ET模式下,如果一次没把数据读完,下次就不会再通知你了。所以用ET模式时,一定要用非阻塞IO,并且循环读取直到返回EAGAIN。

4.5 核心逻辑流程图

下面这张图展示了三种I/O多路复用机制的核心工作流程:

I/O多路复用核心流程对比 用户态(应用程序) 内核态(操作系统) select 拷贝所有fd到内核 遍历所有fd检查状态 返回所有fd(需遍历) poll 拷贝所有fd到内核 遍历所有fd检查状态 返回所有fd(需遍历) epoll epoll_ctl注册fd 内核回调通知就绪 仅返回就绪的fd 性能对比:select ≈ poll < epoll(连接数越多,epoll优势越明显)

4.6 如何选择?

我个人习惯这样选:

  • 跨平台需求:用select或poll。虽然性能差点,但Windows、macOS、Linux都能跑
  • Linux高并发:无脑选epoll。Nginx、Redis、Node.js底层都是它
  • 连接数少(几百以内):select或poll都行,差别不大
  • 连接数上万:必须epoll,否则性能会崩

小技巧:如果你在写一个库,不确定用户会在什么平台上跑,可以考虑用libevent或libuv这类封装库。它们底层会自动选择最优的I/O多路复用机制。

好了,关于select、poll、epoll的原理和对比,就聊到这儿。记住一句话:选对工具,事半功倍。

可可精品资料库