1.介绍
IO 多路复用机制是指通过特定的技术方案,允许一个进程监视多个文件描述符,并在这些描述符中的任何一个变为可操作状态(如准备好进行读或写操作)时得到通知。这种机制简化了对多个网络连接或文件的操作,使得单个进程能够高效地管理大量I/O事件。
在Linux系统中,select、poll和epoll是实现I/O多路复用的三种主要方法。它们都属于同步I/O模式的一部分,这意味着当执行读写操作时,如果数据尚未准备好,进程会被阻塞直到操作可以完成。换句话说,在同步I/O中,程序需要自己处理实际的读写工作;而在异步I/O模型下,操作系统会在后台处理数据的读取并将结果直接放入用户空间,从而避免了程序主动发起读写请求的过程。
总结:
select、poll 和 epoll 都是 Linux 提供的 IO 复用方式。
2.select、poll、epoll详解
2.1select
2.1.1 select函数
/*检查readfds中的第一个NFDS描述符(如果不是NULL)是否已读取、
准备就绪,以writefds(如果不是NULL)表示写准备就绪,
并以exceptfds表示(如果不是NULL)用于特殊情况。
如果超时不为空,则超时在等待其中指定的间隔之后。返回就绪数描述符,
或-1表示错误。*/
int select (int n,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
/*fd_set数据结构*/
typedef long int __fd_mask;
#define __FD_SETSIZE 1024
#define __NFDBITS (8 * (int) sizeof (__fd_mask))
typedef struct
{
/* XPG4.2 requires this member name. Otherwise avoid the name
from the global namespace. */
#ifdef __USE_XOPEN
//long int类型的数组,收到最大的__FD_SETSIZE(1024限制)。
//每一位代表着一位sockfd句柄(以位图的方式记录)
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;
- readfds:内核检测该集合中的IO是否可读。如果想让内核帮忙检测某个IO是否可读,需要手动把文件描述符加入该集合。
- writefds:内核检测该集合中的IO是否可写。同readfds,需要手动把文件描述符加入该集合。
- exceptfds:内核检测该集合中的IO是否异常。同readfds,需要手动把文件描述符加入该集合。
- nfds:以上三个集合中最大的文件描述符数值 + 1,例如集合是{0,1,5,10},那么 maxfd 就是 11
- timeout:用户线程调用select的超时时长。
- 设置成NULL,表示如果没有 I/O 事件发生,则 select 一直等待下去。
- 设置为非0的值,这个表示等待固定的一段时间后从 select 阻塞调用中返回。
- 设置成 0,表示根本不等待,检测完毕立即返回。
函数返回值:
- 大于0:成功,返回集合中已就绪的IO总个数
- 等于-1:调用失败
- 等于0:没有就绪的IO
2.1.2 Select运行逻辑
Select函数用于监视文件描述符(fd)的集合,分为三类:writefds(写事件)、readfds(读事件)和exceptfds(异常事件)。当用户进程调用select时,它会将需要监控的文件描述符集合从用户空间复制到内核空间。假设我们只关心socket是否可读的情况,那么select主要关注的是readfds集合。
一旦进入内核空间,select会对所有被监控的socket进行遍历,逐一调用每个socket缓冲区(skb, Socket Buffer)的poll机制来检查是否有数据可供读取。如果在遍历结束之后没有任何一个socket准备好可读数据,select会让调用它的进程进入睡眠状态,通过调用schedule_timeout函数实现这一过程。如果在指定的timeout时间内有数据到达某个socket使之变为可读,或者timeout时间到了,该进程会被唤醒。随后,select会再次遍历所监控的socket集合,收集所有已准备好的可读事件,并将这些信息返回给用户空间。
2.1.3案例
如何使用SELECT监听预期的端口?
可以使用以下的宏定义。
void FD_ZERO(fd_set *fdset);//全部设置为0
void FD_SET(int fd, fd_set *fdset);//设置为1
void FD_CLR(int fd, fd_set *fdset);//设置为0
int FD_ISSET(int fd, fd_set *fdset);//判断0/1
以下视频是select如何在内核态查看是否有就绪事件的:
made by:IO多路复用——深入浅出理解select、poll、epoll的实现 - 知乎
select在内核态通过遍历fd查找是否又可读事件产生(减少内核态到用户态的转换,在内核态遍历,减少开销)。就发现有可读事件之后,就将所有的时间移动到用户态,在用户态遍历所有的事件,发现有可读事件就执行相应的操作。
以下是服务器如何侦听服务器端口并将新连接加入到select监听是否有可读事件的代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
#define PORT 8080
#define BACKLOG 10
int main() {
int server_fd, max_fd;
struct sockaddr_in address;
int addrlen = sizeof(address);
fd_set masterfds, readfds; // masterfds保存所有需要监控的fd,readfds用于select调用
// 创建socket
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置端口可重用
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt failed");
close(server_fd);
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 绑定socket到指定端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 监听连接
if (listen(server_fd, BACKLOG) < 0) {
perror("listen failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 初始化masterfds集合并加入服务器socket
FD_ZERO(&masterfds);
FD_SET(server_fd, &masterfds);
max_fd = server_fd;
printf("%d",max_fd);
printf("Waiting for connections on port %d...\n", PORT);
//==========================核心代码============================
while (1) {
// 使用masterfds副本进行select调用
readfds = masterfds;
// 这里timeout参数置空代表如果没有io事件到来,则一直等待,有的话就返回就绪事件的总数
// Timeout的参数设置
// 设置成NULL,表示如果没有 I/O 事件发生,则 select 一直等待下去。
// 设置为非0的值,这个表示等待固定的一段时间后从 select 阻塞调用中返回。
// 设置成 0,表示根本不等待,检测完毕立即返回。
int activity = select(max_fd + 1, &readfds, NULL, NULL, NULL);
if (activity < 0) {
perror("select error");
break;
}
// 遍历所有可能的文件描述符
for(int i = 0; i <= max_fd; i++) {
// 遍历所有的readfds可读事件,判断是不是有可读事件到来
// 如有则进入下一层的判断
// 没有则继续查看下一个fd查找其他的fd是否有可读事件到来
if (FD_ISSET(i, &readfds)) {
// 如果是服务器的可读事件到来(新连接建立事件)
if (i == server_fd) {
// 处理新连接
int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
if (new_socket < 0) {
perror("accept failed");
continue;
}
printf("New connection, socket fd is %d, ip is : %s, port: %d\n",
new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 将新连接加入到监控列表中
FD_SET(new_socket, &masterfds);
if (new_socket > max_fd) {
max_fd = new_socket;
}
} else {
// 已保存的连接中处理来自客户端的数据
char buffer[1024] = {0};
// 这里的i是socket的fd,通过系统调用读取数据到buffer中
int valread = read(i, buffer, 1024);
if (valread <= 0) {
// 客户端关闭连接或其他错误
close(i);
FD_CLR(i, &masterfds); // 连接错误就从masterfds中移除该socket
} else {
printf("Received data: %s from socket fd: %d\n", buffer, i);
write(i, buffer, strlen(buffer)); // 回显给客户端
}
}
}
}
}
close(server_fd);
return 0;
}
2.1.4 缺点
-
值-结果参数特性:
select
函数会修改传递给它的文件描述符集合(fd_sets),这意味着每次调用select
之前都需要重新设置这些集合。这是因为select
不仅使用这些集合来确定要监听哪些文件描述符,还会更新它们以指示哪些文件描述符实际上已经准备好进行I/O操作。因此,不能直接重用这些集合,增加了额外的操作复杂性。 -
效率问题:为了找出哪些文件描述符已经准备好可以进行I/O操作,必须遍历整个文件描述符集合,并对每个文件描述符调用
FD_ISSET
宏来检查它是否在已准备好的集合中。如果集合很大但只有少数文件描述符是活动的,这将导致不必要的CPU资源浪费。 -
文件描述符数量限制:传统上,
select
支持的最大文件描述符数量被定义为一个固定值(例如1024)。尽管某些操作系统允许通过重新定义FD_SETSIZE
来突破这一限制,但这不是跨平台兼容的做法,且在一些系统如Linux上可能无法生效。 -
线程安全性和同步问题:当一个文件描述符正在被
select
监听时,其他线程不应对其进行修改。例如,关闭或修改一个由select
正在监听的套接字可能会导致未定义行为。此外,在多线程环境中动态调整监听的文件描述符集合也是一个挑战,因为需要确保线程间的正确同步。 -
最大文件描述符值的管理:在调用
select
时,还需要提供所监控的所有文件描述符中的最大值加一作为参数之一。这要求开发者手动跟踪并提供这个值,增加了编程的复杂度和出错的可能性。
2.1.5何时需要用到SELECT呢
-
跨平台兼容性:如果你的程序需要在多种不同的操作系统上运行,并且你希望保持代码尽可能地简单和兼容,那么
select
由于其普遍支持(几乎所有的Unix-like系统,包括Linux, macOS, 以及Windows上的某些实现),是一个不错的选择。 -
少量文件描述符:对于只需要监听少量文件描述符的应用来说,
select
的性能缺点不会特别明显。在这种情况下,使用select
可以避免引入额外的复杂度。 -
嵌入式或受限环境:在一些资源受限的环境中,比如某些嵌入式系统,可能不支持
epoll
或kqueue
等高级特性,此时select
可能是唯一可用的选择。 -
简单的超时管理:
select
的一个特点是它内置了对超时的支持,这对于需要定期检查任务进度或者执行定时任务的应用来说非常方便。
2.2Poll
2.2.1 Poll函数
poll的实现和select非常相似,只是描述fd集合的方式不同。针对select遗留的问题中,poll只是使用pollfd结构而不是select的fd_set结构,这就解决了select的问题fds集合大小1024限制问题。但poll和select同样存在一个性能缺点就是包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
struct pollfd {
int fd; /*文件描述符*/
short events; /*监控的事件*/
short revents; /*监控事件中满足条件返回的事件*/
};
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
函数参数:
- fds:struct pollfd类型的数组, 存储了待检测的文件描述符,struct pollfd有三个成员:
- fd:委托内核检测的文件描述符
- events:委托内核检测的fd事件(输入、输出、错误),每一个事件有多个取值
- revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果
- nfds:描述的是数组 fds 的大小
- timeout: 指定poll函数的阻塞时长
- -1:一直阻塞,直到检测的集合中有就绪的IO事件,然后解除阻塞函数返回
- 0:不阻塞,不管检测集合中有没有已就绪的IO事件,函数马上返回
- 大于0:表示 poll 调用方等待指定的毫秒数后返回
函数返回值:
- -1:失败
- 大于0:表示检测的集合中已就绪的文件描述符的总个数
2.2.2 运行逻辑
poll
函数通过引入pollfd
结构体来描述文件描述符集合,而不是使用select
中的fd_set
结构。这使得poll
支持的文件描述符数量远超select
默认的1024限制,理论上仅受限于系统资源。然而,尽管poll
解决了文件描述符数量的限制问题,它在实现上并没有优化以下两个方面:
-
用户态与内核态的数据复制:每次调用
poll
时,整个文件描述符数组需要在用户态和内核态之间进行复制。当监控的文件描述符数量非常大时,这种频繁的数据复制会带来显著的性能开销。 -
低效的就绪检查机制:即使只有少数几个文件描述符变为就绪状态,
poll
也需要遍历整个文件描述符数组来识别这些就绪的描述符。这意味着随着被监控socket数量的增加,性能将线性下降。
因此,虽然poll
比select
更适合处理较大规模的文件描述符集合,但在高并发场景下其效率仍然受限。
2.2.3 代码案例
核心运行代码:
//自定义fds,侦听对应的socket
struct pollfd fds[MAX_CLIENTS + 1];
// 设置有多少个nfds,初始为1代表至少有一个server监听端口。如果后续有新连接则加一
nfds=1;
// timeout设置为-1,代表阻塞式等待就绪事件
poll(fds, nfds, -1)
案例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <poll.h>
#define PORT 8080
#define BACKLOG 10
#define MAX_CLIENTS 1024
struct client {
int fd;
struct pollfd pfd;
};
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int addrlen = sizeof(address);
struct pollfd fds[MAX_CLIENTS + 1]; // +1 for the listening socket
struct client clients[MAX_CLIENTS];
int nfds = 1; // 初始值为1,因为至少有一个监听socket
// 创建socket
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置端口可重用
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt failed");
close(server_fd);
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 绑定socket到指定端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 监听连接
if (listen(server_fd, BACKLOG) < 0) {
perror("listen failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 初始化pollfd结构体数组的第一个元素,用于监听服务器socket
fds[0].fd = server_fd;
fds[0].events = POLLIN;
printf("Waiting for connections on port %d...\n", PORT);
//=====================核心代码==================================
while (1) {
int poll_count = poll(fds, nfds, -1); // 等待直到有事件发生
if (poll_count < 0) {
perror("poll error");
break;
}
for (int i = 0; i < nfds; i++) {
// 循环检测fds中的事件,检测是否有POLLIN事件到来
// 如有到来,则执行相应的事件逻辑
if (fds[i].revents & POLLIN) { // 如果该文件描述符准备好可读
// 如果是服务端接收到新的连接请求的话
if (fds[i].fd == server_fd) {
// 处理新的连接请求
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept failed");
continue;
}
printf("New connection, socket fd is %d, ip is : %s, port: %d\n",
new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 将新连接添加到poll监控列表中
// poll监控列表的大小是认为设定的
if (nfds < MAX_CLIENTS + 1) {
fds[nfds].fd = new_socket;
fds[nfds].events = POLLIN;
clients[nfds - 1].fd = new_socket;
clients[nfds - 1].pfd = fds[nfds];
nfds++;
} else {
printf("Too many clients.\n");
close(new_socket);
}
} else {
// 处理客户端的数据
char buffer[1024] = {0};
// fds[i].fd获得socket句柄调用系统调用读取数据到buffer中
int valread = read(fds[i].fd, buffer, 1024);
if (valread <= 0) {
// 客户端关闭连接或其他错误
printf("Client disconnected, socket fd is %d\n", fds[i].fd);
close(fds[i].fd);
// 从fds和clients数组中移除这个客户端
for (int j = i; j < nfds - 1; j++) {
fds[j] = fds[j + 1];
clients[j] = clients[j + 1];
}
nfds--;
} else {
printf("Received data: %s from socket fd: %d\n", buffer, fds[i].fd);
write(fds[i].fd, buffer, strlen(buffer)); // 回显给客户端
}
}
}
}
}
close(server_fd);
return 0;
}
2.2.4 缺点
尽管poll
相较于select
有诸多改进,但其仍存在一些明显的缺点:
-
数据复制的开销:由于每次调用
poll
都需要在用户态和内核态之间复制整个文件描述符数组,对于包含大量文件描述符的应用来说,这会导致显著的性能损耗。 -
遍历开销:一旦某个或某些文件描述符变得可操作(如准备好读或写),
poll
依然需要遍历整个文件描述符集合以确定哪些具体描述符已就绪。这种机制在监视大量文件描述符时效率低下,导致性能随监控socket数量的增加而线性下降。 -
不适合大规模并发:鉴于上述原因,
poll
并不适合用于具有非常高并发需求的应用程序中,因为它无法有效地扩展以应对大量的并发连接。
2.2.5 何时使用poll呢
尽管poll
存在一些局限性,特别是在处理非常高并发的场景时,但它仍然有其适用的情况。
-
跨平台兼容性:与
select
类似,poll
在几乎所有Unix-like系统上都有支持,并且在Windows上也有相应的实现(尽管实现细节可能有所不同)。如果你的应用需要运行在多种操作系统平台上,poll
提供了一种较为统一的方式来管理I/O多路复用,减少了针对不同平台编写特定代码的需求。 -
中等规模的并发需求:对于那些不需要处理成千上万的并发连接的应用来说,
poll
可以是一个合适的选择。它比select
更灵活且没有文件描述符数量的硬性限制,适用于监控几百到几千个文件描述符的场景。 -
资源受限环境:在某些情况下,尤其是资源受限的环境中,可能无法利用更高级的I/O多路复用机制(例如嵌入式系统或其他硬件资源有限的情况)。在这种情况下,
poll
可能因为其普遍的支持和较低的入门门槛而成为首选。 -
测试和开发阶段:在软件的开发和测试阶段,
poll
可以作为快速验证概念或进行初步性能评估的一个工具。它的易用性和广泛可用性使得开发者可以在不同平台上快速部署和测试他们的应用逻辑。
2.3Epoll
2.3.1Epoll函数
epoll模型修改主动轮询为被动通知,当有事件发生时,被动接收通知。所以epoll模型注册套接字后,主程序可做其他事情,当事件发生时,接收到通知后再去处理。
epoll 是基于事件驱动的 IO 方式,与 select 相比,epoll 并没有描述符个数限制。
epoll 使用一个文件描述符管理多个描述符,它将文件描述符的事件放入内核的一个事件表中,从而在用户空间和内核空间的复制操作只用实行一次即可。
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
个人感悟:
其实我感觉这个epoll更多的是将事件全部交由内核进行管理,我们用户只需要在外面创建好事件的本体,然后将事件注册到内核当中就大功告成。用户只需要等待后续的事件到来就可以了。
而poll、select更像是让用户对所有事件的句柄进行操作,需要用户自己去遍历所有的句柄,查看句柄的状态再执行操作。中间会有很多的内核态到用户态、用户态到内核态的转换,造成极大资源浪费。
别人的图更能解释这一点:
made by IO多路复用——深入浅出理解select、poll、epoll的实现 - 知乎
epoll
2.3.2 EOPLL运行逻辑
epoll的接口非常简单,一共就三个函数:
- epoll_create:创建一个epoll句柄
- epoll_ctl:向 epoll 对象中添加/修改/删除要管理的连接
- epoll_wait:等待其管理的连接上的 IO 事件
epoll_create 函数
/* Creates an epoll instance. Returns an fd for the new instance.
The "size" parameter is a hint specifying the number of file
descriptors to be associated with the new instance. The fd
returned by epoll_create() should be closed with close(). */
int epoll_create(int size);
// ***epoll_create的源码实现*** //
SYSCALL_DEFINE1(epoll_create1, int, flags)
{
struct eventpoll *ep = NULL;
//创建一个 eventpoll 对象
error = ep_alloc(&ep);
}
//struct eventpoll 的定义
// file:fs/eventpoll.c
struct eventpoll {
//sys_epoll_wait用到的等待队列
//当有数据到来的时候会通过wq找到epoll上的睡眠的用户进程(一般为调用epollwait的时候没有就绪事件的用户进程)
//具体一点
// 当 sys_epoll_wait 被调用且没有文件描述符就绪时,调用线程会被添加到 wq 等待队列,并进入睡眠状态。这意味着线程暂时停止执行,释放CPU资源,直到某些事件发生唤醒它们。
//当某个被监视的文件描述符的状态发生变化(例如可读、可写),相应的事件处理代码会检查是否有线程正在等待这个事件。如果有,就会遍历相关的等待队列(如 wq),并唤醒适当的线程。这些线程将重新调度执行,返回 epoll_wait 的调用处,并提供已就绪的文件描述符列表。
//一旦有文件描述符变为就绪状态,内核会负责唤醒所有在等待该事件的线程。这些线程从等待的地方继续执行,通常是从 epoll_wait 返回,此时函数会返回就绪文件描述符的数量和相关信息给用户态的应用程序。
wait_queue_head_t wq;
//接收就绪的描述符都会放到这里
//数据就绪的时候,可以直接对就绪链表中的节点操作从而节省对红黑树遍历节点查找是否有数据就绪
struct list_head rdllist;
//每个epoll对象中都有一颗红黑树
//通过红黑树管理所有的socket节点,提高了节点查找、删除、插入操作的效率
struct rb_root rbr;
......
}
static int ep_alloc(struct eventpoll **pep)
{
struct eventpoll *ep;
//申请 epollevent 内存
ep = kzalloc(sizeof(*ep), GFP_KERNEL);
//初始化等待队列头
init_waitqueue_head(&ep->wq);
//初始化就绪列表
INIT_LIST_HEAD(&ep->rdllist);
//初始化红黑树指针
ep->rbr = RB_ROOT;
......
}
- 功能:该函数生成一个 epoll 专用的文件描述符。
- 参数size: 用来告诉内核这个监听的数目一共有多大,参数 size 并不是限制了 epoll 所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。自从 linux 2.6.8 之后,size 参数是被忽略的,也就是说可以填只有大于 0 的任意值。
- 返回值:如果成功,返回epoll 专用的文件描述符,否者失败,返回-1。
对于epoll_create的创建,我们可以发现,epoll句柄创建的时候,是通过ep_alloc这个函数创建的(分配内存、节点初始化等----在一开始创建的时候,create操作创建了一个没有节点的空间)。创建完成之后,后续可以同返回的epoll句柄进行epoll_ctl、epoll_wait等操作。
其中eventpoll结构体中的wq:
- 使用
epoll_wait
等待事件的发生。如果你调用epoll_wait
时没有事件就绪,当前线程可能会被放入wq
等待队列中,直到有事件发生或者达到指定的超时时间。
epoll_ctl 函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 功能:epoll 的事件注册函数,它不同于 select() 是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
- 参数epfd: epoll 专用的文件描述符,epoll_create()的返回值
- 参数op: 表示动作,用三个宏来表示:(红黑树的结构使得很容易的查找、删除、插入句柄)
- EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
- EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
- EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
- 数fd: 需要监听的文件描述符
- 参数event: 告诉内核要监听什么事件,struct epoll_event 结构如:
- events可以是以下几个宏的集合:(代表了不同的事件可读、可写等等)
- EPOLLIN :表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭);
- EPOLLOUT:表示对应的文件描述符可以写;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:表示对应的文件描述符发生错误;
- EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET :将 EPOLL 设为边缘触发(Edge Trigger)模式,这是相对于水平触发(Level Trigger)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里
- 返回值:0表示成功,-1表示失败。
epoll_wait函数
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
- 功能:等待事件的产生,收集在 epoll 监控的事件中已经发送的事件,类似于 select() 调用。
- 参数epfd: epoll 专用的文件描述符,epoll_create()的返回值
- 参数events: 分配好的 epoll_event 结构体数组,epoll 将会把发生的事件赋值到events 数组中(events 不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存)。
- 参数maxevents: maxevents 告之内核这个 events 有多少个 。
- 参数timeout: 超时时间,单位为毫秒,为 -1 时,函数为阻塞。
- 返回值:
- 如果成功,表示返回需要处理的事件数目
- 如果返回0,表示已超时
- 如果返回-1,表示失败
2.3.3代码案例
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <fcntl.h>
#define PORT 8080
#define MAX_EVENTS 1024
#define BACKLOG 10
void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl F_GETFL failed");
return;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL failed");
}
}
int main() {
int server_fd, new_socket, epoll_fd;
struct sockaddr_in address;
int addrlen = sizeof(address);
struct epoll_event event, events[MAX_EVENTS];
// 创建socket
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置端口可重用
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt failed");
close(server_fd);
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 绑定socket到指定端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 监听连接
if (listen(server_fd, BACKLOG) < 0) {
perror("listen failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 将服务器socket设置为非阻塞模式
set_nonblocking(server_fd);
// 创建epoll实例
if ((epoll_fd = epoll_create1(0)) < 0) {
perror("epoll_create1 failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 将服务器socket添加到epoll实例中
event.events = EPOLLIN | EPOLLET; // 使用边缘触发
event.data.fd = server_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event) < 0) {
perror("epoll_ctl failed");
close(server_fd);
close(epoll_fd);
exit(EXIT_FAILURE);
}
printf("Waiting for connections on port %d...\n", PORT);
// =============================核心代码=============================
while (1) {
// 获得epoll_create的句柄、事件发生的句柄(存放就绪事件的events,内核将数据放入,用户只需要接受event的事件即可)
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == server_fd) {
// 处理新的连接请求
while ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) > 0) {
printf("New connection, socket fd is %d, ip is : %s, port: %d\n",
new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
set_nonblocking(new_socket); // 设置新连接为非阻塞模式
event.events = EPOLLIN | EPOLLET; // 使用边缘触发
event.data.fd = new_socket;
// 加入新连接事件到epoll当中
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_socket, &event) < 0) {
perror("epoll_ctl failed");
close(new_socket);
}
}
if (new_socket == -1 && errno != EAGAIN && errno != EWOULDBLOCK) {
perror("accept failed");
}
} else {
// 处理客户端的数据
char buffer[1024] = {0};
int valread = read(events[i].data.fd, buffer, 1023);
if (valread <= 0) {
// 客户端关闭连接或其他错误
printf("Client disconnected, socket fd is %d\n", events[i].data.fd);
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
close(events[i].data.fd);
} else {
printf("Received data: %s from socket fd: %d\n", buffer, events[i].data.fd);
write(events[i].data.fd, buffer, strlen(buffer)); // 回显给客户端
}
}
}
}
close(server_fd);
close(epoll_fd);
return 0;
}
2.3.4epoll的边缘触发与水平触发
水平触发(LT)
关注点是数据是否有无,只要读缓冲区不为空,写缓冲区不满,那么epoll_wait就会一直返回就绪,水平触发是epoll的默认工作方式。
边缘触发(ET)
关注点是变化,只要缓冲区的数据有变化,epoll_wait就会返回就绪。
这里的数据变化并不单纯指缓冲区从有数据变为没有数据,或者从没有数据变为有数据,还包括了数据变多或者变少。即当buffer长度有变化时,就会触发。
假设epoll被设置为了边缘触发,当客户端写入了100个字符,由于缓冲区从0变为了100,于是服务端epoll_wait触发一次就绪,服务端读取了2个字节后不再读取。这个时候再去调用epoll_wait会发现不会就绪,只有当客户端再次写入数据后,才会触发就绪。
这就导致如果使用ET模式,那就必须保证要「一次性把数据读取&写入完」,否则会导致数据长期无法读取/写入。
3.epoll 为什么比select、poll更高效?
epoll
是 Linux 特有的I/O事件通知机制,它在处理大量文件描述符时表现出比传统的 select
和 poll
更高的效率:
1. 使用红黑树管理文件描述符
- 高效的数据结构:
epoll
使用红黑树(一种自平衡二叉查找树)来管理所有被监控的文件描述符。这种数据结构允许对文件描述符的插入和删除操作保持在 O(logN) 的时间复杂度,这意味着即使文件描述符的数量增加,这些操作的性能也不会显著下降。 - 对比:相比之下,
select
和poll
分别采用数组或链表的方式来管理文件描述符,这导致了当文件描述符数量增加时,遍历整个集合以查找就绪文件描述符的时间成本也随之线性增加。
2.文件描述符添加与检测分离
- 减少不必要的拷贝:在
epoll
中,文件描述符的添加(通过epoll_ctl
)和检查(通过epoll_wait
)是分开的。这意味着你只需要一次性地将感兴趣的文件描述符添加到epoll
实例中,后续的检查不需要再次传递这些描述符,从而减少了从用户态到内核态的数据复制次数。 - 效率提升:相反,
select
和poll
在每次调用时都需要将所有监听的文件描述符从用户态空间复制到内核态空间,并且每次都必须线性扫描以找出哪些描述符已经准备好进行I/O操作。这种方法随着文件描述符数量的增加而变得越来越低效。
3. 基于事件驱动的通知模型
- 回调机制:
epoll
利用了回调机制,在文件描述符变为就绪状态时自动将其添加到一个就绪列表中。这意味着只有真正就绪的文件描述符才会被返回给应用程序,极大地提高了效率。 - 唤醒机制:当有文件描述符就绪时,
epoll
只需简单地检查就绪列表是否为空即可,无需像select
或poll
那样遍历整个文件描述符集合。这种方式不仅节省了CPU时间,还使得epoll
在高并发场景下表现尤为出色。
4.总结
select
、poll
和 epoll
都是I/O多路复用机制,它们允许程序监视多个文件描述符(fd),并在某个描述符就绪(即准备好进行读或写操作)时通知程序。尽管这三种机制都属于同步I/O的范畴——因为它们都需要程序在检测到事件后自行执行读写操作,这个过程通常是阻塞的——但它们之间存在显著的区别。
- 同步 vs 异步I/O:值得注意的是,虽然这些机制都是同步I/O,意味着程序需要自己负责数据的读写,而异步I/O则不同,它不需要程序直接处理数据的读写,而是由操作系统自动完成从内核空间到用户空间的数据拷贝。
工作机制与性能对比
-
Select 和 Poll:这两种方法的工作原理要求程序不断轮询所有被监控的文件描述符集合,直到发现某个描述符已就绪。这一过程涉及多次睡眠和唤醒循环,且每次调用都需要将文件描述符集合从用户态复制到内核态。更重要的是,在“醒着”的状态下,程序必须遍历整个文件描述符集合以检查哪些描述符已经就绪,这导致了较大的CPU开销,尤其是在处理大量文件描述符时效率低下。
-
Epoll:相比之下,
epoll
采用了更高效的机制。它通过使用回调函数的方式,在文件描述符就绪时将其添加到一个就绪链表中,并仅在必要时唤醒等待中的进程。这意味着,在“醒着”的时候,epoll
只需简单地检查就绪链表是否为空,而无需遍历整个文件描述符集合。此外,epoll
仅需一次文件描述符集合的用户态到内核态的复制,并且只挂起当前进程一次,这极大地减少了系统调用和上下文切换的开销。
5.参考
【性能篇】多路复用之 Select,Poll,Epoll 的差异与选择_epoll poll 如何选择-优快云博客
IO多路复用——深入浅出理解select、poll、epoll的实现 - 知乎