前提
select、poll、epoll都是IO多路复用的机制,先是监听多个文件描述符FD,一旦某个FD就绪,就可以进行相应的读写操作。但是select、poll、epoll本质都是同步I/O,他们都需要在读写事件就绪之后自己负责读写,即这个读写过程是阻塞的。
select
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。
使用方法:用fd_set(一个数组)来保存要监视的fd,用FD_SET来往里添加想要监视的fd,轮询(死循环)调用select,当监视的信息发生变化了,select解除阻塞状态走到下面,再遍历所有fd,用FD_ISSET确定是想要的fd发生了变化(比如当i=serv_sock时我就调accept去创建用于传数据的套接字,如果不等于就说明是有要接收的数据,直接read就行)。注意:如果新建了一个套接字,需要也用FD_SET把他加入fd_set作为监视的fd。
【select缺点】
【1】单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;
【2】需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大;
具体:1、每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
2、每次调用select,都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
【3】对fd进行的扫描是线性扫描,即采用轮询的方法,效率较低,这是因为select返回的是所有fd,而不是活跃的fd;
具体:当套接字比较多的时候,每次select都要通过遍历FD_SETSIZE个fd来完成调度,不管哪个fd是活跃的,都遍历一遍。这会浪费很多CPU时间,如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。
poll
poll本质上和select没有区别,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构。它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd,这个过程经历了多次无谓的遍历。
它没有最大连接数的限制(与select的不同),原因是它是基于链表来存储的,但是同样有2个缺点(同select相同):
1、大量的fd的数组被整体复制于用户态和内核地址空间之间;
2、poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
epoll
设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?
在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。
epoll的设计和实现与select完全不同,epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树)。把原先的select/poll调用分成了3个部分:
1)调用epoll_create()建立一个epoll对象;
2)调用epoll_ctl()向epoll对象中添加这100万个连接的套接字;
3)调用epoll_wait()收集发生的事件的连接。
如此一来,要实现上面说是的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。
【实现机制】
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示,主要包含两个数据结构:红黑树和双向链表!
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件(每个事件可以理解为一个红黑树的结点)。这些事件都会挂载在红黑树中,这样,重复添加的事件就可以通过红黑树而高效的识别出来。
而所有添加到epoll中的事件都会与设备驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双向链表中(比如当有新连接进来或者有数据进来,双向链表中就会多出一项作为待处理)。
在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:
struct epitem{
struct rb_node rbn; //红黑树节点
struct list_head rdllink; //双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户(具体做法是新建一个events数组用来存放在这段时间里发生的事件,然后应用程序直接到events中取读取这些要处理的事件)。
【epoll优点】(对应select和poll来看)
1、epoll没有连接数量限制,它所支持的fd上限是最大可以打开文件的数目,这个数字一般远大于2048,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接;
2、对应select的两个缺点:
每次调用select时都需要把fd集合从用户态拷贝到内核态,而对于epoll每次注册新事件到epoll对象(在epoll_ctl中指定EPOLL_CTL_ADD)都会把所有的fd拷贝进来,而不是在epoll_wait中重复拷贝,这样确保fd只会被拷贝一次;
每次调用select时都需要在内核遍历传递进来的所有fd,epoll的解决方案不像select或poll一样每次都把fd加入设备等待队列中,而只在epoll_ctl时把fd挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd,有就去处理。
3、select和poll在“醒着””的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间,这就是回调机制带来的性能提升。
【水平触发(Level Trigger)vs 边缘触发(Edge Trigger)】
epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作,而在ET(边缘触发)模式中,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读光,也就是说一直读到read的返回值小于请求值,或者遇到EAGAIN错误。
还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
epoll为什么要有EPOLLET触发模式?
如果采用EPOLLLT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,提醒你去读,但是你还不读,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率.。而采用EPOLLET这种边沿触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!
这种模式比水平触发效率高,系统不会充斥大量不关心的就绪文件描述符。
【思考】为什么ET只触发一次,LT触发多次?
ET的内核读完数据就把它从双向链表中干掉了,LT事件没处理完的话就会多次往双向链表中扔。
【思考】如何选择ET和LT?
ET比LT效率高,但编程难度高,而且如果收发数据包有固定格式,建议LT,也不一定就比ET慢!
小结
表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调,而且select比epoll具有更好的程序兼容性。select低效是因为每次它都需要轮询,但低效也是相对的,视情况而定,也可通过良好的设计改善。