• 欢迎来到本博客,希望可以y一起学习与分享

Epoll详解及源码分析

综合 benz 来源:Epoll详解及源码分析 3年前 (2018-05-19) 121次浏览 0个评论 扫描二维码
文章目录[隐藏]

1.什么是epoll

epoll是当前在Linux下开发大规模并发网络程序的热门人选,epoll 在Linux2.6内核中正式引入,和select相似,都是I/O多路复用(IO multiplexing)技术,按照man手册的说法:是为处理大批量句柄而作了改进的poll。

搞Linux 服务器开发的人肯定了解 select、poll、epoll,他们都是基于事件驱动的IO多路复用技术,而他们之间的区别网上已经有很多的文章了,大家可以去详细的阅读,我在这里主要想写写我对epoll的底层实现的理解。

首先还是先说说 select、poll相比与epoll来说他们效率低下的原因吧:

select、poll、epoll是Linux平台下的IO多路复用技术,适合用来管理大量的文件描述符,但是这些系统调用本身是阻塞的,而他们管理的socket描述符其实是可以阻塞,也可以非阻塞的,但是大部分情况下设置为非阻塞的要更好一些,效率会更高一些。因此,他们并不是真正的异步IO。是伪异步的。

1、select

首先,select的缺点1:是select管理的描述符的数量在不重新编译内核的情况下是一个固定的值:1024,当然,重新编译了Linux内核之后,这个数值可以继续增大到用户的需求,但是这是相对来说比较麻烦的一件事。

其次。select的缺点2:是select对于socket描述符的管理方式,因为Linux内核对select的实现方式为每次返回前都要对所有的描述符进行一遍遍历,然后将有事件发生的socket描述符放到描述符集合里,然后将这个描述符集合返回。这种情况对于描述符的数量不是很大的时候还是可以的,但是当描述符达到数十万,甚至上百万的时候,select的效率就会急剧的降低,因为这样的轮询机制会造成大量的浪费和资源开销。因为每一次的轮询都要将这些所有的socket描述符从用户态拷贝到内核态,在内核态,进行轮询,查看是否有事件发生,这是select的底层需要做的。而这些拷贝完全是可以避免的。

2、poll

poll的实现机制和select是一样的,也是采用轮询机制来查看有事件发生的socket描述符,所以效率也是很低,但是poll对select有一项改进就是能够监视的描述符是任意大小的而不是局限在一个较小的数值上(当然这个描述符的大小也是需要操作系统来支持的)。

综上:在总结一下,select与poll的实现机制基本是一样的,只不过函数不同,参数不同,但是基本流程是相同的;

  • 1、复制用户数据到内核空间
  • 2、估计超时时间
  • 3、遍历每个文件并调用f_op->poll()取得文件状态
  • 4、遍历完成检查状态
    如果有就绪的文件(描述符对应的还是文件,这里就当成是描述符就可以)则跳转到5,
    如果有信号产生则重新启动poll或者select
    否则挂起进程并等待超时或唤醒超时或再次遍历每个文件的状态
  • 5、将所有文件的就绪状态复制到用户空间
  • 6、清理申请的资源

3、epoll

epoll改进了select的两个缺点,使用了三个数据结构从而能够在管理大量的描述符的情况下,对系统资源的使用并没有急剧的增加,而只是对内存的使用有所增加(毕竟存储大量的描述符的数据结构会占用大量内存)。

epoll在实现上的三个核心点是:1、mmap,2、红黑树,3、rdlist(就绪描述符链表)接下来一一解释这三个并且解释为什么会高效;

1、mmap是共享内存,用户进程和内核有一段地址(虚拟存储器地址)映射到了同一块物理地址上,这样当内核要对描述符上的事件进行检查的时候就不用来回的拷贝了。

这点实际上涉及到epoll的具体实现了。内核/用户空间 内存拷贝问题,如何让内核把FD消息通知给用户空间呢?

在这个问题上select采取了内存拷贝方法。Poll也是么?应该是,poll和select基本无区别?(肯定多少还有点区别,只是这些缺点是相同的)

既然是内存拷贝,因为也要拷贝啊,还是慢!

对于poll来说需要将用户传入的 pollfd 数组拷贝到内核空间,因为拷贝操作和数组长度相关,时间上这是一个O(n)操作,当事件发生,poll返回将获得的数据传送到用户空间并执行释放内存和剥离等待队列等善后工作,向用户空间拷贝数据与剥离等待队列等操作的的时间复杂度同样是O(n)。

而epoll是共享内存,拷贝都不用,相对来说应该会更快。epoll是通过内核与用户空间mmap同一块内存实现的。

mmap是什么

mmap操作提供了一种机制,让用户程序直接访问设备内存,这种机制,相比较在用户空间和内核空间互相拷贝数据,效率更高。在要求高性能的应用中比较常用。mmap映射内存必须是页面大小的整数倍,面向流的设备不能进行mmap,mmap的实现和硬件有关。(涉及到内核和用户空间的概念,共享内存有没有安全隐患?)

2、红黑树是用来存储这些描述符的,因为红黑树的特性,就是良好的插入,查找,删除性能O(lgN)。

当内核初始化epoll的时候(当调用epoll_create的时候内核也是个epoll描述符创建了一个文件,毕竟在Linux中一切都是文件,而epoll面对的是一个特殊的文件,和普通文件不同),会开辟出一块内核高速cache区,这块区域用来存储我们要监管的所有的socket描述符,当然在这里面存储一定有一个数据结构,这就是红黑树,由于红黑树的接近平衡的查找,插入,删除能力,在这里显著的提高了对描述符的管理。

3、rdlist   就绪描述符链表这是一个双链表,epoll_wait()函数返回的也是这个就绪链表。

当内核创建了红黑树之后,同时也会建立一个双向链表rdlist,用于存储准备就绪的描述符,当调用epoll_wait的时候在timeout时间内,只是简单的去管理这个rdlist中是否有数据,如果没有则睡眠至超时,如果有数据则立即返回并将链表中的数据赋值到events数组中。这样就能够高效的管理就绪的描述符,而不用去轮询所有的描述符。所以当管理的描述符很多但是就绪的描述符数量很少的情况下如果用select来实现的话效率可想而知,很低,但是epoll的话确实是非常适合这个时候使用。

对与rdlist的维护:当执行epoll_ctl时除了把socket描述符放入到红黑树中之外,还会给内核中断处理程序注册一个回调函数,告诉内核,当这个描述符上有事件到达(或者说中断了)的时候就调用这个回调函数。这个回调函数的作用就是将描述符放入到rdlist中,所以当一个socket上的数据到达的时候内核就会把网卡上的数据复制到内核,然后把socket描述符插入就绪链表rdlist中。

Linux下有以下几个经典的服务器模型:

①Apache模型(Process Per Connection,简称PPC) 和 TPC(Thread Per Connection)模型

这两种模型思想类似,就是让每一个到来的连接都有一个进程/线程来服务。这种模型的代价是它要时间和空间。连接较多时,进程/线程切换的开销比较大。因此这类模型能接受的最大连接数都不会高,一般在几百个左右。

②select模型

最大并发数限制:因为一个进程所打开的fd(文件描述符)是有限制的,由FD_SETSIZE设置,默认值是1024/2048,因此select模型的最大并发数就被相应限制了。

效率问题:select每次调用都会线性扫描全部的fd集合,这样效率就会呈现线性下降,把FD_SETSIZE改大可能造成这些fd都超时了。

内核/用户空间内存拷贝问题:如何让内核把fd消息通知给用户空间呢?在这个问题上select采取了内存拷贝方法。

③poll模型

虽然解决了select 最大并发数的限制,但是依然存在select的效率问题,select缺点的2和3它都没有改掉。

④epoll模型

对比其他模型的问题,epoll的改进如下:

1.支持一个进程打开大数目的socket描述符(FD)
select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

2.IO效率不随FD数目增加而线性下降
传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是”活跃”的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对”活跃”的socket进行操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有”活跃”的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个”伪”AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的—比如一个高速LAN环境,epoll并不比select/poll有什么效率,相反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。

3.使用mmap加速内核与用户空间的消息传递
这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。而如果你想我一样从2.5内核就关注epoll的话,一定不会忘记手工 mmap这一步的。

4.内核微调
这一点其实不算epoll的优点了,而是整个linux平台的优点。也许你可以怀疑linux平台,但是你无法回避linux平台赋予你微调内核的能力。比如,内核TCP/IP协议栈使用内存池管理sk_buff结构,那么可以在运行时期动态调整这个内存pool(skb_head_pool)的大小— 通过echo XXXX>/proc/sys/net/core/hot_list_length完成。再比如listen函数的第2个参数(TCP完成3次握手的数据包队列长度),也可以根据你平台内存大小动态调整。更甚至在一个数据包面数目巨大但同时每个数据包本身大小却很小的特殊系统上尝试最新的NAPI网卡驱动架构。

2.Epoll API

epoll只有epoll_create,epoll_ctl,epoll_wait 3个系统调用。

① int epoll_create(int size);

创建一个epoll的句柄。自从linux2.6.8之后,size参数是被忽略的。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

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

epoll的事件注册函数,它不同于select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
第一个参数是epoll_create()的返回值。

第二个参数表示动作,用三个宏来表示:

  • EPOLL_CTL_ADD:注册新的fd到epfd中;
  • EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
  • EPOLL_CTL_DEL:从epfd中删除一个fd;

第三个参数是需要监听的fd。

第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:

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);

收集在epoll监控的事件中已经发送的事件。参数events是分配好的epoll_event结构体数组,epoll将会把发生的事件赋值到events数组中(events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存)。maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时。

3.Epoll 工作模式

①LT模式:Level Triggered水平触发

这个是缺省的工作模式。同时支持block socket和non-block socket。内核会告诉程序员一个文件描述符是否就绪了。如果程序员不作任何操作,内核仍会通知。

②ET模式:Edge Triggered 边缘触发

是一种高速模式。仅当状态发生变化的时候才获得通知。这种模式假定程序员在收到一次通知后能够完整地处理事件,于是内核不再通知这一事件。注意:缓冲区中还有未处理的数据不算状态变化,所以ET模式下程序员只读取了一部分数据就再也得不到通知了,正确的用法是程序员自己确认读完了所有的字节(一直调用read/write直到出错EAGAIN为止)。

这两种工作模式的区别在于:

当工作在ET模式下,如果一个描述符上有数据到达,然后读取这个描述符上的数据如果没有将数据全部读完的话,当下次epoll_wait返回的时候这个描述符里的数据就再也读取不到了,因为这个描述符不会再次触发返回,也就没法去读取,所以对于这种模式下对一个描述符的数据的正确读取方式是用一个死循环一直读,读到么有数据可读的情况下才可以认为是读取结束。

而工作在LT模式下,这种情况就不会发生,如果对一个描述符的数据没有读取完成,那么下次当epoll_wait返回的时候会继续触发,也就可以继续获取到这个描述符,从而能够接着读。

那么这两种模式的实现方式是什么样的?

基于以上的数据结构是怎么实现这种工作模式的呢?

实现原理:当一个socket描述符的中断事件发生,内核会将数据从网卡复制到内核,同时将socket描述符插入到rdlist中,此时如果调用了epoll_wait会把rdlist中的就绪的socekt描述符复制到用户空间,然后清理掉这个rdlist中的数据,最后epoll_wait还会再次检查这些socket描述符,如果是工作在LT模式下,并且这些socket描述符上还有数据没有读取完成,那么L就会再次把没有读完的socket描述符放入到rdlist中,所以再次调用epoll_wait的时候是会再次触发的,而ET模式是不会这么干的。

ET模式在物理实现上是基于电平的高低变化来工作的,就是从高电平变成低电平,或者从低电平变成高电平的这个上升沿或者下降沿才会触发,也就是状态变化导致触发,而当一个描述符上数据未读完的时候这个状态是不会发生变化的,所以触发不了,LT模式是在只有出现高电平的时候才会触发。

 

高电平和低电平:

LT水平触发:

EPOLLIN的触发事件:当输入缓冲区为空–>低电平,当输入缓冲区不为空–>高电平

高电平的时候触发EPOLLIN事件,如果没有把缓冲区的数据读取完,下次还会触发的,因为始终是高电平

EPOLLOUT的触发事件:当发送缓冲区满–>低电平,当发送缓冲区不满–>高电平

高电平的时候触发EPOLLOUT事件,所以在一开始的时候不要关注EPOLLOUT时间,因为发送缓冲区是不满的所以会导致CPU忙等待,每次都触发。什么时候关注EPOLLOUT事件呢? 当write的时候没有写完全,因为发送缓冲区满了,这个时候才关注EPOLLOUT事件直到下次把所有数据都发送完毕了,才取消EPOLLOUT事件

ET边缘触发:

EPOLLIN事件发生的条件:

有数据到来(输入缓冲区初始为空,为低电平,有数据到来变成了高电平)

EPOLLout事件发生的条件:

内核发送缓冲区不满(当发送缓冲区出现满之后为低电平,然后内核发送出去了部分数据后变成了不满,也就是高电平)

如下图:

0:表示文件描述符未准备就绪

1:表示文件描述符准备就绪

对于水平触发模式(LT):在1处,如果你不做任何操作,内核依旧会不断的通知进程文件描述符准备就绪。

对于边缘出发模式(ET): 只有在0变化到1处的时候,内核才会通知进程文件描述符准备就绪。之后如果不在发生文件描述符状态变化,内核就不会再通知进程文件描述符已准备就绪。

Nginx 默认采用的就是ET。

4.实例

5.Epoll为什么高效

Epoll高效主要体现在以下三个方面:

①从上面的调用方式就可以看出epoll比select/poll的一个优势:select/poll每次调用都要传递所要监控的所有fd给select/poll系统调用(这意味着每次调用都要将fd列表从用户态拷贝到内核态,当fd数目很多时,这会造成低效)。而每次调用epoll_wait时(作用相当于调用select/poll),不需要再传递fd列表给内核,因为已经在epoll_ctl中将需要监控的fd告诉了内核(epoll_ctl不需要每次都拷贝所有的fd,只需要进行增量式操作)。所以,在调用epoll_create之后,内核已经在内核态开始准备数据结构存放要监控的fd了。每次epoll_ctl只是对这个数据结构进行简单的维护。

② 此外,内核使用了slab机制,为epoll提供了快速的数据结构

在内核里,一切皆文件。所以,epoll向内核注册了一个文件系统,用于存储上述的被监控的fd。当你调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file结点。当然这个file不是普通文件,它只服务于epoll。epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的fd,这些fd会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

③ epoll的第三个优势在于:当我们调用epoll_ctl往里塞入百万个fd时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的fd给我们用户。这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的fd外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。而且,通常情况下即使我们要监控百万计的fd,大多一次也只返回很少量的准备就绪fd而已,所以,epoll_wait仅需要从内核态copy少量的fd到用户态而已。那么,这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把fd放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个fd的中断到了,就把它放到准备就绪list链表里。所以,当一个fd(例如socket)上有数据到了,内核在把设备(例如网卡)上的数据copy到内核中后就来把fd(socket)插入到准备就绪list链表里了。

如此,一颗红黑树,一张准备就绪fd链表,少量的内核cache,就帮我们解决了大并发下的fd(socket)处理问题。

  • 1.执行epoll_create时,创建了红黑树和就绪list链表。
  • 2.执行epoll_ctl时,如果增加fd(socket),则检查在红黑树中是否存在,存在立即返回,不存在则添加到红黑树上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪list链表中插入数据。
  • 3.执行epoll_wait时立刻返回准备就绪链表里的数据即可。

6.Epoll源码分析

epoll用kmem_cache_create(slab分配器)分配内存用来存放struct epitem和struct eppoll_entry。

当向系统中添加一个fd时,就创建一个epitem结构体,这是内核管理epoll的基本数据结构:

而每个epoll fd(epfd)对应的主要数据结构为:

eventpoll在epoll_create时创建:

其中,ep_alloc(struct eventpoll **pep)为pep分配内存,并初始化。

其中,上面注册的操作eventpoll_fops定义如下:

这样说来,内核中维护了一棵红黑树,大致的结构如下:

接着是epoll_ctl函数(省略了出错检查等代码):

ep_insert的实现如下:

init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);

revents = tfile->f_op->poll(tfile, &epq.pt);

这两个函数将ep_ptable_queue_proc注册到epq.pt中的qproc。

执行f_op->poll(tfile, &epq.pt)时,XXX_poll(tfile, &epq.pt)函数会执行poll_wait()poll_wait()会调用epq.pt.qproc函数,即ep_ptable_queue_proc。

ep_ptable_queue_proc函数如下:

其中struct eppoll_entry定义如下:

ep_ptable_queue_proc 函数完成 epitem 加入到特定文件的wait队列任务。

ep_ptable_queue_proc有三个参数:

  • struct file *file; 该fd对应的文件对象
  • wait_queue_head_t *whead; 该fd对应的设备等待队列(同select中的mydev->wait_address)
  • poll_table *pt; f_op->poll(tfile, &epq.pt)中的epq.pt

在ep_ptable_queue_proc函数中,引入了另外一个非常重要的数据结构eppoll_entry。eppoll_entry主要完成epitem和epitem事件发生时的callback(ep_poll_callback)函数之间的关联。首先将eppoll_entry的whead指向fd的设备等待队列(同select中的wait_address),然后初始化eppoll_entry的base变量指向epitem,最后通过add_wait_queue将epoll_entry挂载到fd的设备等待队列上。完成这个动作后,epoll_entry已经被挂载到fd的设备等待队列。

由于ep_ptable_queue_proc函数设置了等待队列的ep_poll_callback回调函数。所以在设备硬件数据到来时,硬件中断处理函数中会唤醒该等待队列上等待的进程时,会调用唤醒函数ep_poll_callback

所以ep_poll_callback函数主要的功能是将被监视文件的等待事件就绪时,将文件对应的epitem实例添加到就绪队列中,当用户调用epoll_wait()时,内核会将就绪队列中的事件报告给用户。

epoll_wait实现如下:

ep_send_events函数向用户空间发送就绪事件。

ep_send_events()函数将用户传入的内存简单封装到ep_send_events_data结构中,然后调用ep_scan_ready_list() 将就绪队列中的事件传入用户空间的内存。

用户空间访问这个结果,进行处理。

7.参考

1.http://www.cnblogs.com/apprentice89/p/3234677.html

2.http://www.cnblogs.com/apprentice89/archive/2013/05/06/3063039.html


文章 Epoll详解及源码分析 转载需要注明出处
喜欢 (0)

您必须 登录 才能发表评论!