4. epoll 原理详解

2023年 10月 9日 83.7k 0

epoll 是开发 linux 高性能服务器的必备技术之一,是网络 IO 模型的核心模型,本文将讲述 epoll 实现的核心原理知识。

  • 一文读懂网络 IO 模型 一文讲述了网络 IO 模型的发展过程,并分析了网络 IO 模型的优缺点,这里不再赘述。
  • socket 的具体实现 一文讲述了 socket 的实现原理,有利于理解本节内容,建议先进行学习。

1. epoll 的核心函数

#include  

int epoll_create(int size);

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

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • epoll_create() :创建一个 epoll 对象。
  • epoll_ctl():向这个 epoll 对象(红黑树)中添加、删除、修改要管理的连接。
  • epoll_wait():等待其管理连接上的 IO 事件。

2. epoll_create

int epoll_create(int size);

epoll_create:是内核提供给我们创建 epoll 对象的一个系统调用。

  • 该函数生成一个 epoll 专用的文件描述符。
  • size 用来告诉内核这个监听的数目一共有多大,参数 size 并不是限制了 epoll 所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。自从 linux 2.6.8 之后,size 参数是被忽略的,也就是说可以填只有大于 0 的任意值。
  • 如果成功,返回poll 专用的文件描述符,否者失败,返回-1。

当我们在用户进程中调用 epoll_create 时,内核会为我们创建一个 struct eventpoll 对象,并且也有相应的 struct file 与之关联,和 socket 类似同样需要把这个 struct eventpoll 对象所关联的 struct file 放入进程打开的文件列表 fd_array 中管理。
相关数据结构讲解可以参考 socket 的具体实现 一文,这里不再赘述。

网络IO-第 11 页.png

重要的数据结构 struct eventpoll 主要由三部分组成:

struct eventpoll {

    // 等待队列,阻塞在 epoll 上的进程会放在这里
    wait_queue_head_t wq;
    
    // 就绪队列,IO 就绪的 socket 连接会放在这里
    struct list_head rdllist;
    
    // 红黑树用来管理所有监听的 socket 连接
    struct rb_root rbr;
    
    ......
}
  • wait_queue_head_t wq:epoll 中的等待队列,队列里存放的是阻塞在 epoll 上的用户进程(调用 epoll_wait 阻塞的用户线程)。在 IO 就绪的时候,epoll 可以通过这个队列找到这些阻塞的进程并唤醒它们,从而执行 IO 调用,读写 socket 上的数据。
  • struct list_head rdllist:epoll 中的就绪队列,队列里存放的是都是 IO 就绪的 socket。被唤醒的用户进程可以直接读取这个队列,获取 IO 活跃的 socket,无需再次遍历整个 socket 集合。该队列底层结构为链表,存储的是就绪的文件描述符。当有的连接就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程只需要判断链表就能找出就绪连接,而不用去遍历整棵树。
  • struct rb_root rbr : 红黑树。由于红黑树在查找、插入、删除等综合性能方面是最优的,所以 epoll 内部使用一颗红黑树来管理海量的 socket 连接。

3. epoll_ctl

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

epoll_ctl 向 epoll 对象 eventpoll 中添加、修改、删除监听的 socket。

  • epfd: epoll 专用的文件描述符,epoll_create()的返回值
  • op: 表示动作,用三个宏来表示:
    • EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
    • EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件;
    • EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
  • fd: 需要监听的文件描述符
  • event: 告诉内核要监听什么事件,通过位掩码的方式表示不同的事件,可以同时设置多个,通过“|” 连接,可选项如下:
    • EPOLLIN :文件描述符是否可读;
    • EPOLLOUT:文件描述符是否可写;
    • EPOLLPRI:文件描述符是否异常;
    • EPOLLRDHUP:对端关闭连接(被动),或者套接字处于半关闭状态(主动),这个事件会被触发。当使用边缘触发模式时,很方便写代码测试连接的对端是否关闭了连接
    • EPOLLERR:文件描述符是否错误;
    • EPOLLHUP:套接字被挂起,这个事件用户不设置也会被上报;
    • EPOLLET :设置 epoll 的触发模式为边缘触发模式。如果没有设置这个参数,epoll 默认情况下是水平触发模式。
    • EPOLLONESHOT:设置添加的事件只触发一次,当 epoll_wait(2) 报告一次事件后,这个文件描述符后续所有的事件都不会再报告。只是禁用,文件描述符还在监视队列中,用户可以通过 epoll_ctl() 的 EPOLL_CTL_MOD 重新添加事件
  • 返回值: 0表示成功,-1表示失败。

这里以添加为例进行讲解:epoll_ctl 向 epoll 对象 eventpoll 中添加监听的 socket 的流程如下

  • 分配一个红黑树节点对象 epitem。
  • 添加等待事件到 socket 的等待队列中,其回调函数是 ep_poll_callback。
  • 将 epitem 插入到 epoll 对象的红黑树里。
  • 3.1 创建红黑树节点 struct epitem

    首先要在 epoll 内核中创建一个表示 socket 连接的数据结构 struct epitem,而在 epoll 中为了综合性能的考虑,采用一颗红黑树来管理这些海量 socket 连接。所以 struct epitem 是一个红黑树节点。

    struct epitem
    {
        // 指向所属 epoll 对象
        struct eventpoll *ep; 
        
        // 注册的感兴趣的事件,也就是用户空间的 epoll_event     
        struct epoll_event event; 
        
        // 指向 epoll 对象中的就绪队列
        struct list_head rdllink;  
        
        // 指向 epoll 中对应的红黑树节点
        struct rb_node rbn;     
        
        // 指向 epitem 所表示的s ocket->file 结构以及对应的 fd
        struct epoll_filefd ffd;                  
    }
    

    3.2 添加 epoll 的回调函数到等待项中

    在内核中创建完表示 socket 连接的数据结构 struct epitem 后,我们就需要在 socket 中的等待队列上创建等待项 wait_queue_t 并且注册 epoll 的回调函数 ep_poll_callback。(socket->sock->sk_wq 等待队列中的类型是 wait_queue_t,我们需要在 struct epitem 所指向的 socket 的等待队列上注册 epoll 回调函数 ep_poll_callback),我们先整体浏览一下重要数据结构之间的指向关系。

    网络IO-第 12 页.png

    这样当数据到达 socket 中的接收队列时,内核会回调 sk_data_ready,在 socket 的具体实现 一文中,我们知道这个sk_data_ready 函数指针会指向 sk_def_readable 函数,在 sk_def_readable 中会回调注册在等待队列里的等待项 wait_queue_t -> func 回调函数 ep_poll_callback。

    static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)
    {
        //获取 wait 对应的 epitem
        struct epitem *epi = ep_item_from_wait(wait);
    
        //获取 epitem 对应的 eventpoll 结构体
        struct eventpoll *ep = epi->ep;
    
        //1. 将当前epitem 添加到 eventpoll 的就绪队列中
        list_add_tail(&epi->rdllink, &ep->rdllist);
    
        //2. 查看 eventpoll 的等待队列上是否有在等待
        if (waitqueue_active(&ep->wq))
            wake_up_locked(&ep->wq);
    }
    

    在 ep_poll_callback 中需要找到 epitem,将 IO 就绪的 epitem 放入 epoll 中的就绪队列中。然而 socket 等待队列中类型是 wait_queue_t 无法关联到 epitem。所以就出现了 struct eppoll_entry 结构体,它的作用就是关联 socket 等待队列中的等待项 wait_queue_t 和 epitem。

    struct eppoll_entry { 
        // 指向关联的 epitem
        struct epitem *base; 
        
        // 关联监听 socket 中等待队列中的等待项 (private = null  func = ep_poll_callback)
        wait_queue_t wait;   
        
        // 监听 socket 中等待队列头指针
        wait_queue_head_t *whead; 
        .........
    };
    

    这样在 ep_poll_callback 回调函数中就可以根据 socket 等待队列中的等待项 wait,通过 container_of 宏找到 eppoll_entry,继而找到 epitem 了。

    container_of 在 Linux 内核中是一个常用的宏,用于从包含在某个结构中的指针获得结构本身的指针,通俗地讲就是通过结构体变量中某个成员的首地址进而获得整个结构体变量的首地址。

    这里需要注意下这次等待项 wait_queue_t 中的 private 设置的是 null,因为这里 socket 是交给 epoll 来管理的,阻塞在 socket 上的进程是也由 epoll 来唤醒。 在等待项 wait_queue_t 注册的 func 是ep_poll_callback 而不是 autoremove_wake_function,阻塞进程并不需要 autoremove_wake_function 来唤醒,所以这里设置 private 为 null。

    3.3 将 epitem 插入到 epoll 对象的红黑树里

    当在 socket 的等待队列中创建好等待项 wait_queue_t 并且注册了 epoll 的回调函数 ep_poll_callback,然后又通过 eppoll_entry 关联了 epitem 后。剩下要做的就是将 epitem 插入到 epoll 中的红黑树 struct rb_root rbr 中。

    4. epoll_wait

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

    epoll_wait 等待文件描述符 epfd 引用的 epoll 实例上的事件。

    • epfd: epoll 专用的文件描述符,epoll_create() 的返回值
    • events: 接口的返回参数,epoll 把发生的事件的集合从内核复制到 events 数组中。events数组是一个用户分配好大小的数组,数组长度大于等于 maxevents。(events 不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存)
    • maxevents: 表示本次可以返回的最大事件数目,通常 maxevents 参数与预分配的 events 数组的大小是相等的。
    • timeout: 表示在没有检测到事件发生时最多等待的时间,超时时间(>=0),单位是毫秒ms,-1 表示阻塞,0 表示不阻塞。
    • 返回值:成功返回需要处理的事件数目。失败返回0,表示等待超时。

    用户程序调用 epoll_wait 后,内核首先会查找 epoll 中的就绪队列 eventpoll->rdllist 是否有 IO 就绪的 epitem。(epitem 里封装了socket 的信息)

    • 如果就绪队列中有就绪的 epitem,就将就绪的 socket 信息封装到 epoll_event 返回。
    • 如果就绪队列中没有就绪的 epitem,则会创建等待项 wait_queue_t,将用户进程关联到 wait_queue_t->private 上,并在等待项 wait_queue_t->func 上注册回调函数default_wake_function。最后将等待项添加到 epoll 中的等待队列中(注意不是 socket)。用户进程让出CPU,进入阻塞状态。

    网络IO-第 13 页.png

    注意:epoll_ctl 添加 socket 时也创建了等待队列项。不同的是这里的等待队列项是挂在 epoll 对象上的,而前者是挂在 socket 对象上的。这里和阻塞 IO 模型中的阻塞原理是一样的,只不过在阻塞 IO 模型中注册到等待项 wait_queue_t->func 上的是 autoremove_wake_function,并将等待项添加到 socket 中的等待队列中。这里注册的是 default_wake_function,将等待项添加到 epoll 中的等待队列上。

    5. 网络数据触发

  • 当网络数据包到达网卡时,网卡通过 DMA 的方式将数据放到 RingBuffer 中。
  • 然后向 CPU 发起硬中断,在硬中断响应程序中创建 sk_buffer,并将网络数据拷贝至 sk_buffer 中。
  • 随后发起软中断,内核线程 ksoftirqd 响应软中断,调用 poll 函数将 sk_buffer 送往内核协议栈做层层协议处理(链路层、网络层、传输层)。在传输层 tcp_rcv 函数中,去掉 TCP 头,根据四元组(源IP,源端口,目的IP,目的端口)查找对应的 socket。
  • 最后将 sk_buffer 放到 socket 中的接收队列里。
  • 网络IO-第 14 页.png

  • 当网络数据包在软中断中经过内核协议栈的处理到达 socket 的接收缓冲区时,紧接着会调用 socket 的数据就绪回调指针 sk_data_ready,回调函数为 sock_def_readable。在 socket 的等待队列中找出等待项,其中等待项中注册的回调函数为 ep_poll_callback,另外其 private 没有用了,指向的是空指针 null。
  • 在回调函数 ep_poll_callback 中,根据 struct eppoll_entry 中的 struct wait_queue_t wait 通过 container_of 宏找到 eppoll_entry 对象并通过它的 base 指针找到封装 socket 的数据结构 struct epitem,并将它加入到 epoll 中的就绪队列 rdllist 中。
  • 随后查看 epoll 中的等待队列中是否有等待项,也就是说查看是否有进程阻塞在 epoll_wait 上等待 IO 就绪的 socket。如果没有等待项,则软中断处理完成。
  • 如果有等待项,则回到注册在等待项中的回调函数 default_wake_function,在回调函数中唤醒阻塞进程 (private 指向的进程),并将就绪队列 rdllist 中的 epitem 的 IO 就绪 socket 信息封装到 struct epoll_event 中返回。
  • 用户进程拿到 epoll_event 获取 IO 就绪的 socket,发起系统 IO 调用读取数据。
  • 至此,epoll 所有流程都串起来了,这里画个图总结一下:

    网络IO-第 15 页.png

    6. 边缘触发和水平触发

    水平触发和边缘触发最关键的区别就在于当 socket 中的接收缓冲区还有数据可读时。epoll_wait 是否会清空 rdllist。

    • 水平触发:在这种模式下,用户线程调用 epoll_wait 获取到 IO 就绪的 socket 后,对 socket 进行系统 IO 调用读取数据,假设 socket 中的数据只读了一部分没有全部读完,这时再次调用 epoll_wait,epoll_wait 会检查这些 socket 中的接收缓冲区是否还有数据可读,如果还有数据可读,就将 socket 重新放回 rdllist。所以当 socket 上的 IO 没有被处理完时,再次调用 epoll_wait 依然可以获得这些 socket,用户进程可以接着处理 socket 上的 IO 事件。
    • 边缘触发: 在这种模式下,epoll_wait 就会直接清空 rdllist,不管 socket 上是否还有数据可读。所以在边缘触发模式下,当你没有来得及处理 socket 接收缓冲区的剩下可读数据时,再次调用 epoll_wait,因为这时 rdlist 已经被清空了,socket 不会再次从 epoll_wait 中返回,所以用户进程就不会再次获得这个 socket 了,也就无法在对它进行 IO 处理了。除非,这个 socket 上有新的 IO 数据到达,根据 epoll 的工作过程,该 socket 会被再次放入 rdllist 中。

    一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

    7. epoll + 非阻塞 IO

    网上常说 epoll 要搭配非阻塞 IO 使用,这里简单探索一下原因。
    epoll 本身是阻塞的,但一般会把 socket 设置成非阻塞,原因到底是为什么呢?
    如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作,所以搭配阻塞 IO 是可行的。
    如果使用边缘触发模式,IO 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 IO 搭配使用,程序会一直执行 IO 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。
    另外,使用 IO 多路复用时,最好搭配非阻塞 IO 一起使用,Linux 手册关于 select 的内容中有如下说明:

    Under Linux, select() may report a socket file descriptor as "ready for reading", while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.

    简单点理解,就是多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 IO, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 IO,以便应对极少数的特殊情况。官方都这么说了,那就搭配非阻塞 IO 一起使用吧,别太较真了。

    相关文章

    服务器端口转发,带你了解服务器端口转发
    服务器开放端口,服务器开放端口的步骤
    产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
    如何使用 WinGet 下载 Microsoft Store 应用
    百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
    百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

    发布评论