首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >一文搞懂I/O多路复用:从select到epoll的进化之路

一文搞懂I/O多路复用:从select到epoll的进化之路

作者头像
悠悠12138
发布2026-04-02 16:57:31
发布2026-04-02 16:57:31
2050
举报

最近在处理一个高并发的websocket服务时,又碰到了I/O多路复用相关的问题。说实话,我刚接触Linux网络编程那会儿,看到select、poll、epoll这些概念,脑子里就像一团浆糊。什么叫"多路复用"?为啥要"复用"?今天就来聊聊这个话题,争取把它说明白。

从一个真实场景说起

前段时间,我们客户的在线客服系统出了点问题。这个系统需要同时处理几千个客户的连接,每个客户可能随时发消息,也可能很长时间不说话。最开始的实现很简单粗暴——给每个连接分配一个线程。

结果可想而知,服务器直接撑不住了。几千个线程在那儿空转,CPU占用率飙升,内存也吃紧。这时候我才真正理解了I/O多路复用的价值。

什么是I/O多路复用

简单来说,I/O多路复用就是用一个线程监控多个文件描述符(socket连接),看看哪个有数据可读或可写,然后再去处理。就像一个服务员同时照看多桌客人,谁举手了就去服务谁,而不是每桌配一个服务员。

传统的阻塞I/O是这样的:

代码语言:javascript
复制
// 这段代码会一直等待,直到有数据来
int n = recv(socket_fd, buffer, 1024, 0);

如果没数据,线程就在那儿傻等着,啥也干不了。要是有1000个连接,难道要开1000个线程去等?显然不现实。

I/O多路复用的思路是:既然要等,那就让一个线程同时等多个socket,谁有动静了就处理谁。这就是"复用"的含义——复用一个线程来处理多个I/O。

select:最古老的方案

select是最早的I/O多路复用方案,用起来是这样的:

代码语言:javascript
复制
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(socket1, &readfds);
FD_SET(socket2, &readfds);
FD_SET(socket3, &readfds);

// 等待这些socket中有可读的
select(maxfd + 1, &readfds, NULL, NULL, NULL);

// 检查哪个socket可读
if (FD_ISSET(socket1, &readfds)) {
    // 处理socket1的数据
}

select的问题在于:

  • • 最多只能监控1024个文件描述符(在大部分系统上)
  • • 每次调用都要把整个fd_set从用户空间拷贝到内核空间
  • • 返回后还要遍历所有的fd,看看哪个有事件

我曾经用select写过一个小型聊天服务器,几百个连接就开始卡了。每次select返回,都要循环检查所有的socket,效率低得让人抓狂。

poll:select的小改进

poll解决了select的文件描述符数量限制问题:

代码语言:javascript
复制
struct pollfd fds[1000];
fds[0].fd = socket1;
fds[0].events = POLLIN;
fds[1].fd = socket2;
fds[1].events = POLLIN;

int ret = poll(fds, 2, -1);

但poll的本质问题没解决——还是要遍历所有的文件描述符。想象一下,你监控了10000个连接,但只有10个有数据,你还是得检查所有10000个。这效率...

epoll:Linux的杀手锏

epoll才是真正的游戏规则改变者。它解决了select和poll的核心问题:

代码语言:javascript
复制
int epfd = epoll_create(1);

struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = socket1;
epoll_ctl(epfd, EPOLL_CTL_ADD, socket1, &ev);

struct epoll_event events[10];
int nfds = epoll_wait(epfd, events, 10, -1);

// 只需要处理有事件的fd
for (int i = 0; i < nfds; i++) {
    handle_event(events[i].data.fd);
}

epoll的优势在于:

  • • 没有文件描述符数量限制
  • • 不需要每次都拷贝所有的fd
  • • 只返回有事件的fd,不用遍历所有的

推荐客户用epoll重写了那个客服系统,同样的硬件,轻松支撑上万连接。CPU使用率从80%降到了20%,终于露出了笑容。

实战中的坑

说说我在实际使用中踩过的坑吧。

水平触发 vs 边缘触发

epoll有两种触发模式。水平触发(LT)是默认的,只要有数据可读,每次epoll_wait都会返回这个fd。边缘触发(ET)只在状态变化时通知一次。

我第一次用ET模式时,经常丢数据。原因是ET模式下,必须一次性把数据读完:

代码语言:javascript
复制
// ET模式下的正确读法
while (1) {
    int n = recv(fd, buf, sizeof(buf), 0);
    if (n <= 0) {
        if (errno == EAGAIN) {
            // 数据读完了
            break;
        }
        // 真的出错了
        handle_error();
    }
    // 处理数据
}

惊群问题

多个进程监听同一个端口时,一个连接到来,所有进程都会被唤醒,但只有一个能accept成功。这就是"惊群"。

解决方案是使用SO_REUSEPORT:

代码语言:javascript
复制
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));

或者用nginx的做法——让master进程监听,worker进程通过锁来竞争accept。

实际应用

Redis的单线程模型

Redis虽然是单线程,但能支撑10万+的QPS,靠的就是epoll。它的事件循环大概是这样:

代码语言:javascript
复制
while (1) {
    // 等待事件
    numevents = epoll_wait(epfd, events, eventsize, timeout);
  
    for (j = 0; j < numevents; j++) {
        if (是新连接) {
            accept_handler();
        } else if (是客户端请求) {
            read_handler();
            process_command();
            write_handler();
        }
    }
}

Nginx的高性能秘密

Nginx能够处理百万级并发,也是因为用好了epoll。它的worker进程就是一个事件循环,不断地:

  1. 1. epoll_wait等待事件
  2. 2. 处理连接请求
  3. 3. 处理读写事件
  4. 4. 继续等待

没有线程切换的开销,CPU缓存友好,性能自然高。

写在最后

I/O多路复用看起来很复杂,但核心思想很简单——用一个线程高效地管理多个I/O。从select到poll再到epoll,是一个不断优化的过程。

在实际工作中,除非你在写底层网络库,否则很少直接用这些API。但理解它们的原理,对于理解和优化高并发系统非常重要。下次当你的服务扛不住压力时,不妨想想是不是I/O模型的问题。

记得有一次面试,面试官问我:"为什么Redis是单线程却这么快?"我巴拉巴拉说了一堆,最后他说:"你就说因为用了epoll不就完了吗?"虽然这么说有点简单粗暴,但确实是核心原因之一。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-03-30,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 运维躬行录 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 从一个真实场景说起
  • 什么是I/O多路复用
  • select:最古老的方案
  • poll:select的小改进
  • epoll:Linux的杀手锏
  • 实战中的坑
    • 水平触发 vs 边缘触发
    • 惊群问题
  • 实际应用
    • Redis的单线程模型
    • Nginx的高性能秘密
  • 写在最后
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档