IO多路复用--epoll详解

epoll 或者 kqueue 的原理是什么? 【转自知乎】服务器

 

Epoll 引入简介

首先咱们来定义流的概念,一个流能够是文件,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方式,咱们发现咱们能够同时处理多个流了(把一个流从阻塞模式切换到非阻塞模式再此不予讨论):socket

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和googlegoogle


 

 

第一部分:select和epoll的任务

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

要比较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.减小对可读可写文件句柄的遍历;

相关文章
相关标签/搜索