为什么要有epoll?

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。


为什么需要epoll?

poll是Linux操作系统提供的一种事件驱动的I/O模型,用于高效地处理大量并发连接的网络编程。它相比于传统的select和poll方法,具有更高的性能和扩展性。

  1. 高效处理大量并发连接:epoll采用了事件驱动的方式,只有当有可读或可写事件发生时才会通知应用程序,避免了遍历所有文件描述符的开销。

  2. 内核与用户空间数据拷贝少:使用epoll时,内核将就绪的文件描述符直接填充到用户空间的事件数组中,减少了内核与用户空间之间数据拷贝次数。

  3. 支持边缘触发(Edge Triggered)模式:边缘触发模式下,仅在状态变化时才通知应用程序。这意味着每次通知只包含最新状态的文件描述符信息,可以有效避免低效循环检查。

  4. 支持水平触发(Level Triggered)模式:水平触发模式下,在就绪期间不断地进行通知,直到应用程序处理完该文件描述符。


select 与 poll 的缺陷?

select 和 poll 都是Unix系统中用来监视一组文件描述符的变化的系统调用。它们可以监视文件描述符的三种变化:可读性、可写性和异常条件。select 和 poll 的主要缺陷如下:

  • 文件描述符数量限制:select 和 poll 都有一个限制,就是它们只能监视少于1024个文件描述符的变化。这对于现代的网络编程来说是不够的,因为一个进程往往需要监视成千上万的连接。
  • 效率问题:虽然 select 和 poll 可以监视多个文件描述符,但是它们在每次调用的时候都需要传递所有要监视的文件描述符集合,这会导致效率的降低。
  • 信息不足:select 和 poll 返回的只是哪些文件描述符已经准备好了,但是它们并不告诉你具体是哪一个。这就需要对所有要监视的文件描述符进行遍历,直到找到准备好的文件描述符为止。
  • 信号中断:select 和 poll 调用可以被信号中断,这可能会导致调用失败。

为了解决这些问题,现代操作系统中引入了新的系统调用 epoll 来替代 select 和 poll。

epoll 没有文件描述符的限制,它可以监视大量的文件描述符,并且可以实现即开即用,无需传递所有文件描述符集合。此外,epoll 可以直接告诉你哪些文件描述符已经准备好,这大大提高了处理效率。


纯源码分析博客

epoll的工作方式

ET(边沿触发)

简单来说ET只会通知一次(无论内存缓存区有没有东西),所以要谨慎处理信息,但是速度巨快(高速工作方式)

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

LT(水平触发)

简单来说就是烦死人,如果buffer里面有东西就一直通知你(poll/select经常用)(缺省工作方式)

缺省:又译“默认”。 即系统默认状态,意思与“默认”相同

水平触发,缺省方式,同时支持block和no-block socket,在这种做法中,内核告诉我们一个文件描述符是否被就绪了,如果就绪了,你就可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错的可能性较小。传统的select\poll都是这种模型的代表。

epoll的api

int epoll_create(int size)

创建一个epoll句柄(size是监听的数目) -> 会占用fd -> 使用完记得close()

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

事件注册函数(要先注册事件监听的类型)

(与select不同的是select是在监听事件时告诉内核要监听什么类型的事件)

  1. epoll_create的返回值

  2. op:动作

    主要有三个动作

    1. LL_CTL_ADD: 注册新的 fd 到 epfd 中
    2. EPOLL_CTL_MOD:修改已经注册的fd的监听事件
    3. EPOLL_CTL_DEL:从epfd中删除一个fd
  3. 要监听的fd

  4. 告诉内核需要监听什么事件

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

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

events的可选参数

  • EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
  • EPOLLOUT:表示对应的文件描述符可以写;
  • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
  • EPOLLERR:表示对应的文件描述符发生错误;
  • EPOLLHUP:表示对应的文件描述符被挂断;
  • EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

等待事件的产生,类似于select()调用。

  1. events用来从内核得到事件的集合

  2. maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size

  3. timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

epoll的原理

epoll 在 linux 内核中申请了一个简易的文件系统,把原先的一个 select / poll 调用分为了三个部分:

  1. 调用 epoll_create 建立一个 epoll 对象(在 epoll 文件系统中给这个句柄分配资源)

  2. 调用 epoll_ctl 向 epoll 对象中添加连接的套接字

  3. 调用 epoll_wait 收集发生事件的连接。

这样只需要在进程启动的时候建立一个 epoll 对象,并在需要的时候向它添加或者删除连接就可以了,因此,在实际收集的时候,epoll_wait 的效率会非常高,因为调用的时候只是传递了发生 IO 事件的连接。

epoll的实现

当某一个进程调用 epoll_create 方法的时候,Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个重要的成员。

rb_root rbr; //这是红黑树的根节点,存储着所有添加到 epoll 中的事件,也就是这个 epoll 监控的事件。
list_head rdllist; //这是一个双向链表,保存着将要通过 epoll_wait 返回给用户的、满足条件的事件。
  • rbr: 所有的事件(传说中的红黑树)

每一个 epoll 对象都有一个独立的 eventpoll 结构体,这个结构体会在内核空间中创造独立的内存,用于存储使用 epoll_ctl 方法向 epoll 对象中添加进来的事件。这些事件都会挂到 rbr 红黑树中,这样就能够高效的识别重复添加的节点。

所有添加到 epoll 中的事件都会与设备(如网卡等)驱动程序建立回调关系

也就是说,相应的事件发生时会调用这里的方法。这个回调方法在内核中叫做 ep_poll_callback,它把这样的事件放到 rdllist 双向链表中。在 epoll 中,对于每一个事件都会建立一个 epitem 结构体。

在计算机编程中,回调函数或简称回调(callback)

是指对某一段可执行代码的引用,它被作为参数传递给另一段代码

预期这段代码将回调(执行)这个回调函数作为自己工作的一部分。这种执行可以是即时的,如在同步回调之中;也可以在后来的时间点上发生,如在异步回调之中。

  • rdllist:已经准备好的事件,可以随时发送(实现可以直接传递哪些事件已经准备好的特性)

当调用 epoll_wait 检查是否有发生事件的连接时,只需要检查 eventpoll 对象中的 rdllist 双向链表中是否有 epitem 元素,如果 rdllist 链表不为空,则把这里的事件复制到用户态内存中的同时,将事件数量返回给用户。通过这种方法,epoll_wait 的效率非常高。epoll-ctl 在向 epoll 对象中添加、修改、删除事件时,从 rbr 红黑树中查找事件也非常快。这样,epoll 就能够轻易的处理百万级的并发连接。

epoll的工作模式

epoll 有两种工作模式:LT(水平触发)模式与 ET(边缘触发)模式。

默认情况下,epoll 采用 LT 模式工作。两个的区别是:

  • Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait() 时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。
  • Edge_triggered(边缘触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait() 会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

当然,在 LT 模式下开发基于 epoll 的应用要简单一些,不太容易出错,而在 ET 模式下事件发生时,如果没有彻底地将缓冲区的数据处理完,则会导致缓冲区的用户请求得不到响应。

注意,默认情况下 Nginx 采用 ET 模式使用 epoll 的。

I/O 多路复用

阻塞与非阻塞

即是否持续占用内核空间资源

阻塞的常见解决方案: 线程池

非阻塞的常见解决方法:轮询

我们知道,对于 linux 来说,I/O 设备为特殊的文件,读写和文件是差不多的,但是 I/O 设备因为读写与内存读写相比,速度差距非常大。与 cpu 读写速度更是没法比,所以相比于对内存的读写,I/O 操作总是拖后腿的那个。网络 I/O 更是如此,我们很多时候不知道网络 I/O 什么时候到来,就好比我们点了一份外卖,不知道外卖小哥们什么时候送过来,这个时候有两个处理办法:

第一个是我们可以先去睡觉,外卖小哥送到楼下了自然会给我们打电话,这个时候我们在醒来取外卖就可以了。
第二个是我们可以每隔一段时间就给外卖小哥打个电话,这样就能实时掌握外卖的动态信息了。

第一种方式对应的就是阻塞的 I/O 处理方式,进程在进行 I/O 操作的时候,进入睡眠,如果有 I/O 时间到达,就唤醒这个进程。

第二种方式对应的是非阻塞轮询的方式,进程在进行 I/O 操作后,每隔一段时间向内核询问是否有 I/O 事件到达,如果有就立刻处理。

阻塞的原理

工作队列

阻塞是进程调度的关键一环,指的是进程在等待某事件(如接收到网络数据)发生之前的等待状态,recv、select和epoll都是阻塞方法,以简单网络编程为例

下图中的计算机中运行着A、B、C三个进程,其中进程A执行着上述基础网络程序,一开始,这3个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行(时间片轮转)

等待队列

当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的socket对象(如下图)。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。

等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程。

图片

当程序执行到recv时,操作系统会将进程A从工作队列移动到该socket的等待队列中(如下图)。

依据进程调度,cpu会轮流执行这两个进程的程序,不会执行进程A的程序。所以进程A被阻塞,不会往下执行代码,也不会占用cpu资源。

ps:操作系统添加等待队列只是添加了对这个“等待中”进程的引用,以便在接收到数据时获取进程对象、将其唤醒,而非直接将进程管理纳入自己之下。上图为了方便说明,直接将进程挂到等待队列之下。

唤醒进程

当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有了数据,recv可以返回接收到的数据。

线程池或者轮询(解决方案)

系统总的线程和进程数是一定的。

​ 如果有许多的线程或者进程被挂起,无疑是白白消耗了系统的资源。而且,线程或者进程的切换也是需要一定的成本的,需要上下文切换,如果频繁的进行上下文切换,系统会损失很大的性能。

​ 在选择I/O机制的时候需要考虑,在服务器消耗完所有线程之后就不能对外服务了

​ 这种阻塞的 I/O 模式下,一个线程只能处理一个流的 I/O 事件,这是问题的根源。

​ 所以可以采用线程池限制同时访问的线程数,但是多余的I/O事件会存放在内存中等待加入队列,会导致内存不足的问题(有些流一直没有得到处理)

​ 采用轮询的方式,我们只要不停的把所有流从头到尾问一遍,又从头开始。这样就可以处理多个流了。

轮询的缺点: CPU 空转。(如果所有的流中都没有数据,那么 CPU 时间就被白白的浪费了)

​ 为了避免CPU空转,可以引进了一个代理。代理可以同时观察许多流的I/O事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流,这就是 select 与 poll 所做的事情,可见,采用 I/O 复用极大的提高了系统的效率。

内核接收网络数据的全过程

如下图所示

  1. 进程在recv阻塞期间,计算机收到了对端传送的数据(步骤①)
  2. 数据经由网卡传送到内存(步骤②),然后网卡通过中断信号通知CPU有数据到达
  3. CPU执行中断程序(步骤③)。此处的中断程序主要有两项功能
  4. 先将网络数据写入到对应socket的接收缓冲区里面(步骤④)
  5. 再唤醒进程A(步骤⑤),重新将进程A放入工作队列中。

图片

图片

epoll源码层面

创建一个epoll对象

如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。

eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。

图片

epoll_create 生成的文件描述符:

(进程等内存管理的笔记在linux/内存管理中)

图片

参考代码:

本文讲述的是 kernel 是如何将就绪事件传递给 epoll 并唤醒对应进程上,因此在这里主要聚焦于 (wait_queue_head_t wq) 等成员。

/*
 * 此结构体存储在file->private_data中
 */
struct eventpoll {
	// 自旋锁,在kernel内部用自旋锁加锁,就可以同时多线(进)程对此结构体进行操作
	// 主要是保护ready_list
	spinlock_t lock;
	
	// 这个互斥锁是为了保证在eventloop使用对应的文件描述符的时候,文件描述符不会被移除掉
	struct mutex mtx;
    
	// epoll_wait使用的等待队列,和进程唤醒有关
	wait_queue_head_t wq;
    
	// file->poll使用的等待队列,和进程唤醒有关
	wait_queue_head_t poll_wait;
    
	// 就绪的描述符队列
	struct list_head rdllist;
    
	// 通过红黑树来组织当前epoll关注的文件描述符
	struct rb_root rbr;
    
	// 在向用户空间传输就绪事件的时候,将同时发生事件的文件描述符链入到这个链表里面
	struct epitem *ovflist;
    
	// 对应的user
	struct user_struct *user;
    
	// 对应的文件描述符
	struct file *file;
    
	// 下面两个是用于环路检测的优化
	int visited;
	struct list_head visited_list_link;
};

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。 获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting

就绪列表的数据结构

就绪列表引用着就绪的socket,所以它应能够快速的插入数据。

程序可能随时调用epoll_ctl添加监视socket,也可能随时删除。当删除时,若该socket已经存放在就绪列表中,它也应该被移除。

所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll使用双向链表来实现就绪队列(对应上图的rdllist)。

索引结构

既然epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket。至少要方便的添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。epoll使用了红黑树作为索引结构(对应上图的rbr)

ps:因为操作系统要兼顾多种功能,以及由更多需要保存的数据,rdlist并非直接引用socket,而是通过epitem间接引用,红黑树的节点也是epitem对象。同样,文件系统也并非直接引用着socket。为方便理解,本文中省略了某些间接结构。

维护监视列表

创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。

当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程

接收数据

当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。

eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。

当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。

即当调用wait时,会等待rdlist返回一个非空的rdlist列表代表已经就绪的队列(wait有等待的默认超时时间)

阻塞和唤醒进程

假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程

当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。

对应函数的流程图:

KSE:

运行在用户空间的进程所创建的线程就是用户线程;内核空间的系统内核进程所创建的线程就是内核线程,称为Kernel Scheduling Entity内核调度实体,简称KSEKSE是操作系统内核的最小调度单元(硬件上就是CPU的调度单元)。用户线程调用内核线程完成操作(比如IO系统调用)就是用户态到内核态的切换,称为Mode Switch模态切换。模态切换有一定的性能开销。

EINTR:

EINTR是linux中函数的返回状态,在不同的函数中意义不同。表示某种阻塞的操作,被接收到的信号中断,造成的一种错误返回值。

ep_send_events_proc的执行流程:

将就绪的事件复制至用户空间

图片

纯源码分析

文章作者: cosh
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 cosh'blog
Linux
喜欢就支持一下吧