异步和非阻塞

同步和异步

同步这个词在很多场景下都会出现,例如:我要把我的手机通讯录同步到云端。这个同步指的是要把两个对象中的数据保持一致的意思。实际的操作可能就是上传和下载。另外还有同步卫星,同步直播啊。这些概念都有保持一致或者保持实时的意思。而在程序编码领域,同步在不同地方表达的意思也有点偏差。在操作系统领域,同步表示进程之间的协作。而在web开发领域,同步往往意味着顺序执行,表现的状态就是阻塞。这和进程之间的协作所表达的意思略有不同。同步的反面即为异步,而异步和非阻塞几乎是服务器端开发挂在嘴边的词。据我观察,真正对其内在工作原理进行深入了解的人却不多。我大三的时候接触NodeJS。社区对其口号喊的最响的就是:它是异步的,事件驱动的,函数回调的。当时初入互联网开发的我迷糊了好长一段时间,却找不到最本质的原理。的确照着官方文档画葫芦,谁都会画。但是对原理性的东西懂得多一些,写代码的时候,自己的心里就会有底一些。于是一年多来,不间断地就会关注这方面的话题,渐渐积累了一些知识。记录一下。

Web服务器

Web服务器,撇开它对http协议封装这些不谈,我们在这里只是关注它的工作方式。它的工作方式基本是这样子的:来一个连接请求,与其建立一个连接,然后进行相应的数据传输,最后断开连接。假如它是单进程单线程的,那么一个请求过来,成功建立连接之后。在这个请求结束之前,其它请求都没有办法再被处理了。这样明显不行,web服务器本来就是用来给千万网民访问的嘛。

多进程

ok,我们使用多进程方式来做。那么来一个请求,我就spawn一个子进程。这样同时有多少连接请求过来。它就会生成多少个进程。这样做的确可以解决处理多个任务的问题。但是当同时过来千万甚至是上亿级别的请求时,要一台计算机启动千万个进程来处理。那简直是天方夜谭。机器的资源早就被耗尽了。

多线程

既然瓶颈在资源上,那我们使用线程来替代进程,在操作系统课程中我们学过线程是轻量级的进程,在这里完全可以使用线程来替代进程。这么改进之后,性能的确会有很大的提升。接下来我们观察发现,其实这些线程大多数都是在阻塞态。打个比喻:我们要做一百道菜,于是我们就起了一百个灶,买了一百个锅,开始干了。于是有的开始去买菜,有的开始洗菜,有的开始炒菜,而事实上,在每一个时刻,有很多灶都空着。根本没有用到。这里面有很大的优化空间。

事件模型

ok,事件驱动模型就出现了,目前的nginx服务器就是这个模型。事件驱动这个词一开始我觉得不太好理解。其实它的原理很简单,就是先注册一个事件,然后这个事件发生了,就来通知你,告诉你可以干活了。原理说的很轻巧,但是在计算机中是怎么实现的呢?其实在计算机的世界里,你接触的越深,越会发现站在巨人的肩膀上这句话是多么的适用。是的,nginx作者也不是从底层硬件开发出这么一个事件模型出来的。而是通过操作系统的事件库。目前2.6及以上的linux内核下最常用的事件库就是epoll。还有类BSD系统的kqueue。查看这些系统库的文档,按照要求使用就可以了。我向来喜欢追根问底,epoll又是怎么实现的呢?它是linux内核实现的一个系统调用,其实在它之前还有select, poll等,到后来不断优化到epoll这个系统调用。select的实现原理很简单,就是把一堆注册的事件放在一个事件队列里面,有个进程不断地在看哪个事件生效了。epoll实现原理也是类似,不过在算法上改进很多。更加详细地实现可以Google相关资料进行了解。

除了各个操作系统自己提供的基于事件编程的系统调用之外,开源大牛们也编写了一些事件编程的库。使用这些库来编程,可以简化编码,还可以跨平台,在各个系统上都可以正常运行。顶顶大名的就是libev。注意,libev和libevent不是一个东西,两者都是事件编程的库,但是libev在性能上要优于libevent,成替代libevent的趋势。

协程

在两年前,协程似乎是一个很高级的东西,随后大多数语言或多或少都支持协程。我比较熟悉的有Python的gevent,Lua的coroutine,Go的goroutine。尤其是Lua和Go,语言本身就支持协程。协程也被叫做轻量级线程。通俗点讲就是定义一大堆任务,然后通过一个线程轮着对每个任务都执行一下,协作运行。它的厉害之处在于每运行到一个任务的时候,它都可以从这个任务上一次中断的地方开始运行。在我们一般的印象中,只有操作系统对线程进行调度的时候才会干这样的事情,进行各种进栈,保存状态。而协程,总共也只是运行在一个线程中,要是使用线程本身的系统栈,早就暴了。因此在这里,实现的时候是用内存来模拟栈的操作。具体实现,我想复杂度一定会不小。

我们知道,线程比进程轻量级,因此产生一个线程消耗的资源比进程少,上下文切换也比进程节约。而协程比线程更加轻量级,上下文切换更是迅速。于是在服务器编程方面给人无限想象。尽管目前还没有出现一款主流的采用协程的web服务器。但是Go语言开发的web服务的性能已经崭露头角了。

真正的异步

写到这里,似乎异步和非阻塞的问题也就差不多了。但是在实际使用中,却还有很多疑问。先说一下并发和并行的区别。

并发和并行

而在web开发上遇到的模型,绝大多数都是并发。也就是说,往往瓶颈不是在计算资源上,而是在I/O。这里的I/O包括磁盘I/O和网络I/O。在上面,我主要讨论了web服务器在处理并发情况下的应用手段。目前采用比较普遍的是事件模型。上面事件模型处理的事情有一个共同的特点,全都是对socket事件进行操作。而新的问题恰恰出现在这里。这些事件库并不支持对regular file的非阻塞操作。因此,当我们要对文件进行操作的时候,只能被阻塞了。而NodeJS作者在实现Node的时候采用了这样的方法

node事件库

uv库作为对libeio和libev的一个封装,为上层提供调用接口,socket fileregular file的非阻塞操作由libev和libeio来实现。那么libeio是怎么实现对regular file操作的非阻塞的呢?很简单,线程池+同步模拟。当然内部实现方式仁者见仁,智者见智。我也没有去仔细研究它的源代码。

在讨论异步非阻塞的时候,大多时候忽略了对regular file操作的非阻塞。Node在语言层面上就实现了异步非阻塞。而Python只是通过事件库等方式来实现异步。amix大牛写了一篇关于non-blocking servers博客。其中就讲到了Tornado服务器。当使用阻塞的依赖库的时候,Tornado这样的WebServer的性能将会大打折扣。

在这里分析一下为什么即使使用Tornado的异步效果,在使用阻塞的库的时候还是会导致整个进程阻塞。首先要明确单实例Tornado运行的时候是单线程的,那么当访问数据库的时候阻塞10秒钟,这个进程就会被卡住10秒钟,却干不了其它事情。因为Tornado的异步核心在于IOLoop这个模块。而IOLoop是基于事件库实现的,当你访问数据库的时候,使用的是自己的数据库驱动程序,那么Tornado自身带的异步机制根本就没有办法发挥出来,所以会造成阻塞。因此,根据我的理解,要发挥出Tornado的非阻塞功能,只有使用它自带的异步库,或者使用第三方库也是异步的,否则根本不会有效果。

关于对regular file的AIO,除了libeio这种线程加同步进行模拟的方式,还有其它哪些方式呢?在Linux下有一种原生的AIO,叫Kernel Native AIO。这种AIO有个致命的弱点,就是无法使用操作系统的磁盘访问缓存,Windows倒是有真正的AIO实现:叫做Windows overiapped I/O,算是真正意义上的AIO。另外关于Linux的AIO更多历程,可以看看这篇文章

总结

异步非阻塞的实现有很多种方式,多进程,多线程,基于事件库,使用AIO库。但是要明白事件库和AIO库是两个不同的技术实现。在Linux下,事件库主要用来处理socket,管道等。但是它没有办法处理regular file的异步读写。AIO目前在Linux下有glibc aio, libeio等库。它们主要使用线程池进行模拟。而另外的Kernel Native AIO由于只支持O_DIRECT方式对磁盘读写,应用也甚少。

Xiang Chao 07 May 2013
blog comments powered by Disqus
Fork me on GitHub