网络通信 - IO多路复用

Scroll Down

概述

IO多路复用简单来说就是,单线程或单进程同时监测若干个文件描述符是否可以执行IO操作的能力,IO多路复用解决的本质问题是用更少的资源完成更多的事。需要说明的是,处理IO多路复用的问题需要操作系统提供内核级别的支持,操作系统充当观察者的角色。本篇文章我们就来分析IO多路复用底层实现原理,我们以 Linux 操作系统提供的IO复用API select、poll 以及epoll 为例,逐一进行分析。

思考

实现IO多路复用直接使用用户线程轮询查看若干个文件描述符的状态难道不行吗?为什么要操作系统内核支持?在请求量比较小的时候确实可以使用该方案,但是在大量请求的情况下,这对于 CPU 的使用率来说无疑是种灾难。而使用操作系统内核帮我们观察文件描述符就可以优雅、高效地实现IO多路复用。

操作系统内核虽然清楚知道每个文件描述符对应的 Socket 的状态变化,但是内核如何知道该把哪个文件描述符信息给哪个进程呢?一个 Socket 文件可以由多个进程使用,而一个进程也可以使用多个 Socket 文件,进程和 Socket 之间是多对多的关系。此外,一个 Socket 也会对应多个事件类型。操作系统表示太难了,它很难判断将哪种事件触发的 Socket 给哪个进程。因此,在进程内部就需要维护自己关注哪些 Socket 文件的哪些事件,如读事件、写事件以及异常事件等。也就是说,内核帮应用程序盯着感兴趣的 Socket ,应用程序可以根据内核反馈的信息进一步处理网络请求。

综上,我们需要关注以下三个问题:

多路复用机制可以监听哪些套接字
多路复用机制会监听套接字上的哪些事件
套接字就绪时,多路复用机制要如何找到就绪的套接字
下面我们带着这些问题,结合 Linux 下的IO复用API进行分析。

select

select 实现IO多路复用的思想是:操作系统内核会扫描用户进程传入的 3 类 fd_set 文件描述数组(本质是 bitmap),当对应的 Socket 准备就绪时会置位(标志对应的 Socket 有数据来了) fd_set 数组中对应的元素,最后将内核置位后的 fd_set 数组们拷贝回用户空间。由于 select 并不会明确指出是哪些文件描述符就绪(一股脑返回全部 fd),因此用户进程需要根据内核返回的 fd_set 数组们自行判断哪个文件描述符对应的 Socket 发生了哪种事件,然后再进一步处理。

API定义

#include <sys/select.h>

/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

// 0 监听感兴趣的文件描述符上的事件
int select(int nfds, 
           fd_set *readfds, fd_set *writefds,fd_set *exceptfds, 
           struct timeval *timeout);

// fd_set 可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:
// 1 将一个文件描述符移除集合中 
void FD_CLR(int fd, fd_set *set);

// 2  检查一个文件描述符是否在集合中,可以用这个来检测一次select调用之后有哪些文件描述符可以进行IO操作
int  FD_ISSET(int fd, fd_set *set);

// 3 添加一个文件描述符到集合中
void FD_SET(int fd, fd_set *set);

// 4 清空给定集合
void FD_ZERO(fd_set *set);

select 将监听的文件描述符分为了 3 类,每一类都对应一个 fd_set 数组,本质上是一个 bitmap,也就是字节数组。分别是 writefds(写文件描述符)、readfds(读文件描述符)以及 exceptfds(异常事件文件描述符)。每一类都代表 Socket 对应的事件,每一类存储的都是 Socket 对应的文件描述符。用户进程可以根据需要,准备相关 fd_set 数组,在调用 select 函数时,这三个事件参数可以用 NULL 来表示对应的事件不需要监听。其实也不难看出,select 模型下操作系统内核并没有维护存储文件描述符相关的数据结构,只是定义了 fd_set ,将维护工作交给了用户进程。

下面我们对 select 相关的每个函数进行说明。
void FD_SET

用户进程可以调用 FD_SET 函数将指定的文件描述符 fd 设置到准备的 fd_set 数组中。

void FD_CLR

用户进程可以调用 FD_CLR 函数将指定的文件描述符 fd 从准备的 fd_set 数组移除。

void FD_ZERO

用户进程可以调用 FD_ZERO 函数将 fd_set 数组清空。该函数主要用来每次调用 select 函数之前,清空 fd_set 数组,因为每次调用 select 函数监听就绪的 Socket 时,内核会根据就绪的 Socket 情况修改用户进程传入的数组,将就绪的 Socket 对应在 fd_set 数组中元素置位,也就是说 fd_set 不可重用。

int select

用户进程可以在超时时间内,监听感兴趣的文件描述符上的事件(读/写/异常事件)发生。下面我们对相关参数和返回值进行说明。

参数:

int nfds: fd_set 数组当中最大描述符加 1,用来告知内核扫描的bitmap的范围。
fd_set *readfds: 要监听的读事件就绪的 Socket 的文件描述符数组,传 NULL 表示对应的事件不需要监听。
fd_set *writefds: 要监听的写事件就绪的 Socket 的文件描述符数组,传 NULL 表示对应的事件不需要监听。
fd_set *exceptfds: 要监听的异常事件对应的 Socket 的文件描述符数组,传 NULL 表示对应的事件不需要监听。
struct timeval *timeout: 超时时间
返回值:

监听的就绪 Socket 的描述符其数目,若超时则为0,若出错则为-1

int FD_ISSET

用户进程可以调用 FD_ISSET 函数判断文件描述符是否置位了,如果置位就说明对应的 Socket 已就绪。

原理

准备监听的文件描述符上的事件

应用程序可以根据具体需要,将 Socket 对应的文件描述符放入到 fd_set 数组中,在调用 select 函数时根据要监听的事件类型传入对应的 fd_set 数组。注意,Socket 不限于客户端的 Socket,服务端的 Socket 也可以,比如监听服务端 Socket 的连接事件发生。其中的用户进程通过调用 FD_SET 函数,将文件描述符写入到 fd_set 数组中,也就是将对应的位设置为 1,具体如下:
image
对于 select 模型,操作系统内核只是定义了文件描述符事件相关数据结构 fd_set,并没有在内核中提供维护文件描述符事件的数据结构。也就是说,应用程序需要根据系统内核提供的 fd_set 自行处理文件描述符相关数据。

等待文件描述符就绪

应用进程调用 select 函数,操作系统内核会依次遍历传入的每类 fd_set 数组,判断 fd_set 中元素对应的 Socket 有没有数据,这个过程的事件复杂度为 O(n)。如果有数据就对 fd_set 数组中的该 Socket 对应的元素进行置位,最后内核将 fd_set 拷贝回用户空间,不会阻塞当前调用进程。如果要监听的 fd_set 中的所有 Socket 都没有数据,那么进程将会阻塞在 select 函数上,直到超时或有 Socket 就绪,才会唤醒进程。

在内核遍历 fd_set 数组时,如果对应的 Socket 没有数据,那么内核会将用户进程加入到该 Socket 的等待队列中,这一点非常重要。

文件描述符就绪

当监听的任何一个 Socket 就绪时,中断程序将唤醒 Socket 等待队列中的进程,即每次唤醒都需要从每个 Socket 等待队列中移除进程。当用户进程被唤醒时,它知道至少有一个监视的 Socket 发生了感兴趣的事件。同时,内核会对该 Socket 对应在 fd_set 数组中的元素进行置位,然后将修改后的 fd_set 数组们拷贝回用户空间。

注意,select 虽然可以拿到内核修改后的 fd_set 数组,但是它并不知道是哪个 Socket 发生了哪个事件,需要用户进程自己去判断。

处理网络请求

用户进程拿到内核返回的 fd_set 数组包含整个文件描述符,程序不知道哪些 Socket 就绪,因此需要自行判断是哪个或哪些 Socket 发生了哪个事件,找到对应的 Socket 后,处理网络请求。

使用示例

// 创建一个服务端 Socket 套接字,
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof (addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
listen (sockfd, 5);

// 准备客户端连接对应的文件描述符
 for (i=0;i<5;i++)
 {
  memset(&client, 0, sizeof (client));
  addrlen = sizeof(client);
  // 创建客户端 Socket 套接字,并保存对应的文件描述符
  // 注意,文件描述符是操作系统随机分配的一个非负整数
  fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);

  // 保存最大的文件描述符
  if(fds[i] > max)
      max = fds[i];
 }

// select 实现多路复用
while(1){
   // 1 调用  FD_ZERO 清理 rset 数组
   FD_ZERO(&rset);
   // 2 调用 FD_SET 设置监听的文件描述符到 rset 数组中
   for (i = 0; i< 5; i++ ) {
       FD_SET(fds[i],&rset);
   }
   puts("round again");

   // 3 调用 slect 函数阻塞等待数据的到来,内核会判断 Socket 就绪情况
   // max+1 告知内核扫描 fd_set 数组范围
   // 这里只传入了 fd_set *readfds 参数,表示只监听读事件
   select(max+1, &rset, NULL, NULL, NULL);

   // 4 监听的 Socket 有读就绪
   for(i=0;i<5;i++) {
       // 调用 FD_ISSET 判断 rset 是否有置位
       if (FD_ISSET(fds[i], &rset)){
           memset(buffer,0,MAXBUF);
           read(fds[i], buffer, MAXBUF);
           puts(buffer);
       }
   }  
 }

特点

单个进程能够监视的文件描述符的数量存在最大限制,通常是 1024 ,当然可以更改数量,但由于 select 采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差。
每次调用 select,都需要把 fd 数组在用户空间与内核空间来回拷贝,并且内核需要遍历传递进来的所有 fd 才能知道是否有 fd 准备就绪,这个开销随着 fd 变多而增大。
select 返回的是含有整个文件描述符的数组,并非明确指出哪些文件描述符就绪了,因此应用程序需要遍历整个数组才能发现哪些文件描述符号对应的 Socket 发生了事件。

poll

poll 的实现和 select 非常相似,只是描述 fd 集合的方式不同,它们的工作原理是一样的。select 是将文件描述符分为了 3 类,使用 fd_set 结构存储,针对每一类文件描述符可关联对应的事件。poll 对所有文件描述符一视同仁,针对每个文件描述关联事件即可。具体的做法是通过定义了一个结构体 pollfd,将文件描述符和感兴趣的事件绑定在一起。这就是 poll 和 select 的主要区别,也就是说 poll 使用 pollfd 数组解决了 select 使用 bitmap 存储文件描述符数量限制问题。需要注意的是,poll 仍然没有解决 select 中的其它问题。

API 定义

#include <poll.h>
#include <signal.h>
#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// 文件描述符和关联的事件结构体
struct pollfd {

    // 文件描述符
    int fd; /* file descriptor */

    // 感兴趣的事件
    short events; /* requested events to watch */

    // 内核检测到的实际事件
    short revents; /* returned events witnessed */
    
 };

下面对 poll 函数的参数和返回值说明:

参数:

struct pollfd *fds: 该数组用于存放用户进程监听的 Socket 文件描述符事件信息,每一个元素都是 pollfd 结构。fd 属性用于存放关注的 Socket 文件描述符;events 属性用于存方关注的事件;revents 是内核检测到 fd 对应的 Socket 实际发生的事件。
nfds_t nfds: 用于告诉内核 fds 数组的大小,内核会根据该参数去遍历 fds 数组。
int timeout: 阻塞等待的超时时间
返回值:

fds 集合中就绪的描述符数量,返回 0 表示超时,返回 -1 表示出错。

使用示例

for (i=0;i<5;i++)
  {
   memset(&client, 0, sizeof (client));
   addrlen = sizeof(client);
   // 1 使用 pollfd 结构准备文件描述符
   pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
   // 设置感兴趣的事件
   pollfds[i].events = POLLIN;
  }
 sleep(1);
 while(1){
    puts("round again");

    // 2 调用 poll 阻塞等待数据的到来,内核会判断 Socket 就绪情况
    poll(pollfds, 5, 50000);
    for(i=0;i<5;i++) {

        // 3 用户进程自己判断哪个 Socket 发生了 POLLIN 
        if (pollfds[i].revents & POLLIN){
            // 重置 revents ,
            pollfds[i].revents = 0;
            memset(buffer,0,MAXBUF);
            read(pollfds[i].fd, buffer, MAXBUF);
            puts(buffer);
        }
    }
  }

poll 的改进主要是围绕着存储文件描述符事件的结构体 pollfd 来展开的,用户进程准备的各种文件描述符事件都是由该结构体存储的,此外内核检测到 Socket 就绪会设置对应的 pollfd 中的 revents 属性的值。虽然 poll 提供了更优质的编程接口,但是本质和 select 模型相同。因此千级并发以下的 I/O,可以考虑 select 和 poll 模型,但是如果出现更大的并发量,就需要用 epoll 模型。可以看到,当套接字 Socket 比较多的时候,不管哪个 Socket 是活跃的,对于使用 select 或 poll 模型都需要遍历一遍,这会浪费很多CPU资源。如果能给套接字 Socket 注册某个回调函数,当他们活跃时自动完成相关操作,那就避免了轮询,这正是 epoll 做的。

epoll

epoll 是对 select 和 poll 的改进。它的核心思想是基于事件驱动来实现的,操作系统内核维护一颗红黑树来存储文件描述符相关信息和维护一个链表来存放准备就绪的文件描述符对应的 Socket 相关的事件信息。其实,这两个数据结构存储的元素都和 epitem 结构有关,不过为了方便描述,通常都会说存储的是文件描述符,后面我们会详细介绍 epitme 结构。

API 定义

下面列举 epoll 提供的API:

#include <sys/epoll.h>

    // 创建 epoll 实例,返回 epoll 专用文件描述符(Linux 优化后废弃了参数)
    int epoll_create(int size);

    // 用于往 epoll 实例中增删改要检测的文件描述符事件
    // 根据具体操作调整调整红黑树和就绪链表
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

    // 用于阻塞等待可以执行IO操作的文件描述符事件,直到超时
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

上面列举了Linux中提供的epoll相关API,下面我们依次介绍这些函数。

epoll_create

当某一进程成功调用epoll_create函数时,Linux 内核会创建一个 epoll 实例,并返回其文件描述符。下面是 epoll 实例对应的结构体,我们只关注核心属性。

struct eventpoll {

	// epoll_wait 使用的等待队列,和用户进程唤醒有关
	wait_queue_head_t wq;

	// 就绪队列,用于存放就绪的文件描述符事件信息
	struct list_head rdllist;

	// 红黑树的根节点,这颗树中存储着所有添加到 epoll 中的文件描述符信息
	struct rb_root rbr;

    //.....
};

一般一个进程对应一个 epoll 实例,每个 epoll 实例都有一个独立的 eventpoll 结构体。更详细的结构如下图所示:
image-1686273292081
值得注意的是,进程在调用以上函数创建 epoll 对象的同时,会初始化以上三个核心数据结构:

wq: 等待队列链表。中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程(调用 epoll_wait 函数的进程)。
rdllist: 就绪链表。当有文件描述符对应的 Socket 就绪时,内核会将该 Scocket 对应动作的 epitem 的 rdllink 成员(包含事件和描述符信息)添加到该就绪链表中。
rbr: 一颗红黑树。为了支持对海量连接的高效查找、插入和删除,eventpoll 内部使用了一颗红黑树。通过这颗树来管理用户进程下添加进来的文件描述符。注意,红黑树节点并不是文件描述符,而是内核对文件描述符和事件信息封装的 epitem 的 rbn 成员。
至此,这些成员其实还只是刚被定义或初始化,都还没有用到,它们会在下面被用到。

epoll_ctl

某一进程通过调用 epoll_ctl 函数向 epoll 对象中添加、删除、修改感兴趣的文件描述符事件信息,返回0标识成功,返回-1表示失败。该方法的参数很重要,下面我们详细分析各个参数的作用。

int epfd
表示 epoll 实例的文件描述符,也就是 epoll_create 函数调用成功返回的值。顺便说一句,文件描述符是一个非负整数。

int op
表示对文件描述符 fd 的监听事件的操作,操作类型如下:

EPOLL_CTL_ADD:注册新的 fd 的监听事件
EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件
EPOLL_CTL_DEL:删除 fd 的监听事件
int fd
表示要监听的文件描述符,该文件描述符对应的 Socket 可能发生不同的操作,进而产生不同的事件。

struct epoll_event *event
表示要监听的文件描述符 fd 对应的 Socket 发生的事件,该事件的结构定义如下:

// 用户附加数据定义
typedef union epoll_data {
    void        *ptr; /*指向用户自定义数据*/
    int          fd;  /*注册的文件描述符*/
    uint32_t     u32; 
    uint64_t     u64;
} epoll_data_t;

// epoll 监听事件定义
struct epoll_event {
    // 描述 epoll 事件
    uint32_t     events;      /* Epoll events */

    // 专门给用户使用的,具体见上面的结构体
    epoll_data_t data;        /* User data variable */
};

epoll_event 包括两部分信息,一个是文件描述符的事件信息,另一个是为使用方提供的属性。这个结构非常重要,使用方向 epoll 实例注册监听事件信息时,需要在 data 域写入文件描述符相关信息,当有文件描述符对应的 Socket 准备就绪时,会间接将对应的 epoll_event 拷贝会用户空间,用户进程就可以根据 epoll_event 中的 events 事件信息和 data 中用户指定的文件描述符 fd,进而可以根据事件信息去操作文件描述符对应 Socket 。

常用的 epoll 事件描述如下:

EPOLLIN:描述符处于可读状态
EPOLLOUT:描述符处于可写状态
EPOLLET:将epoll event通知模式设置成 edge triggered
EPOLLONESHOT:第一次进行通知,之后不再监测
EPOLLHUP:本端描述符产生一个挂断事件,默认监测事件
EPOLLRDHUP:对端描述符产生一个挂断事件
EPOLLPRI:描述符有紧急的数据可读
EPOLLERR:描述符产生错误时触发,默认检测事件
下面我们只考虑注册新的文件描述符的监听事件。在调用 epoll_clt 函数注册文件描述符事件时,Linux 内核会做以下工作:

根据传入的参数初始化一个 epitem 对象,该对象是内核管理文件描述符的基础,后续红黑树和就绪链表中的数据都要用到它。
为传入的文件描述符对应的 Socket 新建一个等待队列项,其中的回调函数为 ep_poll_callback(该回调函数会在 Socket 准备就绪后触发),base 指针指向步骤 1 初始化的 epitem,它将来会作为添加到就绪链表的数据源。然后将该等待队列项加入到 Socket 的等待队列中。
将 epitem 的 rbn 成员插入到红黑树中。红黑树主要用来维护进程添加的文件描述符,这样就可以避免每次获取就绪 Socket 信息时都要重新拷贝一遍所有的文件描述符到内核态,并能在插入,查找和删除的操作发生高效执行。
在 epoll 中,内核会根据传入的文件描述符和事件,将相关信息封装成 epitem 对象,epitem 结构如下所示:

struct epitem{
    // 红黑树节点
    struct rb_node  rbn;

    // 就绪链表节点
    struct list_head    rdllink;

    // 文件描述符具体信息
    struct epoll_filefd  ffd;

    //指向其所属的 eventpoll 对象  
    struct eventpoll *ep;   

    // 监听的事件信息
    struct epoll_event event; 
}

struct epoll_filefd{
    // Socket 文件地址
    struct file *file;

    // 文件描述符
    int fd;
}

这里简单说明下,epoll 为啥要使用红黑树呢?使用红黑树是基于 epoll 在查询效率、插入效率、删除效率以及内存开销等多方面均衡的结果。

epoll_wait
某一进程通过调用 epoll_wait 函数阻塞等待就绪文件描述符,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。
参数
int epfd
表示 epoll 实例的文件描述符,也就是 epoll_create 函数调用成功返回的值。

struct epoll_event *events
关注的文件描述符对应的 Socket 有事件触发时,内核会将对应的事件信息写入 events 数组中并拷贝回用户空间。

int maxevents
通知内核 events 的大小,内核会根据该值从就绪链表中写数据到 events 数组中。

原理
epoll_wait 做的事情相对比较简单,当用户进程调用它时会直接观察就绪链表中有没有数据即可。

有数据
内核会将就绪链表中元素对应的事件信息写入到 events 并拷贝回用户空间就结束了。

等待文件描述符就绪
没有数据,则创建一个等待队列项,将用户进程设置到等待队列项,并且设置一个 default_wake_function 回调函数(将来用来唤醒当前进程),然后添加到 eventpoll 的等待队列上,阻塞当前用户进程。需要注意的是,epoll_ctl 过程中是为文件描述符对应的 Socket 创建等待队列项,这里是为 epoll 创建等待队列项。从这个过程也可以看出,epoll 也是会阻塞当前进程的,这个是合理的,因为当前进程没有事情可做了占着 CPU 也没啥意义。

文件描述符就绪

当 Socket 就绪时,内核会找到 Socket 等待队列中设置的回调函数 ep_poll_callback 并执行该函数,该函数会根据等待队列项的 base 属性找到 epitem 对象,进而也可以找到 eventpoll 对象。接着将找到的 epitem 的 rdllink 添加到 epoll 的就绪链表中(内核知道 Socket 发生的事件),最后会查看 eventpoll 的等待队列中是否有等待项,也就是查看是否有用户进程在等待,如果没有则执行中断的事情就做完了。如果有就查找到等待项里设置的回调函数 default_wake_function 并执行,唤醒阻塞的用户进程。

处理网络请求

当进程醒来之后,继续从 epoll_await 时暂停的代码继续执行,同时内核向用户空间拷贝就绪事件信息到 events 参数中,用户进程可以根据返回的具体信息处理网络请求。

使用示例

struct epoll_event events[5];

// 1 创建一个 epoll 实例
int epfd = epoll_create(10);
 ...
 ...
for (i=0;i<5;i++)
 {
   
  // 2 epoll 监听事件定义
  static struct epoll_event ev;
  memset(&client, 0, sizeof (client));
  addrlen = sizeof(client);

  // 2.1 设置 fd 
  ev.data.fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
  // 2.2 设置监听事件
  ev.events = EPOLLIN;

  // 3 向 epoll 注册 文件描述符事件
  epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
 }

while(1){
   puts("round again");

   // 4 调用 epoll_wait 阻塞等待数据的到来,内核会判断 Socket 就绪情况,并把就绪的 Socket 相关的 epoll_event 拷贝出用户空间
   // 返回的就绪 Socket 的个数
   nfds = epoll_wait(epfd, events, 5, 10000);
  
   // 只需要遍历 nfds 个数即可
   for(i=0;i<nfds;i++) {
           memset(buffer,0,MAXBUF);
           read(events[i].data.fd, buffer, MAXBUF);
           puts(buffer);
   }
 }

特点

epoll 为了减少文件描述符频繁的拷贝开销,在内核中维护了一颗红黑树用来存储文件描述符信息。并不是说 epoll 完全避免了文件描述符的拷贝,epoll 只会在新增/修改/删除的时候进行拷贝工作,避免了每次获取就绪数据信息时的重复拷贝。
epoll 使用了一个就绪链表来解决准确通知问题,也就是只会将就绪的 Socket 信息返回给用户空间,即可以直接从 events 参数中获取就绪的文件描述符的信息,无需遍历整个所有文件描述符集合。
epoll 阻塞用户进程时只会将其添加到 epoll 实例的等待队列中,而不需要将用户进程轮流加入到文件描述符对应的 Socket 的等待队列中。并且 epoll 模型为文件描述符对应的 Socket 设置一个回调函数,当 Socket 就绪时会触发该函数的调用,这就是基于事件驱动模型。基于事件驱动内核就可以避免遍历所有文件描述符的开销。

方案比较

select 和 poll 基本类似,都是使用内核定义的数据结构来进行文件描述符的存储,select 采用 bitmap ,poll 采用数组。select 会受到最大连接数的限制,而 poll 在一定程度上解决了这个问题。而 epoll 则是内核专门维护了一颗红黑树来存储文件描述符信息。前两个文件描述符信息需要用户空间维护,而后者是在内核空间维护的。
select 和 poll 都需要将有关文件描述符的数据结构在用户空间和内核空间来回拷贝,而 epoll 只会在新增/修改/删除的时候进行拷贝工作。
select 和 poll 采用轮询的方式来检查文件描述符是否处于就绪状态,而 epoll 采用回调机制。造成的结果是,随着文件描述符的增加,select 和 poll 的效率会线性降低,而 epoll 受到的影响较小,除非活跃的 Socket 较多。
select 、poll 以及 epoll 虽然都会返回就绪的文件描述符数量。但是 select 和 poll 并不会明确指出是哪些文件描述符就绪,而 epoll 可以做到。用户进程返回后,调用 select 和 poll 的程序需要遍历监听整个文件描述符,而 epoll 得益于内核就绪链表则可以直接处理。
注意,虽然 epoll 的性能最好,但是在连接数少并且连接都十分活跃的情况下,select 和 poll 的性能可能比 epoll 好,毕竟 epoll 的通知机制需要很多函数回调,这也是需要有开销的。

触发方式

水平触发

当内核有事件到达,会拷贝给用户空间,如果应用程序没有处理完或者压根都没有处理,那么会在下一次再次返回没有处理的事件。这样,如果应用程序永远不处理这个事件,就导致每次都会有该事件从内核空间到用户空间的拷贝,消耗性能。但是水平触发相对安全,最起码事件不会丢掉,除非用户处理完毕。

边缘触发

边缘触发,相对跟水平触发相反,当内核有事件到达,只会通知应用程序一次,至于应用程序处理还是不处理,以后将不会再通知。这样减少了拷贝过程,增加了性能,但是会产生事件丢失的情况。

对于 select 和 poll 来说,其触发都是水平触发。而 epoll 既支持水平触发也支持边缘触发。