Linux IO多路复用:select、poll、epoll 核心原理与进阶实战
前言
在Linux服务端开发中,高并发网络编程是核心重难点,而IO多路复用是支撑高并发服务的核心技术。Redis、Nginx、Netty等主流高性能框架,底层均依赖IO多路复用实现海量连接的高效处理。本文将从实战场景出发,层层拆解select、poll、epoll 三种IO多路复用模型的原理、优劣、代码实现及选型逻辑,彻底搞懂Linux高并发IO的核心演进逻辑。
一、为什么需要IO多路复用?
1.1 核心业务场景
网络服务端的核心需求:单服务进程同时监听、处理成百上千个客户端TCP连接。如何在不开启大量线程/进程的前提下,高效管理海量连接,是高并发编程的核心问题。
1.2 传统IO模型的致命困境
在IO多路复用出现之前,主流的两种方案均存在严重瓶颈,无法应对C10K(单机万级连接)并发场景:
-
多进程/多线程模型:每一个客户端连接对应一个进程/线程。线程、进程均占用系统内存、CPU调度资源,连接数量过多时,会导致系统资源耗尽、上下文切换频繁,服务直接卡死。
-
非阻塞轮询模型:单线程循环遍历所有文件描述符(fd),轮询判断是否有IO事件就绪。该方式无需多线程,但会造成CPU空转,绝大部分时间无IO事件,却持续占用CPU资源,资源利用率极低。
1.3 IO多路复用核心思想
IO多路复用的终极解决方案:一个线程同时监控多个文件描述符,哪个IO事件就绪,就针对性处理哪个连接。
一句话总结核心价值:用最少的线程,管理最多的网络连接,彻底解决多线程资源浪费和非阻塞轮询CPU空转的双重问题。
二、select:IO多路复用的开山之作
select是Linux最早实现的多路复用模型,奠定了IO多路复用的核心思想,但因底层设计缺陷,仅适用于低并发场景。select 就是让操作系统内核帮你监听文件描述符(socket),内核发现事件就绪后通知你,你再去处理。
2.1 核心接口与数据结构
select系统调用接口定义如下,支持监听读、写、异常三类IO事件:
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数:
- nfds: 要检测的文件描述中最大的fd+1 ---(不知道传多少传1024也行),内核在循环遍历时,是小于这里的fd+1
- readfds: 读集合(一般只用这个参数,后面三个全都传NULL)
- writefds: 写集合(传NULL即可)
- exceptfds: 异常集合(传NULL即可)
- timeout:
- NULL: 永久阻塞,当检测到fd变化的时候返回
- struct timeval a;
- a.tv_sec = 10;(秒) 等待十秒,时间一到就返回
- a.tv_usec = 0;(微秒)
返回值:返回文件描述符表中发生变化的文件描述符的个数,实际就是检测对应与客户端通信的文件描述符的读缓冲区是否有数据
核心数据结构与操作:
-
fd_set:是一个容纳多个位的数组,本质上是一个位掩码,表示需要监控某些文件描述符集合,每一位对应一个文件描述符,bit位为1代表监听该fd,为0代表不监听。
-
四大操作宏:
//全部清空(初始化)
void FD_ZERO(fd_set *set);
//从集合中删除某一项
void FD_CLR(int fd, fd_set *set);
//将某个文件描述符添加到集合
void FD _SET(int fd, fd_set *set);
//判断某个文件描述符是否在集合中(判断是否处于就绪状态)
int FD_ISSET(int fd, fd_set *set);
返回值:
非 0 值(通常为 1):表示 fd 是 set 集合中的有效成员(该文件描述符触发了对应的 I/O 事件,如可读 / 可写);
0:表示 fd 不在 set 集合中(未触发事件)。
2.2 工作流程与核心缺陷
select的所有性能问题,都源于其“全量拷贝、全量遍历”的设计,我们通过执行步骤拆解其痛点:
|
执行步骤 |
执行逻辑 |
核心缺陷 |
|---|---|---|
|
1. 构建fd_set |
用户态手动标记需要监听的所有fd |
无 |
|
2. 调用select阻塞 |
将完整fd_set从用户态拷贝至内核态 |
缺陷1:每次调用全量数据拷贝,用户态内核态交互开销大 |
|
3. 内核等待就绪 |
进程挂载到所有fd的等待队列,阻塞等待事件 |
无 |
|
4. 事件就绪返回 |
内核将修改后的完整fd_set重新拷贝回用户态 |
缺陷2:二次全量拷贝,重复消耗IO资源 |
|
5. 遍历处理事件 |
用户态循环遍历所有fd,判断是否就绪 |
缺陷3:O(n)全量扫描,大并发下严重CPU空转 |
除此之外,select存在硬性数量限制:由 FD_SETSIZE 宏限定,默认最大值为1024,无法支持高并发连接场景。
2.3 编程顺序
1. 初始化集合(FD_ZERO)
2. 添加需要监控的文件描述符到集合中(FD_SET)
3. 启动IO复用select方式,传入集合,阻塞等待文件描述符就绪
4. 当select返回时,使用(FD_ISSET)来判断要监听的文件描述是否就绪
-
2.4 select执行流程
- 调用 select 前:fd_set 里是 你要监听的 fd(位 = 1)。
- select 返回后:fd_set 被内核修改了!
- 仍然是 1 的位 → 这个 fd 发生了事件(可读 / 可写 / 异常)
- 变成 0 的位 → 没动静,不用管
2.5 核心代码骨架
select编程有两个关键注意点:必须复制fd_set、nfds必须为max_fd+1:
fd_set readfds;
FD_ZERO(&readfds); //初始化读集合
FD_SET(listen_fd, &readfds); //将监听套接字加入读集合
int max_fd = listen_fd;
while (1) {
// 关键:select会修改入参集合,每次必须重新拷贝原始监听集合
fd_set tmp = readfds;
// 关键:nfds取值max_fd+1,遍历范围包含所有监听fd
int n = select(max_fd + 1, &tmp, NULL, NULL, NULL);
// 全量遍历所有fd,筛选就绪事件
for (int fd = 0; fd <= max_fd; fd++) {
if (FD_ISSET(fd, &tmp)) {
if (fd == listen_fd) { /* 处理新连接accept */ }
else { /* 处理客户端读写数据 */ }
}
}
}
2.6 使用select函的优缺点
- 优点:跨平台
- 缺点:
- 需要重复初始化:每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 效率低:同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
- 大小限制:select支持的文件描述符数量太小了,默认是1024
2.7 完整可运行代码(服务器端)
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include<sys/types.h>
#include<fcntl.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<sys/select.h>
#define IP "0.0.0.0"
#define PORT 6666
int socket_init();
int main()
{
int sockfd = socket_init();
int count = 1; //加入集合中的文件描述符个数
fd_set readfd, tmp;
FD_ZERO(&readfd);
FD_SET(sockfd, &readfd);
while(1){
tmp = readfd;
int ret = select(sockfd+count, &tmp, NULL, NULL, NULL);
if(-1 == ret){
perror("select err");
exit(1);
}
if(FD_ISSET(sockfd, &tmp)){
//有新的客户端请求连接
int cfd = accept(sockfd, NULL, NULL);
if(-1 == cfd){
perror("accept err");
exit(1);
}
printf("已有新客户端到达...\n");
FD_SET(cfd, &readfd);
count++;
}
//遍历已经加入到集合中文件描述符,看是否在读集合中(若在,表示当前文件描述符已就绪)
for(int i=sockfd+1; i<sockfd+count; i++){
if(FD_ISSET(i, &tmp))
{
//与客户端进行通信
char buf[256] = {0};
// int len = recv(i, buf, 1, 0);
int len = recv(i, buf, sizeof(buf), 0);
if(-1 == len){
perror("recv err");
close(i);
exit(1);
}
else if(0 == len){
printf("客户端已断开连接...\n");
close(i);
continue;
}
else{
printf("buf is: %s\n", buf);
send(i, "accepted\n", 9, 0);
}
}
}
}
}
int socket_init()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(-1 == sockfd){
perror("socket err");
exit(1);
}
struct sockaddr_in serv;
memset(&serv, 0, sizeof(serv));
serv.sin_family = AF_INET;
serv.sin_port = htons(PORT);
inet_pton(AF_INET, IP, &serv.sin_addr);
int flag = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
int ret = bind(sockfd, (struct sockaddr*)&serv, sizeof(serv));
if(-1 == ret){
perror("bind err");
exit(1);
}
ret = listen(sockfd, 10);
if(-1 == ret){
perror("listen err");
exit(1);
}
return sockfd;
}
三、poll:解除数量限制,未突破性能瓶颈
poll是select的优化版本,解决了fd数量上限问题和编程不友好问题,但未解决核心的O(n)性能瓶颈(需要去遍历每个文件描述符)。
3.1 接口与数据结构
int poll(struct pollfd* fd, nfds_t nfds, int timeout);
//参数
·fd:用户自定义的结构体数组的地址
·nfds:数组的最大长度,数组中最后一个使用的元素的下标+1
·内核会轮询检测fd数组的每个文件描述符
·timeout:
·-1:永久阻塞
·0:调用完成立即返回
·>0:等待的时长(毫秒)
返回值:IO发送变化的文件描述符的个数
struct pollfd{
int fd; //文件描述符
short events; //等待的事件
short revents; //实际发生的事件,内核给的反馈
};
3.2 相比select的核心改进
-
突破数量限制:以动态数组替代select的定长位图,无1024连接上限,支持海量连接监听。
-
编程更友好:将监听事件(events)和就绪事件(revents)分离,无需每次调用重新构建监听集合,避免数据覆盖问题。
3.3 遗留的致命性能问题
poll仅优化了使用体验和数量限制,核心性能缺陷完全继承select:
-
每次调用poll,仍需将整个监听数组全量拷贝到内核态;
-
内核需要遍历全部fd数组,查找就绪事件;
-
用户态依旧需要O(n)遍历所有fd判断就绪状态。
结论:poll只解除了数量枷锁,未解决高并发下的性能瓶颈。
3.4 编程顺序
- 初始化 pollfd 数组(清空数组 / 设置监听数组)
- 添加需要监控的文件描述符到 pollfd 数组中(设置 fd + events 事件)
- 启动 IO 复用 poll 方式,传入 pollfd 数组,阻塞等待文件描述符就绪
- 当 poll 返回时,判断 revents 是否包含对应事件,确认文件描述符是否就绪
3.5 核心代码骨架
struct pollfd fds[MAX];
fds[0].fd = listen_fd;
fds[0].events = POLLIN; // 监听读事件
int max_idx = 0;
while (1) {
// 阻塞等待事件就绪
int n = poll(fds, max_idx + 1, -1);
// 全量遍历所有监听fd
for (int i = 0; i <= max_idx; i++) {
if (fds[i].revents & POLLIN) {
if (fds[i].fd == listen_fd) { /* 接收新连接 */ }
else { /* 处理客户端数据读写 */ }
}
}
}
3.6 完整可运行代码(服务器端)
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include<sys/types.h>
#include<fcntl.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<sys/poll.h>
#define IP "0.0.0.0"
#define PORT 6666
typedef struct clientArr
{
char ip[64]; //客户端ip地址
int port; //客户端端口
int fd; //通信的文件描述符
}clientArr;
int socket_init();
int main()
{
int sockfd = socket_init();
clientArr arr[1024];
memset(arr, 0, sizeof(arr));
int arrIdx = 0;
//创建poll结构体数组
struct pollfd pollfds[1024];
for(int i=0; i<1024; i++)
{
pollfds[i].fd = -1;
pollfds[i].revents = 0;
}
int count = 0; //需要监听的套接字个数
//将监听套接字加入pollfds数组
pollfds[count].fd = sockfd;
pollfds[count].events = POLLIN; //期望发生读事件
count++;
while(1){
int n = poll(pollfds, count, -1);
if(-1 == n){
perror("poll err");
exit(1);
}
if(pollfds[0].revents & POLLIN){
//有新的连接到达
struct sockaddr_in client;
socklen_t len = sizeof(client);
int cfd = accept(sockfd, (struct sockaddr*)&client, &len);
if(-1 == cfd){
perror("accept err");
continue;
}
if(1024 == count){
printf("当前服务器连接已达上限...\n");
close(cfd);
continue;
}
//将新连接加入poll数组
pollfds[count].fd = cfd;
pollfds[count].events = POLLIN;
count++;
char ip[64] = {0};
int port = ntohs(client.sin_port);
printf("客户端:%s : %d 已连接服务器...\n",
inet_ntop(AF_INET, &client.sin_addr, ip, sizeof(ip)),
port);
//将客户端的地址与端口存入客户端结构体数组中
arr[arrIdx].fd = cfd;
strcpy(arr[arrIdx].ip, ip);
arr[arrIdx].port = port;
arrIdx++;
}
for(int i=1; i<count; i++){
//处理错误/挂起...事件
if(pollfds[i].revents & (POLLERR | POLLHUP | POLLNVAL)){
close(pollfds[i].fd);
pollfds[i] = pollfds[count-1]; //将当前文件描述符移除
arr[i-1] = arr[arrIdx-1]; //移除该文件描述符对应的客户端结构体
count--; //有效数量-1
i--; //重新检查该索引上的新文件描述符
continue;
}
if(pollfds[i].revents & POLLIN){
//有新的数据到达
char buf[256] = {0};
int len = recv(pollfds[i].fd, buf, sizeof(buf), 0);
if(-1 == len){
perror("recv err");
exit(1);
}
else if(0 == len){
printf("客户端断开连接...\n");
close(pollfds[i].fd);
pollfds[i] = pollfds[count-1];
arr[i-1] = arr[arrIdx-1];
i--;
count--;
continue;
}
else{
//在poll结构提数组中位于第i位的文件描述符,在客户端数组中位于第i-1位
//因为客户端数组中未存储监听套接字,只存储通信套接字
printf("已收到客户端 ip:%s, port:%d, ", arr[i-1].ip,arr[i-1].port);
printf("发送的信息: %s\n", buf);
send(pollfds[i].fd, "accepted\n", 9, 0);
}
}
}
}
}
int socket_init()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(-1 == sockfd){
perror("socket err");
exit(1);
}
struct sockaddr_in serv;
memset(&serv, 0, sizeof(serv));
serv.sin_family = AF_INET;
serv.sin_port = htons(PORT);
inet_pton(AF_INET, IP, &serv.sin_addr);
int flag = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
int ret = bind(sockfd, (struct sockaddr*)&serv, sizeof(serv));
if(-1 == ret){
perror("bind err");
exit(1);
}
ret = listen(sockfd, 10);
if(-1 == ret){
perror("listen err");
exit(1);
}
return sockfd;
}
四、epoll:真正实现事件驱动的高性能模型
epoll是Linux专属的多路复用模型,专为高并发场景设计,彻底重构了底层设计,解决了select/poll的所有性能问题,是现代高并发网络服务的底层核心。
4.1 核心设计思想
select/poll的根本弊端:监听集合维护、就绪事件获取完全耦合,每次调用都必须全量拷贝、全量遍历。
epoll的核心优化:将“fd注册监听”和“获取就绪事件”彻底解耦,通过三个独立接口各司其职,实现一次注册、永久监听、按需获取就绪事件。
4.2 三大核心接口
1. epoll_create:创建 epoll 实例
// 1. 创建epoll内核实例,初始化内核事件管理容器
int epoll_create(int size);
- size:历史参数(无实际意义,只需传大于 0 的值),
epoll上能关注的最大描述符数,当超过这个数时会自动扩展;
- 返回值:成功返回 epoll 实例的文件描述符(epfd),失败返回 -1(设置 errno);
- 作用:在内核中创建一个 epoll 实例,用于管理待检测的 fd 集合。
2. epoll_ctl:管理 epoll 中的 fd(增 / 删 / 改事件)
// 2. 对fd进行增/删/改监听配置(常驻内核,只需注册一次)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//参数
epfd:epoll_create返回的 epoll 实例 fd(根结点)
op:操作类型,
EPOLL_CTL_ADD(添加 fd 到 epoll)
EPOLL_CTL_MOD(修改 fd 的事件)
EPOLL_CTL_DEL(从 epoll 删除 fd)
fd:要检测的文件描述符(如监听 lfd、已连接 cfd)
event:结构体,指定要监听的事件 + 关联数据(见下文)
//返回值:成功返回 0,失败返回 -1;
//关键结构体
struct epoll_event {
uint32_t events; // 要监听的事件(EPOLLIN(读)、EPOLLOUT(写)、EPOLLERR(异常))
epoll_data_t data;// 关联数据(通常存 fd,方便事件触发后识别)
};
typedef union epoll_data {
void *ptr;
int fd; // 最常用:存储待检测的 fd,跟核心参数中的fd相同
uint32_t u32;
uint64_t u64;
} epoll_data_t;
3. epoll_wait:阻塞等待事件触发(相当于前面的select/poll函数)
// 3. 阻塞获取当前就绪的IO事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
//参数释义
epfd:epoll 实例 fd;
events:输出参数:内核填充的“触发事件的 fd 数组”(需用户提前分配内存);
maxevents:定义的events数组最大长度(告诉内核最多返回多少个事件);
timeout:超时时间(ms)
- -1:永久阻塞
- 0:立即返回
- >0:指定超时,毫秒级别
//返回值:
成功:返回触发事件的 fd 数量(n),需遍历前 n 个 events 处理;
超时:返回 0;
失败:返回 -1(设置 errno);
//作用:阻塞等待 epoll 实例中管理的 fd 触发事件,触发后将事件写入 events 数组。
内核会维护一个“就绪链表”,当事件就绪时,会被添加到该链表中,epoll_wait就会从该链表中复制事件到用户空间的events数组中,返回就绪事件的数量,无需遍历所有监控的文件描述符,只需要处理就绪事件。
4.3 底层核心数据结构
epoll在内核维护两个核心数据结构,实现O(1)级别的事件处理:
-
红黑树:存储所有被监听的文件描述符,增、删、改查操作均为 O(log n) 复杂度,高效管理海量监听fd,红黑树的key是fd。
-
就绪链表:当是一个双向链表,仅存储就绪事件,当文件描述符的事件触发时,内核会将对应的文件描述符(其实是epitem)插入该链表,给epoll_wait提供。
-
等待队列:管理阻塞在epoll_wait上的线程/进程,当等待队列为空时,调用epoll_wait的进程会进入休眠状态,挂到等待队列中,当有事件就绪时,唤醒等待队列中的进程
核心优势:fd仅需通过epoll_ctl注册一次,永久驻留内核,无需每次调用拷贝全量数据;epoll_wait直接读取就绪链表,仅返回就绪事件,无无效遍历,零拷贝开销。
4.4 LT水平触发与ET边缘触发深度解析
epoll支持两种事件触发模式,是实战中最容易踩坑的核心知识点:
|
对比维度 |
水平触发(LT) |
边缘触发(ET) |
|---|---|---|
|
通知机制 |
只要缓冲区有未读完数据,持续通知 |
仅在IO状态发生变化的瞬间通知一次(有新数据到达缓冲区) |
|
数据未读完 |
下一次epoll_wait继续触发通知 |
不再通知,剩余数据永久丢失,直到有新数据进来,会再次触发 |
|
编程难度 |
低,容错率高 |
高,必须配合非阻塞IO+循环读写 |
|
默认模式 |
epoll默认开启 |
需手动设置EPOLLET标志位 |
ET模式核心规范与EAGAIN精讲
需将 fd 设置为非阻塞,并用循环读完 / 写完数据,否则会导致数据残留; 边沿触发 ET 只通知一次,必须循环读完所有数据;而循环读必须用非阻塞 fd,否则读完后会卡死在 read!
标准写法如下:
// 步骤1:将fd设置为非阻塞
int flag = fcntl(cfd, F_GETFL);
fcntl(cfd, F_SETFL, flag | O_NONBLOCK);
// 步骤2:添加EPOLLET标志开启ET模式
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // EPOLLIN + 边缘触发
ev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
// 步骤3:循环读取所有数据(ET模式核心)
char buf[1024];
int total = 0;
while (1) {
int len = recv(cfd, buf + total, sizeof(buf) - total, 0);
if (len > 0) {
total += len;
} else if (len == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// 非阻塞模式下,数据已读完,退出循环
break;
} else {
// 客户端断开/错误,清理fd
epoll_ctl(epfd, EPOLL_CTL_DEL, cfd, NULL);
close(cfd);
break;
}
}
len == -1:分两种情况:
情况 A:errno == EAGAIN/EWOULDBLOCK → 接收缓冲区无数据,非错误(需后续重试);
情况 B:errno 为其他值(如 EBADF/ECONNRESET)→ 真正的 I/O 错误(需关闭 fd)。
关键知识点:EAGAIN是非阻塞IO的正常返回值,代表内核缓冲区已无数据可读,是ET模式下唯一合法的循环退出条件。
4.5 epoll核心代码骨架
// 1. 创建epoll实例
int epfd = epoll_create(1);
struct epoll_event ev, events[MAX];
// 2. 注册监听socket fd
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
// 3. 事件循环
while (1) {
// 仅获取就绪事件,无无效遍历
int n = epoll_wait(epfd, events, MAX, -1);
// 只遍历已就绪的fd,效率极高
for (int i = 0; i < n; i++) {
if (events[i].data.fd == listen_fd) {
// 处理新连接,将新conn fd加入epoll监听
int conn = accept(listen_fd, NULL, NULL);
ev.events = EPOLLIN | EPOLLET; // 边缘触发
ev.data.fd = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev);
} else {
// 处理客户端读写数据
}
}
}
4.6 完整可运行代码
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
#include<sys/types.h>
#include<fcntl.h>
#include<arpa/inet.h>
#include<sys/epoll.h>
#include<errno.h>
#define PURPLE "\033[35m"
#define RESET "\033[0m"
#define IP "0.0.0.0"
#define PORT 6666
typedef struct clientArr
{
char ip[64]; //客户端ip地址
int port; //客户端端口
int fd; //通信的文件描述符
}clientArr;
int Idx = 0; //客户端·数目
int socket_init();
void set_fd(int* fd); //设置文件描述符为非阻塞状态
int main()
{
int lfd = socket_init();
//客户端数组
clientArr arr[2000];
memset(arr, 0, sizeof(arr));
//创建epoll根节点
int epfd = epoll_create(2000);
//设置监听套接字的event结构体
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = lfd;
set_fd(&lfd);
//将监听套接字挂载到根节点
if(-1 == epoll_ctl(epfd,EPOLL_CTL_ADD, lfd, &ev)){
perror("epoll_ctl err");
exit(1);
}
//设置epoll_wait填充的events数组
struct epoll_event events[2000];
memset(events, 0, sizeof(events));
while(1){
int n = epoll_wait(epfd,events,2000, 3000);
if(-1 == n){
// 被信号打断 → 不是错误,重新循环即可
if (errno == EINTR) {
continue;
}
//真正的错误
perror("epoll_wait err");
exit(1);
}
else if(0 == n){
printf(PURPLE "已超时...\n" RESET);
}
else{
for(int i=0; i<n; i++){
//有新连接到达
struct sockaddr_in client;
socklen_t len = sizeof(client);
if(events[i].data.fd == lfd){
//接收新连接
int cfd = accept(lfd, (struct sockaddr*)&client, &len);
if(-1 == cfd){
perror("accept err");
exit(1);
}
//将新连接挂载道epoll树上
//设置通信套接字的event结构体
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = cfd;
set_fd(&cfd);
if(-1 == epoll_ctl(epfd,EPOLL_CTL_ADD, cfd, &ev)){
perror("epoll_ctl err");
exit(1);
}
//将客户端信息存入客户端数组中
arr[Idx].fd = cfd;
char ip[64] = {0};
inet_ntop(AF_INET, &client.sin_addr, ip, sizeof(ip));
strcpy(arr[Idx].ip, ip);
arr[Idx].port = ntohs(client.sin_port);
Idx++;
}
//有客户端数据到达服务器
else{
while(1){
char buf[256] = {0};
int ret = recv(events[i].data.fd, buf, 10, 0);
if(-1 == ret){
if(errno == EINTR || errno == EAGAIN) {
continue;
}
}
else if(0 == ret){
printf(PURPLE "客户端已断开连接...\n" RESET);
//将对应的通信套接字从epoll树上移出
epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
close(events[i].data.fd);
//将对应的客户端数组上的客户端结构体也移除
for(int j=0; j<Idx; j++){
if(arr[j].fd == events[i].data.fd){
arr[j] = arr[Idx-1];
Idx--;
break;
}
}
}
else{
for(int j=0; j<Idx; j++){
if(arr[j].fd == events[i].data.fd){
printf(PURPLE "客户端地址:%s,端口:%d " RESET, arr[j].ip, arr[j].port);
break;
}
}
printf(PURPLE "buf:%s\n" RESET, buf);
send(events[i].data.fd, "OK", 2, 0);
}
}
}
}
}
}
}
void set_fd(int* fd)
{
int flag = fcntl(*fd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(*fd, F_SETFL, flag);
}
int socket_init()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(-1 == sockfd){
perror("socket err");
exit(1);
}
struct sockaddr_in serv;
memset(&serv, 0, sizeof(serv));
serv.sin_family = AF_INET;
serv.sin_port = htons(PORT);
inet_pton(AF_INET, IP, &serv.sin_addr);
int flag = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
int ret = bind(sockfd, (struct sockaddr*)&serv, sizeof(serv));
if(-1 == ret){
perror("bind err");
exit(1);
}
ret = listen(sockfd, 10);
if(-1 == ret){
perror("listen err");
exit(1);
}
return sockfd;
}
对服务端来说,客户端的 “连接请求” 和 “发数据” 都是触发读操作就绪:
- 连接请求:读「新连接」(accept);
- 发送数据:读「缓冲区数据」(recv);
- 这也是为什么 select/poll/epoll 只需监听 fd 的 “可读事件(POLLIN (数据可读)/ 读集合)”,就能处理所有客户端主动发起的交互。
五、三大模型全景对比与生产选型指南
5.1 核心维度全景对比
|
对比维度 |
select |
poll |
epoll |
|---|---|---|---|
|
底层数据结构 |
定长位图 |
动态数组 |
红黑树 + 就绪链表 |
|
FD数量上限 |
默认1024,不可修改 |
无上限,受硬件限制 |
无上限 |
|
FD驻留方式 |
每次调用全量拷贝 |
每次调用全量拷贝 |
一次注册,内核永久驻留 |
|
就绪事件获取 |
O(n)全量遍历 |
O(n)全量遍历 |
O(1)获取就绪链表 |
|
触发模式 |
仅水平触发 |
仅水平触发 |
水平触发 + 边缘触发 |
|
跨平台性 |
全平台支持 |
全平台支持 |
仅Linux平台 |
5.2 生产环境选型决策树
1. 项目需要跨平台兼容:
-
连接数 ≤ 1024:select、poll均可使用;
-
连接数 > 1024:优先选择poll。
2. 项目仅部署Linux环境:
-
连接数少、简单场景:三者均可;
-
连接数多、追求高性能:优先epoll。
5.3 高频踩坑故障诊断
|
异常现象 |
根因 |
归属模型 |
|---|---|---|
|
部分fd事件丢失 |
nfds参数未设置为max_fd+1 |
select |
|
连接莫名卡死、数据残留 |
ET模式下仅单次读取,未循环读空缓冲区 |
epoll |
|
fd关闭后程序异常 |
关闭fd前未执行epoll_ctl删除监听 |
epoll |
|
select事件处理错乱 |
未拷贝fd_set,直接使用被内核修改的集合 |
select |
六、进阶延伸:从多路复用到事件驱动架构
epoll解决了高效等待IO事件的问题,但并未解决“事件触发后如何高效组织业务代码”的问题,而Reactor事件驱动架构就是基于epoll的上层工程化落地方案。
6.1 主流Reactor架构模型
|
架构模型 |
核心优势 |
典型开源项目 |
|---|---|---|
|
单Reactor单线程 |
无锁竞争、逻辑简单、性能稳定,适配IO密集型场景 |
Redis |
|
单Reactor多线程 |
IO监听单线程,业务计算交由线程池,释放CPU算力 |
常规自研网络框架 |
|
主从Reactor多线程 |
主Reactor处理连接建立,从Reactor处理IO读写,彻底解耦 |
Nginx、Netty |
核心答疑:为什么Redis单线程Reactor性能极强? Redis核心操作均为内存级操作,无CPU密集计算,性能瓶颈完全在网络IO。单线程+epoll无锁设计,规避了线程上下文切换和锁竞争开销,极致高效。
6.2 优质源码学习推荐
想要吃透epoll工程实践,推荐阅读Redis极简事件驱动源码(ae.c),仅600行左右,逻辑清晰:
-
aeCreateFileEvent:封装fd事件注册逻辑;
-
aeProcessEvents:核心事件循环;
-
ae_epoll.c:epoll底层适配封装。
通过该源码可彻底理解:如何将原生epoll接口,封装为企业级通用事件驱动框架。
七、全文总结
从select到epoll的迭代,是Linux IO多路复用从粗糙轮询到精准事件通知的核心进化:
-
select:多路复用开山之作,受限于定长位图、全量拷贝、O(n)遍历,仅适用于极低并发场景;
-
poll:破除1024连接上限,优化编程体验,但未解决O(n)性能瓶颈;
-
epoll:通过红黑树+就绪链表解耦监听与事件获取,实现O(1)事件处理,是Linux高并发网络编程的终极方案。
终极一句话总结:三者的演进本质,是从主动轮询所有fd,到内核被动推送就绪事件的技术蜕变。
尾语
IO多路复用是Linux高并发网络编程的基石,掌握select、poll、epoll的底层差异与适用场景,不仅能读懂Nginx、Redis等高性能中间件的核心设计思想,更能在实际开发中避开IO模型踩坑、精准优化网络服务性能。在高并发业务场景中,合理选用IO多路复用模型,是实现服务高吞吐、低延迟、高可用的关键一步,也是后端开发进阶必备的核心能力。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)