epoll 或者 kqueue 的原理是什么?

作者:蓝形参
链接:https://www.zhihu.com/question/20122137/answer/14049112

2013-10-27更新:由于此文陆陆续续收到赞同,而且其中有些地方并不完全正确,特在本文最后予以订正

我不了解楼主的层次,我必须从很多基础的概念开始构建这个答案,并且可能引申到很多别的问题。

首先我们来定义流的概念,一个流可以是文件,socket,pipe等等可以进行I/O操作的内核对象。
不管是文件,还是套接字,还是管道,我们都可以把他们看作流。
之后我们来讨论I/O的操作,通过read,我们可以从流中读入数据;通过write,我们可以往流写入数据。现在假定一个情形,我们需要从流中读数据,但是流中还没有数据,(典型的例子为,客户端要从socket读如数据,但是服务器还没有把数据传回来),这时候该怎么办?
  • 阻塞。阻塞是个什么概念呢?比如某个时候你在等快递,但是你不知道快递什么时候过来,而且你没有别的事可以干(或者说接下来的事要等快递来了才能做);那么你可以去睡觉了,因为你知道快递把货送来时一定会给你打个电话(假定一定能叫醒你)。
  • 非阻塞轮询。接着上面等快递的例子,如果用忙轮询的方法,那么你需要知道快递员的手机号,然后每分钟给他挂个电话:“你到了没?”

很明显一般人不会用第二种做法,不仅显很无脑,浪费话费不说,还占用了快递员大量的时间。
大部分程序也不会用第二种做法,因为第一种方法经济而简单,经济是指消耗很少的CPU时间,如果线程睡眠了,就掉出了系统的调度队列,暂时不会去瓜分CPU宝贵的时间片了。

为了了解阻塞是如何进行的,我们来讨论缓冲区,以及内核缓冲区,最终把I/O事件解释清楚。缓冲区的引入是为了减少频繁I/O操作而引起频繁的系统调用(你知道它很慢的),当你操作一个流时,更多的是以缓冲区为单位进行操作,这是相对于用户空间而言。对于内核来说,也需要缓冲区。
假设有一个管道,进程A为管道的写入方,B为管道的读出方。
  1. 假设一开始内核缓冲区是空的,B作为读出方,被阻塞着。然后首先A往管道写入,这时候内核缓冲区由空的状态变到非空状态,内核就会产生一个事件告诉B该醒来了,这个事件姑且称之为“缓冲区非空”。
  2. 但是“缓冲区非空”事件通知B后,B却还没有读出数据;且内核许诺了不能把写入管道中的数据丢掉这个时候,A写入的数据会滞留在内核缓冲区中,如果内核也缓冲区满了,B仍未开始读数据,最终内核缓冲区会被填满,这个时候会产生一个I/O事件,告诉进程A,你该等等(阻塞)了,我们把这个事件定义为“缓冲区满”。
  3. 假设后来B终于开始读数据了,于是内核的缓冲区空了出来,这时候内核会告诉A,内核缓冲区有空位了,你可以从长眠中醒来了,继续写数据了,我们把这个事件叫做“缓冲区非满”
  4. 也许事件Y1已经通知了A,但是A也没有数据写入了,而B继续读出数据,知道内核缓冲区空了。这个时候内核就告诉B,你需要阻塞了!,我们把这个时间定为“缓冲区空”。

这四个情形涵盖了四个I/O事件,缓冲区满,缓冲区空,缓冲区非空,缓冲区非满(注都是说的内核缓冲区,且这四个术语都是我生造的,仅为解释其原理而造)。这四个I/O事件是进行阻塞同步的根本。(如果不能理解“同步”是什么概念,请学习操作系统的锁,信号量,条件变量等任务同步方面的相关知识)。

然后我们来说说阻塞I/O的缺点。但是阻塞I/O模式下,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,要么多进程(fork),要么多线程(pthread_create),很不幸这两种方法效率都不高。
于是再来考虑非阻塞忙轮询的I/O方式,我们发现我们可以同时处理多个流了(把一个流从阻塞模式切换到非阻塞模式再此不予讨论):
while true {
for i in stream[]; {
if i has data
read until unavailable
}
}
我们只要不停的把所有流从头到尾问一遍,又从头开始。这样就可以处理多个流了,但这样的做法显然不好,因为如果所有的流都没有数据,那么只会白白浪费CPU。这里要补充一点,阻塞模式下,内核对于I/O事件的处理是阻塞或者唤醒,而非阻塞模式下则把I/O事件交给其他对象(后文介绍的select以及epoll)处理甚至直接忽略。

为了避免CPU空转,可以引进了一个代理(一开始有一位叫做select的代理,后来又有一位叫做poll的代理,不过两者的本质是一样的)。这个代理比较厉害,可以同时观察许多流的I/O事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流(于是我们可以把“忙”字去掉了)。代码长这样:
while true {
select(streams[])
for i in streams[] {
if i has data
read until unavailable
}
}
于是,如果没有I/O事件产生,我们的程序就会阻塞在select处。但是依然有个问题,我们从select那里仅仅知道了,有I/O事件发生了,但却并不知道是那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。
但是使用select,我们有O(n)的无差别轮询复杂度,同时处理的流越多,每一次无差别轮询时间就越长。再次
说了这么多,终于能好好解释epoll了
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll之会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。(复杂度降低到了O(k),k为产生I/O事件的流的个数,也有认为O(1)的[更新 1])
在讨论epoll的实现细节之前,先把epoll的相关操作列出[更新 2]:
  • epoll_create 创建一个epoll对象,一般epollfd = epoll_create()
  • epoll_ctl (epoll_add/epoll_del的合体),往epoll对象中增加/删除某一个流的某一个事件
    比如
    epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);//有缓冲区内有数据时epoll_wait返回
    epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);//缓冲区可写入时epoll_wait返回
  • epoll_wait(epollfd,...)等待直到注册的事件发生
(注:当对一个非阻塞流的读写发生缓冲区满或缓冲区空,write/read会返回-1,并设置errno=EAGAIN。而epoll只关心缓冲区非满和缓冲区非空事件)。
一个epoll模式的代码大概的样子是:
while true {
active_stream[] = epoll_wait(epollfd)
for i in active_stream[] {
read or write till unavailable
}
}
限于篇幅,我只说这么多,以揭示原理性的东西,至于epoll的使用细节,请参考man和google,实现细节,请参阅linux kernel source。
======================================
[更新1]: 原文为O(1),但实际上O(k)更为准确
[更新2]: 原文所列第二点说法让人产生EPOLLIN/EPOLLOUT等同于“缓冲区非空”和“缓冲区非满”的事件,但并非如此,详细可以Google关于epoll的边缘触发和水平触发。
 
 
 
 
 
 
作者:静海听风
链接:https://www.zhihu.com/question/20122137/answer/146866418


第一部分:select和epoll的任务

关键词:应用程序 文件句柄 用户态 内核态 监控者

要比较epoll相比较select高效在什么地方,就需要比较二者做相同事情的方法。

要完成对I/O流的复用需要完成如下几个事情:

1.用户态怎么将文件句柄传递到内核态?

2.内核态怎么判断I/O流可读可写?

3.内核怎么通知监控者有I/O流可读可写?

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

5.继续循环时监控者怎样重复上述步骤?

搞清楚上述的步骤也就能解开epoll高效的原因了。

select的做法:

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

步骤2的解法:内核针对读缓冲区和写缓冲区来判断是否可读可写,这个动作和select无关;

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

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

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

epoll的做法:

步骤1的解法:首先执行epoll_create在内核专属于epoll的高速cache区,并在该缓冲区建立红黑树和就绪链表,用户态传入的文件句柄将被放到红黑树中(第一次拷贝)。

步骤2的解法:内核针对读缓冲区和写缓冲区来判断是否可读可写,这个动作与epoll无关;

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

步骤4的解法:epoll_wait只监控就绪链表就可以,如果就绪链表有文件句柄,则表示该文件句柄可读可写,并返回到用户态(少量的拷贝);

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

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

第二部分:epoll详解

先简单回顾下如何使用C库封装的3个epoll系统调用吧。

  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 *events,int maxevents, int timeout);

使用起来很清晰:

A.epoll_create建立一个epoll对象。参数size是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。

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

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

从上面的调用方式就可以看到epoll比select/poll的优越之处:

因为后者每次调用时都要传递你所要监控的所有socket给select/poll系统调用,这意味着需要将用户态的socket列表copy到内核态,如果以万计的句柄会导致每次都要copy几十几百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外,还会再建立一个list链表,用于存储准备就绪的事件.

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

那么,这个准备就绪list链表是怎么维护的呢?

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

epoll综合的执行过程:

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

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

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

====>区别就在于epoll_wait将socket返回到用户态时是否情况就绪链表。

第三部分:epoll高效的本质

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

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

 

 

 

 

 

作者:林晓峰
链接:https://www.zhihu.com/question/20122137/answer/63120488


搞清楚这个问题,有三个关键的操作系统背景知识需要理解:

  • 进程执行调度方法
  • 内核执行中断上下文(Interrupt Context
  • Wait Queue

这里挑重点过程说一下,忽略很多细节和边缘 case,详细过程网上很容易查找到,或者有能力的可以直接看内核源码。

进程执行调度方法:操作系统总体上按照时间片来调度进程执行,进程执行调度状态分为三种:Running、Ready 和 Block(具体状态命名可能不是教科书准确)。等待资源就绪的进程会置为 Block 状态(比如调用 accept 并阻塞的进程),资源就绪可以随时运行的进程会放在每个 CPU 的调度队列里,获得当前 CPU 时间片运行中的进程是 Running 状态,等待 CPU 时间片分配的进程是 Ready 状态。

内核执行中断上下文:内核在处理硬件中断时,会直接打断正在执行的 Running 状态进程(包括系统调用),进行必要的内存拷贝和状态更新(比如处理 TCP 握手),结束中断处理后恢复运行被打断的进程。

Wait QueueLinux 内核实现进程唤醒的关键数据结构。通常一个事件体有一个 wait queue,对这个事件体感兴趣的进程或者系统会提供回调函数,并将自己注册到这个事件体的 wait queue 上。当事件发生时,会调用注册在 wait queue 上的回调函数。常见的回调函数是,将对这个事件感兴趣的进程的调度状态置为 Ready,于是在调度系统重新分配 CPU 时间片时,将该进程重新执行,从而实现进程等待资源就绪而唤醒的过程。


有了这三个基本的概念,那么以网络 IO 为代表的事件是如何从网卡到内核,最终通知进程做相关处理的?epoll & kqueue 其实是更复杂的设计和实现,基本原理是一致的。下面以 TCP 新建连接为例子,描述这一内核的处理过程:

  1. 网卡收到 SYN,触发内核中断,直接打断当前执行的进程,CPU 进行中断处理逻辑(不展开 NAPI & 软中断过程),最终将该 SYN 连接信息保存在相应 listen socket半连接队列里,并向对方发送 SYN-ACK,然后恢复运行被打断的进程。
  2. 进程执行完当前作业,调用 accept 系统调用(阻塞)继续处理新连接。accept 发现连接队列当前没有新连接后,于是在 listen socket wait queue 的上注册唤醒自身进程的回调函数,然后内核将这个进程置为 Block 状态,并让出 CPU 执行其他 Ready 状态的进程。
  3. 网卡收到 ACK,继续触发内核中断,内核完成标准的三次握手,将连接从半连接队列移入连接队列,于是 listen socket 有可读事件,内核调用 listen socketwait queue 的唤醒回调函数,将之前阻塞的 accept 进程置为 Ready 调度状态。
  4. 在内核下一个 CPU 调度窗口来临时,Ready 调度状态的 accept 进程被选中执行,发现连接队列有新连接,于是读取连接信息,并最终返回给用户态进程。

从上面过程可见,内核处理硬件事件是一个同步的过程,而把事件传递给用户进程是一个异步的过程

“epoll & kqueue 其实是更复杂的设计和实现,基本原理是一致的”

如上所述,epoll & kqueue 的原理不超越上面内核处理事件过程,今天有点晚了,后续继续酝酿一下,有时间继续作答~ 

 

 

 

 

 

select/poll是通过轮询的方法来获得就绪的状态,调用select/poll后就阻塞住,直到有就绪的文件描述符,或者超时,或者被中断。返回值是就绪的文件描述符的个数,需要遍历作为参数传入的文件描述符的位域或数组获得哪个文件描述符。

epoll是通过后台中断的方式来获得就绪的状态,调用epoll_create创建实例,调用epoll_ctl添加或删除监控的文件描述符,调用epoll_wait阻塞住,直到有就绪的文件描述符,通过epoll_event参数返回就绪状态的文件描述符和事件

简单但不严谨的说:
  • 当调用epoll_ctl时,epoll就向底层(poll(),或tcp_poll())注册了callback
  • 当文件描述符就绪时,callback函数就会被调用,callback函数就会把该文件描述符加入列表并唤醒epoll_wait
  • 当调用epoll_wait时,epoll只是简单地检查下列表是否为空,不为空就返回,为空就挂起,等待被唤醒。

通常来说select和poll属于I/O multiplexing,而epoll可以算作signal driven I/O


posted @ 2018-03-11 10:02  dion至君  阅读(702)  评论(0)    收藏  举报