9.2 I/O 多路复用:select/poll/epoll | 小林coding (xiaolincoding.com)
原始的TCP连接
TCP Socket 基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。
使用多进程优化
种用多个进程来应付多个客户端的方式,可以同时处理多个连接,但是也有很大的缺点:
- 连接数量(开辟的进程)有限
- 进程占用系统资源较多
- 进程间上下文切换的代价很重的,性能会大打折扣。
使用多线程优化
同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。
- 虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。
- 如果使用线程池的方式来避免线程的频繁创建和销毁,会产生多线程竞争
IO多路复用是什么?
在linux系统中,实际上所有的I/O设备都被抽象为了文件这个概念,一切皆文件。磁盘、网络数据、终端,甚至进程间通信工具管道pipe等都被当做文件对待
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。
我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
I/O模型
- 阻塞IO 这是最常用的简单的IO模型。阻塞IO意味着当我们发起一次IO操作后一直等待成功或失败之后才返回,在这期间程序不能做其它的事情。 阻塞IO操作只能对单个文件描述符进行操作,比如read或write。
- 非阻塞IO 进程发起IO系统调用后,如果内核缓冲区没有数据,需要到IO设备中读取,进程返回一个错误而不会被阻塞;进程发起IO系统调用后,如果内核缓冲区有数据,内核就会把数据返回进程。
- 信号驱动IO 当进程发起一个IO操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用IO读取数据。
- 异步IO 当进程发起一个IO操作,进程返回(不阻塞),但也不能返回果结;内核把整个IO处理完后,会通知进程结果。如果IO操作成功则进程直接获取到数据。
- IO多路复用 使用一个进程来维护多个 Socket ,有 select/poll/epoll三种方法
Select
- 将已连接的 Socket 都放到一个文件描述符集合
- 调用 select 函数将文件描述符集合拷贝到内核里,使用遍历的方式,让内核来检查是否有网络事件产生
- 当检查到有事件产生后,将此 Socket 标记为可读或可写
- 再把整个文件描述符集合拷贝回用户态里
- 然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024
,只能监听 0~1023 的文件描述符。
小结
- 位图BitsMap的大小有限,能开辟的文件描述符少
- 需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n)
- 需要在用户态与内核态之间拷贝文件描述符集合
Poll
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
小结
- 相比select ,突破文件描述符个数限制,当时还会受到系统文件描述符限制。
- 需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n)
- 需要在用户态与内核态之间拷贝文件描述符集合
Epoll
- 先用 epoll_create 创建一个 epoll对象 epfd该函数生成一个 epoll 专用的文件描述符。其中 epoll_create 依靠 eventpoll 这个结构体实现,其中中的几个成员的含义如下:
- wq: 等待队列链表。软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。
- rbr: 红黑树。为了支持对海量连接的高效查找、插入和删除,eventpoll 内部使用的就是红黑树。通过红黑树来管理用户主进程accept添加进来的所有 socket 连接。
- rdllist: 就绪的描述符链表。当有连接就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程只需要判断链表就能找出就绪进程,而不用去遍历红黑树的所有节点了。
- 再通过 epoll_ctl 将需要监视的 socket 添加到epfd中 epoll 的事件注册函数,一次性将所有需要监听的socket加入到内核中,后续可以避免再次复制的开销。
- 最后调用 epoll_wait 等待数据。 等待事件的产生,收集在 epoll 监控的事件中已经发送的事件,类似于 select() 调用。
Epoll 的优点
- epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过
epoll_ctl()
函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是O(logn)
,减少了内核和用户空间大量的数据拷贝和内存分配。 - epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用
epoll_wait()
函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
Epoll 边缘触发和水平触发
使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
- 边缘触发:数据没处理完后续不会再触发事件
- 水平触发:是不管数据有没有触发都返回事件
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。
小结
- select: select允许程序同时监控多个文件描述符的读写状态,但受限于位图大小,且每次调用都需从用户空间向内核空间复制位图,性能开销大。
- poll: poll改进了select,使用数组存储文件描述符,无位图大小限制,可处理更多文件描述符。但同样存在每次调用时的用户空间到内核空间的复制开销。
- epoll: epoll是Linux特有的高效IO多路复用机制,基于事件驱动,无需轮询,通过注册感兴趣的事件并在事件发生时通知应用程序,适合处理大量并发连接,性能优越且资源消耗低。