Epoll的使用

Epoll的使用

epoll是Linux特有的系统调用接口,用于监控多个文件描述符的I/O状态变化

int epoll_create(int size);   //创建epoll实例
  • 参数size指定了epoll实例所能处理的最大文件描述符数目,但实际上这个参数已经不再起作用,通常可以将其设置为任意值。
  • 返回值是一个非负整数,称为epoll实例的文件描述符,用于后续的操作。

epoll_event结构体

struct epoll_event {
    uint32_t events; /* Epoll events */
    epoll_data_t data; /* User data variable */
} __attribute.squareup(__packed__);
typedef union epoll_data {
    void* ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

events字段可以是以下值之一:

  • EPOLLIN:表示对应的文件描述符上有数据可读。
  • EPOLLOUT:表示对应的文件描述符上可以写入数据。
  • EPOLLRDHUP:表示对端已经关闭连接,或者关闭了写操作端的写入。
  • EPOLLPRI:表示有紧急数据可读。
  • EPOLLERR:表示发生错误。
  • EPOLLHUP:表示文件描述符被挂起。
  • EPOLLET:表示将epoll设置为边缘触发模式。
  • EPOLLONESHOT:表示将事件设置为一次性事件。

data字段的类型是一个union,可以存放一个指针或文件描述符等数据。用户可以将自己需要的数据存放到这个字段中,当事件触发时,epoll系统调用会返回这个数据,以便用户处理事件。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); //添加修改监控事件
  • 参数epfdepoll实例的文件描述符。
  • 参数op表示要执行的操作类型,可以是以下三种之一:
    • EPOLL_CTL_ADD:向epoll实例中添加新的文件描述符。
    • EPOLL_CTL_MOD:修改已有文件描述符的监听事件。
    • EPOLL_CTL_DEL:从epoll实例中删除文件描述符。
  • 参数fd是要进行操作的文件描述符。
  • 参数event是一个指向struct epoll_event结构体的指针,用于指定相关的事件和数据。
  • 返回值为0表示操作成功,-1表示操作失败。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);   //等待事件发生
  • 参数epfdepoll实例的文件描述符。
  • 参数events是一个指向struct epoll_event结构体数组的指针,用于接收就绪的事件。
  • 参数maxevents表示最多可以接收的事件数目。
  • 参数timeout表示等待的超时时间,单位为毫秒。如果设置为负数,则表示永久等待直到有事件到来。
  • 返回值为就绪事件的数目,如果返回值为0,表示超时但没有事件到来,-1表示出现错误。

Epoll的运行机制

epoll 是一种 IO 多路复用机制,使用的是一颗红黑树,可以随意的往这棵树上添加节点和删除节点。

epoll_create创建一个 epoll instance,实际上是创建了一个 eventpoll 实例,包含了红黑树以及一个双向链表。epoll 使用了一个双向链表来保存就绪的 socket,这样当活跃连接数不多的情况下,应用程序只需要遍历这个就绪链表就行了,而 select 没有这样一个用来存储就绪 socket 的东西,导致每次需要线性遍历所有socket,以确定是哪个或者哪几个 socket 就绪了。这里需要注意的是,这个就绪链表保存活跃链接,数量是较少的,也需要从内核空间拷贝到用户空间

/*
 * 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;

    ...
};

这个 eventpoll 实例是直接位于内核空间的。红黑树的叶子节点都是 epitem 结构体。

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 */
    struct list_head rdllink;

    /* The file descriptor information this item refers to */
    struct epoll_filefd ffd;

    /* The "container" of this item */
    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,即一个 socket 对应的 file descriptor,所以一个 epitem 实例与一个 socket fd 一一对应。 rdllink 这个变量,这个指向了上一步创建的 evnetpoll 实例中的成员变量 rdllist,也就是那个就绪链表。

epoll_struct_link

触发事件

内核收包路径

  1. 包从硬件网卡(NIC) 上进来之后,会触发一个中断,告诉 cpu 网卡上有包过来了,需要处理,同时通过 DMA(direct memory access) 的方式把包存放到内存的某个地方,这块内存通常称为 ring buffer,是网卡驱动程序初始化时候分配的。
  2. 当 cpu 收到这个中断后,会调用中断处理程序,这里的中断处理程序就是网卡驱动程序,因为网络硬件设备网卡需要驱动才能工作。网卡驱动会先关闭网卡上的中断请求,表示已经知晓网卡上有包进来的事情,同时也避免在处理过程中网卡再次触发中断,干扰或者降低处理性能。驱动程序启动软中断,继续处理数据包。
  3. 然后 CPU 激活 NAPI 子系统,由 NAPI 子系统来处理由网卡放到内存的数据包。经过一些列内核代码,最终数据包来到内核协议栈。内核协议栈也就是 IP 层以及传输层。经过 IP 层之后,数据包到达传输层,内核根据数据包里面的 {src_ip:src_port, dst_ip:dst_port} 找到相应的 socket的接收缓冲区。

从 socket 到应用程序

当 socket 就绪后,也就是数据包被放到 socket 的接收缓冲区后,如何通知应用程序呢?这里用到的是等待队列,也就是 wait queue

/**
 *  struct socket - general BSD socket
 *  @state: socket state (%SS_CONNECTED, etc)
 *  @type: socket type (%SOCK_STREAM, etc)
 *  @flags: socket flags (%SOCK_NOSPACE, etc)
 *  @ops: protocol specific socket operations
 *  @file: File back pointer for gc
 *  @sk: internal networking protocol agnostic socket representation
 *  @wq: wait queue for several uses
 */
struct socket {
    socket_state        state;

    short           type;

    unsigned long       flags;

    struct file     *file;
    struct sock     *sk;
    const struct proto_ops  *ops;

    struct socket_wq    wq;
};


struct socket_wq {
    /* Note: wait MUST be first field of socket_wq */
    wait_queue_head_t   wait;
    struct fasync_struct    *fasync_list;
    unsigned long       flags; /* %SOCKWQ_ASYNC_NOSPACE, etc */
    struct rcu_head     rcu;
} ____cacheline_aligned_in_smp;

wait 表示等待队列,fasync_list 表示异步等待队列等待队列和异步等待队列中存放的是关注这个 socket 上的事件的进程。区别是等待队列中的进程会处于阻塞状态,处于异步等待队列中的进程不会阻塞。当 socket 就绪后(接收缓冲区有数据),那么就会 wake up 等待队列中的进程,通知进程 socket 上有事件,可以开始处理了。

收包以及触发的过程

  • 包从网卡进来
  • 一路经过各个子系统到达内核协议栈(传输层)
  • 内核根据包的 {src_ip:src_port, dst_ip:dst_port} 找到 socket 对象(内核维护了一份四元组和 socket 对象的一一映射表)
  • 数据包被放到 socket 对象的接收缓冲区
  • 内核唤醒 socket 对象上的等待队列中的进程,通知 socket 事件
  • 进程唤醒,处理 socket 事件(read/write)

epoll的触发

上面其实提到了等待队列,每当我们创建一个 socket 后(无论是 socket()函数 还是 accept() 函数),socket 对象中会有一个进程的等待队列,表示某个或者某些进程在等待这个 socket 上的事件。

但是当我们往 epoll 红黑树上添加一个 epitem 节点(也就是一个 socket 对象,或者说一个 fd)后,实际上还会在这个 socket 对象的 wait queue 上注册一个 callback function,当这个 socket 上有事件发生后就会调用这个 callback function。这里与上面讲到的不太一样,并不会直接 wake up 一个等待进程,需要注意一下。这个 socket 在添加到这棵 epoll 树上时,会在这个 socket 的 wait queue 里注册一个回调函数,当有事件发生的时候再调用这个回调函数(而不是唤醒进程)

/*
 * 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;
    }
}

这个回调函数会把这个 socket 添加到创建 epoll instance 时对应的 eventpoll 实例中的就绪链表上,也就是 rdllist 上,并唤醒 epoll_wait,通知 epoll 有 socket 就绪,并且已经放到了就绪链表中,然后应用层就会来遍历这个就绪链表,并拷贝到用户空间,开始后续的事件处理(read/write)。

这里其实就体现出与 select 的不同, epoll 把就绪的 socket 给缓存了下来,放到一个双向链表中,这样当唤醒进程后,进程就知道哪些 socket 就绪了,而 select 是进程被唤醒后只知道有 socket 就绪,但是不知道哪些 socket 就绪,所以 select 需要遍历所有的 socket。应用程序遍历这个就绪链表,由于就绪链表是位于内核空间,所以需要拷贝到用户空间,这里要注意一下,网上很多不靠谱的文章说用了共享内存,其实不是。由于这个就绪链表的数量是相对较少的,所以由内核拷贝这个就绪链表到用户空间,这个效率是较高的。

epoll_wait 最终会调用到 ep_send_events_proc 这个函数,从函数名字也知道,这个函数是用来把就绪链表中的内容复制到用户空间,向应用程序通知事件。

static __poll_t ep_send_events_proc(struct eventpoll *ep, struct list_head *head,
                   void *priv)
{
    struct ep_send_events_data *esed = priv;
    __poll_t revents;
    struct epitem *epi, *tmp;
    struct epoll_event __user *uevent = esed->events;   # 这个就是在用户空间分配的一段内存指针,该函数会把 rdllist 拷贝到这块内存
    struct wakeup_source *ws;
    poll_table pt;

    init_poll_funcptr(&pt, NULL);
    esed->res = 0;

    /*
     * We can loop without lock because we are passed a task private list.
     * Items cannot vanish during the loop because ep_scan_ready_list() is
     * holding "mtx" during this call.
     */
    lockdep_assert_held(&ep->mtx);

    list_for_each_entry_safe(epi, tmp, head, rdllink) {
        if (esed->res >= esed->maxevents)
            break;

        /*
         * Activate ep->ws before deactivating epi->ws to prevent
         * triggering auto-suspend here (in case we reactive epi->ws
         * below).
         *
         * This could be rearranged to delay the deactivation of epi->ws
         * instead, but then epi->ws would temporarily be out of sync
         * with ep_is_linked().
         */
        ws = ep_wakeup_source(epi);
        if (ws) {
            if (ws->active)
                __pm_stay_awake(ep->ws);
            __pm_relax(ws);
        }

        list_del_init(&epi->rdllink);

        /*
         * If the event mask intersect the caller-requested one,
         * deliver the event to userspace. Again, ep_scan_ready_list()
         * is holding ep->mtx, so no operations coming from userspace
         * can change the item.
         */
        revents = ep_item_poll(epi, &pt, 1);
        if (!revents)
            continue;

       # 拷贝 rdllist 到 用户空间提供的一个内存指针
        if (__put_user(revents, &uevent->events) ||
            __put_user(epi->event.data, &uevent->data)) {
            list_add(&epi->rdllink, head);
            ep_pm_stay_awake(epi);
            if (!esed->res)
                esed->res = -EFAULT;
            return 0;
        }
        esed->res++;
        uevent++;
        if (epi->event.events & EPOLLONESHOT)
            epi->event.events &= EP_PRIVATE_BITS;
        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);
        }
    }

    return 0;
}

应用程序调用 epoll_wait返回后,开始遍历拷贝回来的内容,处理 socket 事件。

Epoll的工作模式

工作流程

epoll流程

  1. 进程通过 epoll_create 创建 eventpoll 对象。

  2. 进程通过 epoll_ctl 添加关注 listen socket 的 EPOLLIN 可读事件。

  3. 接步骤 2,epoll_ctl 还将 epoll 的 socket 唤醒等待事件(唤醒函数:ep_poll_callback)通过 add_wait_queue 函数添加到 socket.wq 等待队列。

    当 listen socket 有链接资源时,内核通过 __wake_up_common 调用 epoll 的 ep_poll_callback 唤醒函数,唤醒进程。

  4. 进程通过 epoll_wait 等待就绪事件,往 eventpoll.wq 等待队列中添加当前进程的等待事件,当 epoll_ctl 监控的 socket 产生对应的事件时,被唤醒返回。

  5. 客户端通过 tcp connect 链接服务端,三次握手成功,第三次握手在服务端进程产生新的链接资源。

  6. 服务端进程根据 socket.wq 等待队列,唤醒正在等待资源的进程处理。例如 nginx 的惊群现象,__wake_up_common 唤醒等待队列上的两个等待进程,调用 ep_poll_callback 去唤醒 epoll_wait 阻塞等待的进程。

  7. ep_poll_callback 唤醒回调会检查 listen socket 的完全队列是否为空,如果不为空,那么就将 epoll_ctl 监控的 listen socket 的节点 epi 添加到 就绪队列:eventpoll.rdllist,然后唤醒 eventpoll.wq 里通过 epoll_wait 等待的进程,处理 eventpoll.rdllist 上的事件数据。

  8. 睡眠在内核的 epoll_wait 被唤醒后,内核通过 ep_send_events 将就绪事件数据,从内核空间拷贝到用户空间,然后进程从内核空间返回到用户空间。

  9. epoll_wait 被唤醒,返回用户空间,读取 listen socket 返回的 EPOLLIN 事件,然后 accept listen socket 完全队列上的链接资源。

LT(Level Triggered),默认模式

持续通知直到处理事件完毕。

例子

int epoll_fd = epoll_create1(0);
struct epoll_event event;
int sock_fd; // 已初始化并绑定的socket文件描述符

event.events = EPOLLIN; // 监听读事件
event.data.fd = sock_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock_fd, &event);

while (true) {
    struct epoll_event events[MAX_EVENTS];
    int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);

    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == sock_fd) {
            char buffer[1024];
            ssize_t bytes_read = read(sock_fd, buffer, sizeof(buffer));
            // 处理读取到的数据
        }
    }
}

ET(Edge Triggered)

一般只通知一次,不管事件是否处理完毕。在事件通知后,该事件没有被再次触发的情况下只通知一次。

举个例子:内核接收了 64k 数据,用户只读取了 16k,那么剩下 48k 数据在内核缓存里,内核不会再次通知用户处理,除非读操作后,又来了新的数据,重新触发了读事件。可以使得就绪队列上的新的就绪事件能被快速处理。

可以避免共享 “epoll fd” 场景下,发生类似惊群问题。必须搭配非阻塞模式

例子

int sockfd; // 已初始化并绑定的socket文件描述符
int epoll_fd = epoll_create1(0);
struct epoll_event event;

// 设置socket为非阻塞模式
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

event.events = EPOLLIN | EPOLLET; // 监听读事件并设置为ET模式
event.data.fd = sockfd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sockfd, &event);

while (true) {
    struct epoll_event events[MAX_EVENTS];
    int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);

    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == sockfd) {
            while (true) {
                char buffer[1024];
                ssize_t bytes_read = read(sockfd, buffer, sizeof(buffer));
                if (bytes_read == -1) {
                    // 如果errno == EAGAIN,那么已经没有更多数据可以读取
                    if (errno != EAGAIN) {
                        perror("read");
                    }
                    break;
                }
                // 处理读取到的数据
            }
        }
    }
}

分析

阻塞IO 潜在的问题在于,使用 阻塞 IO 去读的时候,会导致在没有数据可读的时候,导致当前工作线程阻塞不工作。而 ET 模式与 LT 模式都是在有数据的情况下触发,只不过触发的时机不同。假定读缓冲区 50b,而收到的包为 100b,有如下情况:

  • 阻塞 IO

  LT 模式下,由于只要有数据就会触发读,因此不会有问题,但是在 ET 模式下,由于在新的数据到来之前,都不会触发读事件,因此会导致剩下的 50b 没有读取到,所以为了保证能够读取到完整的包,需要使用 while(1) 之类的循环去读,这就会导致在数据读完之后,最后一次 read 阻塞,因为所有的数据都已经读完了。

  • 非阻塞 IO  

  在 LT 模式下,使用非阻塞 IO 的效果与阻塞 IO 差不多,在 ET 模式下,处理的逻辑与上面类似,但是由于使用的 非阻塞 IO ,因此不会导致最后一次 read 阻塞,而是会返回 EAGAIN 。

  最后对于 “为什么 ET 模式必须使用非阻塞 IO ?” 这个问题。我的看法是应该将 “必须” 改成 “建议”,因为如果使用 阻塞IO ,也是有办法规避上面的问题的,比如先获取包体的大小之类的,但是这样也会提高复杂度,效率也会更低下。对于监听的 socket,最好使用 LT 模式,ET 模式会导致高并发情况下,有的客户端会连接不上,除非使用 while 循环 accpet,且为非阻塞 socket 。对于读写的 socket,LT 模式下,阻塞和非阻塞效果都一样。ET 模式下,建议使用非阻塞 IO,并一次性地完整读写全部数据。

ET 模式下面临的新问题

上一节我们解释了为什么 LT 模式下会造成惊群问题,究其原因就是内核重新将 epi 加回到了就绪链表。ET 模式下不会将已经上报的事件 epi 重新加回就绪链表,所以也就不会有惊群的问题。那是不是我们将事件设置成 ET 模式就万事大吉了呢?我们来接着看

ep_poll_callback 所做的事情仅仅是将该 epi 自身加入到 epoll 句柄的 “就绪链表”,同时唤醒在 epoll 句柄睡眠队列上的 task,所以这里并不对事件的细节进行计数,比如说,如果 ep_poll_callback 在将一个 epi 加入 “就绪链表” 之前发现它已经在 “就绪链表” 了,那么就不会再次添加,因此可以说,一个 epi 可能 pending 了多个事件。

这个在 LT 模式下没有任何问题,因为获取事件的 epi 总是会被重新添加回 “就绪链表”,那么如果还有事件,在下次 epoll_wait 的时候总会取到。然而对于 ET 模式,仅仅将 epi 从 “就绪链表” 删除并将事件本身上报后就返回了,因此如果该 epi 里还有事件,则只能等待再次发生事件,进而调用 ep_poll_callback 时将该 epi 加入 “就绪队列”。

所以当使用 ET 模式时,epoll_wait 的调用进程必须自己在获取到事件后将其处理干净才可再次调用 epoll_wait,否则 epoll_wait 不会返回,而是必须等到下次产生事件的时候方可返回。

两个模式的原理

lt/et 模式区别的核心逻辑在 epoll_wait 的内核实现 ep_send_events_proc 函数里,划重点:就绪队列

epoll_wait 的相关工作流程:

  • 当内核监控的 fd 产生用户关注的事件,内核将 fd (epi)节点信息添加进就绪队列。
  • 内核发现就绪队列有数据,唤醒进程工作。
  • 内核先将 fd 信息从就绪队列中删除。
  • 然后将 fd 对应就绪事件信息从内核空间拷贝到用户空间。
  • 事件数据拷贝完成后,内核检查事件模式是 lt 还是 et,如果不是 et,重新将 fd 信息添加回就绪队列,下次重新触发 epoll_wait。

为什么说 et 模式会使得新的就绪事件能快速被处理呢?假如 epoll_wait 每次最大从内核取一个事件。

如果是 lt 模式,epi 节点刚开始在内核被删除,然后数据从内核空间拷贝到用户空间后,内核马上将这个被删除的节点重新追加回就绪队列,这个速度很快,所以后面来的新的就绪事件很大几率会排在已经处理过的事件后面。

而 et 模式呢,数据从内核拷贝到用户空间后,内核不会重新将就绪事件节点添加回就绪队列,当事件在用户空间处理完后,用户空间根据需要重新将这个事件通过 epoll_ctl 添加回就绪队列(又或者这个节点因为有新的数据到来,重新触发了就绪事件而被添加)。从节点被删除到重新添加,这中间的过程是比较“漫长”的,所以新来的其它事件节点能排在旧的节点前面,能快速处理。

惊群效应

惊群效应就是多个进程(线程)阻塞等待同一件事情(资源)上,当事件发生(资源可用)时,操作系统可能会唤醒所有等待这个事件(资源)的进程(线程),但是最终却只有一个进程(线程)成功获取该事件(资源),而其他进程(线程)获取失败,只能重新阻塞等待事件(资源)可用,但是这就造成了额外的性能损失。这种现象就称为惊群效应。

为什么操作系统要同时唤醒多个进程呢?只唤醒一个不行吗?这样不就没有这种性能损失了吗?

确实如此,操作系统也想只唤醒一个进程,但是它做不到啊,因为它也不知道该唤醒哪一个,只好把所有等待在这件事情(资源)的进程都一起唤醒了。惊群效应会造成多个进程白白唤醒而啥也做不了。

效率降低

进程上下文包括了进程的虚拟内存,栈,全局变量等用户空间的资源,还包括内核堆栈,寄存器等内核空间的状态。

所以进程上下文切换就首先需要保存用户态资源以及内核态资源,然后再去加载下一个进程,首先是加载了下一个进程的内核态,然后再去刷新进程的用户态空间

然而 CPU 保存进程的用户态以及内核态资源,再去加载下一个进程的内核态和用户态是有代价的,也是耗时的,每次可能在几十纳秒到数微妙的时间,如果频繁发生进程切换,那么 CPU 将有大量的时间浪费在不断保存资源,加载资源,刷新资源等事情上,造成性能的浪费。所以惊群效应会造成多个进程切换,造成性能损失。

惊群的类型

惊群的类型根据 socket 编程采用的不同方式有关。

传统的多进程 socket 编程,通常是 listen() 之后创建多个 worker 进程进行 accept,那么这里就会造成当有新连接过来时,多个 worker 同时去 accept 的情况,但最终只有一个 worker 进程成功 accept,其余的全部失败。此种情况下的惊群称为 accept惊群效应,这在 linux 内核2.6以后就已经解决了,所以通常情况下讨论的惊群通常不是 accept惊群,而是 epoll惊群

我们来看一个场景举例。假设 LT 模式下有 4 个进程共享同一个 epoll fd,此时来了一个请求 client 进入到 accept 队列,进程唤醒过程如下:

  1. 进程 A 的 epoll_wait 首先被 ep_poll_callback 唤醒,内核拷贝 event 到用户空间,然后将 epi 重新加回就绪链表,内核发现就绪链表上仍有就绪的 epi,则继续唤醒进程 B。
  2. 进程 B 在处理 ep_scan_ready_list 时发现依然满足上述条件,于是继续唤醒进程 C。
  3. 上面 1、2 两个过程会一直持续到某个进程完成 accept,此时下一个被唤醒的进程在 ep_scan_ready_list 中的 ep_item_poll 调用中将得不到任何时间,也就不会再将 epi 将会就绪链表了。
  4. LT 水平触发就此结束。

解决方式

详见

Epoll总结

  1. epoll 在内核开辟了一块缓存,用来创建 eventpoll 对象,并返回一个 file descriptor 代表 epoll instance
  2. 这个 epoll instance 中创建了一颗红黑树以及一个就绪的双向链表(当然还有其他的成员)
  3. 红黑树用来缓存所有的 socket,支持 O(log(n)) 的插入和查找,减少后续与用户空间的交互
  4. socket 就绪后,会回调一个回调函数(添加到 epoll instance 上时注册到 socket 的)
  5. 这个回调函数会把这个 socket 放到就绪链表,并唤醒 epoll_wait
  6. 应用程序拷贝就绪 socket 到用户空间,开始遍历处理就绪的 socket
  7. 如果有新的 socket,再添加到 epoll 红黑树上,重复这个过程

Epoll的特点

优点

  • 高性能,适用于大量的并发连接,因为 epoll 使用了红黑树来管理文件描述符,可以快速定位到就绪事件。
  • 内核中保存一份文件描述符集合(红黑树结构),无需用户每次都重新传入,只需进行增删改即可。
  • 内核不再通过轮询的方式找到就绪的文件描述符,而是当有I/O事件发生时,epoll机制会通知应用程序。
  • 支持水平触发和边缘触发两种模式,边缘触发模式下只会触发一次就绪事件,避免了反复触发事件的开销。
  • 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。 没有文件描述符集合的大小限制,可以处理大量的并发连接。

缺点

  • 不能跨平台,epoll 是 Linux 特有的 API,不太容易移植到其他操作系统上。
  • 使用较为复杂,相比于 select 和 poll,epoll 的接口更加底层,需要更多的编程技巧和经验。

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top