I/O多路复用技术
想要理解多路复用技术,首先要了解这个技术出现之前,我们面临的痛点是什么。
以 JAVA 为例,我们想要写一个 TCP 服务端,接收客户端发来的数据,那么我们会这样写:
while (true) { Socket socket = serverSocket.accept(); //读取输入缓冲区数据 //响应数据写入输出缓冲区 }
我们的线程阻塞在 socket 的 accept 方法上等待客户端请求的到来。一旦有客户端发起请求,建立一个新的 socket 连接并返回该 socket 对象,我们从本次连接的输入缓冲区读取客户端发来的数据,然后做出响应。响应后本次处理接触,进入下一次 while 循环,继续阻塞在 accept 方法上等待下一次连接的到来。
这种方式的弊端显而易见,请求的处理是单线程的,我们必须处理完一次请求,才能建立下一次请求需要的连接,没有并发可言。为了解决这个问题,我们可以通过 fork/thread 模型的方式,将建立请求与请求处理分离开来,放在不同的线程中处理。
while (true) { Socket socket = serverSocket.accept(); Runnable readThread = new Runnable() { //接收请求数据 //返回响应 }.run(); }
每次来新的连接,主线程只负责建立连接并开启一条新的线程来处理这次连接,便可以去处理下一次请求。与第一种方式相比,总算有了点并发能力。但问题还是存在的,那就是每次有新的连接,我们都需要为其分配专门的处理线程,代价非常大。在客户端连接非常多的情况下,我们将会开启非常多的线程来处理这些连接,给系统造成巨大的负担。当然为了防止服务将操作系统搞崩,我们可以将创建新的线程改为将任务提交到线程池,将开启的线程数限制在一个安全的范围内。但这仅仅是保证了系统的安全,并没有增加服务端的并发能力。需要注意的是,以上两种方式虽然使用 JAVA 编写,但都是使用的最基础的 soket 操作,并没有涉及到我们所说的多路复用。
对于第二种方式来说,主线程开启的处理客户端请求的线程在做两件事:
1. 阻塞的等待客户端数据的到来。
2. 回复客户端。
在阻塞的等待客户端到来上,任务线程一直是处于阻塞状态的,通常情况下任务线程最多的时间也是消耗在了这上面。解决其存在的线程数过多限制了系统的并发能力的问题,我们可以从这点入手。当数据准备就绪时再开启线程处理,而不是连接到来时便开启一个线程傻傻的阻塞在那里等待数据的到来。
1983年,随着计算机网络的成型,越来越多的人开始使用网络,服务器的并发数开始增加。人们终于意识到了这种问题,所以发明了一种叫做「IO多路复用」的模型,这种模型的好处就是「没必要开那么多条线程和进程了」,一个线程一个进程就搞定了。这便是最初的多路复用技术,select 函数的发明。
使用 select 函数我们可以只使用一个线程来监视所有有效的 socket 连接,当有数据就绪的事件发生时再开启新线程去处理,处理完后线程销毁或放回线程池,并再次将线程放入监视队列。这样不需要将线程与连接绑定,线程只负责处理任务,我们需要维持的线程数将大大减少。
select 函数的核心在于,主线程需要不断的轮询检查其监视的 socket ,判断每个 socket 是否有事件发生。
至于 socket 的状态的变更,不同的操作系统有不同的实现。在未使用多路复用技术时,网卡的数据准备就绪会发送中断,OS 通过中断处理程序将数据从网卡拷贝到内核的输入缓冲区,这个拷贝过程如果由DMA 或者通道完成,在数据拷贝完成时 DMA 或 通道 也会向内核发送中断,OS通过中断处理程序将阻塞在这些数据上的线程置为就绪状态。
OS 可以通过中断处理程序改变线程的状态,也可以做其它事情,比如检查内核空间中某个数据结构确定是否有 select 方法在监视这个缓冲区,如果有则将缓冲区状态映射为事件并同步到用户空间,同时将阻塞在该 select 方法的线程唤醒。这里只是举个例子,具体实现不做深入探讨,但可以确定的是,处理 socket 数据准备就绪时中断的中断处理函数承担了更新事件状态的任务(原理上如此,但特殊情况下os会进行一定的优化,下面会说到)。
select 函数做到了事件驱动,也就是多路复用,但还存在着一些缺点。比如因为需要一个数组来存储监视的 socket 的引用,其可以监视的 socket 的数量有限。这一点后来的 poll 函数做了改进,将数组结构改为链表结构,使其可以监视的 socket 的数量大大增加。另外一个缺点便是每次有事件发生, select 都需要遍历所有监视的 socket 判断是哪个 soket 发生了事件,在监视的 socket 较多时其效率很低,时间复杂度 O(N)。但总的来说,在当时的并发量下,select 函数的性能已经足够人们使用了。
直到2002年,互联网时代爆炸,数以千万计的请求在全世界范围内发来发去,服务器大爆炸,人们通过改进「IO多路复用」模型,进一步的优化,发明了一个叫做epoll的方法。epoll 除了维护监视的 socket 的列表,还维护了当前发生事件的 socket 的列表,虽然这样只是给中断处理程序增加了一点小任务:将当前 socket 的引用加入 ready 列表中。但每次进行事件处理时,epoll 只需要遍历 ready 列表即可,相较 select 的全部遍历,并发能力又得到了进一步提升。
epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。
epoll 对 poll 的改进主要体现在两个方面:
一是额外维护了 Ready 队列,有事件发生时只需要遍历 Ready 队列即可,而不是像 poll 一样遍历整个监听队列。这使得 epoll 被唤醒后可以在 O(1) 时间复杂度内找到发生事件的句柄。
二是支持边缘触发。水平触发与边缘触发的概念很好理解,水平触发便是每次检查与文件描述符关联的内核缓冲区时,只要其有数据便发出可读信号;边缘触发则是只有在与文件描述符关联的内核缓冲区由空转为非空时才发出可读信号。相较水平触发,边缘触发使得用户空间有可能缓存IO状态。因为边缘触发是在每次状态改变时,才会触发事件。也就是说在网卡有数据到达时触发事件,在下次数据到达网卡的间隙,事件列表是不会改变的。而水平触发则要求内核不断检查监听队列中成员的状态,事件列表可能在任何时候被更新,因此水平触发的情况下在用户空间缓存 I/O 状态可行性很低,用户程序很难得知内核空间的 I/O 状态何时发生了改变。其意义在于不必每次检查IO状态都要进行系统调用,减少了系统调用的频率。
我们来看一个震撼人心的并发图:
可以看到,epoll 的性能是一条平稳的直线,其性能几乎不受并发数的影响,无敌的并发。
另外还有一点,epoll 存储监视列表的方式是在内核空间建立了一棵红黑树,而 select 是在用户空间建立监视列表的同时在内核空间同步建立一套一样的监视列表。因此对于 select 来说,各个被监视句柄的状态需要不断的在内核空间和用户空间之间进行同步,而 epoll 则不需要进行这些繁琐的拷贝,因为用户进程与内核共用了一块内存。
总的来说,epoll是继承了select/poll的I/O复用的思想,并在二者的基础上从监控IO流、查找I/O事件等角度来提高效率,具体地说就是内核句柄列表、红黑树、就绪list链表来实现的。
不管是 select/poll 还是 epoll,它们都赋予了我们通过事件驱动的方式来处理 IO 事件的能力。事件驱动听起来是一个高大上的名词,但其实它充斥在 kernel 的方方面面。我们的 kernel 本身就是事件驱动的,不严谨的说,中断机制便是 kernel 实现事件驱动的手段。比如 kernel 管理鼠标键盘等外设,并不知道何时会有输入,我们必须有一种机制可以在这些情况发生时,及时的去处理这些情况。软件是按顺序执行的,而外设的输入这类事件本身便是软件无法预料的情况,程序无法预知何时发生中断,何时执行中断处理函数。只能在某个地址写上一段中断处理代码,待中断发生时由硬件强行跳转到你的中断处理函数去处理中断。
在某个地方编写事件处理代码,待事件发生的时候强行触发这些代码来进行事件的处理,这便是事件驱动的核心概念。我们把处理代码堆叠在某个地方,等待事件的到来便执行,可能这也是Reactor 名称的由来。至于如何强行触发这些代码 ,kernel 层面通过中断,也就是硬件的支持来实现,软件层面则可以发挥我们的创造力,比如通过信号处理函数,通过线程同步机制等等。IO的多路复用本质上依然是基于中断来完成这个动作的。不过特殊情况下,kernel 也会让轮询代劳,因为中断的响应本身是一件比较消耗性能的事情。比如网卡接收数据,如果数据在一段时间内源源不断的到来,每次数据到来网卡都发送中断,处理器将会将大量的时间消耗在中断响应上。这时候,某些 kernel 会干脆禁止网卡中断,采用轮询的方式从网卡读取数据,以此降低系统的综合开销。