网络编程五种IO模型之单线程多路复用模型
目录
一. select/poll/epoll函数详解(函数说明以及代码实现)
1.3.2 核心数据结构(struct epoll_event + epoll_data_t 联合体)
一. select/poll/epoll函数详解(函数说明以及代码实现)
1.1 select 函数
select函数它作为最早的IO多路复用接口,我们主要从【fd_set 文件描述符集合】+【四个宏操作】+【select核心函数】三个方面进行介绍;
1.1.1 fd_set文件描述符集合
fd_set 是select 函数用于存储事件的文件描述符集合,与RTOS中的事件标志组有些相似。与后买你epoll函数的事件表啥的都比较相似;
1.1.2 宏操作
| 宏定义 | 函数原型(简化) | 核心作用 | 适用场景 |
|---|---|---|---|
FD_ZERO |
void FD_ZERO(fd_set *set); |
清空 fd_set 集合中的所有比特位,将所有位设为 0,初始化空集合。 |
每次调用 select 前,必须先调用该宏初始化要使用的 fd_set(避免残留旧数据)。 |
FD_SET |
void FD_SET(int fd, fd_set *set); |
将文件描述符 fd 对应的比特位设为 1,把 fd 添加到 fd_set 集合中(监控该 fd)。 |
初始化 fd_set 后,将监听 socket、客户端 socket 加入待监控集合(如读集合 readfds)。 |
FD_CLR |
void FD_CLR(int fd, fd_set *set); |
将文件描述符 fd 对应的比特位设为 0,把 fd 从 fd_set 集合中删除(取消监控)。 |
客户端断开连接后,将对应的 fd 从监控集合中移除,避免后续无效监控。 |
FD_ISSET |
int FD_ISSET(int fd, fd_set *set); |
判断文件描述符 fd 对应的比特位是否为 1(即该 fd 是否触发了事件)。 |
select 函数返回后,遍历所有监控的 fd,判断哪个 fd 触发了事件(如可读、可写)。 |
1.1.3 select核心函数
select函数是单线程多路IO服用的基础函数,可以实现【一对多】的fd监控,无需为每个FD创建独立的线程,减少了线程资源的开销;同时要注意传入进入的是整个位图,而返回的时候仅保留触发事件的位图为1,所以要设置临时的位图来在select中进行使用;
1. 函数原型:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
2. 函数参数说明:
| 参数名 | 数据类型 | 核心含义与说明 |
|---|---|---|
nfds |
int |
「最大文件描述符 + 1」,告诉内核需要监控的 fd 范围(内核只需遍历到该值即可,无需遍历全部 FD_SETSIZE)。示例:若监控的 fd 有 3、5、7,则 nfds = 7 + 1 = 8。 |
readfds |
fd_set * |
「读事件监控集合」(输入 / 输出参数):- 输入:用户想要监控的、等待可读事件的 fd 集合;- 输出:select 返回后,仅保留触发了可读事件的 fd 集合。无需监控读事件可传入 NULL。 |
writefds |
fd_set * |
「写事件监控集合」(输入 / 输出参数):- 输入:用户想要监控的、等待可写事件的 fd 集合;- 输出:select 返回后,仅保留触发了可写事件的 fd 集合。无需监控写事件可传入 NULL。 |
exceptfds |
fd_set * |
「异常事件监控集合」(输入 / 输出参数):监控 fd 的异常事件(如带外数据到达),使用逻辑同读 / 写集合。无需监控异常事件可传入 NULL。 |
timeout |
struct timeval * |
超时时间设置(输入参数),控制 select 的阻塞行为,结构体定义如下:c<br>struct timeval {<br> long tv_sec; // 秒数<br> long tv_usec; // 微秒数(1秒=10^6微秒)<br>};<br>三种取值:1. NULL:永久阻塞,直到有 fd 触发事件;2. tv_sec=0 && tv_usec=0:非阻塞,立即返回,无论是否有事件;3. 大于 0:阻塞指定时间,超时后若无事件则返回 0。 |
3. 函数返回值说明
| 返回值 | 含义 |
|---|---|
| 大于 0 | 成功,返回触发了事件的 fd 总数量(读、写、异常事件的 fd 总数)。 |
| 0 | 超时,没有任何 fd 触发事件,fd_set 集合被清空。 |
| -1 |
失败,设置 |
1.1.4 TCP+select代码实现
首先简述一下代码实现的流程:
1.初始化需要使用到的位图(FD_ZERO);
2.将服务器的监听套接字设置进入位图(FD_SET);
3.进入while循环,使用select函数进行阻塞,判断是监听套接字还是客户端套接字(FD_ISSET);
4.当为监听套接字后使用accept函数进行接收,并使用FD_SET宏将accept函数的返回值设置进位图;
5.当为客户端套接字后开始遍历整个位图范围,使用FD_ISSET宏判断是否被触发,被触发则处理客户端数据;
#include <stdio.h>
#include <sys/socket.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/un.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>
#define PROCESS_PORT 5001 //进程端口号
#define SERVER_ADDR 0 //服务端接收地址
#define LISTEN_LEN 50 //监听队列长度
#define BUF_MAXLEN 50 //接收字符最大长度
#define handle_error(msg) do{ perror(msg);exit(EXIT_FAILURE);} while(0)
//处理错误
#define MAX_SOCK_FD 1024 //文件描述符容量
int client_pro(int fd);
int main(){
//遍历操作变量定义
int i=0;
//定义函数返回值
int ret=0;
//定义socket描述符
int server_fd = 0;
int client_fd = 0;
int flag = 1; //套接字属性:
//定义套接字信息结构体
struct sockaddr_in addr;
struct sockaddr_in client_addr;
socklen_t addr_len=sizeof(client_addr);
//定义文件描述符集合
fd_set cfd,tmp_cfd;
//初始化文件描述符集合
FD_ZERO(&cfd);
FD_ZERO(&tmp_cfd);
/*1.创建socket描述符*/
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if( server_fd == -1) handle_error("socket");
if( setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag) ) )
perror("setsockopt"); //设置套接字属性
FD_SET(server_fd,&cfd); //将服务端的文件描述符设置进位图
/*2.设置套接字信息结构体*/
addr.sin_family = AF_INET;
addr.sin_port = htons(5001);
addr.sin_addr.s_addr = SERVER_ADDR;
/*3.绑定地址信息*/
ret = bind(server_fd,(struct sockaddr*)&addr,sizeof(addr));
if(ret == -1) handle_error("bind");
/*4.监听客户端*/
ret = listen(server_fd,LISTEN_LEN);
if(ret == -1) handle_error("listen");
while(1){
tmp_cfd =cfd;
if((ret = select(MAX_SOCK_FD,&tmp_cfd,NULL,NULL,NULL)) <0 )
handle_error("select"); //核心函数select阻塞
if(FD_ISSET(server_fd,&tmp_cfd)){
if((client_fd = accept(server_fd,(struct sockaddr*)&client_addr,&addr_len) )< 0){
handle_error("accept");
}
printf("[%s][%d]连接成功!\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port));
FD_SET(client_fd,&cfd);
}else{
for(i=server_fd+1;i<MAX_SOCK_FD;i++){
if(FD_ISSET(i,&tmp_cfd)){
if(client_pro(i) <= 0){
getpeername(i,(struct sockaddr*)&client_addr,&addr_len);
printf("[%s][%d]断开连接\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port));
FD_CLR(i,&cfd);
}
}
}
}
}
/*8.关闭文件描述符*/
close(server_fd);
close(client_fd);
}
int client_pro(int fd){
//定义接收缓冲区
char buf[BUF_MAXLEN]={0};
struct sockaddr_in peeraddr;
socklen_t addrlen=sizeof(peeraddr);
int rev = read(fd,buf,BUF_MAXLEN);
if(rev > 0){
getpeername(fd,(struct sockaddr*)&peeraddr,&addrlen);
printf("[%s][%d]buf:%s\n",inet_ntoa(peeraddr.sin_addr),ntohs(peeraddr.sin_port),buf);
}else if(rev < 0){
perror("read");
}
return rev;
}
1.1.5 select函数总结
(1)核心组成:fd_set 集合 + 4 个操作宏 + select 核心函数,跨平台兼容性好(支持 Linux、Windows、Unix);
(2)核心劣势:fd 数量限制(默认 1024)、重复拷贝(每次调用 select 都要拷贝 fd_set 到内核态)、轮询效率低(返回后需遍历所有 fd 判断事件);
(3)适用场景:低并发(fd < 1024)、需要跨平台运行的简单多路复用场景。
1.2 poll 函数
poll函数是select函数的优化版本,这次围绕【poll函数】+【struct pollfd结构体】+【IO事件宏】来实现;
1.2.1 poll核心函数
1. 函数原型:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
2. 函数参数说明:
| 参数名 | 数据类型 | 核心含义 | 补充说明 |
|---|---|---|---|
fds |
struct pollfd * |
指向 struct pollfd 结构体数组的指针(输入 / 输出参数) |
1. 输入:用户初始化好的,包含所有待监控 fd 及对应监听事件的结构体数组;2. 输出:内核仅修改数组中每个元素的 revents 字段(填充实际发生的事件),fd 和 events 字段保持不变;3. 可预先分配固定大小数组(如 1024),无需动态扩容,简化代码。 |
nfds |
nfds_t |
待监控的 struct pollfd 结构体数组的长度(即要监控的 fd 总数) |
1. nfds_t 是无符号整数类型(通常为 unsigned int);2. 该参数告知内核要遍历的数组元素个数,无「最大 fd+1」的要求(对比 select 的 nfds 参数,更简洁);3. 无硬限制,仅受系统内存和文件描述符上限约束。 |
timeout |
int |
超时时间(单位:毫秒) | 三种取值(与 select 类似,方便联想记忆):1. -1:永久阻塞,直到有 fd 触发 IO 事件;2. 0:非阻塞,立即返回,无论是否有事件发生;3. 大于 0:阻塞指定毫秒数,超时后若无事件则返回 0。 |
3.函数返回值说明:
| 返回值 | 核心含义 |
|---|---|
| 大于 0 | 成功,返回触发了 IO 事件的 fd 总数(即数组中 revents 字段非 0 的元素个数)。 |
| 0 | 超时,没有任何 fd 触发事件,数组中所有元素的 revents 字段均为 0。 |
| -1 | 失败,设置 errno 标识错误原因(如被信号中断、参数错误、数组为空等),可通过 perror() 打印错误信息。 |
1.2.2 struct pollfd结构体
poll没有像select那样的位图,使用的是结构体数组来管理监控的每一个fd及其事件,这是poll函数与select函数的区别:
struct pollfd {
int fd; // 待监控的文件描述符(socket fd、文件 fd 等)
short events; // 输入参数:用户想要监听的 IO 事件(通过事件宏组合)
short revents; // 输出参数:内核填充的、该 fd 实际发生的 IO 事件(无需用户初始化)
};
| 成员名 | 数据类型 | 核心作用 | 关键使用要点 | ||
|---|---|---|---|---|---|
fd |
int |
指定要监控的文件描述符 |
1. 填写有效 fd(如监听 socket、客户端 socket),内核会监控该 fd 的事件; 2. 当某个 fd 无需再监控时(如客户端断开),无需从数组中删除该元素,只需将 3. 后续可复用 |
||
events |
short |
设定要监听的事件(输入参数) | 1. 通过「事件宏」赋值(如 POLLIN 表示监听可读事件);2. 可通过「位或 ` |
||
revents |
short |
存放实际发生的事件(输出参数) | 1. 由内核填充,用户无需初始化,也不要手动修改;2. 内核会根据 fd 的实际状态,填充对应的事件宏(可能包含用户未监听的异常事件,如 POLLERR);3. 需通过「位与 &」判断具体发生了什么事件(如 revents & POLLIN,判断是否可读);4. 事件处理完成后,该字段会保留上一次的结果,下次 poll() 调用时内核会覆盖更新。 |
1.2.3 核心事件宏
1. 常用可读事件
| 事件宏 | 核心含义 | 是否需要主动设置(events 中) |
|---|---|---|
POLLIN |
对应的 fd 可读:1. 套接字(TCP/UDP)接收缓冲区有数据可读取;2. 监听套接字有新的客户端连接请求;3. 对端关闭连接(可读返回 0)。 | 是 |
POLLPRI |
对应的 fd 有紧急数据可读(如 TCP 带外数据到达)。 | 是 |
2. 常用可写事件
| 事件宏 | 核心含义 | 是否需要主动设置(events 中) |
|---|---|---|
POLLOUT |
对应的 fd 可写:1. 套接字发送缓冲区有空闲空间,可发送数据;2. 套接字连接成功(客户端 connect 成功后)。 |
是 |
3. 常用异常 / 被动事件(无需主动监听,内核自动返回)
| 事件宏 | 核心含义 | 是否需要主动设置(events 中) |
|---|---|---|
POLLERR |
对应的 fd 发生错误(如套接字异常关闭、读写失败)。 | 否(内核自动填充到 revents) |
POLLHUP |
对应的 fd 被挂断(如 TCP 客户端主动断开连接、对端进程退出)。 | 否(内核自动填充到 revents) |
POLLNVAL |
对应的 fd 无效(如未打开的 fd、已关闭的 fd 被传入监控数组)。 | 否(内核自动填充到 revents) |
1.2.4 TCP+poll代码实现
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/un.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>
#include <sys/socket.h>
#define PROCESS_PORT 5001 //进程端口号
#define SERVER_ADDR 0
#define LISTEN_LEN 50
#define BUF_MAXLEN 50
#define handle_error(msg) do{ perror(msg);exit(EXIT_FAILURE);} while(0)
#define MAX_SOCK_FD 1024
//客户端处理函数声明
int client_pro(int fd);
int main(){
//遍历使用变量
int i=0,j=0;;
//定义文件描述符计数变量
nfds_t nfds=0;
//定义函数返回值
int ret=0;
//定义socket描述符
int server_fd = 0;
int client_fd = 0;
int flag = 1;
//定义套接字信息结构体
struct sockaddr_in addr;
struct sockaddr_in client_addr;
socklen_t addr_len=sizeof(client_addr);
//创建结构体数组
struct pollfd fds[MAX_SOCK_FD]={};
/*1.创建socket描述符*/
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if( server_fd == -1) handle_error("socket");
if( setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag) ) )
perror("setsockopt");
//将监听套接字放入结构体数组
fds[0].fd = server_fd;
fds[0].events = POLLIN;
nfds++;
/*2.设置套接字信息结构体*/
addr.sin_family = AF_INET;
addr.sin_port = htons(5001);
addr.sin_addr.s_addr = SERVER_ADDR;
/*3.绑定地址信息*/
ret = bind(server_fd,(struct sockaddr*)&addr,sizeof(addr));
if(ret == -1) handle_error("bind");
/*4.监听客户端*/
ret = listen(server_fd,LISTEN_LEN);
if(ret == -1) handle_error("listen");
while(1){
//poll阻塞
if(poll(fds,nfds,-1) == -1) handle_error("poll");
//通过阻塞,遍历判断;
for(i = 0;i < nfds; i++){//这里是顺序表的逻辑
if(fds[i].fd == server_fd && (fds[i].revents) & POLLIN){//判断是监听套接字
if((client_fd = accept(server_fd,(struct sockaddr*)&client_addr,&addr_len)) == -1)
handle_error("accept");//使用accept函数进行接收;
fds[nfds].fd = client_fd;
fds[nfds].events = POLLIN;
nfds++;
printf("[%s][%d][%lu] connect successful\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port),nfds);
}
if(i>0 && (fds[i].revents & POLLIN)){
if(client_pro(fds[i].fd) <= 0){
if(getpeername(fds[i].fd,(struct sockaddr*)&client_addr,&addr_len) == -1) handle_error("getpeername");
printf("[%s][%d][%lu] over\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port),nfds);
close(fds[i].fd);
for(j=i;j<nfds-1;j++) fds[j] = fds[j+1]; //顺序表的删除
nfds--;
i--;
}
}
}
}
/*8.关闭文件描述符*/
close(server_fd);
close(client_fd);
}
int client_pro(int fd){
//定义接收缓冲区
char buf[BUF_MAXLEN]={0};
struct sockaddr_in peeraddr;
socklen_t addrlen=sizeof(peeraddr);
int rev = read(fd,buf,BUF_MAXLEN);
if(rev > 0){
getpeername(fd,(struct sockaddr*)&peeraddr,&addrlen);
printf("[%s][%d]buf:%s\n",inet_ntoa(peeraddr.sin_addr),ntohs(peeraddr.sin_port),buf);
}else if(rev < 0){
perror("read");
}
return rev;
}
1.2.5 poll函数总结
(1)核心组成:poll() 单一函数 + struct pollfd 结构体数组 + 多类事件宏,无额外集合操作宏,接口更简洁;
(2)核心优势:无 fd 数量硬限制、events/revents 分离无需重复初始化、支持更多事件类型;
(3)核心局限:仍采用轮询模式(返回后需遍历数组)、每次调用需拷贝整个结构体数组到内核态,高并发(fd 上万)场景效率下降;
(4)适用场景:中低并发(fd 上千级)、需要跨平台(支持 Linux、Unix,不支持 Windows)、追求接口简洁的多路复用场景。
1.3 epoll函数
epoll函数作为Linux特有的高并发IO多路复用方案,核心围绕【三个核心函数】+【两个数据结构】+【IO事件模式宏】来进行介绍;
1.3.1 epoll函数族
(1)函数 1:epoll_create()—— 创建 epoll 句柄,初始化内核事件表;
1. 函数原型:
#include <sys/epoll.h>
int epoll_create(int size);
2.函数参数说明:
| 参数名 | 数据类型 | 核心含义 | 补充说明 |
|---|---|---|---|
size |
int |
历史遗留参数,用于指定内核事件表的初始大小 | 1. 该参数已被弃用(Linux 2.6.8 及以上版本),仅为兼容旧代码,只需传入一个大于 0 的整数即可(如 1、1024);2. 内核不再受 size 限制,事件表会根据实际需要动态扩容,无需手动指定大小;3. 该参数不影响后续监控的 fd 数量,fd 上限仅受系统内存和文件描述符上限(ulimit -n)约束。 |
3. 函数返回值说明:
| 返回值 | 核心含义 |
|---|---|
| 大于 0 | 成功,返回一个epoll 专用文件描述符(epfd),该 fd 是内核事件表的唯一标识,后续 epoll_ctl 和 epoll_wait 均需依赖该句柄。 |
| -1 | 失败,设置 errno 标识错误原因(如内存不足、参数无效等),可通过 perror() 打印错误信息。 |
4.核心作用
a. 创建一个 epoll 句柄(epfd),内核会为该句柄分配一块内存,用于维护「epoll 内核事件表」(存储待监控的 fd 及其对应的事件信息);
b. 初始化内核事件表,为后续添加、修改、删除 fd 及事件做好准备;
c. 类比:相当于 select 中初始化 fd_set、poll 中初始化 pollfd 数组,但 epoll 的事件表在内核中维护,且无需重复初始化。
5.注意事项
a. 使用完毕后使用close函数关闭;
1.3.2 函数 2:epoll_ctl()—— 控制内核事件表,管理 fd 与事件(添加 / 修改 / 删除)
1.函数原型
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
2.函数参数说明
| 参数名 | 数据类型 | 核心含义 | 补充说明 |
|---|---|---|---|
epfd |
int |
epoll_create() 返回的 epoll 句柄 |
指向要操作的内核事件表,确保后续操作的是同一个事件表,不可传入无效 fd。 |
op |
int |
对内核事件表的操作类型,由 3 个宏指定 |
核心操作(3 种,覆盖 fd 全生命周期管理):1. |
fd |
int |
要管理的文件描述符(监听 socket、客户端 socket 等) | 需传入有效 fd(已打开、未关闭),传入无效 fd 会返回错误(errno=EINVAL)。 |
event |
struct epoll_event * |
指向 struct epoll_event 结构体的指针 |
1. 输入参数:告知内核要监听的事件类型,以及要关联的用户数据;2. 当 op=EPOLL_CTL_DEL 时,该参数可传入 NULL(内核无需再获取事件信息,只需删除 fd 即可);3. 该结构体是 fd 与事件的绑定载体,详细解析见下文「核心结构体」部分。 |
3. 返回值说明
| 返回值 | 核心含义 |
|---|---|
| 0 | 成功,内核事件表已完成对应的添加 / 修改 / 删除操作。 |
| -1 | 失败,设置 errno 标识错误原因(如 epfd 无效、fd 未注册、op 非法等)。 |
4.核心作用
作为「用户态与内核事件表的桥梁」,负责维护内核事件表中的 fd 及其事件信息,实现 fd 的注册、事件修改和注销,是 epoll 实现高效多路复用的基础(仅需一次注册,后续无需重复拷贝 fd 信息)。
(3)函数 3:epoll_wait()—— 等待事件发生,获取触发的 IO 事件
1.函数原型
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
2.参数解析
| 参数名 | 数据类型 | 核心含义 | 补充说明 |
|---|---|---|---|
epfd |
int |
epoll_create() 返回的 epoll 句柄 |
指向要监控的内核事件表,内核会从该事件表中检测触发事件的 fd。 |
events |
struct epoll_event * |
指向用户态预先分配的 struct epoll_event 结构体数组的指针(输出参数) |
1. 内核不会主动分配内存,需用户提前分配固定大小的数组(如 struct epoll_event events[1024]);2. 函数返回后,内核会将「触发了 IO 事件的 fd」对应的事件信息填充到该数组中(仅填充有效事件,无需填充全部);3. 数组中的每个元素对应一个触发事件的 fd,通过 data 字段可获取该 fd,通过 events 字段可获取具体触发的事件。 |
maxevents |
int |
预先分配的 events 数组的长度(元素个数) |
1. 告知内核 events 数组的最大容量,防止内核越界写入;2. 该值必须大于 0,且不能超过数组的实际长度,否则会导致未定义行为;3. 与 epoll_create() 的 size 参数无关,仅限制本次返回的最大事件数量。 |
timeout |
int |
超时时间(单位:毫秒) | 三种取值(与 select/poll 类似,方便联想记忆):1. -1:永久阻塞,直到有 fd 触发 IO 事件(最常用,对应高并发服务端的阻塞场景);2. 0:非阻塞,立即返回,无论是否有事件发生;3. 大于 0:阻塞指定毫秒数, |
3.返回值说明
| 返回值 | 核心含义 |
|---|---|
| 大于 0 | 成功,返回触发了 IO 事件的 fd 总数(即 events 数组中有效元素的个数,小于等于 maxevents)。 |
| 0 | 超时,没有任何 fd 触发事件,events 数组中的内容无有效数据。 |
| -1 | 失败,设置 errno 标识错误原因(如被信号中断、epfd 无效等)。 |
4.核心作用
阻塞等待内核事件表中的 fd 触发 IO 事件,是 epoll 的「事件检测接口」,对应 select/poll 的核心功能;
5.核心优势(对比 select/poll):
无需轮询:仅返回触发事件的 fd 信息,用户只需遍历 events 数组前「返回值」个元素即可,效率为 高;
减少拷贝:仅将触发事件的 fd 信息拷贝到用户态,而非全部待监控 fd 信息,大幅降低拷贝开销;
事件驱动:内核主动将触发事件的 fd 通知给用户态,而非用户态主动轮询检测。
1.3.2 核心数据结构(struct epoll_event + epoll_data_t 联合体)
//联合体
typedef union epoll_data {
void *ptr; // 指向自定义数据结构的指针(如封装了 fd、客户端信息的结构体)
int fd; // 待监控的文件描述符(最常用,直接关联 fd,方便后续处理)
__uint32_t u32; // 32 位无符号整数(少用)
__uint64_t u64; // 64 位无符号整数(少用)
} epoll_data_t;
struct epoll_event {
__uint32_t events; // Epoll 事件类型(通过事件宏组合赋值)
epoll_data_t data; // 用户数据载体(联合体,关联 fd 或自定义数据)
};
在实际过程中,经常使用到的是 events 以及 data.fd 这两个参数,在epoll_ctl 和 epoll_wait两个函数中使用,一个是填写进去,一个输出出来;
1.3.3 核心事件宏
1. 常用可读事件(对应 TCP 实践核心场景,需主动设置)
| 事件宏 | 核心含义 | 是否需要主动设置(events 中) |
|---|---|---|
EPOLLIN |
对应的 fd 可读:1. 套接字接收缓冲区有数据可读取;2. 监听套接字有新的客户端连接请求;3. 对端关闭连接(read 返回 0)。 |
是 |
EPOLLPRI |
对应的 fd 有紧急数据可读(如 TCP 带外数据到达)。 | 是 |
2. 常用可写事件(需主动设置)
| 事件宏 | 核心含义 | 是否需要主动设置(events 中) |
|---|---|---|
EPOLLOUT |
对应的 fd 可写:1. 套接字发送缓冲区有空闲空间,可发送数据;2. 客户端 connect 连接成功后。 |
是 |
3. 常用异常事件(无需主动设置,内核自动填充到 revents)
| 事件宏 | 核心含义 | 是否需要主动设置(events 中) |
|---|---|---|
EPOLLERR |
对应的 fd 发生错误(如套接字异常关闭、读写失败)。 | 否 |
EPOLLHUP |
对应的 fd 被挂断(如 TCP 客户端主动断开连接、对端进程退出)。 | 否 |
4. 特有触发模式宏(epoll 专属,需主动设置,与事件宏组合使用)
| 事件宏 | 核心含义 | 使用要点 | |
|---|---|---|---|
EPOLLET |
将 epoll 设为「边缘触发(Edge Trigger,ET)」模式(默认是水平触发)。 | 1. 仅在 fd 状态「变化瞬间」触发一次事件,后续无状态变化则不再触发;2. 需配合非阻塞 fd 使用,且要一次性处理完缓冲区所有数据;3. 效率更高,是高并发场景首选;4. 组合使用:`EPOLLIN | EPOLLET`。 |
EPOLLLT |
将 epoll 设为「水平触发(Level Trigger,LT)」模式(默认模式,可省略不写)。 | 1. 只要 fd 缓冲区有未处理数据 / 空闲空间,就会持续触发事件;2. 编程简单,容错率高,无需一次性处理完所有数据;3. 兼容 select/poll 的触发逻辑,易上手;4. 组合使用:EPOLLIN(等价于 `EPOLLIN |
EPOLLLT`)。 |
EPOLLONESHOT |
仅监听一次事件,事件处理完成后,若需继续监控该 fd,需重新调用 epoll_ctl 注册。 |
1. 避免同一个 fd 的事件被多次触发(如多线程处理场景);2. 适合一次性 IO 场景,减少无效事件通知。 |
5. 事件宏使用要点
(1)多事件组合:ev.events = EPOLLIN | EPOLLET;(同时监听可读事件和边缘触发模式);
(2)多事件判断:if (events[i].events & EPOLLIN) { ... }(通过位与 & 判断具体事件);
(3)异常事件优先处理:if (events[i].events & (EPOLLERR | EPOLLHUP)) { ... }(避免资源泄露)。
1.3.4 TCP+epoll代码实现
实现过程:
1.创建事件表(epoll_create),获取epoll专用文件描述符
2.将监听套接字添加进事件表(epoll_ctl);
3.进入while循环,进行阻塞等待,等待函数返回被触发的IO个数以及结构体数组(epoll_wait);
4.进入for循环,循环次数为epoll_wait的返回值,被触发事件个数,被触发事件的文件描述符以及宏等信息都存储在结构体数组中;
#include <stdio.h>
#include <sys/socket.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/un.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#define PROCESS_PORT 5001 //进程端口号
#define SERVER_ADDR 0
#define LISTEN_LEN 50
#define BUF_MAXLEN 50
#define handle_error(msg) do{ perror(msg);exit(EXIT_FAILURE);} while(0)
#define MAX_SOCK_FD 1024
int client_pro(int fd);
int main(){
//定义遍历返回值变量
int i=0;
//定义epoll_wait返回值
int nfds=0;
//定义事件表
int epfd=0;
//定义事件表结构体
struct epoll_event tmp,events[MAX_SOCK_FD];
//定义函数返回值
int ret=0;
//定义socket描述符
int server_fd = 0;
int client_fd = 0;
int flag = 1;
//定义套接字信息结构体
struct sockaddr_in addr;
struct sockaddr_in client_addr;
socklen_t addr_len=sizeof(client_addr);
/*1.创建socket描述符*/
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if( server_fd == -1) handle_error("socket");
if( setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag) ) )
perror("setsockopt");
//创建事件表
epfd = epoll_create(1);
if(epfd == -1) handle_error("epoll_create");
//将监听套接字写进事件表
tmp.events = EPOLLIN;
tmp.data.fd = server_fd;
if(epoll_ctl(epfd,EPOLL_CTL_ADD,server_fd,&tmp) == -1) handle_error("epoll_ctl");
/*2.设置套接字信息结构体*/
addr.sin_family = AF_INET;
addr.sin_port = htons(5001);
addr.sin_addr.s_addr = SERVER_ADDR;
/*3.绑定地址信息*/
ret = bind(server_fd,(struct sockaddr*)&addr,sizeof(addr));
if(ret == -1) handle_error("bind");
/*4.监听客户端*/
ret = listen(server_fd,LISTEN_LEN);
if(ret == -1) handle_error("listen");
while(1){
//使用epoll_wait进行阻塞
nfds = epoll_wait(epfd,events,MAX_SOCK_FD,-1);
if(nfds == -1) handle_error("epoll_wait");
//进行循环
for(i=0;i<nfds;i++){
if(events[i].data.fd == server_fd){ //接收客户端
client_fd = accept(server_fd,(struct sockaddr*)&client_addr,&addr_len);
if(client_fd == -1) handle_error("accept");
tmp.events = EPOLLIN;
tmp.data.fd= client_fd;
if(epoll_ctl(epfd,EPOLL_CTL_ADD,client_fd,&tmp) == -1) handle_error("epoll_ctl");
printf("[%s][%d] connect success\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port));
}
else if(events[i].events & EPOLLIN){ //处理客户端数据;
if(client_pro(events[i].data.fd) <= 0){
if(getpeername(events[i].data.fd,(struct sockaddr*)&client_addr,&addr_len) == -1) handle_error("getpeername");
printf("[%s][%d] exit\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port));
epoll_ctl(epfd,EPOLL_CTL_DEL,events[i].data.fd,NULL);
}
}
}
}
/*8.关闭文件描述符*/
close(epfd);
close(server_fd);
close(client_fd);
}
int client_pro(int fd){
//定义接收缓冲区
char buf[BUF_MAXLEN]={0};
struct sockaddr_in peeraddr;
socklen_t addrlen=sizeof(peeraddr);
int rev = read(fd,buf,BUF_MAXLEN);
if(rev > 0){
getpeername(fd,(struct sockaddr*)&peeraddr,&addrlen);
printf("[%s][%d]buf:%s\n",inet_ntoa(peeraddr.sin_addr),ntohs(peeraddr.sin_port),buf);
}else if(rev < 0){
perror("read");
}
return rev;
}
1.3.5 epoll核心总结
(1)核心组成:3 个函数族(epoll_create/epoll_ctl/epoll_wait)+ 2 个数据结构(struct epoll_event/epoll_data_t)+ 多类事件宏,分工明确,接口高效;
(2) 核心优势:
a.无 fd 数量硬限制,支持百万级 fd 监控,满足高并发场景;
b.事件驱动模式,无需轮询,效率 高,不随 fd 数量增长而下降;
c.仅一次拷贝(注册时),后续无需重复拷贝 fd 信息,拷贝开销极小;
d.支持水平触发 / 边缘触发两种模式,灵活性远超 select/poll;
(3) 核心局限
a.仅支持 Linux 平台,无跨平台兼容性(对比 select/poll);
b.边缘触发(ET)模式编程复杂度高,需配合非阻塞 IO,且要一次性处理完缓冲区数据;
二. select/poll/epoll函数的优劣对比
2.1 epoll函数的核心优势
(1)减少【用户态<------>内核态】的数据拷贝开销;
这是epoll节约资源的基础。首先select函数,select需要每次将位图fd_set在内核和用户态复制来复制去的,很麻烦,特别是在高并发场景中,而且是读写异常三个事件类型的位图,而poll则需要将结构体数组在内核与用户态中复制来复制去;结构体数组占用的内存空间就更大了;
epoll函数避免了这样的问题,epoll函数仅在注册时拷贝一次,后续循环调用 epoll_wait() 时,无需再拷贝任何待监控 fd 的信息,当调用 epoll_ctl(EPOLL_CTL_ADD) 注册 fd 时,会将该 fd 及其对应的事件信息(struct epoll_event)从用户态拷贝到内核态,并存入「epoll 内核事件表」(红黑树存储)。因为内核已经持久化存储了这些信息,无需重复传递,这就避免了 select/poll 的「重复全量拷贝」。而且当epoll_wait函数调用时仅拷贝有效数据;
(2) 减少CPU遍历开销;
select/poll 调用返回后,只告诉你「有多少个 fd 触发了事件」,但不告诉你「具体是哪些 fd」,因此你必须进行「全量遍历」:
select:遍历所有待监控的 fd(从 0 到 max_fd),逐个调用 FD_ISSET() 判断该 fd 是否触发事件,大部分 fd 都是未触发事件的,遍历这些 fd 属于「无效 CPU 运算」;
poll:遍历整个 pollfd 结构体数组,逐个判断 revents 是否非 0,同样,大部分数组元素都是无效的,遍历过程消耗大量 CPU 资源;
时间复杂度:两者的遍历时间复杂度都是 O(n)(n 为待监控 fd 总数),fd 越多,CPU 遍历开销越大,在 fd 上万的场景下,CPU 会被大量无效遍历占据,无法处理实际业务逻辑。
(3)减少内核内部的存储与事件检测开销
· 这部分是 epoll 底层的优化,虽然不直接暴露给用户,但对资源节省至关重要,select/poll 在核内部存在大量「重复工作」,而 epoll 做到了「持久化存储,高效检测」。
1. 先看 select/poll 的内核资源浪费
无持久化存储:select/poll 内核内部没有专门的结构存储待监控 fd 信息,每次调用都要重新构建待监控集合,再逐个 fd 调用底层驱动接口查询事件状态,这个过程重复且低效;
轮询式事件检测:内核内部对 select/poll 的待监控 fd 采用「轮询检测」(逐个查询 fd 是否有事件),fd 越多,轮询时间越长,内核资源消耗越大;
资源限制:select 有 FD_SETSIZE 硬限制,无法支撑大量 fd 监控,即使 poll 无硬限制,内核也无法承受大量 fd 的轮询与存储开销。
2. 再看 epoll 的内核资源优化
持久化存储:红黑树管理待监控 fd
epoll 内核用「红黑树」存储待监控 fd 及其事件信息,红黑树的插入、删除、查找效率都很高(时间复杂度 O (logn)),支持大量 fd 的高效管理,且无需每次调用都重新构建集合,持久化存储减少了内核的重复工作;
高效检测:回调机制替代轮询
epoll 采用「回调机制」检测事件,当 fd 准备就绪(有数据 / 可写)时,底层驱动会主动调用 epoll 注册的回调函数,将该 fd 加入就绪队列,无需内核主动轮询所有 fd,大幅节省内核 CPU 资源;
低资源消耗:就绪队列仅存有效事件
内核的就绪队列只存储触发事件的 fd 信息,无需存储所有待监控 fd,内存开销极小,即使待监控 100 万个 fd,就绪队列也只占用少量内存。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)