IO多路复用epoll及其在nginx事件驱动框架中的使用
什么是IO复用?
多个请求,就会对中间件产生多个IO流,处理整个IO流的请求有很多的实现方式
-
单线程的方式
单线程处理多个IO流请求,类似于就是串行的,一个阻塞了,就全部阻塞了
对于IO流请求,在操作系统的内核,有并行处理和串行处理的概念。串行就是一个一个处理,很容易造成阻塞,所以用并行,只使用一个socket来完成多个IO流的请求。
-
多进程/多线程的方式
多线程的意思就是每个IO流对应一个处理线程
优点是:不会阻塞了
缺点是:多线程的方式会产生一定的性能消耗,比如线程切换等
-
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多路复用就是内核态对于IO请求的时候,内核会主动发送所需要的文件对象就绪时,会发送文件就绪的可用信息给应用。
应用这端在整个fd(file description)就绪前是阻塞的,阻塞住对应的socket请求,会维护一个fd的列表。
fd就绪后进行数据拷贝,将数据从内核态read到用户态。
这里的一个关键环节:
在内核态发送fd就绪命令之前,
内核
会不断的轮询其fd列表,select模型在这里采用的是线性遍历的方式。这种线性遍历的方式存在如下缺点
线性扫描效率低下。它要不断地线性扫描队列,来确保队列中的所有线程都完成
能够监视的文件描述符的数量存在最大限制(最大1024)
poll实现
poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,其他的都差不多
epoll实现
-
epoll采用事件驱动模型,每当FD就绪,采用系统的回调函数将fd放入就绪的列表,这样就知道队列中的哪个线程完成了,效率更高(不需要再进行轮询了)
-
最大连接无限制
三种实现的性能比较
横轴:句柄数
纵轴:处理消耗时间
随着句柄数的增加,即随着并发连接数的增加,所消耗时间的趋势
epoll基本上是与句柄数增加无关的,适合大并发连接的处理
nginx默认使用epoll来运行自己的事件驱动框架
在nginx的事件驱动模型中,最关键的环节就是nginx怎样能够快速的从操作系统的kernel中获取到等待处理的事件。
比如说nginx现在处理100万的连接,我们nginx在两次处于等待处理事件期间,也就是nginx等待接收新的连接的时间可能会非常的短,在短短的几百毫秒这样的一个量级中,所能收到的报文数量是有限的,而这些有限的事件对应的连接也是有限的,也就是每次nginx处理事件时,虽然nginx有100万个并发连接,但是nginx可能只接收到100个活跃的连接,nginx只需要处理几百个活跃的请求。
而select和poll,其实是有问题的,因为每一次nginx去取事件的时候,都是把这100万个连接扔给操作系统,让操作系统依次判断,哪些连接上面有事件进来了,可以看到这里操作系统做了大量的无用功,扫描了大量不活跃的连接。
epoll就使用了这样的一个特性,即高并发连接中,每次处理的活跃连接数量比较小
nginx具体是如何利用epoll来运行自己的事件驱动框架
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来做对比
三个请求:分别是蓝色的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处理高并发:
-
不做连接切换
-
依赖操作系统的进程调用实现并发
nginx处理高并发
-
用户态完成连接切换
-
尽量减少OS进程切换
引申
- Node.js 也是这样的,只用一个线程来处理所有请求,由事件驱动编程。不要等,立刻返回。接收事件通知。
- 还有Web 编程中的AJAX ,当浏览器中的JavaScript 发出一个HTTP请求的时候,也不会等待服务器端返回的数据,只是设置一个回调函数,服务器响应数据返回的时候调用一下就行了