epoll - 高效的I/O事件处理机制
1. 背景
在资源为就绪时,阻塞式 IO 会导致进程阻塞以等待资源。比如在 socket 上执行 recv 操作时,如果该 socket 上没有数据可读,当前进程会等待在该 socket 上,直到 socket 上有数据可读后,当前进程会被唤醒。
一个网络服务程序,往往需要服务成千上万的客户端,如果使用阻塞式 IO,线程就会被一个 socket 所阻塞。如果想要要同时处理成千上万的客户端连接,该如何实现呢?下面是一些可以考虑的方案:
方案一:多线程方案
为每个客户端 socket 创建一个线程,在单个线程里对 socket 使用阻塞型 IO 进行读写。但如果客户端较多,这需要创建大量的线程。对于需要处理大量并发请求的服务器来说,这种方式会导致线程资源的快速耗尽。在当下海量并发的场景下,这种方式早已经不再适用。
方案二:轮询
使用非阻塞 IO,并对所有的客户端不断进行轮询,当发现有有可读的 socket 时,对其进行处理。处理完成后继续轮训,这样就可以在单个线程中处理所有客户端了。但在任意时刻,可能只有很少的 socket 有数据可接收,这种轮询的方案遍历所有 socket,这会做大量的无用功,在实践中是不可取的。
方案三:IO 多路复用
轮询的目的实际上是为了得到当前可读写的 socket,操作系统负责对 socket 进行数据读写,因此操作系统可以知道 socket 是否可以读写。如果可以让操作系统在 socket 可读写的时候通知应用程序,如此可以在得到通知后无阻塞地处理该 socket。这样就不需要应用程序来轮询,只需要向操作系统订阅 socket 上的可读写事件,随后等操作系统通知即可,这正是 IO 多路复用采用的思想。在 Linux 平台上,可以使用 epoll 来完成实现上述的功能。
2. epoll 的用法
epoll 是 Linux 平台提供的一种 I/O 事件通知机制,它使用一组函数来进行 I/O 事件的处理。它包含如下 API:
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout)
epoll_create
首先,调用 epoll_create 来创建一个 epoll 实例,epoll_create 返回一个文件描述符,用于后续的 epoll 操作。
#include <sys/epoll.h>
int efd = epoll_create(10); // 历史原因,目前参数 size 目前没有实际用处,只要大于 0 即可
if (efd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
epoll_ctl
然后,使用 epoll_ctl 将需要订阅的文件描述符添加到 epoll 实例中。epoll_ctl 函数接受四个参数:
- epoll 文件描述符
- 操作类型
- 要操作的文件描述符
- 一个
epoll_event结构体的指针,给定要关注的事件类型和数据
epoll_event 的定义如下:
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
}
在注册事件时,可以在 data 字段中记录一些信息,这样在事件发生后,可以通过 data 字段获取到是哪个文件描述符上发生了事件。
struct epoll_event ev;
ev.events = EPOLLIN; // 监听读事件
ev.data.fd = fd; // 要操作的文件描述符
// 添加文件描述符到epoll实例
if (epoll_ctl(efd, EPOLL_CTL_ADD, fd, &ev) == -1) {
perror("epoll_ctl: add");
exit(EXIT_FAILURE);
}
这里 EPOLLIN 表示监听输入(IN)事件。fd 是添加的文件描述符。你可以使用 epoll_ctl 添加大量的文件描述符,并关注不同的事件。
epoll_ctl 的 op 参数可取值为:EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL。这里的 ADD 是指将 fd 符加入到 epoll 中,此 fd 上注册的事件由 event 参数控制。
我发现有人将此 ADD 理解为添加事件,比如之前注册了读时间,现在想要再注册个可写事件,此时就使用 EPOLL_CTL_ADD,并且在 event 中只添加一个 EPOLLOUT 事件,这是错误的想法。此处 ADD 是指添加 fd 到 epoll 中。如果一个文件描述符已经加入到 epoll 中了,想要修改注册的事件,此时需要使用 EPOLL_CTL_MOD。无论是 EPOLL_CTL_ADD 还是 EPOLL_CTL_MOD,在调用时都需要传入目前所关注的全部事件。
为了决定是该使用 EPOLL_CTL_ADD 还是 EPOLL_CTL_MOD,通常我们需要使用一个与 fd 关联的变量,用此变量来记录目前此 fd 上已经注册的事件,并以此决定该使用 ADD 还是 MOD。
epoll_wait
添加了订阅之后,你可以使用 epoll_wait 来等待事件发生。这个函数会阻塞进程,直到有事件发生或者超时,它的用法如下:
// 等待事件发生
struct epoll_event events[MAX_EVENTS];
int num_events = epoll_wait(efd, events, MAX_EVENTS, -1);
if (num_events == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
// 处理事件
for (int i = 0; i < num_events; i++) {
if (events[i].data.fd == fd) {
// 处理事件...
}
}
当有订阅的事件发生后,epoll_wait 会返回,其返回值为发生的事件数量,发生的事件信息会被填充到 events 数组中。你可以遍历这个数组来处理每个事件。
初次看到多路复用这个名词时,我想到之前学习过的通信原理,感觉像是把多个信号通过调制编码后使用同一个信道来传输。在这里为什么用多路复用这个词呢?观察上面> 的例子,此前要想读取一个 socket,需要使用
recv阻塞地读取它,要同时读取多个 socket 就需要在多个线程中阻塞地读取,之前每一个 socket 都是一个阻塞点,而现在只有一个阻塞点,那就是epoll_wait。如下图所示,使用 epoll 来监听多个 socket 的状态,上层应用只需要关注 epoll 就可以了。这大概就是 IO 多路复用名称的来源吧。
epoll 的工作原理
使用阻塞式 IO 读 socket 时,如果 socket 上没有数据可读,这个 read 操作就会阻塞。阻塞的本质是操作系统将当前线程设置为挂起状态,并且等待该 socket 上有数据可读。当有数据可读后,首先网卡驱动会将数据读入,并中断通知操作系统。操作系统发现有数据可读了,此时会唤醒阻塞在 socket 上的进程。
char buffer[1024];
int n = read(fd, buffer, sizeof(buffer));
为了避免阻塞,我们可以仅在有数据可读时才执行 read 操作,什么时候有数据可读,这就需要使用 epoll 提供的通知机制。这一节将描述 epoll 是如何实现这种通知机制的。
Linux 内核中 epoll 被抽象为一个文件,epoll_create 返回的是一个文件描述符,而与该文件关联的 eventpoll 对象是 epoll 的核心,eventpoll 的主要字段如下:
/*
* This structure is stored inside the "private_data" member of the file
* structure and represents the main data structure for the eventpoll
* interface.
*/
struct eventpoll {
/* List of ready file descriptors */
struct list_head rdllist;
/* RB tree root used to store monitored fd structs */
struct rb_root_cached rbr;
};
Linux 中对文件定义了一个 poll 方法,任何抽象为文件的资源都需要实现此方法。
在使用 epoll_ctl 添加订阅时,epoll 会在该 fd 上执行 poll 操作,执行 poll 操作时会做两件事情:
- 返回当前 TCP 连接上的状态(可读、可写、错误等等)
- 在这个 fd 上注册一个回调,当文件的状态变化时会调用此回调
之后该 fd 会被添加到 eventpoll::rbr 所表示的红黑树中。如果执行 poll 后得到的状态和订阅的事件匹配,则将此 fd 加入到 eventpoll::rdllist 所表示的就绪队列中。同时,后续当文件的状态变化后,都会调用 epoll 注册的对调,在回调中会再次检查文件的状态,如果和当前该文件上订阅的事件匹配,也会将其加入到 eventpoll::rdllist 中。
在调用 epoll_wait 的时候,会立刻检查 eventpoll::rdllist 中是否有已经发生的事件,如果有则将 eventpoll::rdllist 中的事件拷贝到 epoll_wait 第二个参数 events 中。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
如果没有就绪的事件,则会将当前进程挂起,并等待唤醒。在 epoll_wait 调用被挂起阶段,如果订阅的某个文件上状态有变化,则会调用此前传入的回调,在回调函数中,会判断是否和订阅的事件匹配,如果是将此文件描述符加入就绪列表 eventpoll::rdllist 中,并唤醒阻塞在 epoll_wait 上的进程。
抛开很多细节来看,epoll 的实现原理是比较容易理解的,核心就是通过注册回调,在文件读写状态变化时执行此回调,并收集就绪的事件,然后在调用 epoll_wait 时就绪的事件返回给上层应用。
边缘触发与水平触发
在 I/O 多路复用中,存在两种触发模式,即水平触发(Level Trigger)、边缘触发(Edge Trigger)。初次看到这两个词的时候,挺莫名其妙的,这好像是电子领域的两个词。
以前参与电子制作时,常常需要使用传感器,控制器要采集传感器的数据,会使用外部中断。把传感器的输出接到控制器的某个引脚上,传感器有变化时,输出的电压就会变化,此时就会触发控制器的中断。在设置中断的时候,常常会涉及到水平触发(高/低电平触发)或者边缘触发(上升沿/下降沿触发)。如高电平触发时,给中断引脚输入一个高电平时,就会触发中断。上升沿触发,输入电平由低电平变为高电平的时会触发中断。电平触发模式下,比如高电平触发,当高电平存在时,就会一直触发,直到高电平消失,或关闭了中断。而边缘触发模型下,如上升沿触发,电平状态由低电平变为高电平时会触发。
回到 I/O 多路复用模型的水平触发和边缘触发上,这里没有高低电平的说法,也没有上升沿下降沿的说法,但我觉得它是借鉴了电子领域的术语。
水平触发
如果 fd 可读或可写,这个时候就会触发通知。如果这种状态一直持续(数据未读完、还可以写数据),那么就会不断地触发。在读取的场景下,使用水平触发模式,如果没有一次性读完,那么再次调用 epoll_wait 该 fd 上可读事件还会再次触发。epoll 默认采用水平触发。
对于写场景,如果可写事件触发,说明当前最少可写一个字符。如果待写入的数据比较多,且分散在多个 buffer 中,执行 write 后需要关注返回值,一旦发现写入数据量比传入的少,说明 socket 输出缓冲区写满了,如果再次调用 write 就会阻塞。这种情况可以考虑使用 writev 来执行写操作,以避免多次调用 write。
边缘触发
边缘触发,显然就是文件描述的状态发生了改变,比如从不可读变成了可读,或者从不可写变成了可写,这个时候会触发通知。比如一个读取场景,如果文件描述符上有数据时,会收到通知,如果数据没有读完,将不会再次收到通知。直到有新数据写入时,才会再次触发通知。因此,在使用边缘触发模式读取数据时,要一次性把可读的数据都读完,否则在下次收到数据之前,epoll_wait 不会再返回该描述符。
因为需要一次性把所有可读的数据全部读出来,因此在读取数据的时候,往往要在一个循环中不断读取。这种场景下,判断读取完毕的一种可能的依据是缓冲区是否读满,比如接收缓存区是 100 字节,而只读取到了 60 字节,那说明已经读完了。但是如果恰好有 100 字节可读,此时会觉得还存在数据,其实已经没有数据可读了。再次读取的时候,由于没数据可读,因此就会阻塞。因此这种方法并不可靠。
在使用边缘触发模式时,通常采用非阻塞模式进行读取。非阻塞模式下,如果当前没有数据可读,read 会立刻返回,返回值为 -1,并设置 errno 为 EAGAIN 或者 EWOULDBLOCK,此时就可以跳出循环。
事件触发时机
在 epoll_ctl 的文档中,描述了很多事件类型,在 epoll_wait 返回后,需要处理每一个就绪的事件,理解各种事件意味着什么,这对正确使用 epoll 有很大作用。
时间是在执行 poll 时设置的,因此想要知道事件何时触发,可以通过阅读 net/ipv4/tcp.c 中的 tcp_poll 这个函数。
EPOLLIN
- 接收缓冲区数据字节数达到低水位标记,默认低水位是 1。
- 接收端端被关闭时会触发,可能是主动关闭了接收端,或者对端关闭了写入端
- 对于一个监听套接字,如果已经完成的连接数大于 0,则会触发此事件
相关源码:
if (shutdown & RCV_SHUTDOWN)
mask |= EPOLLIN | EPOLLRDNORM | EPOLLRDHUP;
if (tcp_stream_is_readable(sk, target))
mask |= EPOLLIN | EPOLLRDNORM;
static inline __poll_t inet_csk_listen_poll(const struct sock *sk)
{
return !reqsk_queue_empty(&inet_csk(sk)->icsk_accept_queue) ?
(EPOLLIN | EPOLLRDNORM) : 0;
}
水平触发模式下,只要尚有数据在输入缓冲区中,则此事件会一直触发。边缘触发模式下,读过一次后,即使还有数据可读,也不会触发了。但如果将 EPOLLIN 事件删除,然后再次注册,若此时有数据可读,则事件会触发,因为再次注册的时候又会检查一下当前文件的状态。
有时,可能希望对客户端流量做限流,读取到一定数据量后,服务端就主动停止读取。此时如果客户端不再发送新数据,EPOLLIN 就一直不会触发。比如客户端一次性发送了 10 个请求,而服务端接收了 4 个,希望待这 4 个请求请求处理完了后,再处理后续的 6 个。但客户端一直在等待 10 个请求的响应,因此不会再发送数据过来。这种场景下,服务端可以在处理完 4 个请求后,删除掉 EPOLLIN 事件并重新注册,这样就会再次触发 EPOLLIN 事件了。
EPOLLOUT
- 发送缓冲区有空间的时候触发(空间达到低水位线(默认1字节))。
- 套接字写端关闭,此时执行 write 会得到 EPIPE 错误。
- 非阻塞地执行 connect 操作的套接字,已经发送 SYN 后会触发此事件,此时可以立刻发送数据。
相关源码:
if (!(shutdown & SEND_SHUTDOWN)) {
if (__sk_stream_is_writeable(sk, 1)) {
mask |= EPOLLOUT | EPOLLWRNORM;
}
} else
mask |= EPOLLOUT | EPOLLWRNORM;
if (state == TCP_SYN_SENT && inet_test_bit(DEFER_CONNECT, sk)) {
/* Active TCP fastopen socket with defer_connect
* Return EPOLLOUT so application can call write()
* in order for kernel to generate SYN+data
*/
mask |= EPOLLOUT | EPOLLWRNORM;
}
如果触发模式是 ET,当执行 write 后,如果依然可写,下一轮会再次触发 EPOLLOUT。如果某次触发后没有执行写入操作,EPOLLOUT 会停止触发。
如果使用水平触发,常态下不应该注册 EPOLLOUT 事件,因为发送数据较少时,socket 通常都是可以发送数据的,所以一旦注册了这个事件,此事件就会不断地触发。使用边缘触发,也不应该一直注册着可写事件,因为执行写操作后会触发 EPOLLOUT,但通常我们第一次触发的时候就把数据写完了,再次触发完全没有必要。
初次注册 EPOLLOUT 的时候,因为 socket 通常是可以发送数据的,此时 EPOLLOUT 会立刻触发。因此,接受到新的连接后,如果没有数据要立刻发送,不应该在接收到连接后就立刻注册 EPOLLOUT。
EPOLLRDHUP
- 当接收端被关闭时会触发该事件,这包含多种情况:
- 对端调用
shutdown(fd, SHUT_WR)关闭写端 - 或者执行 close 关闭读写两端
- 对端调用
- 其他会导致连接断开的情况都会触发:
- 网络断开了,同时启动了 tcp keepalive,会在 keepalive 超时后触发。
- 连接出现故障的时候会触发,比如超时、网络不可达等等。
相关源码:
if (shutdown & RCV_SHUTDOWN)
mask |= EPOLLIN | EPOLLRDNORM | EPOLLRDHUP;
EPOLLHUP
- 当读写两端都关闭时候会触发此事件
- 执行 close 关闭了连接(关闭连接时会 shutdown 读写两端)
相关源码:
shutdown = READ_ONCE(sk->sk_shutdown);
if (shutdown == SHUTDOWN_MASK || state == TCP_CLOSE)
mask |= EPOLLHUP;
需要注意的是,如果对端进程意外退出,或者对端宕机了,此时是不会触发 EPOLLHUP 的。前者会由对端操作系统发送 FIN,此时执行 read 会返回 0,表示收到了 EOF,这仅仅意味着对方不再发送数据了,并不意味着不再接收数据。
EPOLLERR
- 套接字出错的时候触发,如套接字超时出错、对端不可达、收到 RST
影响事件触发的标记
epoll 提供了几个标记,使用 epoll_ctl 是使用这些标记可以影响事件触发的行为,这里做详细描述。
EPOLLET
如果设置了 EPOLLET,表示使用边缘触发模式。epoll 默认使用水平触发,在水平触发场景下,当一个事件触发后,该事件会被再次加入到就绪队列中。下次执行 epoll_wait 的时候,会发现就绪队列不为空,并对就绪队列中所有的文件再次做检查,如果与订阅的事件匹配,事件就会再次触发。
而设置了 EPOLLET 标记后,事件发生后,不会将其加入就绪队列,因此只有该文件状态变化后,触发回调后,该事件才会再次发生。
相关源码:
else if (!(epi->event.events & EPOLLET)) {
/*
* If this file has been added with Level
* Trigger mode, we need to insert back inside
* the ready list, so that the next call to
* epoll_wait() will check again the events
* availability. At this point, no one can insert
* into ep->rdllist besides us. The epoll_ctl()
* callers are locked out by
* ep_scan_ready_list() holding "mtx" and the
* poll callback will queue them in ep->ovflist.
*/
list_add_tail(&epi->rdllink, &ep->rdllist);
ep_pm_stay_awake(epi);
}
EPOLLONESHOT
在多线程场景下,你可能在一个线程中执行 epoll_wait,并使用线程池处理事件,那么当一个文件描述符上事件触发后,该文件描述符将会在一个线程中被处理。在此时如果该文件描述符上事件再次触发了,很可能被另外一个线程处理,这就导致一个文件被多个线程处理。
如果设置了 EPOLLONESHOT 标记,在事件触发后,epoll 会自动清除该文件描述符上注册的部分事件,仅保留如下 EP_PRIVATE_BITS 表示的事件。
#define EP_PRIVATE_BITS (EPOLLWAKEUP | EPOLLONESHOT | EPOLLET | EPOLLEXCLUSIVE)
if (epi->event.events & EPOLLONESHOT)
epi->event.events &= EP_PRIVATE_BITS;
这样以来,后续该文件描述符上就不会再次触发读写事件了。如果想要继续关注读写事件,需要再次注册。
EPOLLEXCLUSIVE
如果一个文件被添加到多个 epoll 中,当文件上事件发生后,每个 epoll 都会收到事件,如果设置了 EPOLLEXCLUSIVE,则只有一个 epoll 会得到通知。