Bootstrap

Unix/Linux编程:epoll详解

同IO多路复用和信号驱动IO一样,Linux的epoll(event poll) API可以检查多个文件描述符上的IO就绪状态。epoll API的主要优点如下

  • 当检查大量的文件描述符时,epoll的性能扩展性比select和poll高很多
  • epoll API即支持水平触发也支持边缘触发。与之相反,select()和 poll()只支持水平触发,而信号驱动 I/O 只支持边缘触发

性能表现上,epoll与信号驱动IO类似。但是epoll有一些胜过信号驱动IO的优点

  • 可以避免复杂的信号处理流程(比如信号队列溢出时的处理)
  • 灵活性高,可以指定我们希望检查的事件类型(比如,检测套接字文件描述符的读就绪、写就绪或者两种同时指定)

epoll API 是 Linux 系统专有的,在 2.6 版中新增

epoll API的核心数据结构称为epoll实例,它和一个打开的文件描述符相关联。这个文件描述符不是用来做IO操作的。相反,它是内核数据结构的句柄,这些内核数据结构实现了两个目的:

  • 记录了在进程中声明过的感兴趣的文件描述符列表—interest list(兴趣列表)。
  • 维护了处于 I/O 就绪态的文件描述符列表—ready list(就绪列表)

ready list 中的成员是 interest list 的子集

对于由epoll检测的每一个文件描述符,我们可以指定一个位掩码来表示我们感兴趣的事件。这些位掩码同poll()所使用的位掩码有紧密的关联。

系统调用

epoll API由下面三个系统调用组成

  • 系统调用epoll_create()创建一个epoll实例,返回代表该实例的文件描述符
  • 系统调用 epoll_ctl()操作同 epoll 实例相关联的兴趣列表。通过 epoll_ctl(),我们可以增加新的描述符到列表中,将已有的文件描述符从该列表中移除,以及修改代表文件描述符上事件类型的位掩码。
  • 系统调用 epoll_wait()返回与 epoll 实例相关联的就绪列表中的成员。

创建epoll实例:epoll_create()

系统调用epoll_create()创建了一个新的epoll实例,其对应的兴趣列表初始化为空。

这个epoll实例被用来调用epoll_ctl和epoll_wait,如果这个epoll实例不再需要,比如服务器正常关闭,需要调用close()方法释放epoll实例,这样系统内核就可以回收epoll实例所分配的内核资源

NAME
       epoll_create, epoll_create1 - open an epoll file descriptor

SYNOPSIS
       #include <sys/epoll.h>

       int epoll_create(int size);
       int epoll_create1(int flags);

RETURN VALUE
       成功时,这些系统调用返回一个非负的文件描述符。 
       出错时,返回 -1,并设置 errno 以指示错误 

epoll_create1() 的用法和 epoll_create() 基本一致,如果 epoll_create1() 的输入 size 大小为 0,则和 epoll_create() 一样,内核自动忽略。可以增加如 EPOLL_CLOEXEC 的额外选项

参数

size

  • 参数 size 指定了我们想要通过 epoll 实例来检查的文件描述符个数
  • 该参数并不是一个上限,而是告诉内核应该如何为内部数据结构划分初始大小。
  • 从 Linux 2.6.8 版以来,size 参数被忽略不用(但是该值仍需要一个大于 0 的整数),因为内核实现做了修改意味着该参数之前提供的信息已经不再需要了。

返回值

作为函数返回值,epoll_create()返回了代表新创建的 epoll 实例的文件描述符。

  • 这个文件描述符在其他几个 epoll 系统调用中用来表示 epoll 实例。

  • 当这个文件描述符不再需要时,应该通过 close()来关闭。

  • 多个文件描述符可能引用到相同的 epoll 实例,这是由于调用了 fork()或者 dup()这样类似的函数所致

  • 当所有与 epoll 实例相关的文件描述符都被关闭时,实例被销毁,相关的资源都返还给系统。。

从 2.6.27 版内核以来,Linux 支持了一个新的系统调用 epoll_create1()。

  • 该系统调用执行的任务同 epoll_create()一样,但是去掉了无用的参数 size,并增加了一个可用来修改系统调用行为的 flags 参数。
  • 目前只支持一个 flag 标志:EPOLL_CLOEXEC,它使得内核在新的文件描述符上启动了执行即关闭(close-on-exec)标志(FD_CLOEXEC)。
  • 出于同样的原因,这个标志同 4.3.1 节中描述的 open()的 O_CLOEXEC 标志一样有用。

最多能够创建多少个实例

max_user_watches 上限

  • 因为每个注册到epoll实例上的文件描述符需要占用一小段不能被交换的内核内存空间,因此内核提供了一个接口用来定义每个用户可以注册到epoll实例上的文件描述符总数
  • 这个上限值可以通过 max_user_watches 来查看和修改。max_user_watches 是专属于 Linux 系统的/proc/sys/fd/epoll 目录下的一个文件。
  • 默认的上限值根据可用的系统内存来计算得出

示例

  int epfd=epoll_create(1);//只需要监听一个描述符——标准输入

修改 epoll 的兴趣列表:epoll_ctl()

在创建完 epoll 实例之后,可以通过调用 epoll_ctl 往这个 epoll 实例增加或删除监控的事件。

NAME
       epoll_ctl - control interface for an epoll descriptor

SYNOPSIS
       #include <sys/epoll.h>

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


 返回值: 若成功返回 0;若返回 -1 表示出错

参数

参数epfd

  • 是刚刚调用 epoll_create 创建的 epoll 实例描述字,可以简单理解成是 epoll 句柄。

参数op

参数 op 用来表示增加还是删除一个监控事件,它有三个选项可供选择:

  • EPOLL_CTL_ADD
    • 向 epoll 实例注册文件描述符对应的事件,将描述符 fd 添加到 epoll 实例 epfd 中的兴趣列表中去。
    • 对于 fd 上我们感兴趣的事件,都指定在 ev 所指向的结构体中。
    • 如果我们试图向兴趣列表中添加一个已存在的文件描述符,epoll_ctl()将出现 EEXIST 错误。
  • EPOLL_CTL_MOD
    • 修改描述符 fd 上设定的事件,需要用到由 ev 所指向的结构体中的信息。
    • 如果我们试图修改不在兴趣列表中的文件描述符,epoll_ctl()将出现 ENOENT 错误。
  • EPOLL_CTL_DEL
    • 向 epoll 实例删除文件描述符对应的事件,将文件描述符 fd 从 epfd 的兴趣列表中移除。该操作忽略参数 ev。
    • 如果我们试图移除一个不在 epfd 的兴趣列表中的文件描述符,epoll_ctl()将出现 ENOENT 错误。
    • 关闭一个文件描述符会自动将其从所有的 epoll 实例的兴趣列表中移除。

参数fd

  • 参数fd是注册的事件的文件描述符
  • 该参数可以是代表管道、FIFO、套接字、POSIX 消息队列、inotify 实例、终端、设备,甚至是另一个 epoll 实例的文件描述符(例如,我们可以为受检查的描述符建立起一种层次关系)。
  • 但是,这里 fd 不能作为普通文件或目录的文件描述符(会出现 EPERM错误(权限不被允许))。

参数ev

参数 ev 是指向结构体 epoll_event 的指针,表示的是注册的事件类型,并且可以在这个结构体里设置用户需要的数据,其中最为常见的是使用联合结构里的 fd 字段,表示事件所对应的文件描述符。结构体的定义如下。

		 typedef union epoll_data {
               void        *ptr;   // pointer to user-defined data
               int          fd;   // file descriptor
               uint32_t     u32;  // 32-bit integer
               uint64_t     u64;  // 64-bit integer
           } epoll_data_t;

           struct epoll_event {
               uint32_t     events;      /* Epoll events */
               epoll_data_t data;        /* User data variable */
           };

参数 ev 为文件描述符 fd 所做的设置如下。

  • 结构体epoll_event 中的events字段是一个位掩码,它指定了我们为待检查的描述符fd上所感兴趣的时间集合,比较常用的有:
    • EPOLLIN:表示对应的文件描述字可以读;
    • EPOLLOUT:表示对应的文件描述字可以写;
    • EPOLLRDHUP:表示套接字的一端已经关闭,或者半关闭;
    • EPOLLHUP:表示对应的文件描述字被挂起;
    • EPOLLET:设置为 edge-triggered,默认为 level-triggered。
  • data字段是一个联合体,当描述符fd成为就绪态时,联合体的成员可以用来指定传回给调用进程的信息

示例

	int epfd;
	struct epoll_event ev;
	
    epfd = epoll_create(s);
    if (epfd == -1){
		 errExit("epoll_create");
	}
       

	ev.data.fd = fd;
	ev.events = EPOLLIN;
	if(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev) == -1){
		 errExit("epoll_ctl");
	}

事件等待:epoll_wait()

epoll_wait() 返回epoll实例中处于就绪态的文件描述符信息。单个epoll_wait()调用能返回多个就绪态文件描述符的信息

NAME
       epoll_wait, epoll_pwait - wait for an I/O event on an epoll file descriptor

SYNOPSIS
       #include <sys/epoll.h>

       int epoll_wait(int epfd, struct epoll_event *evlist,
                      int maxevents, int timeout);
       int epoll_pwait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout,
                      const sigset_t *sigmask);

返回值: 成功返回的是一个大于 0 的数,表示事件的个数;返回 0 表示的是超时时间到;若出错返回 -1.

epoll_wait() 函数类似之前的 poll 和 select 函数,调用者进程被挂起,在等待内核IO事件的分发

参数

参数epfd

  • 是刚刚调用 epoll_create 创建的 epoll 实例描述字,可以简单理解成是 epoll 句柄。

参数evlist

  • 返回给用户空间需要处理(处于就绪态文件描述符)的 I/O 事件,这是一个数组,这个数组的每个元素都是一个需要待处理的 I/O 事件
  • evlist的空间由调用者负责申请,所包含的元素个数在参数maxevents中指定。

在数组evlist中,每个元素返回的都是单个就绪态文件描述符的信息。

  • events 字段返回了在该描述符上已经发生的事件掩码。
  • data 字段返回的是我们在描述符上使用 cpoll_ctl()注册感兴趣的事件时在 ev.data 中所指定的值,也就是用户空间和内核空间调用时需要的数据。注意,data 字段是唯一可获知同这个事件相关的文件描述符号的途径。因此,当我们调用 epoll_ctl()将文件描述符添加到兴趣列表中时,应该要么将 ev.data.fd 设为文件描述符号,要么将 ev.data.ptr 设为指向包含文件描述符号的结构体

参数 timeout

参数 timeout 用来确定 epoll_wait()的阻塞行为,有如下几种。

  • 如果 timeout 等于−1,调用将一直阻塞,直到兴趣列表中的文件描述符上有事件产生,或者直到捕获到一个信号为止。
  • 如果 timeout 等于 0,执行一次非阻塞式的检查,看兴趣列表中的文件描述符上产生了哪个事件。
  • 如果 timeout 大于 0,调用将阻塞至多 timeout 毫秒,直到文件描述符上有事件发生,或者直到捕获到一个信号为止

返回值

  • 调用成功后,epoll_wait()返回数组 evlist 中的元素个数。
  • 如果在 timeout 超时间隔内没有任何文件描述符处于就绪态的话,返回 0。
  • 出错时返回−1,并在 errno 中设定错误码以表示错误原因。
  • 在多线程程序中,可以在一个线程中使用epoll_ctl()将文件描述符添加到另一个线程中有epoll_wait()所监视的epoll实例的兴趣列表中去。
  • 这些对兴趣列表的修改将立刻得到处理,而epoll_wait()调用将返回有关新添加的文件描述符的就绪信息。

返回的条件

epoll_wait返回的条件是rdlist不空,而使rdlist不空的途径有两个。

1. 文件描述符状态的改变
【对于读操作】
  • 当buffer由不可读状态变为可读的时候,即由空变为不空的时候。
    在这里插入图片描述
  • 当有新数据到达时,即buffer中的待读内容变多的时候。
    在这里插入图片描述
【对于写操作】:
  • 当buffer由不可写变为可写的时候,即由满状态变为不满状态的时候。
    在这里插入图片描述
  • 当有旧数据被发送走时,即buffer中待写的内容变少的时候。
    在这里插入图片描述
2. 文件描述符的事件位events置1
【对于读操作】:
  • buffer中有数据可读的时候,即buffer不空的时候fd的events的可读位就置1
【对于写操作】:
  • buffer中有空间可写的时候,即buffer不满的时候fd的events的可写位就置1

在这里插入图片描述

说明

ET和LT模式下的epitem都可以通过途径1加入rdlist从而唤醒epoll_wait,但LT模式下的epitem还可以通过途径2重新加入rdlist唤醒epoll_wait。

从源码角度看,ET模式下,文件描述符fd只会加入rdlist一次,所以epoll_wait只会被触发一次,然后移除此epitem;而LT模式下只要满足相应读写条件就会再次加入rdlist,epoll_wait会被触发多次

epoll事件

当我们调用epoll_ctl()时可以在ev.events中指定的位掩码以及由epoll_wait()返回的evlist[].events 中的值如下表。

除了有一个额外的前缀 E 外,大多数这些位掩码的名称同 poll()中对应的事件掩码名称相同。这种名称上有着对应关系的原因是当我们在 epoll_ctl()中指定输入,或通过 epoll_wait()得到输出时,这些比特位表达的意思同对应的 poll()的事件掩码所表达的意思一样

位掩码作为 epoll_ctl()的输入?由 epoll_wait()返回?描述
EPOLLIN可读取非高优先级的数据
EPOLLPRI可读取高优先级数据
EPOLLRDHUP套接字对端关闭(始于 Linux 2.6.17 版)
EPOLLOUT普通数据可写
EPOLLET采用边缘触发事件通知
EPOLLONESHOT在完成事件通知之后禁用检查
EPOLLERR有错误发生
EPOLLHUP出现挂断
EPOLLONESHOT 标志
  • 默认情况下,一旦通过 epoll_ctl()的 EPOLL_CTL_ADD 操作将文件描述符添加到 epoll 实例的兴趣列表中后,它会保持激活状态(即,之后对 epoll_wait()的调用会在描述符处于就绪态时通知我们),直到我们显式地通过 epoll_ctl()的 EPOLL_CTL_DEL 操作将其从列表中移除

  • 如果我们希望在某个特定的文件描述符上只得到一次通知,那么可以在传给 epoll_ctl()的ev.events 中指定 EPOLLONESHOT(从 Linux 2.6.2 版开始支持)标志。如果指定了这个标志,那么在下一个 epoll_wait()调用通知我们对应的文件描述符处于就绪态之后,这个描述符就会在兴趣列表中被标记为非激活态,之后的 epoll_wait()调用都不会再通知我们有关这个描述符的状态了。

  • 如果需要,我们可以稍后通过 epoll_ctl()的 EPOLL_CTL_ MOD 操作重新激活对这个文件描述符的检查。(这种情况下不能用 EPOLL_CTL_ADD 操作,因为非激活态的文件描述符仍然还在 epoll 实例的兴趣列表中。

深入探究epoll的语义

epoll_wait()调用的目的就是让内核负责监视打开的文件描述

这表示我们必须对之前的观点做改进:如果一个文件描述符是 epoll 兴趣列表中的成员,当关闭它后会自动从列表中移除。改进版应该是这样的:一旦所有指向打开的文件描述的文件描述符都被关闭后,这个打开的文件描述将从 epoll 的兴趣列表中移除。这表示如果我们通过 dup()(或类似的函数)或者 fork()为打开的文件创建了描述符副本,那么这个打开的文件只会在原始的描述符以及所有其他的副本都被关闭时才会移除

接下来,我们来探究一下它的源代码

基本数据结构

在开始研究源代码之前,我们先看一下epoll中使用的数据结构,分别是eventpoll、epitem、eppoll_entry。

我们先看一下eventpoll这个数据结构,这个数据结构是我们在调用epoll_create之后内核测创建的一个句柄,表示一个epoll实例。后继如果我们再调用epoll_ctl和epoll_wait等,都是对这个eventpoll数据进行操作,这部分数据会被保存在epoll_create创建的匿名文件file的private_data字段中。

/*
 * 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 {
    /* Protect the access to this structure */
    spinlock_t lock;
 
    /*
     * This mutex is used to ensure that files are not removed
     * while epoll is using them. This is held during the event
     * collection loop, the file cleanup path, the epoll file exit
     * code and the ctl operations.
     */
    struct mutex mtx;
 
    /* Wait queue used by sys_epoll_wait() */
    // 这个队列里存放的是执行 epoll_wait 从而等待的进程队列
    wait_queue_head_t wq;
 
    /* Wait queue used by file->poll() */
    // 这个队列里存放的是该 eventloop 作为 poll 对象的一个实例,加入到等待的队列
    // 这是因为 eventpoll 本身也是一个 file, 所以也会有 poll 操作
    wait_queue_head_t poll_wait;
 
    /* List of ready file descriptors */
    // 这里存放的是事件就绪的 fd 列表,链表的每个元素是下面的 epitem
    struct list_head rdllist;
 
    /* RB tree root used to store monitored fd structs */
    // 这是用来快速查找 fd 的红黑树
    struct rb_root_cached rbr;
 
    /*
     * This is a single linked list that chains all the "struct epitem" that
     * happened while transferring ready events to userspace w/out
     * holding ->lock.
     */
    struct epitem *ovflist;
 
    /* wakeup_source used when ep_scan_ready_list is running */
    struct wakeup_source *ws;
 
    /* The user that created the eventpoll descriptor */
    struct user_struct *user;
 
    // 这是 eventloop 对应的匿名文件,充分体现了 Linux 下一切皆文件的思想
    struct file *file;
 
    /* used to optimize loop detection check */
    int visited;
    struct list_head visited_list_link;
 
#ifdef CONFIG_NET_RX_BUSY_POLL
    /* used to track busy poll napi_id */
    unsigned int napi_id;
#endif
};

从上面可以看到epitem,这个eepitem结构是干什么的呢?

每当我们调用epoll_ctl增加一个fd时,内核就会为我们创建出一个epitem实例,并且把这个实例作为红黑树的一个子节点,增加到eventpoll结构体中的红黑树中,对应的字段是rbr,查找每一个fd上是否有事件发生都是通过红黑树上的peitem来操作。

/*
 * Each file descriptor added to the eventpoll interface will
 * have an entry of this type linked to the "rbr" RB tree.
 * Avoid increasing the size of this struct, there can be many thousands
 * of these on a server and we do not want this to take another cache line.
 */
struct epitem {
    union {
        /* RB tree node links this structure to the eventpoll RB tree */
        struct rb_node rbn;
        /* Used to free the struct epitem */
        struct rcu_head rcu;
    };
 
    /* List header used to link this structure to the eventpoll ready list */
    // 将这个 epitem 连接到 eventpoll 里面的 rdllist 的 list 指针
    struct list_head rdllink;
 
    /*
     * Works together "struct eventpoll"->ovflist in keeping the
     * single linked chain of items.
     */
    struct epitem *next;
 
    /* The file descriptor information this item refers to */
    //epoll 监听的 fd
    struct epoll_filefd ffd;
 
    /* Number of active wait queue attached to poll operations */
    // 一个文件可以被多个 epoll 实例所监听,这里就记录了当前文件被监听的次数
    int nwait;
 
    /* List containing poll wait queues */
    struct list_head pwqlist;
 
    /* The "container" of this item */
    // 当前 epollitem 所属的 eventpoll
    struct eventpoll *ep;
 
    /* List header used to link this item to the "struct file" items list */
    struct list_head fllink;
 
    /* wakeup_source used when EPOLLWAKEUP is set */
    struct wakeup_source __rcu *ws;
 
    /* The structure that describe the interested events and the source fd */
    struct epoll_event event;
};

每当一个fd关联到一个epoll实例,就会有一个eppoll_entry产生。eppoll_entry的结构如下:

/* Wait structure used by the poll hooks */
struct eppoll_entry {
    /* List header used to link this structure to the "struct epitem" */
    struct list_head llink;
 
    /* The "base" pointer is set to the container "struct epitem" */
    struct epitem *base;
 
    /*
     * Wait queue item that will be linked to the target file wait
     * queue head.
     */
    wait_queue_entry_t wait;
 
    /* The wait queue head that linked the "wait" wait queue item */
    wait_queue_head_t *whead;
};

epoll_create

我们在使用epoll的时候,首先会调用epoll_create来创建一个epoll实例

  • 当我们通过epoll_create()创建一个epoll实例时,内核在内存中创建了一个新的i-node并打开文件描述,随后在调用进程中为打开的这个文件描述分配一个新的文件描述符。

  • 同epoll实例的兴趣列表相关联的是打开的文件描述,而不是epoll文件描述符。这将产生下列结果:

    • 如果我们使用 dup()(或类似的函数)复制一个 epoll 文件描述符,那么被复制的描述符所指向的 epoll 兴趣列表和就绪列表同原始的 epoll 文件描述符相同。
    • 上一条观点同样也适用于 fork()调用之后的情况。此时子进程通过继承复制了父进程的 epoll 文件描述符,而这个复制的文件描述符所指向的 epoll 数据结构同原始的描述符相同

epoll_create函数是如何工作的呢?

首先,epoll_create会对传入的flags参数做简单的验证。

/* Check the EPOLL_* constant for consistency.  */
BUILD_BUG_ON(EPOLL_CLOEXEC != O_CLOEXEC);
 
if (flags & ~EPOLL_CLOEXEC)
    return -EINVAL;
/*

接下来,内核申请分配eventpoll需要的内存空间:

/* Create the internal data structure ("struct eventpoll").
*/
error = ep_alloc(&ep);
if (error < 0)
  return error;

然后,epoll_create为epoll实例分配了匿名文件和文件描述符,其中fd是文件描述符,file是一个匿名文件。这里充分体现了UNIX下一切都是文件的思想。注意,eventpoll的实例会保存一份匿名文件的引用,通过调用fd_install函数将匿名文件和文件描述符完成绑定。

这里还有一个特别需要注意的地方,在调用anon_inode_get_file的时候,epoll_create将eventpoll作为匿名文件file的private_data保存了起来,这样,在之后通过epoll实例的文件描述符来查找时,就可以快速定位到eventpoll对象了。

最后,这个文件描述符作为epoll的文件句柄,被返回给epoll_create的调用者

/*
 * Creates all the items needed to setup an eventpoll file. That is,
 * a file structure and a free file descriptor.
 */
fd = get_unused_fd_flags(O_RDWR | (flags & O_CLOEXEC));
if (fd < 0) {
    error = fd;
    goto out_free_ep;
}
file = anon_inode_getfile("[eventpoll]", &eventpoll_fops, ep,
             O_RDWR | (flags & O_CLOEXEC));
if (IS_ERR(file)) {
    error = PTR_ERR(file);
    goto out_free_fd;
}
ep->file = file;
fd_install(fd, file);
return fd;

epoll_ctl

接下来,我们看一下一个套接字是如何被添加到epoll实例中的。

当我们指向epoll_ctl()的EPOLL_CTL_ADD操作时,内核在epoll兴趣列表中添加了一个元素,这个元素同时记录了需要检查的文件描述符数量以及对应的打开文件描述的引用。

下面解析一下epoll_ctl函数实现

查找epoll实例

首先,epoll_ctl函数通过epoll实例句柄来获得对应的匿名文件,这一点很好理解,unix下一切都是文件,epoll的实例也是一个匿名文件

// 获得 epoll 实例对应的匿名文件
f = fdget(epfd);
if (!f.file)
    goto error_return;

接下来,获得添加的套接字对应的文件,这里tf表示的是target file,即待处理的目标文件

/* Get the "struct file *" for the target file */
// 获得真正的文件,如监听套接字、读写套接字
tf = fdget(fd);
if (!tf.file)
    goto error_fput;

在接下来,进行了一系列的数据验证,以保证用户传入的参数是合法的,比如epfd真的是一个epoll实例句柄,而不是一个普通的文件描述符

/* The target file descriptor must support poll */
// 如果不支持 poll,那么该文件描述字是无效的
error = -EPERM;
if (!tf.file->f_op->poll)
    goto error_tgt_fput;
...

如果获得了一个真正的epoll实例句柄,就可以通过private_data获取之前创建的eventpoll实例了

/*
 * At this point it is safe to assume that the "private_data" contains
 * our own data structure.
 */
ep = f.file->private_data;

红黑树查找

接下来epoll_ctl通过目标文件和对应描述符,在红黑树中查找是否存在该套接字,这也是epoll为什么高效的地方。这里eventpoll通过红黑树跟踪了当前监听的所有文件描述符,而这棵树的根就保存在eventpoll数据结构中

/* RB tree root used to store monitored fd structs */
struct rb_root_cached rbr;

对于每个被监听的文件描述符,都有一个对应的epitem与之对应,epitem作为红黑树中的节点就保存在红黑树中

/*
 * Try to lookup the file inside our RB tree, Since we grabbed "mtx"
 * above, we can be sure to be able to use the item looked up by
 * ep_find() till we release the mutex.
 */
epi = ep_find(ep, tf.file, fd);

红黑树是一棵二叉树,作为二叉树上的节点,epitem 必须提供比较能力,以便可以按大小顺序构建出一棵有序的二叉树。其排序能力是依靠 epoll_filefd 结构体来完成的,epoll_filefd 可以简单理解为需要监听的文件描述字,它对应到二叉树上的节点。

可以看到这个还是比较好理解的,按照文件的地址大小排序。如果两个相同,就按照文件文件描述字来排序。

struct epoll_filefd {
	struct file *file; // pointer to the target file struct corresponding to the fd
	int fd; // target file descriptor number
} __packed;
 
/* Compare RB tree keys */
static inline int ep_cmp_ffd(struct epoll_filefd *p1,
                            struct epoll_filefd *p2)
{
	return (p1->file > p2->file ? +1:
		   (p1->file < p2->file ? -1 : p1->fd - p2->fd));
}

在进行完红黑树查找之后,如果发现是一个ADD操作,并且在树中没有找到对应的二叉树节点,就会调用ep_insert进行二叉树节点的增加

case EPOLL_CTL_ADD:
    if (!epi) {
        epds.events |= POLLERR | POLLHUP;
        error = ep_insert(ep, &epds, tf.file, fd, full_check);
    } else
        error = -EEXIST;
    if (full_check)
        clear_tfile_check_list();
    break;

ep_insert

ep_insert 首先判断当前监控的文件值是否超过了 /proc/sys/fs/epoll/max_user_watches 的预设最大值,如果超过了就直接返回错误

user_watches = atomic_long_read(&ep->user->epoll_watches);
if (unlikely(user_watches >= max_user_watches))
    return -ENOSPC;

接下来是分配资源和初始化动作:

if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL)))
        return -ENOMEM;
 
    /* Item initialization follow here ... */
    INIT_LIST_HEAD(&epi->rdllink);
    INIT_LIST_HEAD(&epi->fllink);
    INIT_LIST_HEAD(&epi->pwqlist);
    epi->ep = ep;
    ep_set_ffd(&epi->ffd, tfile, fd);
    epi->event = *event;
    epi->nwait = 0;
    epi->next = EP_UNACTIVE_PTR;

再接下来的事情非常重要,ep_insert 会为加入的每个文件描述字设置回调函数。这个回调函数是通过函数 ep_ptable_queue_proc 来进行设置的。这个回调函数是干什么的呢?其实,对应的文件描述字上如果有事件发生,就会调用这个函数,比如套接字缓冲区有数据了,就会回调这个函数。这个函数就是 ep_poll_callback。

/*
 * This is the callback that is used to add our wait queue to the
 * target file wakeup lists.
 */
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,poll_table *pt)
{
    struct epitem *epi = ep_item_from_epqueue(pt);
    struct eppoll_entry *pwq;
 
    if (epi>nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL))) {
        init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
        pwq->whead = whead;
        pwq->base = epi;
        if (epi->event.events & EPOLLEXCLUSIVE)
            add_wait_queue_exclusive(whead, &pwq->wait);
        else
            add_wait_queue(whead, &pwq->wait);
        list_add_tail(&pwq->llink, &epi->pwqlist);
        epi->nwait++;
    } else {
        /* We have to signal that an error occurred */
        epi->nwait = -1;
    }
}

ep_poll_callback

ep_poll_callback函数的作用非常重要,它将内核事件真正的和epoll对象联系了起来。它又是怎么实现的呢?

首先,通过这个文件的 wait_queue_entry_t 对象找到对应的 epitem 对象,因为 eppoll_entry 对象里保存了 wait_quue_entry_t,根据 wait_quue_entry_t 这个对象的地址就可以简单计算出 eppoll_entry 对象的地址,从而可以获得 epitem 对象的地址。这部分工作在 ep_item_from_wait 函数中完成。一旦获得 epitem 对象,就可以寻迹找到 eventpoll 实例。

/*
 * This is the callback that is passed to the wait queue wakeup
 * mechanism. It is called by the stored file descriptors when they
 * have events to report.
 */
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
    int pwake = 0;
    unsigned long flags;
    struct epitem *epi = ep_item_from_wait(wait);
    struct eventpoll *ep = epi->ep;

接下来,进行一个加锁操作。

spin_lock_irqsave(&ep->lock, flags);

下面对发生的事件进行过滤,为什么需要过滤呢?为了性能考虑,ep_insert向对应监控文件注册的是所有的事件,而实际用户侧订阅的事件未必和内核事件相对应。比如,用户向内核订阅了一个套接字的可读事件,在某个时刻套接字的可写事件发生时,并不需要向用户空间传递这个事件。

/*
 * Check the events coming with the callback. At this stage, not
 * every device reports the events in the "key" parameter of the
 * callback. We need to be able to handle both cases here, hence the
 * test for "key" != NULL before the event match test.
 */
if (key && !((unsigned long) key & epi->event.events))
    goto out_unlock;

接下来,判断是否需要把该事件传递给用户空间。

if (unlikely(ep->ovflist != EP_UNACTIVE_PTR)) {
  if (epi->next == EP_UNACTIVE_PTR) {
      epi->next = ep->ovflist;
      ep->ovflist = epi;
      if (epi->ws) {
          /*
           * Activate ep->ws since epi->ws may get
           * deactivated at any time.
           */
          __pm_stay_awake(ep->ws);
      }
  }
  goto out_unlock;
}

如果需要,而且该事件对应的event_item不在eventpoll对应的已完成队列中,就把它放入该队列,以便将该事件传递给用户空间

/* If this file is already in the ready list we exit soon */
if (!ep_is_linked(&epi->rdllink)) {
    list_add_tail(&epi->rdllink, &ep->rdllist);
    ep_pm_stay_awake_rcu(epi);
}

我们知道,当我们调用 epoll_wait 的时候,调用进程被挂起,在内核看来调用进程陷入休眠。如果该 epoll 实例上对应描述字有事件发生,这个休眠进程应该被唤醒,以便及时处理事件。下面的代码就是起这个作用的,wake_up_locked 函数唤醒当前 eventpoll 上的等待进程。

/*
 * Wake up ( if active ) both the eventpoll wait list and the ->poll()
 * wait list.
 */
if (waitqueue_active(&ep->wq)) {
    if ((epi->event.events & EPOLLEXCLUSIVE) &&
                !((unsigned long)key & POLLFREE)) {
        switch ((unsigned long)key & EPOLLINOUT_BITS) {
        case POLLIN:
            if (epi->event.events & POLLIN)
                ewake = 1;
            break;
        case POLLOUT:
            if (epi->event.events & POLLOUT)
                ewake = 1;
            break;
        case 0:
            ewake = 1;
            break;
        }
    }
    wake_up_locked(&ep->wq);
}

epoll_wait

查找 epoll 实例

epoll_wait 函数首先进行一系列的检查,例如传入的 maxevents 应该大于 0。

/* The maximum number of event must be greater than zero */
if (maxevents <= 0 || maxevents > EP_MAX_EVENTS)
    return -EINVAL;
 
/* Verify that the area passed by the user is writeable */
if (!access_ok(VERIFY_WRITE, events, maxevents * sizeof(struct epoll_event)))
    return -EFAULT;`

和前面介绍的 epoll_ctl 一样,通过 epoll 实例找到对应的匿名文件和描述字,并且进行检查和验证。

/* Get the "struct file *" for the eventpoll file */
f = fdget(epfd);
if (!f.file)
    return -EBADF;
 
/*
 * We have to check that the file structure underneath the fd
 * the user passed to us _is_ an eventpoll file.
 */
error = -EINVAL;
if (!is_file_epoll(f.file))
    goto error_fput;

还是通过读取 epoll 实例对应匿名文件的 private_data 得到 eventpoll 实例。

/*
 * At this point it is safe to assume that the "private_data" contains
 * our own data structure.
 */
ep = f.file->private_data;

接下来调用ep_poll来完成对应的事件收集并传递到用户空间

/* Time to fish for events ... */
error = ep_poll(ep, events, maxevents, timeout);

ep_poll

ep_poll 就分别对 timeout 不同值的场景进行了处理。如果大于则产生一个超时时间,如果等于就立即检查是否有事件发生

*/
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,int maxevents, long timeout)
{
int res = 0, eavail, timed_out = 0;
unsigned long flags;
u64 slack = 0;
wait_queue_entry_t wait;
ktime_t expires, *to = NULL;
 
if (timeout > 0) {
    struct timespec64 end_time = ep_set_mstimeout(timeout);
    slack = select_estimate_accuracy(&end_time);
    to = &expires;
    *to = timespec64_to_ktime(end_time);
} else if (timeout == 0) {
    /*
     * Avoid the unnecessary trip to the wait queue loop, if the
     * caller specified a non blocking operation.
     */
    timed_out = 1;
    spin_lock_irqsave(&ep->lock, flags);
    goto check_events;
}

接下来尝试获得 eventpoll 上的锁:

spin_lock_irqsave(&ep->lock, flags);

获得这把锁之后,检查当前是否有事件发生,如果没有,就把当前进程加入到eventpoll的等待队列wq中,这样做的目的是当事件发生了,ep_poll_callback函数可以把该等待进程唤醒。

if (!ep_events_available(ep)) {
    /*
     * Busy poll timed out.  Drop NAPI ID for now, we can add
     * it back in when we have moved a socket with a valid NAPI
     * ID onto the ready list.
     */
    ep_reset_busy_poll_napi_id(ep);
 
    /*
     * We don't have any available event to return to the caller.
     * We need to sleep here, and we will be wake up by
     * ep_poll_callback() when events will become available.
     */
    init_waitqueue_entry(&wait, current);
    __add_wait_queue_exclusive(&ep->wq, &wait);

紧接着是一个无限循环,这个循环中通过调用schedule_hrtimeout_range,将当前进程陷入休眠,CPU时间被调度器调度给其他进程使用,当然,当前进程可能会被唤醒,唤醒的条件包括下面四种:

  • 当前进程超时
  • 当前进程收到一个signal信号
  • 某个描述符上有事件发生
  • 当前进程被CPU重新调度,进入for循环重新排队,如果没有满足前三个条件,就又重新进入休眠

前三个条件会break跳出循环,直接返回

// 这个循环里,当前进程可能会被唤醒,唤醒的途径包括
//1. 当前进程超时
//2. 当前进行收到一个 signal 信号
//3. 某个描述字上有事件发生
// 对应的 1.2.3 都会通过 break 跳出循环
// 第 4 个可能是当前进程被 CPU 重新调度,进入 for 循环的判断,如果没有满足 1.2.3 的条件,就又重新进入休眠
for (;;) {
    /*
     * We don't want to sleep if the ep_poll_callback() sends us
     * a wakeup in between. That's why we set the task state
     * to TASK_INTERRUPTIBLE before doing the checks.
     */
    set_current_state(TASK_INTERRUPTIBLE);
    /*
     * Always short-circuit for fatal signals to allow
     * threads to make a timely exit without the chance of
     * finding more events available and fetching
     * repeatedly.
     */
    if (fatal_signal_pending(current)) {
        res = -EINTR;
        break;
    }
    if (ep_events_available(ep) || timed_out)
        break;
    if (signal_pending(current)) {
        res = -EINTR;
        break;
    }
 
    spin_unlock_irqrestore(&ep->lock, flags);
 
    // 通过调用 schedule_hrtimeout_range,当前进程进入休眠,CPU 时间被调度器调度给其他进程使用
    if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
        timed_out = 1;
 
    spin_lock_irqsave(&ep->lock, flags);
}

如果进程从休眠中返回,则将当前进程从 eventpoll 的等待队列中删除,并且设置当前进程为 TASK_RUNNING 状态。

// 从休眠中结束,将当前进程从 wait 队列中删除,设置状态为 TASK_RUNNING,接下来进入 check_events,来判断是否是有事件发生
    __remove_wait_queue(&ep->wq, &wait);
    __set_current_state(TASK_RUNNING);

最后,调用ep_send_events将事件拷贝到用户空间

//ep_send_events 将事件拷贝到用户空间
/*
 * Try to transfer events to user space. In case we get 0 events and
 * there's still timeout left over, we go trying again in search of
 * more luck.
 */
if (!res && eavail &&
    !(res = ep_send_events(ep, events, maxevents)) && !timed_out)
    goto fetch_events;
 
 
return res;

ep_send_events

ep_send_events这个函数会将ep_send_events_proc作为回调函数并调用ep_scan_ready_list 函数,ep_scan_ready_list 函数调用ep_send_events_proc 对每个已经就绪的事件循环处理。

ep_send_events_proc 循环处理就绪事件,会再次调用每个文件描述符的poll方法,以便确定确实有事件发生。为什么这样做呢?这是为了确定注册的事件在这个时刻还是有效的。

可以看到,尽管 ep_send_events_proc 已经尽可能的考虑周全,使得用户空间获得的事件通知都是真实有效的,但还是有一定的概率,当 ep_send_events_proc 再次调用文件上的 poll 函数之后,用户空间获得的事件通知已经不再有效,这可能是用户空间已经处理掉了,或者其他什么情形。比如如果套接字不是非阻塞的,整个进程将会被阻塞,这也是为什么将非阻塞套接字配合 epoll 使用作为最佳实践的原因。

在进行简单的事件掩码校验之后,ep_send_events_proc 将事件结构体拷贝到用户空间需要的数据结构中。这是通过 __put_user 方法完成的。

// 这里对一个 fd 再次进行 poll 操作,以确认事件
revents = ep_item_poll(epi, &pt);
 
/*
 * If the event mask intersect the caller-requested one,
 * deliver the event to userspace. Again, ep_scan_ready_list()
 * is holding "mtx", so no operations coming from userspace
 * can change the item.
 */
if (revents) {
    if (__put_user(revents, &uevent->events) ||
        __put_user(epi->event.data, &uevent->data)) {
        list_add(&epi->rdllink, head);
        ep_pm_stay_awake(epi);
        return eventcnt ? eventcnt : -EFAULT;
    }
    eventcnt++;
    uevent++;

Level-triggered VS Edge-triggered

在 ep_send_events_proc 函数的最后,针对 level-triggered 情况,当前的 epoll_item 对象被重新加到 eventpoll 的就绪列表中,这样在下一次 epoll_wait 调用时,这些 epoll_item 对象就会被重新处理。

在前面我们提到,在最终拷贝到用户空间有效事件列表中之前,会调用对应文件的poll方法,以确定这个事件是不是依然有效。所以,如果用户空间程序已经处理掉该事件,就不会被再次通知;如果没有处理,意味着该事件依然有效,就会被再次通知

// 这里是 Level-triggered 的处理,可以看到,在 Level-triggered 的情况下,这个事件被重新加回到 ready list 里面
// 这样,下一轮 epoll_wait 的时候,这个事件会被重新 check
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);
}

epoll触发模式

select(),poll()模型都是水平触发模式,信号驱动IO是边缘触发模式,epoll()模型即支持水平触发,也支持边缘触发,默认是水平触发 。

简单点描述就是:网卡收到包以后,会把它的接口设置一个电势差。操作系统的驱动层其实是会一直去扫描网卡的接口的电势差的。

  • LT: 水平触发,就是只要电势差存在就触发。在接收包的队列清空以前不reset网卡接口的电势差。也就是说你不接收完包就会一直触发中断事件。

  • ET:
    边缘触发,就是发现电势差存在以后,先把电势差reset了,然后触发中断事件。于是乎你在应用层就要尝试把网卡的队列清空,否则后面就没有事件通知了。

条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断的把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

一般我们认为,边缘触发的效率比条件触发的效率高,这一点也是epoll的杀手锏之一。

水平触发(TL)

  • level trigger,LT
  • 默认工作模式
  • 只要内核缓冲区有数据就一直通知,只要socket处于可读状态或可写状态,就会一直返回sockfd
  • LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket
  • 当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。

  • 使用此种模式,当数据可读的时候,epoll_wait()将会一直返回就绪事件。如果你没有处理完全部数据,并且再次在该epoll实例上调用epoll_wait()才监听描述符的时候,它将会再次返回就绪事件,因为有数据可读

在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你 的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表

边缘触发

  • edge trigger,ET
  • 只有状态发生变化才通知,只有当socket由不可写到可写或由不可读到可读,才会返回其sockfd
  • ET (edge-triggered)是高速工作方式,只支持no-block socket
  • 当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

  • 使用此种模式,只能获取一次就绪通知,如果没有处理完全部数据,并且再次调用epoll_wait()的时候,它将会阻塞,因为就绪事件已经释放出来了

  • 使用 epoll 的边缘触发通知在语义上类似于信号驱动 I/O,只是如何有多个IO事件发生的话,epoll会将它们合并成一次单独的通知,通过epoll_wait返回,而在信号驱动IO则可能会产生多个信号

ET (edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述 符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致 了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

设置

要使用边缘触发通知,我们在调用epoll_ctl时在ev.events字段中指定EPOLLET标志:

		struct epoll_event ev;
		
		ev.data.fd = fd;
		ev.evengs = EPOLLIN | EPOLLET;
		if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1){
            perror("epoll_ctl EPOLL_CTL_ADD");
            exit(EXIT_FAILURE);
        }

采用epoll的边缘触发通知机制的程序的基本框架

边缘触发通知通常和非阻塞的文件描述符结合使用。因此,采用epoll的边缘触发通知机制的程序的基本框架如下:

  • 让所有待监视的文件描述符都成为非阻塞的
  • 通过epoll_ctl构建epoll的兴趣列表
  • 通过如下的循环处理IO事件
    • 通过epoll_wait()取得出于就绪态的描述符列表
    • 针对每一个出于就绪态的文件描述符,不断进行IO处理直到相关的系统调(例如 read()、write()、recv()、send()或 accept())返回 EAGAIN 或 EWOULDBLOCK
      错误)

当采用边缘触发通知时避免出现文件描述符饥饿现象

什么叫做文件描述符饥饿现象
  • 假设我们采用边缘触发通知监视多个文件描述符,其中一个处于就绪态的文件描述符上有着大量的输入存在(可能是一个不间断的输入流)。

  • 如果在检测到该文件描述符处于就绪态后,我们将尝试通过非阻塞式的读操作将所有的输入都读取,那么此时就会有使其他的文件描述符处于饥饿状态的风险存在(即,在我们再次检查这些文件描述符是否处于就绪态并执行 I/O 操作前会有很长的一段处理时间)。

解决方案

该问题的一种解决方案是让应用程序维护一个列表,列表中存放着已经被通知为就绪态的文件描述符。通过一个循环按照如下方式不断处理。

  • 调用 epoll_wait()监视文件描述符,并将处于就绪态的描述符添加到应用程序维护的列表中
    • 如果这个文件描述符已经注册到应用程序维护的列表中了,那么这次监视操作的超时时间应该设为较小的值或者是 0。
    • 这样如果没有新的文件描述符成为就绪态,应用程序就可以迅速进行到下一步,去处理那些已经处于就绪态的文件描述符了。
  • 在应用程序维护的列表中,只在那些已经注册为就绪态的文件描述符上进行一定限度的 I/O 操作
    • (可能是以轮转调度(round-robin)方式循环处理,而不是每次epoll_wait()调用后都从列表头开始处理)。
    • 当相关的非阻塞 I/O 系统调用出现 EAGAIN 或EWOULDBLOCK 错误时,文件描述符就可以从应用程序维护的列表中移除了。

因为信号驱动 I/O 也是采用的边缘触发通知机制,因此也需要考虑文件描述符饥饿的情况。

水平触发有没有文件描述符饥饿现象

与之相反,在采用水平触发通知机制的应用程序中,考虑文件描述符饥饿的情况并不是必须的。

  • 这是因为我们可以采用水平触发通知在非阻塞式的文件描述符上通过循环连续地检查描述符的就绪状态
  • 然后在下一次检查文件描述符的状态前在处于就绪态的描述符上做一些 I/O 处理就可以了

实例

实例1:不读出数据的ET和LT

#include <stdio.h>
#include <unistd.h>
#include <sys/epoll.h>

int main(void)
{
    int epfd,nfds;

    //用于添加事件
    struct epoll_event ev;

    //用于存放检测到的事件
    struct epoll_event events[5];

    //创建一个epoll实例
    epfd = epoll_create(1);

    //设置事件参数
    ev.data.fd =STDIN_FILENO;

    //设置为检测读事件,默认时水平触发(LT)
    ev.events = EPOLLIN;

    //设置为检测读事件,默认时边沿触发(ET)
  //  ev.events = EPOLLIN | EPOLLET;

    //注册事件
    epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);

    while(1)
    {
        //等待事件发生,返回值是发生事件的数目
        nfds = epoll_wait(epfd,events,5,-1);

        int i;

        //遍历处理
        for(i=0;i<nfds;++i)
        {
            if(!(events[i].events & EPOLLIN))
            {
                continue;
            }

            if(events[i].data.fd == STDIN_FILENO)
            {
                printf("welcome to epoll's world!\n");
            }
        }
    }

    //关闭描述符(句柄)
    close(epfd);


    return 0;
}

现象

当前为LT模式,并且数据没有读走:

程序出现死循环,因为用户输入任意数据之后,数据被送入buffer而且没有被读出,所以LT模式下每次epoll_wait都认为buffer可读返回读就绪,导致不断输出"welcome to epoll’s world!\n"
在这里插入图片描述

当前为ET模式,并且数据没有读走:

  • 当用户输入一组字符,这组字符被送入buffer,字符停留在buffer中,又因为buffer由空变为不空,所以ET返回读就绪,输出"welcome to epoll’s world!\n"
  • 之后程序再次执行epoll_wait,此时虽然buffer中有内容可读,但是ET并不返回就绪(只有当buffer由空变为不空或者有新数据到达才返回就绪),导致epoll_wait阻塞。(底层原因是ET下就绪fd的epitem只被放入rdlist一次)。
  • 用户再次输入一组字符,导致buffer中的内容增多(有新数据到达),这将导致fd状态的改变,对应的epitem再次加入rdlist,从而使epoll_wait返回读就绪,再次输出“hello world!”。
    在这里插入图片描述

实例2:读出数据的ET和LT

#include <stdio.h>
#include <unistd.h>
#include <sys/epoll.h>

int main(void)
{
    int epfd,nfds;

    //用于添加事件
    struct epoll_event ev;

    //用于存放检测到的事件
    struct epoll_event events[5];

    //创建一个epoll实例
    epfd = epoll_create(1);

    //设置事件参数
    ev.data.fd =STDIN_FILENO;

    //设置为检测读事件,默认时水平触发(LT)
    ev.events = EPOLLIN;

    //设置为检测读事件,默认时边沿触发(ET)
   // ev.events = EPOLLIN | EPOLLET;

    //注册事件
    epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);

    while(1)
    {
        //等待事件发生,返回值是发生事件的数目
        nfds = epoll_wait(epfd,events,5,-1);

        int i;

        //遍历处理
        for(i=0;i<nfds;++i)
        {
            if(!(events[i].events & EPOLLIN))
            {
                continue;
            }

            if(events[i].data.fd == STDIN_FILENO)
            {
                char buffer[256] = {0};

                read(STDIN_FILENO,buffer,sizeof(buffer));

                printf("input:%s",buffer);

                printf("welcome to epoll's world!\n");
            }
        }
    }

    //关闭描述符(句柄)
    close(epfd);


    return 0;
}

现象

当前为LT模式,并且数据读走了:

  • 首次调用epoll_wait时,STDIN_FILENO描述符上没有数据可读,因此阻塞
  • 然后用户输入数据,epoll_wait发现buffer中有数据可读,因此不在阻塞,就会返回读就绪
  • 返回后,程序使用了将buffer(缓冲)中的内容read出来,也就是说buffer已经清空了,因此,下一次再调用epoll_wait就会阻塞

简单的来说,LT模式下每次epoll_wait返回读就绪的时候我们都将buffer(缓冲)中的内容read出来,所以导致buffer再次清空,下次调用epoll_wait就会阻塞。

在这里插入图片描述

当前为ET模式,并且数据读走了:

  • 刚开始时,epoll_wait会阻塞,直到用户输入数据,此时buffer由空变为非空,epoll_wait就会将当前epitem加入就绪列表,然后返回读就绪
  • 当epoll_wait返回后,程序调用read就buffer中的内容清空。因此,下一个调用epoll_wait时仍会阻塞,也就是回到了第一步

在这里插入图片描述

实例3:写数据的ET模式

#include <unistd.h>
#include <iostream>
#include <sys/epoll.h>
using namespace std;
int main(void)
{
    int epfd,nfds;
    struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
    epfd=epoll_create(1);//只需要监听一个描述符——标准输出
    ev.data.fd=STDOUT_FILENO;
    ev.events=EPOLLOUT|EPOLLET;//监听读状态同时设置ET模式
    epoll_ctl(epfd,EPOLL_CTL_ADD,STDOUT_FILENO,&ev);//注册epoll事件
    //   cout<<"****";
    for(;;)
    {
     //   cout<<"****";
        nfds=epoll_wait(epfd,events,5,-1);
        for(int i=0;i<nfds;i++)
        {
            if(events[i].data.fd==STDOUT_FILENO){
                cout<<"hello world!\n";
              //  cout<<"hello world!";
            }

        }
    }
};

现象

cout<<"hello world!\n";

  • 初始时buffer为空,buffer中有空间可以写,因此ET将对应的epitme加入rdlist,导致epoll_wait返回写就绪
  • 程序想标准输出输出”hello world!”和换行符,因为标准输出为控制台时缓冲是“行缓冲”,所以换行符导致buffer中的内容清空 -----当有旧数据被发送走的时候,也就是说buffer中待写的内容变少得时候会触发fd状态的改变。所以下次epoll_wait会返回写就绪。之后重复这个过程一直循环下去
    在这里插入图片描述

cout<<"hello world!"

我们看到程序成挂起状态。

  • 因为第一个epoll_wait返回写就绪之后,程序向标准输出的buffer中写入“hello world!"”,所以buffer中的内容一直存在,下次epoll_wait的时候,虽然有写空间但是ET模式下不再返回写就绪。这种情况的原因是第一次buffer为空,导致epitem加入rdlist,返回一次就绪后移除此epitem,之后虽然buffer仍然可写,但是由于对应epitem已经不在rdlist中,就不会对其就绪fd的events的在检测了
    在这里插入图片描述

实例4:写数据的LT模式

#include <unistd.h>
#include <iostream>
#include <sys/epoll.h>
using namespace std;
int main(void)
{
    int epfd,nfds;
    struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
    epfd=epoll_create(1);//只需要监听一个描述符——标准输出
    ev.data.fd=STDOUT_FILENO;
    ev.events=EPOLLOUT;//监听读状态同时设置ET模式
    epoll_ctl(epfd,EPOLL_CTL_ADD,STDOUT_FILENO,&ev);//注册epoll事件

    for(;;)
    {

        nfds=epoll_wait(epfd,events,5,-1);
        for(int i=0;i<nfds;i++)
        {
            if(events[i].data.fd==STDOUT_FILENO){
                //  cout<<"hello world!\n";
               cout<<"hello world!";
            }

        }
    }
};

使用默认的LT模式,程序再次死循环。这时候原因已经很清楚了,因为当向buffer写入”hello world!”后,虽然buffer没有输出清空,但是LT模式下只要buffer有写空间就返回写就绪,所以会一直输出”hello world!”,当buffer满的时候,buffer会自动刷清输出,同样会造成epoll_wait返回写就绪。

问题

ET模式下的读写注意事项

解决方法(非阻塞模式)

经过前面的分析,我们可以知道,当epoll工作在ET模式下时:

  • 对于读操作,如果read一次没有读尽buffer中的数据,那么下次将得不到读就绪的通知,造成buffer中的已有数据没有机会读出,除非有新的数据再次到达
  • 对于写操作,主要是因为ET模式下fd通常为非阻塞造成的一个问题-----如何保证将用户要求的数据写完

要解决上面两个ET模式下的读写问题,我们必须实现:

  • 对于读,只要buffer中还有数据就一直读
  • 对于写,只要buffer还有空间且用户请求写的数据还未写完,就一直写
if (events[i].events & EPOLLIN) 
{
	n = 0;
	// 一直读直到返回0或者 errno = EAGAIN
    while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) 
  	{
  		n += nread;
    }
 	if (nread == -1 && errno != EAGAIN) 
	{
		perror("read error");
	}
    ev.data.fd = fd;
    ev.events = events[i].events | EPOLLOUT;
    epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
}

if (events[i].events & EPOLLOUT) 
{ 
	int nwrite, data_size = strlen(buf);
	n = data_size;
	while (n > 0) 
	{
		nwrite = write(fd, buf + data_size - n, n);
		if (nwrite < n) 
		{
			if (nwrite == -1 && errno != EAGAIN) 
			{
				perror("write error");
			}
			break;
		}
		n -= nwrite;
	}
	ev.data.fd=fd; 
	ev.events=EPOLLIN|EPOLLET; 
	epoll_ctl(epfd,EPOLL_CTL_MOD,fd,&ev);  //修改sockfd上要处理的事件为EPOLIN
} 

使用这种方式一定要使得每个连接的套接字工作在非阻塞模式,因为读写需要一直读或者写直到出错(对于读,当读到的实际字节数小于请求字节数时就可以停止),而如果你的文件描述符如果不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞(最后一次read肯定要返回0,表示缓冲区没有数据可读了,因此最后一次read会阻塞)。这样就不能在阻塞在epoll_wait上了,造成其他文件描述符的任务饿死。

EAGAIN

在Linux环境下开发经常会碰到很多错误(设置errno),其中EAGAIN是其中比较常见的一个错误(比如用在非阻塞操作中)。

  • 从字面上来看,是提示再试一次。这个错误经常出现在当应用程序进行一些非阻塞(non-blocking)操作(对文件或socket)的时候。
    • 如果你连续做read操作而没有数据可读。
    • 此时程序不会阻塞起来等待数据准备就绪返回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。
  • 又例如,当一个系统调用(比如fork)因为没有足够的资源(比如虚拟内存)而执行失败,返回EAGAIN提示其再调用一次(也许下次就能成功)。

上述方法中写操作的改进

仔细分析上述的写操作,我们发现这种方式并不很完美,因为写操作返回EAGAIN就终止写,但是返回EAGAIN只能说名当前buffer已满不可写,并不能保证用户(或服务端)要求写的数据已经写完。那么如何保证对非阻塞的套接字写够请求的字节数才返回呢(阻塞的套接字直到将请求写的字节数写完才返回)?

我们需要封装socket_write()的函数用来处理这种情况,该函数会尽量将数据写完再返回,返回-1表示出错。在socket_write()内部,当写缓冲已满(send()返回-1,且errno为EAGAIN),那么会等待后再重试

ssize_t socket_write(int sockfd, const char* buffer, size_t buflen)
{
	ssize_t tmp;
  	size_t total = buflen;
  	const char* p = buffer;
  	while(1)
  	{
    	tmp = write(sockfd, p, total);
    	if(tmp < 0)
    	{
	      // 当send收到信号时,可以继续写,但这里返回-1.
 	     if(errno == EINTR)
  	      return -1;
  	    // 当socket是非阻塞时,如返回此错误,表示写缓冲队列已满,
  	    // 在这里做延时后再重试.
 	    if(errno == EAGAIN)
 	    {
 	    	usleep(1000);
        	continue;
      	}
      	return -1;
    }
    if((size_t)tmp == total)
    	return buflen;
    total -= tmp;
    p += tmp;
  }
  return tmp;//返回已写字节数
}

ET模式下的accept注意事项

考虑这种情况:多个连接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪连接,由于是边缘触发模式,epoll 只会通知一次,accept 只处理一个连接,导致 TCP 就绪队列中剩下的连接都得不到处理

解决办法是用while 循环抱住 accept 调用,处理完 TCP 就绪队列中的所有连接后再退出循环。如何知道是否处理完就绪队列中的所有连接呢?accept 返回 -1 并且 errno 设置为 EAGAIN 就表示所有连接都处理完

while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote, 
		(size_t *)&addrlen)) > 0)
{
	handle_client(conn_sock);
}
if (conn_sock == -1)
{
	if (errno != EAGAIN && errno != ECONNABORTED && 
		errno != EPROTO && errno != EINTR)
		perror("accept");
}

多路IO复用accept为什么应该工作在非阻塞模式

如果accept工作在阻塞模式,考虑这种情况:TCP连接被客户端夭折,即在服务器调用 accept 之前(此时select等已经返回连接到达读就绪),客户端主动发送RST终止连接,导致刚刚建立的连接从就绪队列中移除,如果套接字被设置成阻塞模式,服务器就会一直阻塞在accept调用上,直到其他某个客户建立一个新的连接为止。但是在此期间,服务器单纯的阻塞在accept调用上(实际应该阻塞在select上),就绪队列中的其他描述符都得不到处理。

解决方法是把监听套接字设置为非阻塞,当客户在服务器调用accept之前终止某个连接时,accept调用可以立即返回-1。这时源自 Berkeley 的实现会在内核中处理该事件,并不会将该事件通知给 epoll,而其他实现把 errno 设置为 ECONNABORTED 或者 EPROTO 错误,我们应该忽略这两个错误

LT模式下会不停触发socket可写事件,如何处理?

  • 需要向socket写数据的时候才把socket加入epoll,等待可写事件。接收到可写事件后,调用write或者send发送数据。接受到可写事件后,调用write或者send发送数据。当所有数据都写完后,把socket移出epoll。
  • 使用ET模式(边沿触发),这样socket有可写事件,只会触发一次。
  • 在epoll_ctl()使用EPOLLONESHOT标志,当事件触发以后,socket会被禁止再次触发。

epoll读到一半又有新事件来了怎么办

最直接的方法: 不要多个线程读写同一个 TCP 连接

待研究的方法: EPOLL_ONESHOT

Epoll+LT 模式下,客户端发异常报文,如何处理

回答:

  • 这个其实和 epoll 或者 LT 模式没关系的,其实就是客户端不根据协议规定格式,给服务器发送非法数据。服务器的资源是有限的,要尽可能给更多的正常客户端服务,对于这类不按协议格式发送数据的客户端即可认为是非法客户端,一般直接关闭该路连接就行了。
  • 当然,在实际的商业项目中,如果一个主机或者ip总是这样非法发数据,会被记录到服务器的黑名单中,直接在连接时就断开了。

回答:

  • 客户端不按协议来,是客户端的锅,发现边界不对或第二个包头特征不对时就应该关闭连接了

回答:

协议设计的问题,一般的原则是:

  • 无论客户端发送什么都不可以对服务端造成致命影响,比如崩溃或者拒绝服务
  • 无论客户端发送什么都不可以对其他用户或者有价值的数据造成破坏
  • 除此之外的后果可以让这个客户端自己负责

因此一般的设计方法是:

  • 如果解析某个字段可能导致安全问题,比如使用了过多的内存导致内存溢出等等,则相关的字段需要进行强校验;
  • 如果客户端长时间保持连接不响应可能导致连接耗尽,则相应的步骤需要设超时(实际上由于TCP的特性,所有的状态都应该有超时)
  • 对于需要授权的操作必须进行认证
  • 除此以外的问题都通过异常处理来解决,一旦发现协议解析失败就返回错误或者直接断开连接

用Epoll并不会改变这个结论

顺便说一下,checksum并没有太大作用,它只能防止物理故障和无意导致的错误,无法防止故意发送的错误,而且TCP层已经有checksum了。magic可以部分防止接受到错误协议的请求,但也并不根本性解决问题。

来自

使用epoll时需要将socket设为非阻塞吗?【待研究】

一个 socket 是否设置为阻塞模式,只会影响到 connect/accept/send/recv 等四个 socket API 函数,不会影响到 select/poll/epoll_wait 函数,后三个函数的超时或者阻塞时间是由其函数自身参数控制的。

来自

从实现的角度看epoll/poll/select

我们从实现角度来说明一下为什么epoll的效率要远远高于poll/select。

(1)首先,poll/select先将要监听的fd从用户空间拷贝到内核空间,然后在内核空间里面进行处理之后,在拷贝给用户空间。这里面就设计到内核空间申请内存、释放内存等过程,这在大量fd的情况下,是非常耗时的。而epoll维护了一个红黑树,通过对这颗红黑树进行操作,可以避免大量的内存申请和释放,而且查找速度会非常快

下面的代码就是poll/select在内核空间申请内存的展示。可以看到select先尝试申请栈上资源,如果需要监听的fd比较多,就会去申请堆空间的资源

int core_sys_select(int n, fd_set __user *inp, fd_set __user *outp,
               fd_set __user *exp, struct timespec64 *end_time)
{
    fd_set_bits fds;
    void *bits;
    int ret, max_fds;
    size_t size, alloc_size;
    struct fdtable *fdt;
    /* Allocate small arguments on the stack to save memory and be faster */
    long stack_fds[SELECT_STACK_ALLOC/sizeof(long)];
 
    ret = -EINVAL;
    if (n < 0)
        goto out_nofds;
 
    /* max_fds can increase, so grab it once to avoid race */
    rcu_read_lock();
    fdt = files_fdtable(current->files);
    max_fds = fdt->max_fds;
    rcu_read_unlock();
    if (n > max_fds)
        n = max_fds;
 
    /*
     * We need 6 bitmaps (in/out/ex for both incoming and outgoing),
     * since we used fdset we need to allocate memory in units of
     * long-words. 
     */
    size = FDS_BYTES(n);
    bits = stack_fds;
    if (size > sizeof(stack_fds) / 6) {
        /* Not enough space in on-stack array; must use kmalloc */
        ret = -ENOMEM;
        if (size > (SIZE_MAX / 6))
            goto out_nofds;
 
 
        alloc_size = 6 * size;
        bits = kvmalloc(alloc_size, GFP_KERNEL);
        if (!bits)
            goto out_nofds;
    }
    fds.in      = bits;
    fds.out     = bits +   size;
    fds.ex      = bits + 2*size;
    fds.res_in  = bits + 3*size;
    fds.res_out = bits + 4*size;
    fds.res_ex  = bits + 5*size;
    ...

(2)select/poll从休眠中被唤醒时,如果监听多个fd,只要其中有一个fd有事件发生,内核就会遍历内部的list去检查到底是哪一个事件到达,并没有像epoll一样,通过fd直接关联eventpoll对象,快速的把fd直接加入的eventpoll的就绪列表中

static int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
{
    ...
    retval = 0;
    for (;;) {
        unsigned long *rinp, *routp, *rexp, *inp, *outp, *exp;
        bool can_busy_loop = false;
 
        inp = fds->in; outp = fds->out; exp = fds->ex;
        rinp = fds->res_in; routp = fds->res_out; rexp = fds->res_ex;
 
        for (i = 0; i < n; ++rinp, ++routp, ++rexp) {
            unsigned long in, out, ex, all_bits, bit = 1, mask, j;
            unsigned long res_in = 0, res_out = 0, res_ex = 0;
 
            in = *inp++; out = *outp++; ex = *exp++;
            all_bits = in | out | ex;
            if (all_bits == 0) {
                i += BITS_PER_LONG;
                continue;
            }
        
        if (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE,
                   to, slack))
        timed_out = 1;
...

总结

  • epoll维护了一颗红黑树来跟踪所有待检测的文件描述符,红黑树的使用减少了内核和用户空间大量的数据拷贝和内存分配,大大提高了性能
  • 同时,epoll 维护了一个链表来记录就绪事件,内核在每个文件有事件发生时将自己登记到这个就绪事件列表中,通过内核自身的文件 file-eventpoll 之间的回调和唤醒机制,减少了对内核描述字的遍历,大大加速了事件通知和检测的效率,这也为 level-triggered 和 edge-triggered 的实现带来了便利。

epoll 同 I/O 多路复用的性能对比

随着被监视的文件描述符数量的上升,poll()和 select()的性能表现越来越差。与之相反,当 N 增长到很大的值时,epoll 的性能表现几乎不会降低

为什么 epoll 的性能表现会更好?

(1)

  • 每次调用select和poll时,内核必须检查所有在调用中指定的文件描述符。
  • 与之相反,当通过epoll_ctl指定了需要监视的文件描述符时,内核会在与打开的文件描述上下文相关联的列表中记录该描述符。
  • 之后每当执行IO操作使得文件描述符成为就绪态时,内核就在epoll描述符的就绪列表中添加一个元素(单个打开的文件描述上下文中的一次 I/O 事件可能导致与之相关的多个文件描述符成为就绪态)。
  • 之后的epoll_wait()调用就从就绪列表中简单的取出这些元素

(2)

  • 每次调用select或者poll时,
    • 我们传递了一个标记了所有待监视的文件描述符的数据结构给内核,
    • 调用返回时,内核将所有标记为就绪态的文件描述符的数据结构再传回给我们。
  • 与之相反,在epoll中:
    • 我们使用epoll_ctl在内核空间中建立一个数据结构,该数据结构会将待监视的文件描述符都记录下来。
    • 一旦这个数据结构建立完成, 稍后每次调用epoll_wait时就需要不再传递任何与文件描述符相关的信息给内核了
    • 而调用返回的信息只包含那些已经出于就绪态的描述符

epoll API 的应用场景就是需要同时处理许多客户端的服务器:需要监视大量的文件描述符,但大部分出于空闲状态,只有少数文件描述符出于就绪态

实例

例子

#include <sys/epoll.h>
#include <fcntl.h>

#define MAX_BUF     1000        /* 单个 read() 获取的最大字节数  */
#define MAX_EVENTS     5        /* 从单个 epoll_wait() 调用返回的最大事件数 */
int main(int argc, char *argv[]){
    int epfd, ready, fd, s, j, numOpenFds;
    struct epoll_event ev;
    struct epoll_event evlist[MAX_EVENTS];
    char buf[MAX_BUF];

    if (argc < 2 || strcmp(argv[1], "--help") == 0){
        printf("%s file...\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    //创建一个 epoll 实例
    epfd = epoll_create(argc - 1);
    if (epfd == -1){
        perror("epoll_create");
        exit(EXIT_FAILURE);
    }

    /* Open each file on command line, and add it to the "interest
      list" for the epoll instance */

    for (j = 1; j < argc; j++) {
        // 打开由命令行参数指定的每个文件,以此作为输入
        fd = open(argv[j], O_RDONLY);
        if (fd == -1){
            perror("open");
            exit(EXIT_FAILURE);
        }
        printf("Opened \"%s\" on fd %d\n", argv[j], fd);

        //并将得到的文件描述符添加到epoll 实例的兴趣列表中。将需要检查的事件集合设定为 EPOLLIN
        ev.events = EPOLLIN;            /* Only interested in input events */
        ev.data.fd = fd;
        if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1){
            perror("epoll_ctl EPOLL_CTL_ADD");
            exit(EXIT_FAILURE);
        }
    }
    numOpenFds = argc - 1;
    while (numOpenFds > 0){
        /* 从 epoll 实例的就绪列表中获取最多 MAX_EVENTS 项  */
        /* Fetch up to MAX_EVENTS items from the ready list of the
          epoll instance */

        printf("About to epoll_wait()\n");
        ready = epoll_wait(epfd, evlist, MAX_EVENTS, -1);
        if (ready == -1) {
            //在 epoll_wait()调用之后,程序检查是否返回了 EINTR 错误码。如果在 epoll_wait()
            //调用执行期间程序被一个信号打断,之后又通过 SIGCONT 信号恢复执行,此时就可能出现这个错误。
            if (errno == EINTR)
                continue;               /* Restart if interrupted by signal */
            else{
                perror("epoll_wait");
                exit(EXIT_FAILURE);
            }
        }

        printf("Ready: %d\n", ready);

        /* 如果 epoll_wait()调用成功,程序就再执行一个内层循环检查 evlist 中每个已就绪的元素。*/
        for (j = 0; j < ready; j++) {
            printf("  fd=%d; events: %s%s%s\n", evlist[j].data.fd,
                   (evlist[j].events & EPOLLIN)  ? "EPOLLIN "  : "",
                   (evlist[j].events & EPOLLHUP) ? "EPOLLHUP " : "",   //在 FIFO 的对端关闭
                   (evlist[j].events & EPOLLERR) ? "EPOLLERR " : "");  //当终端挂起时出现

            if (evlist[j].events & EPOLLIN) {  //如果返回的是 EPOLLIN,程序从对应的文件描述符中读取一些输入并在标准输出上打印出来
                s = read(evlist[j].data.fd, buf, MAX_BUF);
                if (s == -1){
                    perror("read");
                    exit(EXIT_FAILURE);
                }
                printf("    read %d bytes: %.*s\n", s, s, buf);

            } else if (evlist[j].events & (EPOLLHUP | EPOLLERR)) {
                //如果返回的是 EPOLLHUP 或 EPOLLERR,程序就关闭对应的文件描述符⑩并递减打开文件数的统计量
                /* 在 epoll_wait() 之后,EPOLLIN 和 EPOLLHUP 可能都已设置。
                 * 但如果 EPOLLIN 没有设置,我们只会到这里,从而关闭文件描述符。
                 * 这确保在关闭文件描述符之前(通过进一步的循环迭代)消耗所有未完
                 * 成的输入(可能超过 MAX_BUF 字节)。  */

                printf("    closing fd %d\n", evlist[j].data.fd);
                if (close(evlist[j].data.fd) == -1){
                    perror("close");
                    exit(EXIT_FAILURE);
                }

                numOpenFds--;
            }
        }
    }
}

使用:如下用到了两个终端窗口,在其中一个窗口上用该程序来检查两个 FIFO 文件的输入。在另外一个窗口上,我们运行 cat(1)程序将数据写到这些 FIFO 中去
在这里插入图片描述
在这里插入图片描述

http://blog.chinaunix.net/uid-28541347-id-4285054.html

https://blog.csdn.net/daaikuaichuan/article/details/88777274

;