IO多路复用之epoll
I/O多路复用概括:
I/O多路复用是一种让单个线程能够同时监控多个输入/输出通道(如网络连接)状态的技术。当某个通道准备好进行读或写操作时,它会通知程序,从而高效地处理多个I/O任务,而无需创建多个线程。
比较一下传统的阻塞I/O和I/O多路复用:
| 特性 | 传统阻塞I/O (BIO) | I/O 多路复用 |
|---|---|---|
| 核心思想 | 一个线程服务一个连接。 | 一个线程服务多个连接。 |
| 资源消耗 | 高。连接数增多时,线程数量暴涨,内存和CPU切换开销大。 | 低。仅使用少量线程,资源占用稳定。 |
| 工作方式 | 线程在等待数据时会被挂起(阻塞),啥也干不了。 | 线程主动监控所有连接,只在有连接真正准备好时才去处理,避免空等。 |
| 适用场景 | 连接数较少、且每个连接活动性高的场景。 | 高并发场景,如Web服务器、实时通信系统。 |
在Linux系统中,主要有三种机制来实现I/O多路复用,它们不断演进以追求更高的性能:
你可以把I/O多路复用想象成一个高效的餐厅服务员。在传统模式下,每个顾客(I/O流)都需要一个专属服务员(线程)从头跟到尾,大部分时间服务员都在等待,效率低下。而I/O多路复用则像一个超级服务员,他同时照看多张餐桌(多个I/O通道),不停巡视哪张桌子的菜好了(数据可读)或可以上菜了(数据可写),然后只在那张桌子需要服务时才过去处理
- select / poll: 早期的实现。它们的工作方式类似于“轮询”,服务员需要拿着完整的顾客名单,挨个去问“好了吗?”,连接数一多,效率就会下降,且select最多只能监控1024个连接。
- epoll: Linux下目前效率最高的模型。它采用了“事件通知”机制。服务员不用再挨个问了,而是坐在那里等铃响。每张桌子(连接)上有个服务铃,当菜好了(数据就绪)时,厨房(内核)会主动“叮”一声通知服务员,服务员直接去处理那几张响铃的桌子就行。这种方式在连接数巨大时依然能保持高效。
epoll初识
epoll是linux操作系统上特有的I/O多路复用机制,它自 Linux 2.6 内核开始被正式引入,旨在高效处理海量的并发网络连接,是现代高性能 Linux 服务器程序的基石之一。
-
epoll系统调用也可以让我们的程序同时监视多个文件描述符上的事件是否就绪,与select和poll的定位是一样的,适用场景也相同。
-
epoll在命名上比poll多了一个e,这个e可以理解成是extend,epoll就是为了同时处理大量文件描述符而改进的poll。
-
epoll在2.5.44内核中被引进,它几乎具备了select和poll的所有优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。
epoll的相关系统调用
epoll有三个相关的系统调用,分别是epoll_create、epoll_ctl和epoll_wait。
epoll_create函数
epoll_create函数用于创建一个epoll模型,该函数的函数原型如下:
int epoll_create(int size);
参数说明:
- size:自从Linux2.6.8之后,size参数是被忽略的,但size的值必须设置为大于0的值。
返回值说明:
- epoll模型创建成功返回其对应的文件描述符,否则返回-1,同时错误码会被设置。
注意: 当不再使用时,必须调用close函数关闭epoll模型对应的文件描述符,当所有引用epoll实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。
epoll_ctl函数
epoll_ctl函数用于向指定的epoll模型中注册事件,该函数的函数原型如下:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数说明:
- epfd:指定的epoll模型。
- op:表示具体的动作,用三个宏来表示。
EPOLL_CTL_ADD:注册新的文件描述符到指定的epoll模型中。EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件。EPOLL_CTL_DEL:从epoll模型中删除指定的文件描述符
- fd:需要监视的文件描述符。
- event:需要监视该文件描述符上的哪些事件。
返回值说明:
- 函数调用成功返回0,调用失败返回-1,同时错误码会被设置。
第四个参数对应的struct epoll_event结构如下:

struct epoll_event结构中有两个成员,第一个成员events表示的是需要监视的事件,第二个成员data是一个联合体结构,一般选择使用该结构当中的fd,表示需要监听的文件描述符。
events的常用取值如下:
EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。EPOLLOUT:表示对应的文件描述符可以写。EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。EPOLLERR:表示对应的文件描述符发送错误。EPOLLHUP:表示对应的文件描述符被挂断,即对端将文件描述符关闭了。EPOLLET:将epoll的工作方式设置为边缘触发(Edge Triggered)模式。EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到epoll模型中。
这些取值实际也是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。

epoll_wait函数
epoll_ctl函数用于收集监视的事件中已经就绪的事件,该函数的函数原型如下:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数说明:
- epfd:指定的epoll模型。
- events:内核会将已经就绪的事件拷贝到events数组当中(events不能是空指针,内核只负责将就绪事件拷贝到该数组中,不会帮我们在用户态中分配内存)。
- maxevents:events数组中的元素个数,该值不能大于创建epoll模型时传入的size值。
- timeout:表示epoll_wait函数的超时时间,单位是毫秒(ms)。
参数timeout的取值:
- -1:epoll_wait调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
- 0:epoll_wait调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,epoll_wait检测后都会立即返回。
- 特定的时间值:epoll_wait调用后在直到的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后epoll_wait进行超时返回。
返回值说明:
- 如果函数调用成功,则返回有事件就绪的文件描述符个数。
- 如果timeout时间耗尽,则返回0。
- 如果函数调用失败,则返回-1,同时错误码会被设置。
epoll_wait调用失败时,错误码可能被设置为:
EBADF:传入的epoll模型对应的文件描述符无效。EFAULT:events指向的数组空间无法通过写入权限访问。EINTR:此调用被信号所中断。EINVAL:epfd不是一个epoll模型对应的文件描述符,或传入的maxevents值小于等于0。
epoll服务器
为了简单演示一下epoll的使用方式,这里我们也实现一个简单的epoll服务器,该服务器也只是读取客户端发来的数据并进行打印。
socket类
首先我们可以编写一个Socket类,对套接字相关的接口进行一定程度的封装,为了让外部能够直接调用Socket类当中封装的函数,于是将这些函数定义成了静态成员函数。
#pragma once#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdlib>class Socket{
public://创建套接字static int SocketCreate(){int sock = socket(AF_INET, SOCK_STREAM, 0);if (sock < 0){std::cerr << "socket error" << std::endl;exit(2);}//设置端口复用,防止timewait状态无法重启服务器int opt = 1;setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));return sock;}//绑定static void SocketBind(int sock, int port){struct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(port);//绑定当前主机任意iplocal.sin_addr.s_addr = INADDR_ANY;socklen_t len = sizeof(local);if (bind(sock, (struct sockaddr*)&local, len) < 0){std::cerr << "bind error" << std::endl;exit(3);}}//监听static void SocketListen(int sock, int backlog){if (listen(sock, backlog) < 0){std::cerr << "listen error" << std::endl;exit(4);}}
};
EpollServer类
EpollServer类当中除了需要包含监听套接字和端口号两个成员变量之外,最好将epoll模型对应的文件描述符也作为一个成员变量。
- 在构造EpollServer对象时,需要指明epoll服务器的端口号,当然也可以在初始化epoll服务器的时候指明。
- 在初始化epoll服务器的时候调用Socket类当中的函数,依次进行套接字的创建、绑定和监听,此外epoll模型的创建可以在服务器初始化的时候进行。
- 在析构函数中调用close函数,将监听套接字和epoll模型对应的文件描述符进行关闭。
#include "Socket.hpp"
#include <sys/epoll.h>#define BACK_LOG 5
#define SIZE 256class EpollServer{
private:int _listen_sock; //监听套接字int _port; //端口号int _epfd; //epoll模型
public:EpollServer(int port): _port(port){}void InitEpollServer(){_listen_sock = Socket::SocketCreate();Socket::SocketBind(_listen_sock, _port);Socket::SocketListen(_listen_sock, BACK_LOG);//创建epoll模型_epfd = epoll_create(SIZE);if (_epfd < 0){std::cerr << "epoll_create error" << std::endl;exit(5);}}~EpollServer(){if (_listen_sock >= 0){close(_listen_sock);}if (_epfd >= 0){close(_epfd);}}
};
启动服务器
服务器初始化完毕后就可以开始运行了,而epoll服务器要做的就是不断调用epoll_wait函数,从就绪队列当中获取就绪事件进行处理即可。
- 首先,在epoll服务器开始死循环调用epoll_wait函数之前,需要先调用epoll_ctl将监听套接字添加到epoll模型当中,表示服务器刚开始运行时只需要监视监听套接字的读事件。
- 此后,epoll服务器就不断调用epoll_wait函数监视读事件是否就绪。如果epoll_wait函数的返回值大于0,则说明已经有文件描述符的读事件就绪,并且此时的返回值代表的就是有事件就绪的文件描述符个数,接下来就应该对就绪事件进行处理。
- 如果epoll_wait函数的返回值等于0,则说明timeout时间耗尽,此时直接准备进行下一次epoll_wait调用即可。如果epoll_wait函数的返回值为-1,此时也让服务器准备进行下一次epoll_wait调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用epoll_wait函数。
代码如下:
#define MAX_NUM 64void Run(){AddEvent(_listen_sock, EPOLLIN); //将监听套接字添加到epoll模型中,并关心其读事件for (;;){struct epoll_event revs[MAX_NUM];int num = epoll_wait(_epfd, revs, MAX_NUM, -1);if (num < 0){std::cerr << "epoll_wait error" << std::endl;continue;}else if (num == 0){std::cout << "timeout..." << std::endl;continue;}else{//正常的事件处理//std::cout<<"有事件发生..."<<std::endl;//HandlerEvent(revs, num);}}}void AddEvent(int sock, uint32_t event){struct epoll_event ev;ev.events = event;ev.data.fd = sock;epoll_ctl(_epfd, EPOLL_CTL_ADD, sock, &ev);}
说明一下:
-
默认情况下,只要底层有就绪事件没有处理,epoll也会一直通知用户,也就是调用epoll_wait会一直成功返回,并将就绪的事件拷贝到我们传入的数组当中。
-
需要注意的是,所谓的事件处理并不是调用epoll_wait将底层就绪队列中的就绪事件拷贝到用户层,比如当这里的读事件就绪后,我们应该调用accept获取底层建立好的连接,或调用recv读取客户端发来的数据,这才算是将读事件处理了。
-
如果我们仅仅是调用epoll_wait将底层就绪队列当中的事件拷贝到应用层,那么这些就绪事件实际并没有被处理掉,底层注册的回调函数会被再次调用,将就绪的事件重新添加到就绪队列当中,本质原因就是我们实际并没有对底层就绪的数据进行读取。
事件处理
如果底层就绪队列当中有就绪事件,那么调用epoll_wait函数时就会将底层就绪队列中的事件拷贝到用户提供的revs数组当中,接下来epoll服务器就应该对就绪事件进行处理了,事件处理过程如下:
-
根据调用epoll_wait时得到的返回值,来判断操作系统向revs数组中拷贝了多少个struct epoll_event结构,进而对这些文件描述符上的事件进行处理。
-
对于每一个拷贝上来的struct epoll_event结构,如果该结构当中的events当中包含读事件,则说明该文件描述符对应的读事件就绪,但接下来还需要进一步判断该文件描述符是监听套接字还是与客户端建立的套接字。
-
如果是监听套接字的读事件就绪,则调用accept函数将底层建立好的连接获取上来,并调用epoll_ctl函数将获取到的套接字添加到epoll模型当中,表示下一次调用epoll_wait函数时需要监视该套接字的读事件。
-
如果是与客户端建立的连接对应的读事件就绪,则调用recv函数读取客户端发来的数据,并将读取到的数据在服务器端进行打印。
-
如果在调用recv函数时发现客户端将连接关闭或recv函数调用失败,则epoll服务器也直接关闭对应的连接,并调用epoll_ctl函数将该连接对应的文件描述符从epoll模型中删除,表示下一次调用epoll_wait函数时无需再监视该套接字的读事件。
代码如下:
void HandlerEvent(struct epoll_event revs[], int num)
{for (int i = 0; i < num; i++){int fd = revs[i].data.fd; //就绪的文件描述符if (fd == _listen_sock && revs[i].events&EPOLLIN){ //连接事件就绪struct sockaddr_in peer;memset(&peer, 0, sizeof(peer));socklen_t len = sizeof(peer);int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);if (sock < 0){ //获取连接失败std::cerr << "accept error" << std::endl;continue;}std::string peer_ip = inet_ntoa(peer.sin_addr);int peer_port = ntohs(peer.sin_port);std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;//将获取到的套接字添加到epoll模型中,并关心其读事件AddEvent(sock, EPOLLIN); }else if (revs[i].events&EPOLLIN){ //读事件就绪char buffer[64];ssize_t size = recv(fd, buffer, sizeof(buffer)-1, 0);if (size > 0){ //读取成功buffer[size] = '\0';std::cout << "echo# " << buffer << std::endl;}else if (size == 0){ //对端连接关闭std::cout << "client quit" << std::endl;close(fd);DelEvent(fd); //将文件描述符从epoll模型中删除}else{std::cerr << "recv error" << std::endl;close(fd);DelEvent(fd); //将文件描述符从epoll模型中删除}}}
}void DelEvent(int sock)
{epoll_ctl(_epfd, EPOLL_CTL_DEL, sock, nullptr);
}
epoll服务器测试
运行epoll服务器时需要先实例化出一个EpollServer对象,对epoll服务器进行初始化后就可以运行服务器了。
代码如下:
#include "EpollServer.hpp"
#include <string>static void Usage(std::string proc)
{std::cout << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char* argv[])
{if (argc != 2){Usage(argv[0]);exit(1);}int port = atoi(argv[1]);EpollServer* svr = new EpollServer(port);svr->InitEpollServer();svr->Run();return 0;
}
因为编写epoll服务器在调用epoll_wait函数时,我们将timeout的值设置成了-1,因此运行服务器后如果没有客户端发来连接请求,那么服务器就会在调用epoll_wait函数后进行阻塞等待。

当我们用telnet工具连接epoll服务器后,epoll服务器调用的epoll_wait函数在检测到监听套接字的读事件就绪后就会调用accept获取建立好的连接,并打印输出客户端的IP和端口号,此时客户端发来的数据也能够成功被epoll服务器收到并进行打印输出。

此外,我们这里编写的也是一个单进程的epoll服务器,但是它可以同时为多个客户端提供服务。

我们可以用ls /proc/PID/fd命令,查看当前epoll服务器的文件描述符的使用情况。其中文件描述符0、1、2是默认打开的,分别对应的是标准输入、标准输出和标准错误,3号文件描述符对应的是监听套接字,4号文件描述符对应的是服务器创建的epoll模型,5号和6号文件描述符对应的分别是正在访问服务器的两个客户端。

注意图中22,23,26描述符无需理会,是我使用vscode时,它自己打开的。
epoll工作原理
红黑树和就绪队列

当某一进程调用epoll_create函数时,Linux内核会创建一个eventpoll结构体,也就是我们所说的epoll模型,eventpoll结构体当中的成员rbr和rdlist与epoll的使用方式密切相关。
-
epoll模型当中的红黑树本质就是告诉内核,需要监视哪些文件描述符上的哪些事件,调用epll_ctl函数实际就是在对这颗红黑树进行对应的增删改操作。
-
epoll模型当中的就绪队列本质就是告诉内核,哪些文件描述符上的哪些事件已经就绪了,调用epoll_wait函数实际就是在从就绪队列当中获取已经就绪的事件。
在epoll中,对于每一个事件都会有一个对应的epitem结构体,红黑树和就绪队列当中的节点分别是基于epitem结构中的rbn成员和rdllink成员的,epitem结构当中的成员ffd记录的是指定的文件描述符值,event成员记录的就是该文件描述符对应的事件。
struct epitem{struct rb_node rbn; //红黑树节点struct list_head rdllink; //双向链表节点struct epoll_filefd ffd; //事件句柄信息struct eventpoll *ep; //指向其所属的eventpoll对象struct epoll_event event; //期待发生的事件类型
}
- 对于epitem结构当中rbn成员来说,ffd与event的含义是,需要监视ffd上的event事件是否就绪。
- 对于epitem结构当中的rdlink成员来说,ffd与event的含义是,ffd上的event事件已经就绪了。
说明一下:
- 红黑树是一种二叉搜索树,因此必须有键值key,而这里的文件描述符就天然的可以作为红黑树的key值。
- 调用epoll_ctl向红黑树当中新增节点时,如果设置了
EPOLLONESHOT选项,当监听完这次事件后,如果还需要继续监听该文件描述符则需要重新将其添加到epoll模型中,本质就是当设置了EPOLLONESHOT选项的事件就绪时,操作系统会自动将其从红黑树当中删除。 - 而如果调用epoll_ctl向红黑树当中新增节点时没有设置
EPOLLONESHOT,那么该节点插入红黑树后就会一直存在,除非用户调用epoll_ctl将该节点从红黑树当中删除。
回调机制
所有添加到红黑树当中的事件,都会与设备(网卡)驱动程序建立回调方法,这个回调方法在内核中叫ep_poll_callback。
-
对于select和poll来说,操作系统在监视多个文件描述符上的事件是否就绪时,需要让操作系统主动对这多个文件描述符进行轮询检测,这一定会增加操作系统的负担。
-
而对于epoll来说,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调方法,将就绪的事件添加到就绪队列当中。
-
当用户调用epoll_wait函数获取就绪事件时,只需要关注底层就绪队列是否为空,如果不为空则将就绪队列当中的就绪事件拷贝给用户即可。
采用回调机制最大的好处,就是不再需要操作系统主动对就绪事件进行检测了,当事件就绪时会自动调用对应的回调函数进行处理。
说明一下:
-
只有添加到红黑树当中的事件才会与底层建立回调方法,因此只有当红黑树当中对应的事件就绪时,才会执行对应的回调方法将其添加到就绪队列当中。
-
当不断有监视的事件就绪时,会不断调用回调方法向就绪队列当中插入节点,而上层也会不断调用epoll_wait函数从就绪队列当中获取节点,这是典型的生产者消费者模型。
-
由于就绪队列可能会被多个执行流同时访问,因此必须要使用互斥锁对其进行保护,eventpoll结构当中的lock和mtx就是用于保护临界资源的,因此epoll本身是线程安全的。
,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调方法,将就绪的事件添加到就绪队列当中。 -
当用户调用epoll_wait函数获取就绪事件时,只需要关注底层就绪队列是否为空,如果不为空则将就绪队列当中的就绪事件拷贝给用户即可。
采用回调机制最大的好处,就是不再需要操作系统主动对就绪事件进行检测了,当事件就绪时会自动调用对应的回调函数进行处理。
