IO多路复用epoll及其在nginx事件驱动框架中的使用

什么是IO复用?

多个请求,就会对中间件产生多个IO流,处理整个IO流的请求有很多的实现方式

  1. 单线程的方式

    单线程处理多个IO流请求,类似于就是串行的,一个阻塞了,就全部阻塞了

    对于IO流请求,在操作系统的内核,有并行处理和串行处理的概念。串行就是一个一个处理,很容易造成阻塞,所以用并行,只使用一个socket来完成多个IO流的请求。

  2. 多进程/多线程的方式

    IO多路复用epoll及其在nginx事件驱动框架中的使用

    多线程的意思就是每个IO流对应一个处理线程

    优点是:不会阻塞了

    缺点是:多线程的方式会产生一定的性能消耗,比如线程切换等

  3. IO多路复用

    多个文件描述符的I/O操作都能在一个线程内并发交替地顺序完成,这里的复用,指的是复用同一个线程。

    仍然还是一个线程来处理多个IO流请求,但与单线程的方式不同,其由IO流来主动上报,上报了,那么这个线程就去处理,其他线程等待

    I/O多路复用就通过一种机制,可以监视多个文件描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

    同步IO和异步IO

    select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的

    而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

IO多路复用的实现

select实现

当用户进程调用了select函数,select是一个阻塞方法,会把进程阻塞住,同时会监听所有select负责的socket

对应下图中的步骤1:进程受阻于select调用。等待多个socket中的任意一个变为可读

当任意一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用,将数据从内核拷贝到用户进程。

对应下图中的步骤2:如果可读了,数据复制到应用缓冲区期间进程阻塞

IO多路复用epoll及其在nginx事件驱动框架中的使用

IO多路复用就是内核态对于IO请求的时候,内核会主动发送所需要的文件对象就绪时,会发送文件就绪的可用信息给应用。

应用这端在整个fd(file description)就绪前是阻塞的,阻塞住对应的socket请求,会维护一个fd的列表。

fd就绪后进行数据拷贝,将数据从内核态read到用户态。

这里的一个关键环节:

在内核态发送fd就绪命令之前,内核会不断的轮询其fd列表,select模型在这里采用的是线性遍历的方式。

这种线性遍历的方式存在如下缺点

  1. 线性扫描效率低下。它要不断地线性扫描队列,来确保队列中的所有线程都完成

  2. 能够监视的文件描述符的数量存在最大限制(最大1024)

poll实现

poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,其他的都差不多

epoll实现

  1. epoll采用事件驱动模型,每当FD就绪,采用系统的回调函数将fd放入就绪的列表,这样就知道队列中的哪个线程完成了,效率更高(不需要再进行轮询了)

  2. 最大连接无限制

三种实现的性能比较

IO多路复用epoll及其在nginx事件驱动框架中的使用

横轴:句柄数

纵轴:处理消耗时间

随着句柄数的增加,即随着并发连接数的增加,所消耗时间的趋势

epoll基本上是与句柄数增加无关的,适合大并发连接的处理

nginx默认使用epoll来运行自己的事件驱动框架

在nginx的事件驱动模型中,最关键的环节就是nginx怎样能够快速的从操作系统的kernel中获取到等待处理的事件。

比如说nginx现在处理100万的连接,我们nginx在两次处于等待处理事件期间,也就是nginx等待接收新的连接的时间可能会非常的短,在短短的几百毫秒这样的一个量级中,所能收到的报文数量是有限的,而这些有限的事件对应的连接也是有限的,也就是每次nginx处理事件时,虽然nginx有100万个并发连接,但是nginx可能只接收到100个活跃的连接,nginx只需要处理几百个活跃的请求。

而select和poll,其实是有问题的,因为每一次nginx去取事件的时候,都是把这100万个连接扔给操作系统,让操作系统依次判断,哪些连接上面有事件进来了,可以看到这里操作系统做了大量的无用功,扫描了大量不活跃的连接。

epoll就使用了这样的一个特性,即高并发连接中,每次处理的活跃连接数量比较小

nginx具体是如何利用epoll来运行自己的事件驱动框架

IO多路复用epoll及其在nginx事件驱动框架中的使用

nginx维护了一个数据结构eventpoll,其通过两个数据结构,把下面两件事分开了。

第一件事:操作系统–>事件队列(图中上面的队列)<–nginx

也就是我们nginx每次取活跃连接的时候,只需要去遍历一个链表,这个链表中仅仅只有活跃的连接,这样效率就很高。

第二件事:nginx–>事件队列(图中下面的队列)<–操作系统

我们还会经常做的操作,比如nginx接收到80端口建立的请求,连接建立成功之后,nginx要建立一个读事件,这个读事件是用来读取http消息的,这个时候可能还会添加一个新的事件,比如写事件,添加进来。添加这个操作,会放到一个平衡二叉树中,其能保证插入效率是O(logN)。如果nginx现在不想处理读事件或者写事件,只需要在这个平衡二叉树中移除这个节点就可以了,同样是O(logN)的时间复杂度。

什么时候这个链表有增减呢?

当nginx读取一个事件时,链表中自然就没有了,当操作系统接收到网卡中发送过来的报文的时候,这个链表就会增加一个元素

所以nginx在使用epoll的时候,其添加、修改、删除是O(logN)的时间复杂度,非常快的

而我们在获取句柄的时候,其实就是遍历rdllink (ready link 准备好的连接),只是将其读取出来而已,从内核态读取到用户态,读的内容并不大,所以效率比较高

nginx使用epoll后实现的请求切换效果

以多进程处理方式和nginx+epoll来做对比

IO多路复用epoll及其在nginx事件驱动框架中的使用

三个请求:分别是蓝色的req1,绿色的req2以及黄色的req3

先看左边的传统server多进程的处理方法:比如apache

每一个进程Process同一时间只处理一个请求。比如说Process1在处理req1,当前网络状态不满足的时候,就会切换为Process2,去处理Process2上的req2,Request2可能很快又不满足了,比如说想写一个响应的时候,发现写缓冲区已经满了,即网络中已经比较拥塞了,所以我们的滑动窗口没有向前滑动,以至于我们调用write方法没有办法写入我们需要写入的字节。此时,阻塞类的write方法会导致又发生一次进程切换,切换到了Process3,操作系统选择了Process3,因为Process3上的req3属于网络满足状态,执行了一段时间,比如Process3已经用完了它的时间片,操作系统又切换进程,切换为Process1,如此往复下去。

这里存在一个很大的问题,每做一次切换,在我们当前cpu的频率下,其所消耗的时间大约是5微秒,5微秒很小,但是当我们并发的连接和并发的进程数开始增加的时候,其不是一个线性增加,而是一个指数增加,这个进程间切换的消耗是非常大的。

即传统server是依靠操作系统的进程切换来实现其的并发连接数,而操作系统的进程切换只适用于几百至上千个进程之间做切换。

再看右边nginx是如何处理的。

当蓝色req1的网络不满足的情况下,其在用户态直接切换到了绿色的req2…,这样就没有了进程间切换的成本。除非是我们nginx worker使用的时间片已经到了,而时间片的长度一般是5毫秒到800毫秒。所以我们一般会在nginx的配置上将其优先级加到最高。比如-19。

总结来说:

传统server处理高并发:

  1. 不做连接切换

  2. 依赖操作系统的进程调用实现并发

nginx处理高并发

  1. 用户态完成连接切换

  2. 尽量减少OS进程切换

引申

  1. Node.js 也是这样的,只用一个线程来处理所有请求,由事件驱动编程。不要等,立刻返回。接收事件通知。
  2. 还有Web 编程中的AJAX ,当浏览器中的JavaScript 发出一个HTTP请求的时候,也不会等待服务器端返回的数据,只是设置一个回调函数,服务器响应数据返回的时候调用一下就行了