详解 select、poll、epoll
select、poll
我们熟悉的 select/poll/epoll 是内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 2 次遍历文件描述符集合,一次是在内核态里,一次是在用户态里,而且还会发生 2 次拷贝文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个是有限制的,在 Linux 系统中,有内核中的额FD_SETSIZE 限制,默认最大值为 1024,只能监听 0~1023 的文件描述符。
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符的限制。
但是 poll 和 select 并没有太大的本质区别,都是使用线性结构存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能损耗会呈指数级增长。
epoll
epoll 通过两个方面,很好的解决了 select/poll 的问题。
- epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl()函数加入到内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度都是 O(logn),通过对这颗红黑树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
- epoll 使用事件驱动的机制,内核里面维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其将入到这个就绪事件列表中,当用户调用epoll_wait()时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
epoll 相关接口的作用如下:
epoll 的方式监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常多,上限就为系统定义的进程打开的最大文件描述符个数。
epoll 支持两种事件触发模式,分别是边缘触发和水平触发。
- 使用边缘触发时,当被监控的 Socket 描述符有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即时进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完。
- 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务端不断地从 epoll_wait 中苏醒,知道内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取。
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。
巨人的肩膀: