LINUX内核研究----IO复用函数epoll内核源代码深度剖析

select和poll的效率瓶颈有两个

    1、每次调用这些函数的时候都需要将监控的fd和需要监控的事件从用户空间拷贝到内核空间,非常影响效率。

而epoll就是自己保存用户空间拷入的fd和需要监控的事件,只需在调用epoll_ctl的时候就把所有的fd和需要监控的事件只进行一次从用户空间到内核空间的拷贝。

    2、select和poll在内核中都是采用线性轮询的方式检查整个数组(poll是链表)里的活跃fd,对于许多没有数据的fd来说这浪费了不必要的时间。

如果我们不再检查活跃的fd,而是活跃的fd自动调用一个回调函数,把自己挂到就绪队列里。那不是简单的多么?

EPOLL是通过回调函数自动把就绪的文件描述符放入到一个就绪链表中而不需要遍历文件描述符。通过epoll_wait()函数将就绪的文件描述符返回给用户。

 

EPOLL应用广泛的情景

链接的人数非常多,活跃的人数相对来说少CPU利用率提升非常明显,这是epoll的优势所在。

多链接多活跃的情况下效率的提升并不明显,三种多路IO转接都差别不大。如果活跃量很大的话回调函数反复调用反而影响效率。

这个时候设置的最大打开文件描述符可以通过更改到硬件水平,软件水平一般都是65535 epoll需要突破系统资源设置65535,在配置文r件里加入硬件限制和软件限制设置。

$ulimit –n 90000

通过命令

$cat /pro/sys/fs/fs/file-max

查看最大的文件打开数量。

 

Epoll内核实现:

具题剖析过程比较复杂,请点击图片扩大详细查看!!!以下只是简练的概括:


内核为EPOLL做的准备工作:

这个模块在内核初始化时(操作系统启动)注册了一个新的文件系统,叫"eventpollfs"(在eventpoll_fs_type结构里),然后挂载此文件系统。

另外创建两个内核cache(在内核编程中,如果需要频繁分配小块内存,应该创建kmem_cahe来做“内存池”),分别用于存放struct epitem和eppoll_entry。

这个内核高速cache区,就是建立连续的物理内存页,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的内存。

现在想想epoll_create为什么会返回一个新的fd?

因为它就是在这个叫做"eventpollfs"的文件系统里创建了一个新文件!返回的就是这个文件的fd索引。很好地遵行了Linux一切皆文件的特色。

int epoll_create(int size);

epoll_ create时,内核除了帮我们在epoll文件系统里建了新的文件结点,将该节点返回给用户。并在内核cache里建立一个红黑树用于存储以后epoll_ctl传来的需要监听文件fd外,这些fd会以红黑树节点的形式保存在内核cache里,以便支持快速的查找、插入、删除操作。

int epoll_wait(int epfd,struct epoll_event *events,int maxevents, int timeout);

内核还会再建立一个list链表,用于存储事件就绪的fd。内核将就绪事件会拷贝到传入参数的events中的用户空间。就绪队列的事件数组events需要自己创建,并作为参数传入这样才可以在epoll_wait返回时接收需要处理的描述符集合。

当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。

有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。

而且,通常情况下即使我们要监控百万计的fd,大多一次也只返回很少量的准备就绪fd而已因为在基数大,活跃量少的情况下应用优势明显,如果活跃量很大的话回调函数反复调用影响效率。所以,epoll_wait仅需要从内核态copy少量的fd到用户态. 所以,epoll_wait非常高效。

 

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

那么准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socketf放到epoll对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个socketf的中断了也就是有数据要处理时调用回调函数,这个回调函数也就把fd放到准备就绪list链表里。所以,当一个fd(例如socket)上有数据到了,内核在把设备(例如网卡)上的数据copy到内核中后就来把fd插入到准备就绪list链表里了。

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

1.执行epoll_create时,创建了红黑树

2.执行epoll_ctl时,创建就绪list链表,如果增加fd添加到红黑树上,然后向内核注册有事件到来时的回调函数,当设备上的中断事件来临时这个回调函数会向list链表中插入就绪的fd。

3.执行epoll_wait时立刻返回准备就绪链表里的数据即可

 

epoll的优点:

1)支持一个进程打开大数目的socket描述符(FD)

select最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是1024。对于那些需要支持的上万连接数目的IM服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译内核。

不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于1024,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

2)IO效率不随FD数目增加而线性下降

传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是”活跃”的,但是select/poll每次调用都会线性轮询扫描全部的fd集合,导致效率呈现线性下降。

epoll不存在这个问题,它只会对”活跃”的socket进行操作—这是因为在内核实现中epoll是根据每个fd上面的回调函数实现的。那么,只有”活跃”的socket才会主动的去调用callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个”伪”AIO,因为这时候推动力在Linux内核。

3)两种触发模式,ET模式减少epoll_wait()的调用次数

EPOLL ET 边沿触发:只触发一次,无论缓冲区中是否还有剩余数据,直到有新的数据到达才会被触发,再去读取缓冲区里面的数据。

EPOLL LT 水平触发(默认): LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket,每次缓冲区都有数据都要触发。

Epoll可以监控管道文件,任意文件,不仅仅是socket文件


边沿触发的应用场景之一

客户端给我写的数据带有自己设计的协议头

而我只需要读取客户端数据的协议头,判断是否需要继续往下读取

如果不需要则不继续读取剩下的数据,增加程序运行的效率。

epoll工作在ET模式的时候,必须使用非阻塞套文件读写,以避免由于一个文件句柄的阻塞读/阻塞写操作容易阻塞在read函数时,因为没有读取到需要的字节数,而服务器又不能脱离read的阻塞状态去调用epoll函数接收客户端的数据造成死锁。

解决方法

1、非阻塞读取用fcntl 修改文件描述符的非阻塞读属性。

2、在open时指定非阻塞打开属性

Epoll-ET的非阻塞IO模型::read非阻塞轮询+边沿触发

去读取文件描述符的数据,直到为0,最效率的方法。

减少epoll_wait的调用次数提高程序的效率。

支持ET模式的原理,具体看最后面的内核的分析!!!!

当一个fd上有事件发生时,内核会把该fd插入上面所说的准备就绪rdlist链表,这时我们调用epoll_wait,会把准备就绪的fd拷贝到用户态内存,然后清空准备就绪list链表。

  最后,epoll_wait检查这些fd,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪rdlist链表了。

所以,非ET的句柄,只要它上面还有事件,epoll_ wait每次都会返回。而ET模式的句柄,除非有新中断到,即使fd上的事件没有处理完,也是不会次次从epoll_wait返回的

4能处理EPOLLONESHOT事件

当我们使用ET模式,一个socket上的某个事件还是可能被触发多次,这在并发程序中就会引起一个问题。

比如在线程池或者进程池模型中,某一个线程或进程正在处理一个有事件的socket文件描述符时,在这个时候这个socket文件描述符又有新的数据可以读取,此时另外一个线程被唤醒来读取这些新的数据,于是就出现了连个线程或者进程同时操作一个文件描述符的情况,这个时候就容易发生错误因为某个线程调度的时间和顺序是不能确定的,很有可能一个线程或者进程已经把数据读取完成,而且另外的一个进程或者线程还在读取这个文件描述符。

    这是我们所不希望的,我们希望在任意时刻都只有一个线程或者进程在操作一个文件描述符,关于这一点要求epoll提供了一个叫做EPOLLONESHOT事件的实现。

对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其注册的一个可读可写或者异常事件,且只触发一次。除非我们使用epoll_ctl()函数重置该文件描述符上注册的EPOLLONESHOT事件。

反过来思考的话,注册EPOLLONESHOT事件的文件描述符一旦被某个线程或者进程处理完成后,该线程或进程就应该立即重置这个sock文件描述符上的EPOLLONESHOT事件,以确保这个sockfd文件描述符下一次可读时,其EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个sockfd文件描述符.。

图片中是剖析内核中整个EPOLL源码的过程,强烈推介的技术干货,剖析完就觉得自己的编程能力实在是渣的不行,也会学习和收获到前辈的璀璨思想,不可言传只可意会。




猜你喜欢

转载自blog.csdn.net/run32875094/article/details/79371818