【Linux】Linux多路复用-epoll
参考博客:https://blog.csdn.net/sjsjnsjnn/article/details/128371848
一、epoll 的基本概念
epoll 是 Linux 系统下实现多路复用的一种高效机制,属于 I/O 多路复用模型的一种,用于解决服务器同时处理大量客户端连接的问题。相较于早期的select和poll,epoll 在高并发场景下具有显著优势,是当前高性能网络服务器的核心技术之一。
二、epoll函数的基本介绍
epoll有三个相关的系统调用,分别是epoll_create、epoll_ctl和epoll_wait。
epoll_create函数
函数原型
//创建一个epoll模型
int epoll_create(int size);
参数介绍
size参数自从Linux2.6.8之后,size参数是被忽略的,但size的值必须设置为大于0的值。size只是给内核一个提示,告诉它事件需要多大。仅仅只是给内核提一个建议,具体多大还是操作系统说了算。
返回值
- epoll模型创建成功返回其对应的文件描述符,否则返回-1,同时错误码会被设置。
- 该函数返回的文件描述符将作为其他epoll系统调用的第一个参数,以指定要访问的内核事件表。
- 当不再使用时,必须调用close函数关闭epoll模型对应的文件描述符,当所有引用epoll实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。
epoll_ctl函数
函数原型
//向指定的epoll模型中注册事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数介绍
-
epfd参数就是刚刚epoll_create创建出来的文件描述符(对应着一个内核事件表)
-
fd参数是要操作的文件描述符,op参数则是指定操作类型。 操作类型有如下三种:
- EPOLL_CTL_ADD,往事件表中注册fd上的事件。
- EPOLL_CTL_MOD,修改fd上的注册事件。
- EPOLL_CTL_DEL,删除fd上的注册事件。
3. event参数是指定需要关心的事件,它是epoll_event结构指针类型。epoll_event的定义如下:
struct epoll_event
{__uint32_t events; /*epoll事件*/epoll_data_t data; /*用户数据*/
};
其中events成员描述的是事件类型。epoll支持的事件类型和poll基本相同。就是多了一个“E”:
- EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。
- EPOLLOUT:表示对应的文件描述符可以写。
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
- EPOLLERR:表示对应的文件描述符发送错误。
- EPOLLHUP:表示对应的文件描述符被挂断,即对端将文件描述符关闭了。
- EPOLLET:将epoll的工作方式设置为边缘触发(Edge Triggered)模式。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述,需要再次把这个socket加入到EPOLL队列里。
其中data成员用于存储用户数据,其定义如下:
struct union epoll_data
{void* ptr;int fd;uint32_t u32;uint64_t u64;
}epoll_data_t;
-
epoll_data_t是一个联合体,其4个成员使用最多的是fd,它指定事件所从属的目标文件描述符。
-
ptr成员可以用来指定与fd相关的用户数据。
-
但是由于epoll_data_t是一个联合体,我们不能不同使用ptr和fd。如果要将文件描述符和用户数据关联起来,已实现数据快速访问,只能使用其他手段,比如放弃使用epoll_data_t的fd成员,而在ptr指向的用户数据中包含fd。
返回值
- epoll_ctl成功时返回0,失败则返回-1并设置errno
epoll_wait 函数
函数原型
//用于收集监视的事件中已经就绪的事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数介绍
-
epfd参数就是刚刚epoll_create创建出来的文件描述符(对应着一个内核事件表)
-
timeout参数的含义和poll接口的timeout相同
- -1:epoll_wait调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
- 0:epoll_wait调用后进行非阻塞等待,epoll_wait检测后都会立即返回。
- 特定的时间值:epoll_wait调用后在直到的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后epoll_wait进行超时返回。
-
maxevents参数指定最多监听多少个事件,它必须大于0。
-
events参数:它其实是一个数组。epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表(就是epfd所指向的事件表)中复制到它的第二个参数events指向的数组中。这个数组指用于输出epoll_wait检测到的就绪事件,而不像select和poll的数组那样既作用于传入用户注册事件,又用于输出内核检测到的就绪事件。这样极大的提高了应用程序索引就绪文件描述符的效率。
返回值
- 该函数成功时返回就绪的文件描述符个数,失败则返回-1并设置errno
三、epoll的底层原理
-
首先,我们在创建好监听套接字后,开始调用epoll_create函数,它的返回值也是一个文件描述符(epoll_fd),该文件描述符(epoll_fd)就会对应内核中的事件表,本质就是在内核中创建出一棵红黑树和一个就绪队列。
-
红黑树就是epoll用来存储用户所关心的文件描述符(key值)和所关心的事件(value值)
-
就绪队列就是用来存储已经发生事件就绪的文件描述符和相应的就绪事件。
-
紧接着我们继续调用epoll_ctl函数,这个函数是用来将用户所关心的文件描述符添加到刚刚内核所创建出来的红黑树节点当中(图中就是将listen_fd添加到红黑树中,当然所关心的事件也是设置好的,假设就是读事件),这个函数除了进行添加操作,还做了一件很重要的事情,那就是为添加到红黑树节点中的文件描述符都设置了相应的回调函数。
-
回调函数:首先,数据会来自不同的设备,每个设备都会与相应的文件描述符关联起来,也就是通过回调函数参数关联。回调函数的作用在于,当红黑树节点中的文件描述符有事件就绪时,就会通过回调函数,将就绪事件(及相应的fd)添加到就绪队列当中。
-
然后,读取数据的时候就是从就绪队列当中把数据经由内核缓冲区拷贝到用户缓冲区。
对于监听套接字而言,它accept上来的新连接还需要再次添加到红黑树当中,由epoll关心,用户就通过这样的内核处理方法,就能够获取到每个连接上的数据。
epoll的基本工作流程
-
首先,和slect、poll一样,都是先完成基本的创建套接字、绑定和监听。
-
然后就是调用epoll_create函数,创建出epoll所对应的内核事件表(或叫做epoll模型)。
-
在进行epoll_ctl函数时,就是将所关心的文件描述符添加到内核事件表中,我们需要先创建epoll_event结构,将该结构中的events成员注册你所关系的事件(我以EPOLLIN为例),和你所需要关心的文件描述(data.fd),这一步就是为调用epoll_ctl做准备。
-
此时就是事件循环,不断的就检测文件描述符上是否发送就绪事件。也就是循环调用epoll_wait函数,但是在此之前,我们还需要创建出一个epoll_event revs[ ]数组结构,用来存储epoll_wait检测到的就绪事件(由内核自动填充的)
-
如果成功,epoll_wait的返回值一定是已经就绪的文件描述符的数量,必然是有序的,我们就可以通过遍历的方式,来判断其上是否有读事件就绪。
-
如果是监听套接字,我们不能立即读取(其原因在之前的说过了),而是需要进行accept获取新连接,然后填充好你想要关心的事件并将其添加到刚才创建好的epoll模型当中。
-
如果是普通的套接字,就可以正常读取,对于TCP的粘包问题,暂时不考虑。
3.1 epoll 的核心数据结构
epoll 的高效性源于其精心设计的内核数据结构,主要包括:
-
epoll 实例(eventpoll 结构体)
- 每个 epoll 实例对应一个
eventpoll
结构体,包含:- 红黑树(rbr):存储所有被监控的文件描述符(FD)。
- 就绪链表(rdllist):存储所有就绪的 FD 事件。
- 等待队列(wq):当 epoll_wait 阻塞时,进程会挂在此队列。
- 每个 epoll 实例对应一个
-
epitem 结构体
- 每个被监控的 FD 对应一个
epitem
,包含:- FD 本身、监听的事件类型(如
EPOLLIN
)。 - 指向目标 FD 的引用。
- 回调函数(当 FD 事件发生时被调用)。
- FD 本身、监听的事件类型(如
- 每个被监控的 FD 对应一个
3.2 epoll 的初始化与注册流程
-
创建 epoll 实例(epoll_create)
- 调用
epoll_create()
时,内核创建eventpoll
结构体,并返回文件描述符epfd
。 - 此时
rbr
和rdllist
为空。
- 调用
-
注册 FD 到 epoll(epoll_ctl)
- 调用
epoll_ctl(ADD)
时:- 内核为 FD 创建
epitem
结构体,并插入红黑树rbr
。 - 为 FD 注册回调函数(如
sock_poll_wait
)。 - 修改 FD 的等待队列,将回调函数挂载到队列上。
- 内核为 FD 创建
- 调用
3.3 事件触发与就绪队列机制
-
FD 事件发生时
- 当 FD(如 socket)有数据可读时:
- 硬件中断通知内核。
- 内核处理中断,调用 FD 对应的回调函数。
- 回调函数将
epitem
加入eventpoll
的就绪链表rdllist
。
- 当 FD(如 socket)有数据可读时:
-
epoll_wait 的执行逻辑
- 当用户调用
epoll_wait
时:- 检查
rdllist
是否为空。 - 若不为空,将事件复制到用户空间并返回。
- 若为空且超时时间 > 0,将当前进程加入
wq
并阻塞。 - 当新事件加入
rdllist
时,唤醒wq
上的进程。
- 检查
- 当用户调用
3.4 水平触发(LT)与边缘触发(ET)的差异
-
水平触发(默认模式)
- 当 FD 处于就绪状态(如接收缓冲区非空)时:
- 每次调用
epoll_wait
都会返回该 FD 的事件。 - 直到用户完全处理数据(如读完所有缓冲区数据)。
- 每次调用
- 当 FD 处于就绪状态(如接收缓冲区非空)时:
-
边缘触发(EPOLLET)
- 仅在 FD 状态变化时触发一次(如从无数据变为有数据)。
- 用户必须一次性处理完所有数据(需配合非阻塞 I/O),否则剩余数据将被忽略。
关键区别:
- LT 模式允许 “分批处理” 数据,而 ET 模式要求 “一次性处理完毕”。
首先,epoll在ET模式下,只会通知一次,这样就会倒逼着程序员必须一次性将数据读取完毕。
-
假设有这样一种场景:假设有320个字节的数据要读取,现在是ET模式,如何才能保证数据被全部读取完呢?只能循环读取。假设你设置读取的字节数是一次性读取100字节,当进程读取第一次时还剩220,第二次还剩120,第三次还剩下20,第四次读取只读到了20个字节,我们可以发现,前面在读取的时候,都能个最大限度的满足读取要求,因此就能继续读取,当要读取100个字节的数据时,只读到了20个字节,就表明没有数据了,因此不会继续读取,这样数据就能够全部读取完了。
-
但是如果只有300个字节的数据呢?读完第三次后,依然会继续读取,因为上一次读取能读到100个字节,进程认为还有数据会继续recv,但实际上已经没有数据了,文件描述符不设置为非阻塞,recv调用就会阻塞,进而导致进程被挂起。如果有多个这样的进程挂起,后果可想而知。
-
为了解决这样的问题,就必须将文件描述符设置为非阻塞。
四、epoll 服务器实现
4.1 代码实现
下面代码用于封装Linux
下socket
接口
#pragma once
#include <iostream>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <cstring>
#include <netinet/in.h>class Sock
{public:static int Socket(){int sock = socket(AF_INET, SOCK_STREAM, 0);if (sock < 0){std::cerr << "socket error" << std::endl;exit(1);}return sock;}static void Bind(int sock, uint16_t port){struct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_addr.s_addr = INADDR_ANY;local.sin_port = htons(port);if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0){std::cout << "bind error !" << std::endl;exit(2);}}static void Listen(int sock){if (listen(sock, 5) < 0){std::cerr << "listen error" << std::endl;exit(3);}}static int Accept(int sock){struct sockaddr_in peer;socklen_t len = sizeof(peer);int fd = accept(sock, (struct sockaddr *)&peer, &len);if (fd >= 0){return fd;}else{return -1;}}static void Connect(int sock, std::string ip, uint16_t port){struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(port);server.sin_addr.s_addr = inet_addr(ip.c_str());if (connect(sock, (struct sockaddr *)&server, sizeof(server) == 0)){std::cout << "connect sucess" << std::endl;}else{std::cout << "connect failed" << std::endl;exit(4);}}
};
完整epoll 服务器代码
#include<sys/epoll.h>
#include<string>#include"sock.hpp"
using namespace std;#define NUM 64
#define SIZE 128int listen_sock = 0;struct epoll_event fd_arrays[NUM]; //内核填充,存储epoll就绪事件void handle( int epfd,int index){int sock = fd_arrays[index].data.fd;if(fd_arrays[index].events & EPOLLIN){std::cout << "sock:" << sock << " has read event ready !" << std::endl;if(sock == listen_sock){std::cout << "sock:" << sock << " has a new connection event !" << std::endl;int newSock = Sock::Accept(sock);if(newSock > 0){struct epoll_event ev;ev.events = EPOLLIN;ev.data.fd = newSock;epoll_ctl(epfd,EPOLL_CTL_ADD,newSock,&ev);std::cout << "accept a new connection sucess !" << std::endl;}else{std::cout << "accept a new connection fail !" << std::endl;}}else{std::cout << "sock:" << sock << " has a read event !" << std::endl;char buffer[1024];memset(buffer,0,sizeof(buffer));ssize_t len = recv(sock,buffer,sizeof(buffer),0);if(len > 0){buffer[len] = '\0';std::cout << "recv sock " << sock << ":" << buffer << std::endl;}else if(len == 0){close(sock);epoll_ctl(epfd,EPOLL_CTL_DEL,sock,nullptr);std::cout << "sock has been closed !" << std::endl;}else{std::cout << "sock recv failed !" << std::endl;}}}
}int main(int argc , char* argv[]){if(argc < 2){std::cerr << "argc < 2" << std::endl;return 1;}uint16_t port = (uint16_t)atoi(argv[1]);listen_sock = Sock::Socket();Sock::Bind(listen_sock,port);Sock::Listen(listen_sock);int epfd = epoll_create(SIZE); //创建epoll模型struct epoll_event ev;ev.events = EPOLLIN; //LT模式(默认)// ev.events = EPOLLIN | EPOLLET //ET模式ev.data.fd = listen_sock;epoll_ctl(epfd,EPOLL_CTL_ADD,listen_sock,&ev); // 添加监听套接字while (true){int timeout = -1;int ret = epoll_wait(epfd,fd_arrays,NUM,timeout);if(ret == 0){std::cout << "epoll timeout !" << std::endl;continue;}else if(ret == -1){std::cout << "epoll error !" << std::endl;break;}else{std::cout << "epoll sucess !" << std::endl;for(int i = 0 ; i < NUM ; ++i){handle(epfd,i);}}}close(epfd);close(listen_sock);return 0;}
Makefile文件
CXX = g++CXXFLAGS = -Wall -std=c++14SRCS = main.cpp OBJS = $(SRCS:.cpp=.o)TARGET = serverall:$(TARGET)$(TARGET):$(OBJS)$(CXX) $(CXXFLAGS) -o $(TARGET) $(OBJS)%.o:%.cpp$(CXX) $(CXXFLAGS) -c $< -o $@clean:rm -f $(OBJS) $(TARGET).PHONY:all clean
4.2 运行结果
编译
客户端代码
#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <thread>#define PORT 8080
#define BUFFER_SIZE 1024// 接收服务器消息
void receive_messages(int socket) {char buffer[BUFFER_SIZE];while (true) {memset(buffer, 0, sizeof(buffer));ssize_t bytes_received = recv(socket, buffer, BUFFER_SIZE, 0);if (bytes_received <= 0) {std::cout << "服务器断开连接。" << std::endl;close(socket);return;}std::cout << "收到消息: " << buffer << std::endl;}
}int main() {int client_socket;struct sockaddr_in server_addr;// 创建客户端套接字client_socket = socket(AF_INET, SOCK_STREAM, 0);if (client_socket < 0) {std::cerr << "套接字创建失败。" << std::endl;return -1;}// 初始化服务器地址server_addr.sin_family = AF_INET;server_addr.sin_port = htons(PORT);server_addr.sin_addr.s_addr = inet_addr("10.22.79.251"); // 服务器 IP// 连接到服务器if (connect(client_socket, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {std::cerr << "连接服务器失败。" << std::endl;return -1;}std::cout << "成功连接服务器。" << std::endl;// 创建线程接收服务器消息std::thread receive_thread(receive_messages, client_socket);receive_thread.detach(); // 分离线程以便独立运行// 发送消息给服务器char message[BUFFER_SIZE];while (true) {std::cin.getline(message, BUFFER_SIZE);send(client_socket, message, strlen(message), 0);}close(client_socket);return 0;
}
服务器运行
客户端连接到服务器
客户端发送消息到服务端
客户端关闭连接
五、总结
5.1 epoll 与 select/poll 的性能对比
特性 | select/poll | epoll |
---|---|---|
时间复杂度 | O (n)(遍历所有 FD) | O (1)(仅处理就绪 FD) |
数据结构 | 位图(select)/ 数组(poll) | 红黑树(管理 FD)+ 链表(就绪 FD) |
FD 数量限制 | 通常 1024(FD_SETSIZE) | 无硬性限制(取决于内存) |
内核 - 用户空间拷贝 | 每次调用全量拷贝 | 仅拷贝就绪 FD |
事件通知机制 | 轮询(用户空间) | 回调(内核直接通知) |
5.1.1 相同点
-
select、poll和epoll三种I/O多路转接的系统调用,都能够同时监听多个文件描述符。他们都由timeout参数指定超时时间,直到有一个或多个文件描述符上有事件发生时返回,返回值就是文件描述符的数量。返回0表示没有事件发生
-
这3组函数都是通过某种结构体变量来告诉内核关心哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核的处理结果。
5.1.2 不同点
-
select的参数fd_set没有将文件描述符和事件进行绑定,它仅仅是一个文件描述符的集合。因此select需要提供3个这种类型的参数来分别传入和输出可读、可写和异常等事件。这一方面使得select不能处理更多类型的事件,另一方面select每次调用前都需要重置这三个参数。
-
poll将文件描述符和事件都定义到了pollfd中,任何事件都能被同一处理。从而使得编程接口简洁很多。并且内核每次修改的都是revents成员,events成员保持不变,因此下一次调用poll时无需重置events参数。
-
select和poll调用都会返回整个用户注册的事件集合(其中包括就绪的和未就绪的),所以应用程序在索引文件描述符时的时间复杂度为O(n)。
-
epoll不再使用单独的接口,通过epoll_create在内核中维护一个事件表,并提供了一个独立的系统调用epoll_ctl来控制往其中添加、删除和修改操作。通过epoll_wait拿到的是就绪的事件集合,未就绪的只存与内核事件表中,这样索引就绪文件描述符的时间复杂度达到O(1)。
-
select和poll只能工作在LT模式下,epoll在LT模式和ET模式下都可以。
5.2 epoll 的优点
-
接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效。
-
数据拷贝轻量:只在新增监视事件的时候调用epoll_ctl将数据从用户拷贝到内核,而select和poll每次都需要重新将需要监视的事件从用户拷贝到内核。此外,调用epoll_wait获取就绪事件时,只会拷贝就绪的事件,不会进行不必要的拷贝操作。
-
事件回调机制:避免操作系统主动轮询检测事件就绪,而是采用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中。调用epoll_wait时直接访问就绪队列就知道哪些文件描述符已经就绪,检测是否有文件描述符就绪的时间复杂度是O(1),因为本质只需要判断就绪队列是否为空即可。
-
没有数量限制:监视的文件描述符数目无上限,只要内存允许,就可以一直向红黑树当中新增节点。
5.3 epoll 的典型应用场景
-
高性能 Web 服务器(如 Nginx)
- 单线程处理数万并发连接,依赖 epoll 的低延迟事件通知。
-
实时通信系统(如 IM、推送服务)
- 长连接管理,快速响应客户端数据。
-
数据库系统(如 Redis、PostgreSQL)
- 高效处理网络 IO 和客户端请求。
更多资料:https://github.com/0voice