为什么需要I/O多路复用

IO多路复用的本质是为了提升操作系统维护对外连接的能力,在尽可能节省资源的同时,在网络编程中提升操作系统维护Socket的能力。

在基础的TCP Socket通信模型下,服务端和客户端会分别通过Socket建立一对一的连接,大致流程如下:

  • 服务端创建Socket(可选网络层和传输层协议),并使用bind()绑定IP和端口,启动调用listen()开始监听连接
  • 客户端创建Socket(可选网络层和传输层协议),使用connect()建立连接
  • 接下来进行TCP三次握手,完成后,服务端使用accept()从TCP全连接队列中取出该socket,进行数据传输

这个过程中,服务端的进程会通过自己的文件描述符数组找到自己在内核中的Socket数据结构,其中包含一个发送队列和一个接收队列,两个队列里存放了许多sk_buff,它们由链表的形式进行组织。

多进程模型:如果我们需要让服务器连接多个客户端,最简单的方案便是创建多个进程,父进程使用fork()创建和自己一样的子进程。在创建时,两个进程除了返回值是一模一样的,这就包括了指向Socket的文件描述符,即子进程可以使用父进程的“已连接Socket”进行通信。

但这一模型下存在一个巨大的问题,便是资源消耗。进程有自己独立的地址空间,包括虚拟内存、进程栈、内核堆栈等等资源,多进程的上下文切换代价极大。此外,子进程退出时,如有未被及时回收的子进程资源便会产生大量僵尸进程,消耗系统资源,这时我们便会考虑代价更小的多线程模型。

多线程模型:总所周知,同一进程内的线程会共享线程栈以外的一切资源,包括文件描述符列表、代码、全局数据等等,其资源消耗和上下文切换代价比进程要小得多。当然,在网络连接的场景下,频繁创建和销毁大量线程的代价也决不可小觑,这时我们便可以考虑使用线程池。

父进程可以通过创建一个队列(注意线程安全),将accept()取得的已连接Socket连接放入该队列中,线程池则从其中取出已连接Socket进行处理。但当服务器需要维护的连接达到更大规模时(参考C10K),大量线程带来的消耗也是相当恐怖的。

这时我们便会想能否使用一种方式无需创建大量进程或线程,以更小的代价让操作系统维护更多的连接?

I/O多路复用

I/O多路复用(Input/Output Multiplexing)是一种在单个线程中管理多个输入/输出通道的技术。它允许一个线程同时监听多个输入流(比如文件描述符),并在有数据可读或可写时进行相应的处理,而不需要为每个通道创建一个独立的线程。

常见的I/O多路复用机制包括select、poll和epoll.

select

数据结构bitsmap[FD_SETSIZE]

fd检查:轮询

应用场景:低并发需求,有少量的fd需要监控

select会将已连接的Socket文件描述符集合拷贝到内核空间,内核遍历整个Socket集合检查连接中是否有网络事件发生,并标记该Socket为可读或可写,然后将这个集合整个拷贝回用户空间,在用户空间中再次遍历找到那些被标记的Socket,并进行处理。

整个过程发生了2次拷贝和两次遍历,随着文件描述符的增加,其性能会线性下降(线性数据结构遍历O(n)O(n)、反复拷贝操作);

select表示文件描述符集合的数据结构是固定长度的BitsMap,大小由内核FD_SETSIZE宏定义,默认值为1024,即select最多可以监听0~1023的文件描述符。

poll

数据结构:结构体数组

fd检查:轮询

应用场景:低并发需求,有大量的fd需要监控

poll在select的基础上将底层数据结构改为结构体数组,解决了select文件描述符上限的问题。由此,在连接数较多时,性能会优于select。

epoll

数据结构:红黑树

fd检查:事件驱动

应用场景:高并发需求,有大量fd需要监控,fd活跃数较低

epoll在内核中维护了一个红黑树。调用epoll_ctl()将需要监控的socket放入内核的数据结构中,解决了select/poll每次都完整拷贝整个数据结构和时间复杂度高的问题(红黑树增删改效率一般为O(logn)O(logn))。

epoll引入了事件驱动机制和回调机制。epoll在内核中维护了一个就绪队列,仅在fd状态有改变时才会触发回调,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数。解决了select/poll完整轮询遍历整个socket列表的问题,且在fd活跃数较低时,epoll优势更加明显。

epoll的事件触发

epoll支持两种事件触发模式:水平触发(level-triggered,LT)边缘触发(edge-triggered,ET)

水平触发(LT) 边缘触发(ET)
触发条件 只要文件描述符(fd)对应的事件条件满足(如缓冲区有数据可读或可写),每次调用epoll_wait都会触发。 只有在fd状态发生变化的边缘时刻触发,如从不可读变为可读、从不可写变为可写时触发,之后状态不变则不再触发(除非再次变化)。
事件通知频率 相对较高。如果应用程序处理事件不及时,会频繁收到相同事件的通知。 相对较低。只有状态改变边缘才触发,减少了不必要的通知。
编程难度 较简单。开发人员可以按照自己的节奏处理事件,不用担心错过事件,因为会持续收到通知。 较复杂。要求应用程序在一次触发后尽可能完整地处理相关事件,否则可能错过事件,需要更精细的编程逻辑。
适用场景 适用于简单的网络编程场景或对性能要求不是极高,处理事件速度能跟上的情况。 适用于高并发、高性能要求的场景,如大型网络服务器,能有效降低系统开销。
数据处理方式 可以每次处理部分数据,剩余数据下次调用epoll_wait时还会收到通知。 通常需要在一次触发后循环处理数据,尽量一次性处理完所有相关数据,否则可能遗漏。

参考阅读