这篇文章上次修改于 408 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

IO多路复用——linux

需求

我们需要创建一个服务器,用于处理多方请求和回复,应用程序需要处理来自多个客户端的并发连接或通信。这可以是一个网络服务器,如Web服务器、聊天服务器、游戏服务器等,或者是一个文件服务器,需要同时处理多个文件读写请求。

核心函数

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

accept函数用于接收来自fd套接字的连接,当连接产生时,将会输出一个新fd用于接收数据。

recv函数用于接收来自fd套接字的数据,当数据存在时,将会读取出来。

问题

这两个函数可以很好的完成TCP连接和数据传输,但是这两个函数也有堵塞问题:

  • accept当没有连接的时候,将一直等待到出现连接,此时函数堵塞,程序堵塞。

  • recv当这个fd的缓存区没有内容,将会堵塞,一直等待到出现传输的数据内容。

一旦程序发生堵塞,将无法处理其他的连接问题,无法顾及到其他fd的数据。

为了一个程序同时处理多个I/O操作,而不会阻塞整个程序,提出了I/O多路复用

我们的主要需求就是解决acceptrecv的堵塞情况

例如:

  1. accept在接受新的连接时堵塞,无法去recv已经建立连接传输过来的消息

  2. recv在等待接收新的传输时堵塞,无法去accept其他的新连接

可以使用多线程进行解决,但是多个fd需要多个线程,而且每次的检查都需要内核态和用户态的切换,需要很大的开销

介绍

I/O多路复用(Input/Output Multiplexing)是一种用于同时监视多个I/O操作的技术,以提高系统的资源利用率和响应性能。它允许一个单一的线程或进程管理多个I/O通道(如套接字、文件句柄等),而不需要为每个通道创建一个独立的线程。

对于不同的系统,他们给出了不同的系统调用用于支持不堵塞的方法

Linux下的I/O多路复用

Linux下提供了以下系统调用用于处理多路复用问题

selectpollepoll

select

select 系统调用最早出现在早期的Unix操作系统中。Unix 操作系统的历史可以追溯到 1960 年代末和 1970 年代初,而 select 系统调用首次出现在那个时期。具体来说,select 的最早版本可以追溯到 BSD Unix,即伯克利软件分发版本的 Unix,这是一个常见的 Unix 衍生版本。它于 1983 年发布,包括了 select 系统调用。select 最初用于实现套接字(socket)编程,以支持多客户端服务器应用程序的开发。

解决问题的原理

通过一次性检查多个fd,一次性返回有产生事件的fd,再集中处理这些fd

为了提高检查fd的效率,我们将检查fd这项任务交给内核态来完成。

多个fd包含了用于acceptfd 也包含了已经建立连接后的fd,这确保了他们不会因为等待某一个而使其他无法判断,一旦产生对应事件,则记录,否则则不记录。

提供的系统调用

select 的基本语法如下:

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • nfds 是要监听的最大文件描述符值加一,在select到这个位置就会结束
  • readfds 用于记录 需要监听 读事件 的多个fd
  • writefds 用于记录 需要监听 写事件 的多个fd
  • exceptfds 用于记录 需要监听 异常事件 的多个fd
  • timeout 是一个指定超时时间的结构,如果为 NULL,select 将一直阻塞直到有事件发生。

select 返回后,对应的传入为参数的fds会根据情况被修改,根据被修改的fds可以找到对应的,有事件的fd,运用相应函数进行处理。

fd_set位图数组

在C语言中,fd_set 通常是一个由宏定义和位操作函数组成的数据结构,用于设置、清除和测试文件描述符的状态。也就是多个01,例如:100个fd, 我们可以用100个位(bit)来记录是否有事件。

它通常包括以下三个宏定义:

  1. FD_ZERO(fd_set *set):将 set 中的所有位都清零。
  2. FD_SET(int fd, fd_set *set):将 set 中的第fd位置1
  3. FD_CLR(int fd, fd_set *set):将 set 中的第fd位置0
  4. FD_ISSET(int fd, fd_set *set):查看set 中的第fd位置是否为1

其中,在大多数系统中,FD_SETSIZE 的默认值是 1024,这意味着一个 fd_set 数据结构最多可以包含 1024 个文件描述符。

参数解读

readfds writefds exceptfds

当为参数时,第fd位为1代表监听该fd的对应事件,第fd位为0代表不监听该fd的对应事件。

当为结果时,第fd位为1代表该fd的对应事件触发,第fd位为0代表该fd的对应事件没触发。

代码解释

while(1){
    // 初始化rset
    FD_ZERO(&rset);
    for (int i = 0; i< 5; i++ ) {
        FD_SET(fds[i], &rset); // fds是一个int数组,存储了要监听的fd
    }
    
    // 此时rset存放了我们要监听的fd,第fd位都为1,其他为0
    puts("round again");
    select(max+1, &rset, NULL, NULL, NULL); // 进行select操作
    // 此时rset存放了监听的fd是否发生事件的结果,发生了的fd,第fd位都为1,其他为0
    
    for(i = 0; i < 5;i++) {
        if (FD_ISSET(fds[i], &rset)){ // 判断是否发生事件
            fmemset(buffer, 0, MAXBUF); // 处理fd的读取
            read(fds[i], buffer, MAXBUF);
            puts(buffer);
    	}
    }
}

select总结

select解决了基本的多路复用问题,但仍有不足

  • FD_SETSIZE设置了fd_set的大小,一般不会更改,导致监听的最大的fd序号必须小于1024
  • fd_set同时作为输入和结果,导致fd_set不可复用,每次都要重新初始化一个fd_set
  • select在传入rset时候,将该fd_set从用户态切换到内核态,仍然有一定的开销
  • 产生结果的时候,我们要遍历一遍fd_set,这有O(n)的复杂度
  • select还是一个堵塞函数,在所有的fd都没有事件监听到的时候,会堵塞至有一个变化为止

poll

poll 最早由 Doug McIlroy 于 1986 年提出,并最早实现于 4.3BSD Unix 操作系统中。Doug McIlroy是 Unix 操作系统的早期开发者之一,他提出了 poll 来替代 select 函数,以解决 select 在文件描述符限制方面的一些不足之处。随后,poll 函数成为 POSIX 标准的一部分,因此在支持 POSIX 标准的各种 Unix 和 Unix-like 操作系统上都可以使用。

解决问题的原理

select很相似,同样是一次性处理多个fd,但poll主要替换了fd_set,使用了自己的结构体,具体流程就是,建立一个自己的结构体的数组,负责输入和输出。

提供的系统调用

poll的基本语法如下:

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  1. fds:一个 pollfd 数组的指针,其中包含了要监视的fd以及它们的监视事件。
  2. nfdsfds 数组的大小,即要监视的文件描述符数量。
  3. timeout:指定超时时间,以毫秒为单位。有以下几种情况:
    • 正数:表示等待多少毫秒后超时返回,或者直到有fd准备就绪为止。
    • 0:表示立即返回,不等待。
    • -1:表示一直等待,直到有文件描述符准备就绪。

结构体pollfd

struct pollfd {
    int fd;
    short events;
    short revents;
};

结构包含三字段:

  • fd:文件描述符,即要监视的句柄。
  • events:表示要监视的事件类型,可以使用以下标志的组合:
    • POLLIN:可读事件。
    • POLLOUT:可写事件。
    • POLLERR:错误事件。
    • POLLHUP:挂起事件。
    • POLLNVAL:无效事件。
  • revents:在 poll 调用返回后,内核会修改此字段,表示实际发生的事件。

代码解读

for (int i = 0; i < 5; i++){
    memset(&client, 0, sizeof (client));
    addrlen = sizeof(client);
    pollfds[i].fd = accept(sockfd, (struct sockaddr*)&client, &addrlen); // 获取fd
    pollfds[i].events = POLLIN; // 设置该pollfd监听可读事件
} // 建立多个 fd 创建pollfds数组
// 准备完毕
while(1){
    puts("round again");
    // 处理pollfds,所有的pollfds的revents都是0
    poll(pollfds, 5, 50000);
    // poll完成,pollfds中的revents,若有此事件,则会被设置为此事件
    
    for(int i = 0, i < 5; i++){
        if(pollfds[i].revents & POLLIN){ // 判断pollfds[i] 是否发生了 POLLIN 事件
            pollfds[i].revents = 0; // 恢复状态
            // 开始处理和接受处理数据
            memset(buffer, 0, MAXBUF); 
            read(pollfds[i].fd, buffer, MAXBUF);
            puts(buffer);
        }
    }
}

poll总结

poll的实现思路和select几乎一样,不同之处就是不使用位图数组使用了新的结构体pollfd,相比原来的用一个位(bit)表示fd状态到一个pollfd表示fd状态。可以使得fd有自己的需要检查事件的输入和结果事件的区分。

解决的不足:

  • 不会被限定fd大小限制,使用了自己结构体的数组
  • 可以复用pollfds,不需要重新创建
  • poll可以通过设置timeout为0就不再堵塞

未解决的不足:

  • poll在传入pollfds时候,将该pollfds从用户态切换到内核态,仍然有一定的开销
  • 产生结果的时候,我们要遍历一遍pollfds,这有O(n)的复杂度

epoll

epoll 最早由Ingo Molnár在2002年提出,并被引入到Linux内核中。它的设计和实现使得它适用于高性能网络服务器等需要处理大量并发连接的应用。相比于selectpollepoll的性能更好,因为它使用了基于事件的模型,而不是每次调用时都扫描整个文件描述符集合。

解决问题的原理

创建了一个epfd实例高效管理多个事件的添加删除查找等管理,epfd实例实际上使用了红黑树双向链表以及哈希表,一旦fd的事件发生,内核会将其在红黑树上标记,并将其转移到一个储存完成事件fd双向链表

创建一个epoll_event结构体数组,快速得到发生事件的多个fd,进行处理。

提供的系统调用

int epoll_create(int size);

size(已被忽略):表示epoll实例的大小,以前用于指定epoll实例能够管理的文件描述符的数量。现代的epoll实现已经不再受此限制,所以可以将size设置为0。

epoll_create() 返回一个整数,这个整数是epoll实例的文件描述符(通常称为epfd,表示 epoll file descriptor)。即该epfd代表了这个epoll实例,可以在后续的函数中使用。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • epfd:是 epoll 实例的文件描述符,即之前通过 epoll_create() 创建的文件描述符。
  • op:表示操作类型,可以是以下值之一:
    • EPOLL_CTL_ADD:将文件描述符 fd 注册到 epoll 实例中,并关联 event 中指定的事件。
    • EPOLL_CTL_MOD:修改文件描述符 fdepoll 实例中的关联事件为 event 中指定的新事件。
    • EPOLL_CTL_DEL:从 epoll 实例中删除文件描述符 fd
  • fd:是要注册、修改或删除的文件描述符。(不同于epfd)
  • event:是一个指向 struct epoll_event 结构的指针,它包含了要注册的事件类型和相关的数据。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • epfd:是 epoll 实例的文件描述符,即之前通过 epoll_create() 创建的文件描述符。
  • events:是一个指向 struct epoll_event 结构数组的指针,用于存储已发生事件的信息。(储存结果)
  • maxevents:表示 events 数组的最大容量,即数组中能够存储的最大事件数量。
  • timeout:指定 epoll_wait() 的超时时间,以毫秒为单位。有以下几种情况:
    • 如果 timeout 为正数,表示等待事件发生,最多等待 timeout 毫秒,如果在这段时间内有事件发生,epoll_wait() 会立即返回。
    • 如果 timeout 为零,表示非阻塞地检查事件,如果没有事件发生,立即返回。
    • 如果 timeout 为负数,表示一直等待,直到有事件发生才返回。

结构体epoll_event以及epoll实例对象

struct epoll_event {
    __uint32_t events;  // 表示感兴趣的事件类型(如 EPOLLIN、EPOLLOUT 等)
    epoll_data_t data;  // 用户数据,通常是一个指针,用于标识或关联事件
};

typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;

通常让data处于fd状态储存要监听的fd

epoll_create创建了一个epoll实例对象,它有众多特性

  • 高效的事件管理epoll 使用内部数据结构(红黑树和双向链表)来管理感兴趣的文件描述符,以及跟踪已就绪的事件。这使得 epoll 能够高效地处理大量并发事件,而不需要遍历所有文件描述符。

  • 支持大量文件描述符epoll 可以轻松地管理大量文件描述符,因此适用于需要同时处理大量客户端连接的高并发网络应用。

  • 事件驱动epoll 是事件驱动的,它通常不需要轮询文件描述符,而是等待事件的发生并在事件就绪时通知应用程序。这减少了不必要的 CPU 消耗。

实例对象创建了红黑树,双向链表

红黑树用于储存事件和fd的对应关系,可以快速找到fd和事件,以及快速增加删除。每个fd事件在发生后,会自动转移到双向链表中,等待epoll_wait提取,这是它的事件驱动特性。这样的话,就不需要轮询红黑树中的所有fd只要轮询这个双向链表。使用了红黑树的数据结构,方便epoll_ctl对其实例进行各种修改。

使用双向链表,支持边缘触发(Edge-Triggered)epoll可以以边缘触发或水平触发模式工作。在边缘触发模式下,已就绪事件只会通知一次,而不会在事件未被处理时重复通知。使用双向链表可以更好地支持这种模式,因为已就绪事件会从链表中移除,确保不会重复通知。

边缘触发和水平触发

  1. 水平触发(Level-Triggered)
    • 水平触发是默认的触发模式,在文件描述符上的事件处于就绪状态时通知应用程序。
    • 当应用程序收到通知后,它可以不立即处理部分事件,然后等待下一次通知。
  2. 边缘触发(Edge-Triggered)
    • 边缘触发是一种更严格的触发模式,它只在文件描述符上的状态变化时通知应用程序,而不是在每次事件都通知。
    • 当应用程序收到边缘触发通知后,它需要立即处理事件,否则可能错过事件。边缘触发模式要求应用程序在每个通知时处理尽可能多的事件,以确保不会错过任何事件。
    • 边缘触发模式通常需要更多的注意和更复杂的事件处理逻辑,但可以提供更高的性能,特别是在高并发的情况下。

边缘触发和水平触发在以下方面有不同之处:

  • 通知粒度:水平触发通知任何时刻文件描述符上的就绪事件,而边缘触发只在状态变化时通知,即从未就绪到就绪或从就绪到未就绪。
  • 处理要求:边缘触发要求应用程序能够快速响应并处理事件,否则可能错过事件,而水平触发允许应用程序选择何时处理事件。
  • 性能和效率:边缘触发通常更高效,因为它避免了重复的事件通知。然而,它需要更谨慎的事件处理。

代码解读

struct epoll_event events[5]; // 创建epoll_event结构体数组,用于存储拷贝出来的已完成事件
int epfd = epoll_create(10); // 创建epoll示例对象,此处的10已经没有什么意义了
...
...
for (i=0;1<5;i++)
{
    static struct epoll_event ev;
    memset(&client, 0, sizeof(client));
    addrlen= sizeof(client);
    ev.data.fd = accept(sockfd, (struct sockaddr*)&client, &addrlen);
    ev.events= EPOLLIN; // ev.events = EPOLLIN | EPOLLET; 边缘触发模式,不设置默认为水平触发模式
    epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); // 添加fd到epoll实例之中
} // 此时,不论是否epoll_wait,一旦事件触发,该fd会自动转移到双向队列中


while(1){
    puts("round again");
    nfds = epoll_wait(epfd, events, 5, 10000); // nfds即有触发事件的个数
    // epoll_wait只负责检查和拷贝双向队列中的fd,对于红黑树中的fd不会轮询
    
    for(int i = 0; i < nfds; i++){
        //读取和处理 events[i] 即可访问对应 fd
        memset(buffer, 0, MAXBUF);
        read(events[i].data.fd, buffer, MAXBUF);
        puts(buffer);
    }
}

epoll总结

新的优化:

  • 减少了用户态和内核态的切换,减少了一定的开销
  • 产生结果的时候,我们只需要遍历从双向队列中拷贝出来的fd,并不用全部轮询