Nginx

要了解Nginx的I/O模型,我们需要先明确两对重要概念:同步与异步、阻塞与非阻塞。

同步与异步

同步和异步关注的是消息通信机制
所谓同步,就是调用发出后会一直等待结果的返回,在没有得到结果之前该调用就不返回,而一旦得到结果了该调用立即返回。
异步则刚好相反,调用者在调用发出后这个调用就直接返回了,不需要等待结果的返回,也即没有返回值,换句话说在异步调用的时候调用者只能在事后通过被调用者以某种方式(比如:状态、信号、通知等方式)来通知自己。

举个生活中的例子:你最近想换家运营商的网络,但是忘记跟目前这家运营商的合同是什么时候到期,于是你打电话给现在的运营商查询合同过期时间。如果是同步通信机制,运营商则会说你稍等我帮你查一下,然后就查啊查,查了一会儿(可能是20秒,也可能更长时间)告诉你明天就到期啦,这时你就知道结果了,但是你一直拿着电话等了好久;如果是异步机制,运营商则会跟你说,好的我们帮你查下,查到了回电话给你,然后你就把电话挂断了,过了一会儿,运营商主动打电话过来了说明天就到期了,这样你也知道结果了。这种“回电”的方式其实也就等效于程序中的”回调“。

阻塞与非阻塞

阻塞和非阻塞关注的是进程在等待调用结果时的状态。
阻塞调用是指调用结果返回之前当前进程回被挂起,在等待的同时不能干其它事情了,进程只有在得到结果之后才会继续往下执行。
非阻塞调用则是指在没有得到返回结果前当前进程不会被挂起,在等待的同时还可以继续干其它的事情。

还是上面的例子:如果是阻塞调用,你会一直等待对方的回复中途不干其它的事;如果是非阻塞调用,你在等待的同时可以喝杯咖啡什么的。注意在这里的阻塞与非阻塞跟是采用同步还是异步通行是没有关系的。

I/O模型

因为用户进程是不可以直接访问外部设备的,所以我们只能让内核(系统调用,用户空间与内核空间切换)去访问外部设备,然后外部设备比如磁盘,读出存储在设备自身的数据以后传输给内核缓冲区,内核缓冲区再copy数据到用户进程的缓冲区。在外部设备响应到用户进程的过程中,包含了两个阶段,由于数据响应方式的不同,所以就有了不同的I/O模型。

一般有5种I/O模型

阻塞式I/O模型:

默认情况下,所有套接字都是阻塞的。进程挂起,内核等待外部IO响应,IO完成传送数据到kernel buffer,数据再从kernel buffer复制到用户的进程空间。

非阻塞式I/O模型:

在内核请求IO设备响应指令发出后,数据就开始准备,在此期间用户进程没有阻塞,也就是没有挂起,它一直在询问或者check数据有没有传送到kernel buffer中。但是第二个阶段(数据从kernel bufer复制到用户进程空间)依然是阻塞的。这种IO模型会大量占用CPU的时间,效率很低,所以实际应该的比较少。

I/O多路复用(select、poll、epoll):

内核在请求IO设备响应指令发出后,数据就开始准备,在此期间用户进程是阻塞的。数据从kernel buffer复制到用户进程空间的过程也是阻塞的。单是和阻塞I/O所不同的是它可以同时阻塞多个I/O操作,而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数,也就是说一个进程可以响应多个请求。

信号驱动式I/O(事件驱动):

第一阶段是非阻塞的,当数据传输到kernel buffer以后,直接用信号的方式通知进程,用的不多。

异步I/O:

在整个操作(包括将数据从内核拷贝到用户空间)完成后才通知用户进程。

Nginx中的epool、poll、select

这三种模式都属于I/O多路复用模型。

select、poll是主动查询,它们可以同时查询多个fd的状态,但select有fd个数限制,poll没有限制。select和poll不同的是,它们创建的事件描述符不同,select创建读、写、异常三个集合,而poll在一个集合中设定三种描述,由于select和poll每个循环都会检查事件的发生,而poll的事件比较少,所以性能上比select要好一些。

epoll是基于回调函数的,五轮询。当套接字比较多的时候,每次select()都要遍历FD_SETSIZE个socket来完成调度,不管是哪个socket活跃,都要遍历一边,这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当它们活跃时自动完成相关操作那就避免了轮询也就提升了效率。这正是epoll(Linux)、kqueue(FreeBSD)、/dev/poll(Soloris)做的。

在高并发服务器中,轮询I/O是最耗时间的操作之一,所以select、epoll、poll的性能高低也就十分明了了。

Web请求一般流程如下:

客户端发送一个请求到Web服务器,Web服务器网卡收到数据 —> 网卡将请求交由内核空间的内核处理,其实就是数据拆包,发现请求是80端口 —> 内核将请求发给在用户空间的Web服务器,Web服务器解包发现请求的是index.html文件 —> Web服务器调用系统调用将请求发给内核 —> 内核发现在请求一个磁盘文件,便调用磁盘驱动程序连接磁盘 —> 内核通过驱动调用获得了磁盘文件 —> 内核将取得的文件保存在自己的缓冲区中便通知Web服务器进程来取这个文件 —> Web服务器通过系统调用将内核缓存中的文件复制到自己的缓冲区域中 —> Web服务器用此文件来响应用户请求,再次通过系统调用将页面文件发给内核 —> 内核将文件数据进行封装并通过网卡发送出去 —> 当报文到达网卡时通过网络响应给请求的客户端。

并行处理

Web服务器是一对多的关系,通常完成并行处理的方式有:多进程、多线程、异步三种方式。

**多进程:**每个进程对应一个连接来处理请求,进程独立响应自己的请求,一个进程异常并不会影响到其它请求,而且设计简单,不会产生内存泄露等问题,因此这种方式比较稳定。但是进程在创建的时候一般是fork机制,会存在内存复制的问题,另外在高并发的情况下,上下文切换将很频繁,这样将消耗很多的性能和时间。早期的Apache使用的prework模型就是多进程方式,但是apache会预先创建几个进程,等待用户的响应,请求完毕进程也会结束,因此性能上有所优化。

**多线程:**每个线程响应一个请求,由于线程之间共享进程的数据,所以线程的开销较小,性能就会提高。但是由于线程管理需要程序自己申请和释放内存,所以当存在内存等问题时,可能会运行很长时间才会暴露问题,所以在一定程度上还不是很稳定。Apache的worker模式就时这种方式。

**异步:**Nginx的epoll、Apache的event都是支持异步方式的。

Nginx的IO模型是基于事件驱动的,这使得应用程序在多个IO句柄间快速切换,实现所谓的异步IO。事件驱动服务器,最适合做的就是IO密集型工作,比如反向代理,它在客户端与Web服务器之间起一个数据中转作用,纯粹的IO操作,自身并不设计到复杂计算。反向代理用事件驱动来做,显示更好,一个进程就可以工作了,没有进程、线程的管理开销,CPU、内存消耗都很小。

像科学计算、图形图像处理等,它们都是CPU密集型服务,事件驱动并不合适。例如一个计算耗时5秒,那么这5秒就时完全的阻塞,什么event都没用。比如MySQL如果改成事件驱动的话一个大型的join或者group by就会阻塞住所有的客户端,这个时候多进程多线程就体现出优势了,每个进程各干各的事,互不阻塞和干扰。

总的来说,事件驱动适合IO密集型服务,多进程多线程适合CPU密集型服务。