一、开篇: 为什么IO多路复用在网络编程中如此重要?

IO 多路复用是高性能网络编程的基石,是 Redis、Nginx、Netty 等所有业界标杆级高性能中间件的底层核心能力。不搞懂 IO 多路复用,永远只能停留在「用中间件」的层面,无法看懂源码、理解高性能设计的本质,更无法写出高并发的网络服务。

二、前置知识: 搞懂IO多路复用,先吃透这几个核心概念

1.一次完整的网络 IO,到底经历了什么?

一次sokcet的读操作,分为两个不可拆分的核心阶段,这是理解所有 IO 模型的前提:

  • 数据准备阶段:内核等待网卡把数据收到内核缓冲区,确认数据就绪
  • 数据拷贝阶段:内核把内核缓冲区的数据,拷贝用户态的程序缓冲区,供业务代码使用

2.用户态 vs 内核态

Linux 系统为了安全,把程序的执行权限分为两个级别:

  • 用户态:普通业务代码的执行权限,无法直接操作硬件、无法直接访问内核内存
  • 内核态:操作系统内核的执行权限,可以操作硬件、管理内存、调度进程
    所有的 IO 操作(包括 Socket 读写),都必须经过内核态完成,用户态代码只能发起系统调用,等待内核完成操作后返回结果。

3.别再搞混:阻塞 / 非阻塞 vs 同步 / 异步

  • 阻塞 / 非阻塞:描述的是发起 IO 调用后,线程是否会挂起等待,针对的是「数据准备阶段」
    阻塞 IO: 发起调用后线程直接挂起,直到数据就绪才被唤醒
    非阻塞 IO: 发起调用后立刻返回,线程不挂起,需要轮询检查数据是否就绪
  • 同步 / 异步:描述的是数据拷贝阶段,是否需要用户线程参与
    同步 IO: 数据从内核态拷贝到用户态的过程,需要用户线程阻塞等待
    异步 IO: 整个 IO 过程(数据准备 + 数据拷贝)全部由内核完成,完成后通知用户线程,全程无阻塞

4.传统 IO 模型的死穴:为什么 1 连接 1 线程扛不住高并发?

传统阻塞 IO 模型,为了处理多个并发连接,采用「1 个连接对应 1 个线程」的方案,在高并发场景下有致命缺陷:

  • 线程资源耗尽:Linux 下一个线程默认占用 8MB 栈内存,1 万个连接就需要 80GB 内存,单机根本无法支撑
  • 上下文切换开销爆炸:CPU 需要频繁切换线程上下文,切换开销远大于业务代码执行开销,CPU 利用率极低
  • C10K 问题:单机如何支撑 1 万个并发连接?这个问题的标准答案,就是 IO 多路复用。

三、核心原理:IO 多路复用的三大实现,从根上搞懂演进逻辑

IO 多路复用的本质: 内核提供的能力,允许单个线程 / 进程,同时监听大量的文件描述符(fd),一旦其中任意一个 fd 就绪(可读 / 可写 / 异常),就通知用户程序进行对应的 IO 操作。 它用「1 个线程监听海量 fd」,替代了「1 线程 1 连接」的臃肿模型,彻底解决了高并发下的线程资源开销问题。
Linux 下 IO 多路复用有三大经典实现: select → poll → epoll,每一个都是对前一个的缺陷的优化。
为了方便文章讲述,这里先给出三种写法实现方法的代码链接IO多路复用

1.select:初代 IO 多路复用实现
核心工作原理

用 3 个固定大小的位图(fd_set),分别标记需要监听的可读、可写、异常事件的 fd;用户把 fd_set 拷贝到内核态,内核遍历位图检查每个 fd 是否就绪,将就绪的 fd 标记后返回给用户,用户遍历位图找到就绪的 fd 进行处理。 这里仅讨论可读情况的代码:

	fd_set rfds,rset;
    FD_ZERO(&rfds);
    FD_SET(sockfd,&rfds);

    int maxfd=sockfd;
    while(1){
        rset=rfds;//副本  -- select会修改文件描述符集合
        int nready=select(maxfd+1,&rset,NULL,NULL,NULL);//返回事件的个数

        if(FD_ISSET(sockfd,&rset)){
           
            struct sockaddr_in clientaddr;
            socklen_t len=sizeof(clientaddr);

            int clientfd=accept(sockfd,(struct sockaddr*)&clientaddr,&len);
            printf("sockfd:%d\n",clientfd);
            FD_SET(clientfd,&rfds);//放入总集
            maxfd=clientfd;
        }
        int i=0;
        for(i=sockfd+1;i<=maxfd;i++){
            if(FD_ISSET(i,&rset)){
               char buf[128] = {0};
                int recv_len = recv(i, buf, 128, 0);
                if(recv_len <= 0) {
                    printf("disconnect\n");
                    FD_CLR(i,&rfds);//不需要再监控该客户端
                    close(i);
                    continue;
                }
                send(i, buf, recv_len, 0); 
                printf("clientfd:%d  recv_len :%d buffer :%s\n",i,recv_len,buf);
            }
        }

    }
核心缺陷:
  1. 固定 fd 数量上限: 默认仅支持 1024 个 fd,无法支撑高并发场景,且编译内核才能修改上限,灵活性极差
  2. 全量拷贝开销: 每次调用 select,都必须把整个 fd_set 从用户态拷贝到内核态,并发越高拷贝开销越大
  3. O (n) 遍历性能衰减: 内核必须遍历所有注册的 fd,才能找到就绪的 fd,并发越高,遍历开销越大,性能线性衰减
  4. 易用性极差: select 调用后会修改原 fd_set,每次调用前必须重新重置 fd_set,开发成本高
2. poll:select 的改良版,到底优化了什么?
核心工作原理

用动态的pollfd结构体数组替代固定位图,每个pollfd结构体保存要监听的 fd、要监听的事件、就绪的事件,彻底突破了 1024 的 fd 数量上限。代码如下:

struct pollfd fds[1024]={0};
    //初始化监听套接字的pollfd结构
    fds[sockfd].fd=sockfd;
    fds[sockfd].events=POLLIN;//--events:需要关注的事件 POLLIN:可读
    int maxfd=sockfd;

    while(1){
        int nready=poll(fds,maxfd+1,-1);
        //revents:由内核填充的实际发生的事件
        if(fds[sockfd].revents&POLLIN){
            struct sockaddr_in clientaddr;
            socklen_t len=sizeof(clientaddr);

            int clientfd=accept(sockfd,(struct sockaddr*)&clientaddr,&len);
            printf("sockfd:%d\n",clientfd);
            fds[clientfd].fd=clientfd;
            fds[clientfd].events=POLLIN;
            maxfd=clientfd;
        }

        int i=0;
        for(i=sockfd+1;i<=maxfd;i++){
            if(fds[i].revents&POLLIN){
                char buf[128] = {0};
                int recv_len = recv(i, buf, 128, 0);
                if(recv_len <= 0) {
                    printf("disconnect\n");
                    fds[i].fd=-1;//poll忽略fd=-1的条目
                    fds[i].revents=0;
                    close(i);
                    continue;
                }
                send(i, buf, recv_len, 0); 
                printf("clientfd:%d  recv_len :%d buffer :%s\n",i,recv_len,buf);
            }
        }
    }
对比 select 的优化点
  1. 无 1024 的 fd 数量上限,仅受系统最大可打开文件数限制,可支持更大规模的并发
  2. 分离了「输入事件」和「输出事件」,每次调用无需重置结构体,易用性更好
仍未解决的核心痛点
  1. 每次调用 poll,仍需把整个pollfd数组全量拷贝到内核态,拷贝开销问题完全没解决
  2. 内核仍需 O (n) 遍历所有注册的 fd,高并发下性能线性衰减的问题完全没解决

poll 比 select 有优化,为什么实际生产中用的很少?
poll 只解决了 select 的 fd 上限和易用性问题,没有解决最核心的性能痛点(全量拷贝、O (n) 遍历),高并发场景下性能和 select 一样差;而 Linux 下有性能更好的 epoll,跨平台场景下 select 兼容性更好,所以 poll 的定位很尴尬,实际用的极少。

3. epoll:Linux 高并发场景的终极方案(核心重点)
核心工作原理

epoll 在内核中维护了两个核心结构:

  • 红黑树: 存储所有用户注册的、需要监听的 fd,增删改查的时间复杂度都是 O (logn),支持海量 fd 的高效管理
  • 就绪链表: 存储已经就绪的 fd,当某个 fd 的事件就绪时,内核会通过回调函数,把这个 fd 添加到就绪链表中

用户调用epoll_wait时,只需要检查就绪链表是否有内容,有就直接返回就绪的 fd 列表,无需遍历所有注册的 fd,时间复杂度是 O (1)。

三大核心API
  1. int epoll_create(int size)
    作用: 创建一个 epoll 实例,在内核中初始化对应的红黑树和就绪链表
    参数: size 参数在内核 2.6 之后已经废弃,只需传入大于 0 的数即可
    返回值: epoll 实例的文件描述符(epfd),后续所有操作都基于这个 epfd

  1. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    作用:对epoll实例中监听的文件描述符(fd)进行增、删、改操作
    参数说明
  • epfd:epoll实例对应的文件描述符(由epoll_create/epoll_create1返回)
  • op:操作类型,取值为:
    • EPOLL_CTL_ADD:新增一个待监听的fd
    • EPOLL_CTL_MOD:修改已监听fd的关注事件
    • EPOLL_CTL_DEL:从监听列表中删除一个fd
  • fd:需要操作的目标文件描述符(如socket fd)
  • event:指向struct epoll_event结构体的指针,用于指定要监听的事件类型,常见事件包括:
    • EPOLLIN:可读事件
    • EPOLLOUT:可写事件
    • EPOLLET:边缘触发模式(默认是水平触发)

核心优势:仅在 fd 或其监听事件发生变化时调用,无需每次监听都全量拷贝所有 fd 到内核态,彻底解决了传统 select/poll 中全量拷贝的性能开销问题。

  1. int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
    作用: 阻塞等待注册的 fd 事件就绪,返回就绪的 fd 列表
    参数:
    epfd:epoll 实例的 fd
    events:用户态的数组,用于接收内核返回的就绪 fd 事件
    maxevents:本次最多返回多少个就绪事件
    timeout:阻塞超时时间,-1 表示永久阻塞,0 表示非阻塞立刻返回

核心优势:直接从就绪链表中获取就绪 fd,无需遍历全量 fd,时间复杂度 O (1),高并发下性能无衰减。

	int epfd=epoll_create(1);
    struct epoll_event ev;
    ev.events=EPOLLIN;
    ev.data.fd=sockfd;

    epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&ev);
    struct epoll_event events[1024]={0};

    while(1){
        int nready=epoll_wait(epfd,events,1024,-1);
        int i=0;
        for(i=0;i<nready;i++){
            int connfd=events[i].data.fd;
            if(sockfd==connfd){
                struct sockaddr_in clientaddr;
                socklen_t len=sizeof(clientaddr);

                int clientfd=accept(sockfd,(struct sockaddr*)&clientaddr,&len);
                ev.events=EPOLLIN;
                ev.data.fd=clientfd;
                epoll_ctl(epfd,EPOLL_CTL_ADD,clientfd,&ev);
                printf("clientfd:%d\n",clientfd);
            }else if(events[i].events&EPOLLIN){
                char buf[128] = {0};
                int recv_len = recv(connfd, buf, 128, 0);
                if(recv_len <= 0) {
                    printf("disconnect\n");
                    epoll_ctl(epfd,EPOLL_CTL_DEL,connfd,NULL);
                    close(connfd);
                    continue;
                }
                send(connfd, buf, recv_len, 0); 
                printf("clientfd:%d  recv_len :%d buffer :%s\n",connfd,recv_len,buf);
            }
        }
    }
对比 select/poll 的革命性优化

1.无 fd 数量上限: 仅受系统最大可打开文件数限制,单机可轻松支持数十万级并发连接

  1. 零重复拷贝: 仅用epoll_ctl把 fd 拷贝一次到内核,后续监听无需重复拷贝,彻底解决了全量拷贝的开销
  2. O (1) 就绪检测: 内核通过回调函数将就绪 fd 加入链表,epoll_wait只需读取就绪链表,无需遍历全量 fd,高并发下性能无明显衰减
    4.支持两种触发模式,可适配不同的业务场景
触发模式:LT 水平触发 vs ET 边缘触发
  1. LT(Level Triggered,水平触发,默认模式)
  • 触发规则:只要 fd 的内核缓冲区有数据,就会持续通知用户程序
  • 特性:支持阻塞 / 非阻塞 IO,编程简单,只要有数据就会通知,不易丢数据
  • 适用场景:通用场景,业务逻辑简单,不想处理复杂的边缘情况,比如 Redis 就用了 LT 模式
  1. ET(Edge Triggered,边缘触发)
  • 触发规则:仅在 fd 的状态发生变化时(比如缓冲区从无数据到有数据),通知用户程序一次
  • 特性:仅支持非阻塞 IO,必须循环调用 read,直到返回 EAGAIN,否则会导致数据丢失;减少了事件触发的次数,性能更高
  • 适用场景:极致高性能场景,业务逻辑复杂,能处理好非阻塞 IO 的循环读写,比如 Nginx 就用了 ET 模式
epoll 为什么用红黑树?不用哈希表、跳表等其他数据结构?
  • 红黑树的增删改查时间复杂度稳定在 O (logn),即使是数十万级的 fd,性能也不会有明显衰减
  • 哈希表在哈希冲突严重时,性能会退化到 O (n),且需要提前预估容量,扩容开销大;而 epoll 的 fd 数量是动态变化的,红黑树更适配
  • 跳表的内存占用比红黑树高,且内核中红黑树的实现已经非常成熟,无需额外开发新的数据结构
select、poll、epoll对比表格
特性 select poll epoll
fd 数量上限 固定 1024 无上限 无上限
就绪检测时间复杂度 O(n) O(n) O (1)(就绪事件)
fd 拷贝开销 每次调用全量拷贝 每次调用全量拷贝 仅修改时拷贝一次
触发模式 仅 LT 仅 LT LT+ET
跨平台兼容性 全平台支持 全平台支持 仅 Linux 支持
高并发性能 极差 一般 极佳
适用场景 低并发、跨平台兼容 低并发、兼容老系统 Linux 高并发生产环境

四、实战落地:从零写一个 epoll 高并发回显服务器

请直接参考这个链接: reactor网络服务器

五、工业级应用:Redis/Nginx 里的 IO 多路复用实践

1. Redis:单线程 + epoll,支撑十万级 QPS 的核心

  • Redis 的核心业务逻辑是单线程执行的,却能支撑十万级的 QPS,核心就是 epoll 的事件驱动模型:
  • Redis 自己封装了一个 ae 事件驱动库,底层适配了 epoll、select、kqueue 等多种 IO 多路复用实现,Linux 下默认用 epoll
  • Redis 用 epoll 的 LT 水平触发模式,而非 ET 模式:因为 LT 模式编程更简单,不易丢数据,且 Redis 的业务逻辑是纯内存操作,执行速度极快,不会出现事件处理不及时的问题
  • Redis 的事件循环同时处理文件事件(客户端的网络 IO)和时间事件(过期 key 清理、RDB 持久化等定时任务),用单线程实现了高并发的处理

2. Nginx:多进程 + epoll,高性能反向代理的底层逻辑

  • Nginx 采用 master-worker 多进程模型,每个 worker 进程单线程运行,用 epoll 实现高并发的请求处理:
  • 每个 worker 进程都有一个独立的 epoll 实例,监听客户端的连接和请求,worker 之间通过进程间通信共享资源
  • Nginx 用 epoll 的 ET 边缘触发模式,极致提升性能,减少事件触发的次数
  • Nginx 通过互斥锁解决了 epoll 的惊群问题:只有一个 worker 进程会监听新的连接事件,避免多个 worker 同时被唤醒,导致上下文切换开销
什么是 epoll 的惊群问题?怎么解决?

答:惊群问题是指,当多个进程 / 线程同时监听同一个 fd 的事件时,事件就绪后,所有等待的进程 / 线程都会被唤醒,但最终只有一个进程 / 线程能处理这个事件,其他的进程 / 线程被唤醒后又休眠,导致无效的上下文切换,浪费 CPU 资源。

解决方法:

  1. Nginx 的方案: 加互斥锁,只有持有锁的 worker 进程才能监听新连接事件,避免多个进程同时监听
  2. Linux 内核 3.9 之后的方案: 引入了SO_REUSEPORT选项,允许多个进程 / 线程绑定同一个端口,内核只会把事件唤醒给其中一个进程 / 线程,从根本上解决了惊群问题
  3. 单进程单 epoll 实例: 避免多个进程 / 线程监听同一个 epoll 实例,从架构上避免惊群问题
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐