【硬核解析】:select、poll、epoll 的底层实现与性能差异
一、开篇: 为什么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);
}
}
}
核心缺陷:
- 固定 fd 数量上限: 默认仅支持 1024 个 fd,无法支撑高并发场景,且编译内核才能修改上限,灵活性极差
- 全量拷贝开销: 每次调用 select,都必须把整个 fd_set 从用户态拷贝到内核态,并发越高拷贝开销越大
- O (n) 遍历性能衰减: 内核必须遍历所有注册的 fd,才能找到就绪的 fd,并发越高,遍历开销越大,性能线性衰减
- 易用性极差: 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 的优化点
- 无 1024 的 fd 数量上限,仅受系统最大可打开文件数限制,可支持更大规模的并发
- 分离了「输入事件」和「输出事件」,每次调用无需重置结构体,易用性更好
仍未解决的核心痛点
- 每次调用 poll,仍需把整个pollfd数组全量拷贝到内核态,拷贝开销问题完全没解决
- 内核仍需 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
int epoll_create(int size)
作用: 创建一个 epoll 实例,在内核中初始化对应的红黑树和就绪链表
参数: size 参数在内核 2.6 之后已经废弃,只需传入大于 0 的数即可
返回值: epoll 实例的文件描述符(epfd),后续所有操作都基于这个 epfd
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:新增一个待监听的fdEPOLL_CTL_MOD:修改已监听fd的关注事件EPOLL_CTL_DEL:从监听列表中删除一个fd
fd:需要操作的目标文件描述符(如socket fd)event:指向struct epoll_event结构体的指针,用于指定要监听的事件类型,常见事件包括:EPOLLIN:可读事件EPOLLOUT:可写事件EPOLLET:边缘触发模式(默认是水平触发)
核心优势:仅在 fd 或其监听事件发生变化时调用,无需每次监听都全量拷贝所有 fd 到内核态,彻底解决了传统 select/poll 中全量拷贝的性能开销问题。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
作用: 阻塞等待注册的 fd 事件就绪,返回就绪的 fd 列表
参数:epfd:epoll 实例的 fdevents:用户态的数组,用于接收内核返回的就绪 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 数量上限: 仅受系统最大可打开文件数限制,单机可轻松支持数十万级并发连接
- 零重复拷贝: 仅用epoll_ctl把 fd 拷贝一次到内核,后续监听无需重复拷贝,彻底解决了全量拷贝的开销
- O (1) 就绪检测: 内核通过回调函数将就绪 fd 加入链表,epoll_wait只需读取就绪链表,无需遍历全量 fd,高并发下性能无明显衰减
4.支持两种触发模式,可适配不同的业务场景
触发模式:LT 水平触发 vs ET 边缘触发
- LT(Level Triggered,水平触发,默认模式)
- 触发规则:只要 fd 的内核缓冲区有数据,就会持续通知用户程序
- 特性:支持阻塞 / 非阻塞 IO,编程简单,只要有数据就会通知,不易丢数据
- 适用场景:通用场景,业务逻辑简单,不想处理复杂的边缘情况,比如 Redis 就用了 LT 模式
- 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 资源。
解决方法:
- Nginx 的方案: 加互斥锁,只有持有锁的 worker 进程才能监听新连接事件,避免多个进程同时监听
- Linux 内核 3.9 之后的方案: 引入了SO_REUSEPORT选项,允许多个进程 / 线程绑定同一个端口,内核只会把事件唤醒给其中一个进程 / 线程,从根本上解决了惊群问题
- 单进程单 epoll 实例: 避免多个进程 / 线程监听同一个 epoll 实例,从架构上避免惊群问题
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)