Epoll的高效之处

一、进行I/O流复用需要完成的工作

1、用户态如何将文件句柄传递到内核态;

2、内核态如何判断I/O流可读可写;

3、内核态如何通知监控者有I/O流可读写;

4、监控者如何找到可读写I/O流并传递给用户态应用程序;

5、监控者如何循环地完成监控和传递工作。

1)select的做法

Step 1:select创建3个文件描述符集,并将这些文件描述符拷贝到内核中,这里限制了文件句柄的最大数量为1024(第1次拷贝,全部传入);

Step 2:内核针对读缓冲区和写缓冲区来判断文件句柄是否可读写,该动作和select无关;

Step 3:内核在检测到文件句柄可读写时产生中断通知监控者selectselect被内核触发之后就返回可读写文件句柄的总数;

Step 4:select会将之前传递给内核的文件句柄再次从内核传到用户态(第2次拷贝),select返回给用户态的只是可读写的文件句柄总数,再使用FD_ISSET宏函数来检测哪些文件I/O可读写(遍历);

Step 5:select对于事件的监控是建立在内核的修改之上的,也就是说经过一次监控之后,内核会修改文件句柄位,因此再次监控时需要再次从用户态向内核态进行拷贝(第N次拷贝)。

2)Epoll的做法

Step 1:首先执行epoll_create创建内核中专属epoll的高速cache,并在该缓冲区建立红黑树及就绪链表,用户态传入的文件句柄将被放在红黑树中(第1次拷贝);

Step 2:内核针对读缓冲区和写缓冲区来判断文件句柄是否可读写,该动作和epoll无关;

Step 3:epoll_ctl执行add动作时除了将文件句柄放到红黑树上之外,还向内核注册该文件句柄的回调函数,内核在检测到某句柄可读写时则调用该回调函数,回调函数将文件句柄放到就绪链表;

Step 4:epoll_wait只监控就绪链表即可,如果就绪链表有文件句柄,则标识该文件句柄可读写,并返回到用户态(少量拷贝);

Step 5:由于内核不修改文件句柄位,因此在第一次传入时就可以重复监控,直到使用epoll_ctl删除,否则不需要重新传入,因此无多次拷贝。

总结:简单说epoll是继承了select/pollI/O复用思想,并在二者的基础上从监控I/O流、查找I/O事件等角度来提高效率,具体地说就是使用内核句柄列表、红黑树、就绪链表来提高效率。

二、Epoll详解

三个epoll相关的系统调用(Linux / C语言):

1、int epoll_create(int size);

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

3、int epoll_wait(int epfd, struct epoll_event *event, int maxevents, int timeout);

调用方式:

1、epoll_create建立一个epoll对象,参数size是内核保证能处理的最大句柄数,多于这个最大数时内核可不保证效果;

2、epoll_ctl可以操作上面建立的epoll对象,例如将刚建立的socket加到epoll中让其监控,或者把epoll正在监控的某个socket句柄移出epoll,不再监控它(也就是将I/O流放到内核)等等;

3、epoll_wait被调用,在给定的timeout时间内,当被监控的句柄中有事件发生时,就返回用户态的进程(也就是在内核层面捕获可读写的I/O事件)。

从上面的调用方式就可以看出epollselect/poll的优越处:

后者每次调用都要传递你所要监控的所有socketselect/poll系统调用,这意味着需要将用户态的socket列表拷贝到内核态,如果以万计的句柄会导致每次都要拷贝几百KB的内存到内核,非常低效。使用epoll_wait同样能确保select/poll功能实现,但不用传递socket句柄给内核,因为内核已经在epoll_ctl中拿到了要监控的句柄列表。

总结:select监控的句柄列表在用户态,每次调用都需要从用户态将句柄拷贝到内核态,但是epoll中句柄就是建立在内核中,这减少了内核和用户态的拷贝,高效的原因之一。

所以,实际上在调用epoll_create之后,内核就已经开始准备帮你存储要监控的句柄了,每次调用epoll_ctl只是在往内核的数据结构里塞入新的socket句柄。在内核里,一切皆文件。所以epoll向内核注册了一个文件系统,用于存储上述被监控的socket,当你调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file节点,当然这个file不是普通文件,它只服务于epoll

epoll在被内核初始化时(操作系统启动),会开辟出epoll自己的内核高速cache区,用于安置每一个想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说,就是物理上分配好你想要的size大小内存对象,每次使用时都是使用空闲的已分配好的对象。

三、Epoll高效的原因

在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个链表,用于存储准备就绪的事件。

epoll_wait调用时,仅仅观察这个链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已。

就绪链表的维护方法

当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。

epoll综合的执行过程:

如此,一棵红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

epoll水平触发和边缘触发的实现:

当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪链表,这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪链表, 最后,epoll_wait干了件事,就是检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了,所以,非ET的句柄,只要它上面还有事件,epoll_wait每次都会返回。而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也是不会次次从epoll_wait返回的。

四、Epoll高效的本质

1、减少用户态和内核态之间的文件句柄拷贝

2、减少对可读可写文件句柄的遍历

五、Reference

知乎提问:epoll或者kqueue的原理是什么?(https://www.zhihu.com/question/20122137)

posted @ 2022-03-10 17:51  HOracle  阅读(76)  评论(0编辑  收藏  举报