一、 select和poll的缺点

一个select可以监听很多socket套接字,但是它每次都要把这个套接字通过fd_set设置到位数组当中,响应以后内核又要把发生事件的位数组从内核再拷贝到用户空间,然后用户空间就会遍历整个位数组来判断到底哪个socket发生事件。

  • 单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,所以如果 __FD_SETSIZE调整的太大,那么在应用层面不断去扫描文件描述符的位数组所花费的时间也是线性的,文件描述符数量越多,性能越差;(在Linux内核头文件中,有这样的定义 #define __FD_SETSIZE 1024)
  • 内核/用户空间内存拷贝问题(因为每次调用select或者poll的时候,都会去填写相应的位数组,填写相应的数据,然后通过select或poll的调用把填写好的句柄数据从用户空间拷贝到内核空间,内核空间去监听事件是否发生,发生了以后再把发生事件的句柄再从内核空间拷贝到用户空间)select需要复制大量的句柄数据结构(文件描述符),产生巨大的开销。
  • select返回的是含有整个句柄的数组,所以应用程序在判断的时候并不清楚到底是哪个句柄发生事件,需要遍历整个数组才能发现那些句柄发生了事件(对poll来说就是遍历链表)。
  • select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。(就是说如果select或者poll上报了发生事件的fd,如果把文件句柄上的数据没有读完的话,是不断地会触发的,出发次数多,比较浪费效率)

相比于select模型,poll使用链表保存文件描述符,因此没有监视文件数量的限制,poll所能操作的fd地数量就是Linux系统给每个进程默认配置的fd数量的上限,不像select被位数组大小限制,但其他三个缺陷依然存在。

以select模型为例,假设我们的服务器支持100万的并发连接,则在__FD_SETSIZE为1024的情况下,则我们至少开辟1k个进程才能实现100万的并发连接。除了进程上下文切换的时间消耗外,从内核/用户空间大量的句柄结构内存拷贝、数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到100万级别的并发访问,是一个很难完成的任务。

二、 epoll的原理及优势

epoll的实现机制与select、poll完全不同,他们的缺点在epoll上不复存在。

设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景是这种情况)。如何实现这样的高并发?

在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制到句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完成后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。

epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树,磁盘IO消耗低,效率很高)。把原先的select/poll调用分成以下3个部分:

  • 调用epoll_create()创建一个epoll对象(在epoll文件系统中为这个句柄对象分配资源,底层有两个数据结构,红黑树和双向链表)
  • 调用epoll_ctl向epoll对象中添加这100万个连接的套接字(epoll_ctl一次性向epoll对象中添加感兴趣相应事件的socket,都添加到底层的红黑树上,红黑树的效率很高,增删查可以达到对数时间,大概1000万的数据只有20层左右,100万也就10多层,最多开销也就10多个节点的遍历,效率很高)
  • 调用epoll_wait收集发生的事件的fd资源(当这个事件发生以后,底层的epoll会从红黑树的摘出发生事件的节点,并把它放到双向链表中,这个双向链表就直接存储了发生事件的事件记录,在我们应用程序当中,我们不需要遍历100万个套接字,看谁发生事件,epoll_wait返回的就是发生事件的集合,不像select和poll,我们需要自己遍历哪些句柄发生事件,明显效率很低。)

如此一来,要实现上面说的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除事件。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。

epoll_create()在内核上创建的eventpoll结构如下:

structceventpoll
{
	//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监控事件
	struct rb_root rbr;
	//双向链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件
	struct list_head rdlist;
}

三、 LT模式和ET模式

1. LT模式(水平触发)

epoll设置默认是LT模式,内核数据没被读完,就一直上报数据

应用程序可以每次读一部分数据,没读完,下一次epoll_wait时内核还会上报。能够平均地处理每个socket上的读写事件,能够及时回到epoll_wait,让所有的socket都得到及时的处理,让客户端感觉服务器延时低。

2. ET模式(边沿触发)

ET模式数据只上报一次,就是从不可读到可读、从不可写到可写这种状态的变化会上报一次,如果不读完或者写完就不会再触发。

我们可能会认为ET模式只触发一次,效率高,LT模式就没有存在的价值,其实不是这样的。

在极端情况下,epoll工作在ET模式下,一个socket上产生源源不断的数据,由于应用程序会循环读取这个socket上的数据,这就导致应用程序无法及时回到epoll_wait。假如此时又有其他请求到了,红黑树上有一些fd发生了事件被放到双向链表返回给应用程序,而此时应用程序还在读某个socket上的数据,无法及时回到epoll_wait,对于客户端来说,服务器就没有及时处理它的请求,客户端会觉得服务器的延时很高。