Linux下的IO复用:epoll详解

一、 引入

在初学网络编程时,我们难免会遇到这样的问题,我们用最基本的socket函数编写出来的服务端程序往往只能同事处理一个客户端的连接,如果这时候我新开了第二个客户端程序,就无法connect()到该服务端的listen()上,除非第一个客户端程序断开连接,但在这个连接的生命周期中,绝大部分时间都是空闲的,活跃时间(发送数据和接收数据的时间)占比极少。
但是落实到实际的场景中,一个网站往往会接受到成千上万人的访问,这样的访问必定是同时进行的,但正常情况下人们又可以得到很好的响应,那这种情况到底是怎么处理的呢?
我们很容易想到多线程,即每一个客户端请求都交给一个线程或者进程去处理,但者很明显会遇到性能瓶颈,特别是当客户端数量非常庞大时。想象有1000个连接同时到达的情况,难道还有开1000个线程去处理吗。
这时候就要用到一个概念,IO复用。IO复用是通过一个进程或者线程去管理多个客户端的请求的技术,而不需要为每个连接创建一个单独的线程。linux提供了select、poll、epoll三种io复用方式。

二、 selectpollepoll:逐步升级

selectpollepoll 三者之间,主要的差异体现在它们如何处理大量文件描述符(即客户端连接)。我们从最原始的 select 开始,逐步解释 pollepoll 的优点。

a) select 机制的工作原理

select 是最早的 I/O 多路复用机制,它的工作方式简单但有明显的性能问题。它的基本流程是:

  • 你告诉操作系统(内核),你感兴趣的文件描述符(如socket)有哪些。
  • 然后,内核会检查这些文件描述符,看看哪些是就绪的(有数据可以读或写),并将这些就绪的文件描述符返回给应用程序。

在实现上,select 会用一个位图来表示文件描述符,它会检查所有感兴趣的文件描述符。假设你有一个非常大的连接池,select 每次调用时,都要检查所有连接,看看它们是否有事件(如读或写)发生,这样做的效率很低,尤其是当连接数达到几千、几万时,性能瓶颈就显现出来了。

select 的典型函数签名:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • nfds:指定 fd_set 中最大的文件描述符加1(即文件描述符的数量)。
  • readfds:感兴趣的可读文件描述符集合。
  • writefds:感兴趣的可写文件描述符集合。
  • exceptfds:异常文件描述符集合(很少用到)。
  • timeout:超时时间,设置为 NULL 表示阻塞直到事件发生。
b) poll 机制的工作原理

poll 的出现是为了弥补 select 的不足,特别是它能够处理任意数量的文件描述符,因为 select 受限于 FD_SETSIZE 的大小。

poll 中,我们使用链表来存储文件描述符,每个文件描述符都有一个事件标志,内核会根据这些标志来判断事件是否就绪。与 select 不同的是,poll 不需要扫描位图,而是扫描链表,因此它可以支持更大的文件描述符集合,但同样的问题在于每次调用时,内核还是需要遍历所有的文件描述符,效率依然不高。

poll 的函数签名:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds:一个 pollfd 结构体数组,表示所有感兴趣的文件描述符。
  • nfds:文件描述符的数量。
  • timeout:最大等待时间,单位是毫秒,设置为 -1 表示阻塞等待。

每个 pollfd 结构体如下:

struct pollfd {
    int fd;         // 文件描述符
    short events;   // 感兴趣的事件
    short revents;  // 发生的事件
};
c) epoll 机制的工作原理

epoll 是专门为高并发设计的,相比于 selectpoll,它不再是遍历所有的文件描述符,而是采用了更高效的机制。epoll 使用了 红黑树双向链表 来管理文件描述符,因此它能够做到以下几点:

  • 只关注事件:当有事件发生时,epoll 会返回所有就绪的文件描述符,不需要扫描所有的文件描述符。
  • 高效的事件通知:当文件描述符有事件发生时,epoll 会通过回调机制及时通知应用程序,避免了重复通知。

selectpoll 的不同之处在于,epoll 并不需要轮询所有事件,而是通过事件驱动方式,只有发生变化的事件会被自动返回给 epoll

Epoll 事件处理流程
  1. 创建 epoll 实例,获取 epfd
  2. 使用 epoll_ctl() 注册事件。
  3. 使用 epoll_wait() 等待事件的发生。
  4. 处理事件(包括接受新的连接、读取数据等)。
  5. 删除已处理的事件 或继续监听。
Epoll 伪代码
// 设置 socket 非阻塞,因为之后要使用 ET 模式
setNoblocking(sockfd);

// 创建 epoll 实例
int epfd = epoll_create1(0);

// 注册服务器 socket 事件
struct epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN; // 关注可读事件
ev.data.fd = sockfd; // 关联服务器 socket
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); // 添加 socket 到 epoll 

// 循环等待事件并处理
while (true) {
    nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); // 阻塞直到有事件发生
    // 遍历发生的事件
    for (int i = 0; i < nfds; i++) {
        if (events[i].data.fd == sockfd) {
            // 如果事件发生在服务器 socket 上,表示有新客户端连接
            int client_fd = accept(sockfd, NULL, NULL);
            // 设置非阻塞
            setnonblocking(client_fd)

            // 注册客户端 socket 事件
            ev.events = EPOLLIN | EPOLLET; // 关注可读事件并启用边缘触发(ET)
            ev.data.fd = client_fd;
            epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

            printf("New client connected: fd %d\n", client_fd);
        } else if (events[i].events & EPOLLIN) {
            // 如果是客户端 socket 上发生了可读事件
            handle_client(events[i].data.fd); // 处理该客户端的请求
        } else {
            // 处理其他逻辑
        }
    }
}

三、epoll 函数接口详解

epoll 的主要接口有 3 个:epoll_create1()epoll_ctl()epoll_wait()。当然还有一个非常重要的结构体epoll_event,下面就让我来以此介绍:

1. epoll_create1()

epoll_create1() 用于创建一个 epoll 实例,并返回一个文件描述符。通过这个文件描述符,程序可以管理需要监控的事件。

函数原型:

int epoll_create1(int flags); // epoll_create(int num) 已经废弃

参数:

  • flags:控制 epoll 行为的标志,通常设置为 0,但有一个有效的标志是 EPOLL_CLOEXEC,用于设置在 exec() 调用时关闭文件描述符。
    • EPOLL_CLOEXEC:当执行 exec() 时,自动关闭文件描述符。通常用于避免不必要的文件描述符在新进程中泄漏。

返回值:

  • 成功时,返回 epoll 实例的文件描述符。
  • 失败时,返回 -1,并设置 errno

使用流程:

  1. 调用 epoll_create1() 创建一个 epoll 实例,得到 epfdepoll 文件描述符)。
  2. 之后,使用 epfd 进行对事件的添加、删除和监控。

例子:

int epfd = epoll_create1(0);
//一个非常经典的错误处理代码段,自己实际开发时可以将其打包成一个函数
if (epfd == -1) {
    perror("epoll_create1");
    exit(EXIT_FAILURE);
}
2 struct epoll_event 结构体

struct epoll_eventepoll 的核心数据结构之一,它用于表示一个文件描述符的事件信息。通过它,我们可以指定要监听的事件类型,并存储一些用户自定义的数据(通常是文件描述符或者指针)。

结构体定义:

struct epoll_event {
    uint32_t events;      // 事件类型,表示关注的 I/O 事件
    epoll_data_t data;    // 用户数据,关联文件描述符或者其他自定义信息
};

1. events 字段(事件类型)
events 字段用于指定文件描述符(FD)感兴趣的事件类型,它是一个位掩码,支持多个事件的组合。常用的事件类型包括:

  • EPOLLIN:表示文件描述符可读,通常用于监控 socket 是否有数据可读取。
  • EPOLLOUT:表示文件描述符可写,通常用于监控 socket 是否可写。
  • EPOLLERR:表示文件描述符出现错误。
  • EPOLLHUP:表示文件描述符被挂起(如连接断开等)。
  • EPOLLET:边缘触发模式(Edge Triggered),设置该标志后,epoll 将在事件的状态变化时触发通知。
  • EPOLLONESHOT:单次触发模式。事件触发后,将会删除该文件描述符的事件,直到重新注册。

事件类型的组合events 字段支持位掩码操作,你可以同时监控多个事件。例如,你可以同时关注可读和可写事件:

ev.events = EPOLLIN | EPOLLOUT;  // 同时监听可读和可写事件

2. data 字段(用户数据)
data 字段是一个联合体(union),它允许你存储各种类型的数据。data 字段用于存储与文件描述符关联的用户自定义数据,通常是文件描述符本身,或者是指向结构体的指针。

epoll_data_t 定义:
union的使用方法请自行搜索

typedef union epoll_data {
    void *ptr;   // 用户数据,可以是一个指针
    int fd;      // 文件描述符,常用于存储事件关联的 fd
    uint32_t u32; // 无符号 32 位整数,用户自定义类型
    uint64_t u64; // 无符号 64 位整数,用户自定义类型
} epoll_data_t;

data 字段的四个可能类型:

  • ptr:指向一个结构体或数据的指针,可以用来传递更多的信息。
  • fd:用于存储文件描述符,通常与 epoll 事件相关联的文件描述符会被存储在此。
  • u32:一个 32 位无符号整数,适用于存储一些简单的 ID 或状态码等。
  • u64:一个 64 位无符号整数,适用于存储一些大范围的数字数据。
如何赋值和使用 struct epoll_event
示例:监听单一事件

假设你想监听一个 socket 是否有数据可读,即 EPOLLIN

struct epoll_event ev;
ev.events = EPOLLIN;       // 关注可读事件
ev.data.fd = sockfd;      // 关联文件描述符 sockfd
示例:监听多个事件

如果你希望同时监听某个 socket 的可读和可写事件:

struct epoll_event ev;
ev.events = EPOLLIN | EPOLLOUT | EPOLLET; // 同时监听可读、可写事件,并启用边缘触发模式
ev.data.fd = sockfd;             // 关联文件描述符 sockfd

3. epoll_ctl()

epoll_ctl() 用于向 epoll 实例中添加、修改或删除文件描述符的监控事件。

函数原型:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数:

  • epfd:由 epoll_create1() 返回的 epoll 文件描述符。
  • op:操作类型,指定是添加(EPOLL_CTL_ADD)、修改(EPOLL_CTL_MOD)还是删除(EPOLL_CTL_DEL)监控事件。
  • fd:需要监控的文件描述符(如客户端的 socket、服务器的监听 socket 等)。
  • event:指向一个 struct epoll_event 的指针,描述要监控的事件以及附带的用户数据。

4. epoll_wait()

epoll_wait() 是事件等待的核心函数,用于获取发生的事件,通常被阻塞在这里,直到有事件发生。

函数原型:

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数:

  • epfd:由 epoll_create1() 返回的 epoll 文件描述符。
  • events:指向 epoll_event 数组的指针,用于保存发生事件的文件描述符。
  • maxeventsevents 数组的大小。即每次最多返回多少个事件。
  • timeout:等待时间,单位是毫秒。如果设置为 -1,表示无限期等待;设置为 0,表示非阻塞;设置为正值表示等待一定时间。

返回值:

  • 成功时,返回发生的事件个数。
  • 失败时,返回 -1,并设置 errno

使用流程:

  1. 调用 epoll_wait() 等待事件发生。
  2. 内核检查文件描述符的事件,如果有事件发生,将相关的事件返回给应用程序。
  3. 根据返回的事件(例如 EPOLLINEPOLLOUT 等),应用程序执行相应的操作。

例子:

struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);  // 阻塞等待事件发生
if (nfds == -1) {
    perror("epoll_wait");
    exit(EXIT_FAILURE);
}
for (int i = 0; i < nfds; i++) {
    // 处理事件
}

四、 epoll 的事件触发模式

epoll 支持两种事件触发模式:

  • 水平触发(LT, Level Triggered)
  • 边缘触发(ET, Edge Triggered)

对于触发模式我们只需要记住两者的区别即可,需要注意的是边缘触发模式需要一次性读取所有到达的数据,这些需要你在 handle_client()中自己实现。编程并不会难很多

1. 水平触发(LT, Level Triggered)

水平触发epoll 的默认模式。在这种模式下,只要文件描述符上有事件发生(例如,数据可读),epoll_wait() 就会返回该事件,并持续返回,直到你对该文件描述符执行了相关操作(如 read()write())来消耗或处理事件。

  • 工作原理:当事件发生时,epoll 会返回事件,并等待你处理。若数据未被完全读取,它将继续返回该文件描述符的可读事件,直到你读取完所有的数据。
  • 优点:编程简单,因为每次 epoll_wait() 返回事件时,你只需对返回的文件描述符进行相应的处理,处理完后就可以清除该事件,等待下一个事件。
  • 缺点:可能会导致重复通知,尤其在高并发场景中。如果你只读取部分数据,epoll 会一直通知你有可读数据,直到你将所有数据读取完,这会导致性能下降。

适用场景:水平触发模式适合大多数应用,尤其是当你希望简单、直观地处理事件时。它对于那些对事件的处理可以按顺序、逐一完成的应用非常合适,如单一任务的客户端-服务器通信。

2. 边缘触发(ET, Edge Triggered)

边缘触发是相对于水平触发的更高效的模式。它的特点是只在文件描述符的状态发生变化时才通知应用程序。具体来说,当文件描述符从不可读变为可读时,epoll 会通知你一次。之后,除非文件描述符变得不可读,epoll 不会再通知你。

  • 工作原理:当事件从“无事件”变为“有事件”时,epoll 会触发一次通知。换句话说,当数据变得可读时,epoll 会通知你一次,但在数据还未被读取之前,它不会再次通知你,直到数据变为不可读状态。
  • 优点:减少了重复通知,适用于高并发应用中。特别是在高并发时,边缘触发模式显著降低了不必要的事件轮询,提高了性能和资源的利用率。
  • 缺点:编程稍微复杂,因为你需要在一次事件通知时尽可能读取所有的数据。如果读取不完全,可能会错过后续的事件通知。另外,边缘触发模式要求你使用非阻塞IO,否则可能会错过事件的通知。

适用场景:边缘触发非常适合那些需要高效处理大量连接的应用,比如高并发的Web服务器或实时数据处理应用。对于这些应用,减少重复通知和提高事件处理的效率是非常关键的。

3. 设置触发模式

在使用 epoll 时,可以在注册事件时通过 epoll_ctl() 来设置触发模式。通常情况下,水平触发是默认模式,而边缘触发模式需要显式指定。

设置为边缘触发模式时,ev.events 需要包含 EPOLLET 标志,例如:

ev.events = EPOLLIN | EPOLLET;  // 设置为边缘触发模式(ET)

如果不希望使用边缘触发模式,只需忽略 EPOLLET 标志,epoll 默认会使用水平触发模式。

注意事项:

  • 在使用边缘触发模式时,必须确保文件描述符设置为非阻塞模式(O_NONBLOCK),否则可能会导致程序陷入死锁,无法正确处理事件。
  • 使用边缘触发时,应该一次性读取所有可用数据,否则下次事件不会再被触发,导致丢失数据。

你可能感兴趣的:(linux,网络,c++)