Epoll的高效之处
1、用户态如何将文件句柄传递到内核态;
2、内核态如何判断
I/O
流可读可写;3、内核态如何通知监控者有
I/O
流可读写;4、监控者如何找到可读写
I/O
流并传递给用户态应用程序;5、监控者如何循环地完成监控和传递工作。
1)select
的做法
Step 1:select
创建3个文件描述符集,并将这些文件描述符拷贝到内核中,这里限制了文件句柄的最大数量为1024(第1次拷贝,全部传入);
Step 2:内核针对读缓冲区和写缓冲区来判断文件句柄是否可读写,该动作和select
无关;
Step 3:内核在检测到文件句柄可读写时产生中断通知监控者select
,select
被内核触发之后就返回可读写文件句柄的总数;
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/poll
的I/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
事件)。
从上面的调用方式就可以看出epoll
比select/poll
的优越处:
后者每次调用都要传递你所要监控的所有socket
给select/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
水平触发和边缘触发的实现:
当一个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)