IO多路复用之Epoll

IO多路复用的原理

IO多路复用的四个关键词:监控者、内核态、用户态、文件句柄。
IO多路复用的五个关键问题(步骤):

  • 1.监控者如何将文件句柄从用户态传递到内核态。
  • 2.内核如何判断文件句柄的读写状态
  • 3.内核如何通知监控者文件句柄可读可写
  • 4.监控着如何找到可读写的文件句柄并通知应用态的应用程序
    不同的多路复用接口都是基于对这四个问题的不同解答实现的。
  • 5.继续循环时,监控者怎么实现上面的流程

select的原理

针对这五个问题,select给出的解答是:

  • 1.select设置三个文件描述符集,并将他们全部拷贝到内核中。fd数量最大为1024。
  • 2.内核根据文件描述符的缓冲区来判断可读写状态
  • 3.内核自己实现。
  • 4.select遍历所有的文件描述符集合,通知用户可读写的fd数量,并将所有的文件描述符拷贝到用户空间。应用程序用FD_ISSET来检测可读写的描述符。
  • 5.再次重复上面的流程。

epoll的原理

epoll使用的五个步骤:

  • 1.epoll_create 会在内核中开辟一个专门的高速缓冲区,并在缓冲区上建立一个就绪链表和红黑树。红黑树用来存储监控的文件句柄。就绪链表用来存储可读写的文件句柄。
  • 2.epoll_ctl用来删除或者添加文件句柄。添加的时候,epoll会把文件句柄放到红黑树上,同时注册一个回掉函数。这个回掉函数会把文件句柄拷贝到就绪列表中。删除的时候则把文件句柄从红黑树中删除即可。
  • epoll_wait则监控就绪链表。一旦有时间发生,则返回用户态进程。
    针对这五个问题,epoll给出的解答是:
  • 1.epoll将所有的文件句柄拷贝到内核的红黑树上
  • 2.内核通过检测fd缓冲区来判断fd的读写状态
  • 3.内核通过回调函数将可读写的文件句柄拷贝到就绪链表中
  • 4.epoll_wait将就绪链表(少量)的句柄拷贝到用户空间,并返回到用户态。
  • 5.内核没有修改文件句柄的位,可以一直重复监控,直到用epoll_del删除,无需重复上述过程。

epoll高效的两个原因

  • 1.epoll只需要在add时将文件句柄拷贝到内核态,select每次调用后都要将所有的文件句柄拷贝到内核态,毫无疑问效率更低。(减少用户态跟内核态fd的拷贝)
  • 2.事件发生时,select要遍历所有的文件描述符。epoll则不需要。(避免fd的线性遍历)

 

大家都明白epoll是一种IO多路复用技术,可以非常高效的处理数以百万计的socket句柄,比起以前的select和poll效率高大发了。

我们用起epoll来都感觉挺爽,确实快,那么,它到底为什么可以高速处理这么多并发连接呢?

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

 

int epoll_create(int size); 
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

使用起来很清晰,首先要调用epoll_create建立一个epoll对象。参数size是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。
epoll_ctl可以操作上面建立的epoll,例如,将刚建立的socket加入到epoll中让其监控,或者把 epoll正在监控的某个socket句柄移出epoll,不再监控它等等。

epoll_wait在调用时,在给定的timeout时间内,当在监控的所有句柄中有事件发生时,就返回用户态的进程。
从上面的调用方式就可以看到epoll比select/poll的优越之处:

因为后者每次调用时都要传递你所要监控的所有socket给select/poll系统调用,这意味着需要将用户态的socket列表copy到内核态,如果以万计的句柄会导致每次都要copy几十几百KB的内存到内核态,非常低效。

而我们调用epoll_wait时就相当于以往调用select/poll,但是这时却不用传递socket句柄给内核,因为内核已经在epoll_ctl中拿到了要监控的句柄列表。

所以,实际上在你调用epoll_create后,内核就已经在内核态开始准备帮你存储要监控的句柄了,每次调用epoll_ctl只是在往内核的数据结构里塞入新的socket句柄。

在内核里,一切皆文件。所以,epoll向内核注册了一个文件系统,用于存储上述的被监控socket。

当你调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file结点。当然这个file不是普通文件,它只服务于epoll。epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。

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

 

static int __init eventpoll_init(void) { 
    ... ... 
    /* Allocates slab cache used to allocate "struct epitem" items */ 
    epi_cache = kmem_cache_create("eventpoll_epi", sizeof(struct  epitem),0,SLAB_HWCACHE_ALIGN| EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL); 
    /* Allocates slab cache used to allocate "struct eppoll_entry" */ 
    pwq_cache = kmem_cache_create("eventpoll_pwq", sizeof(struct eppoll_entry), 0, EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL); 
    ... ...

epoll的高效就在于,当我们调用epoll_ctl往里塞入百万个句柄时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。

这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

那么,这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。

所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。
如此,一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。

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

最后看看epoll独有的两种模式LT和ET。无论是LT和ET模式,都适用于以上所说的流程。

区别是,LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用epoll_wait时次次返回这个句柄,而ET模式仅在第一次返回。

这件事怎么做到的呢?当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait干了件事,就是检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。

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


 

边缘触发的问题:

1. sockfd 的边缘触发,高并发时,如果没有一次处理全部请求,则会出现客户端连接不上的问题。不需要讨论 sockfd 是否阻塞,因为 epoll_wait() 返回的必定是已经就绪的连接,所以不管是阻塞还是非阻塞,accept() 都会立即返回。

2. 阻塞 connfd 的边缘触发,如果不一次性读取一个事件上的数据,会干扰下一个事件,所以必须在读取数据的外部套一层循环,这样才能完整的处理数据。但是外层套循环之后会导致另外一个问题:处理完数据之后,程序会一直卡在 recv() 函数上,因为是阻塞 IO,如果没数据可读,它会一直等在那里,直到有数据可读。但是这个时候,如果用另一个客户端去连接服务器,服务器就不能受理这个新的客户端了。

3. 非阻塞 connfd 的边缘触发,和阻塞版本一样,必须在读取数据的外部套一层循环,这样才能完整的处理数据。因为非阻塞 IO 如果没有数据可读时,会立即返回,并设置 errno。这里我们根据 EAGAIN 和 EWOULDBLOCK 来判断数据是否全部读取完毕了,如果读取完毕,就会正常退出循环了。

总结一下:

1. 对于监听的 sockfd,最好使用水平触发模式,边缘触发模式会导致高并发情况下,有的客户端会连接不上。如果非要使用边缘触发,可以用 while 来循环 accept()。

2. 对于读写的 connfd,水平触发模式下,阻塞和非阻塞效果都一样,建议设置非阻塞。

3. 对于读写的 connfd,边缘触发模式下,必须使用非阻塞 IO,并要求一次性地完整读写全部数据。

现在明白为什么说epoll边缘触发要求非阻塞socket了吧?

如果你的文件描述符如果不是非阻塞的.

1.对于读:由于需要 一直读直到把数据读完,所以大家在编写程序的时候一般会用一个循环一直读取socket,那这个循环势必 会在最后一次阻塞,即没有数据可读的情况下,阻塞式socket会在数据读完之后一直阻塞下去,而非阻塞 式的socket则返回<0,并让errno 返回 EAGAIN 。

2.对于写,当使用阻塞式socket时,socket的 unwritable/writable状态变化没有任何意义!!因为此时无论发送多大的数据write总是会阻塞直到所有 数据都发送出去。(也就是说,边缘触发的epoll如果不和非阻塞的socket搭配,使用起来会产生问题)

 

  • 深入理解

上面的简单理解在网上随便找一篇都会讲到,但是LT和ET真正使用起来,还是存在一定难度的。

  • LT的读写操作

LT对于read操作比较简单,有read事件就读,读多读少都没有问题,但是write就不那么容易了,一般来说socket在空闲状态时发送缓冲区一定是不满的,假如fd一直在监控中,那么会一直通知写事件,不胜其烦。

所以必须保证没有数据要发送的时候,要把fd的写事件监控从epoll列表中删除,需要的时候再加入回去,如此反复。

天下没有免费的午餐,总是无代价地提醒是不可能的,对应write的过度提醒,需要使用者随用随加,否则将一直被提醒可写事件。

  • ET的读写操作

fd可读则返回可读事件,若开发者没有把所有数据读取完毕,epoll不会再次通知read事件,也就是说如果没有全部读取所有数据,那么导致epoll不会再通知该socket的read事件,事实上一直读完很容易做到。

若发送缓冲区未满,epoll通知write事件,直到开发者填满发送缓冲区,epoll才会在下次发送缓冲区由满变成未满时通知write事件。

ET模式下只有socket的状态发生变化时才会通知,也就是读取缓冲区由无数据到有数据时通知read事件,发送缓冲区由满变成未满通知write事件。

  • 一道面试题

使用Linux epoll模型的LT水平触发模式,当socket可写时,会不停的触发socket可写的事件,如何处理?

网络流传的腾讯面试题

这道题目对LT和ET考察比较深入,验证了前文说的LT模式write问题。

       普通做法:  

当需要向socket写数据时,将该socket加入到epoll等待可写事件。接收到socket可写事件后,调用write()或send()发送数据,当数据全部写完后, 将socket描述符移出epoll列表,这种做法需要反复添加和删除。

改进做法:

向socket写数据时直接调用send()发送,当send()返回错误码EAGAIN,才将socket加入到epoll,等待可写事件后再发送数据,全部数据发送完毕,再移出epoll模型,改进的做法相当于认为socket在大部分时候是可写的,不能写了再让epoll帮忙监控。

上面两种做法是对LT模式下write事件频繁通知的修复,本质上ET模式就可以直接搞定,并不需要用户层程序的补丁操作。

 

  • ET模式的线程饥饿问题

如果某个socket源源不断地收到非常多的数据,在试图读取完所有数据的过程中,有可能会造成其他的socket得不到处理,从而造成饥饿问题。

解决办法:为每个已经准备好的描述符维护一个队列,这样程序就可以知道哪些描述符已经准备好了但是并没有被读取完,然后程序定时或定量的读取,如果读完则移除,直到队列为空,这样就保证了每个fd都被读到并且不会丢失数据,流程如图:

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值