“异步”一词的大规模流行是在Web 2.0的浪潮中,它与Javascript和AJAX一起席卷了网络。但是在大多数高级编程语言中,异步是罕见的。PHP最能体现这个特点:它不仅屏蔽了异步,而且不提供多线程,PHP是以同步阻塞的方式执行的。这一优势有利于程按顺序编写业务逻辑,但在复杂的网络应用中,阻塞导致其并发性较差。
在服务器端,I/O非常昂贵,而分布式I/O更贵。只有后端能够快速响应资源,前端体验才能变得更好。Node.js是第一个以异步为主要编程方法和设计理念的平台。异步I/O伴随着事件驱动和单线程,构成了Node的基调。本文将介绍Node如何实现异步输入输出
1.基本概念
“异步”和“非阻塞”听起来是一回事,从实际效果来看,两者都达到了并行的目的。但就计算机内核的I/O而言,只有阻塞和非阻塞两种方式。因此,异步/同步和阻塞/非阻塞实际上是两回事。
1.1阻塞输入/输出和非阻塞输入/输出
阻塞I/O的一个特点是,在调用之后,必须等到所有操作都在系统内核级完成之后,调用才会结束。以读取磁盘上的文件为例,系统内核完成磁盘寻道,读取数据并将数据复制到内存中,然后调用结束。
阻塞I/O导致CPU等待I/O,浪费等待时间,充分利用CPU的处理能力。非阻塞I/O的特点是调用后会立即返回,返回后可以用CPU时间片处理其他事务。因为完整的I/O没有完成,所以立即返回的数据不是业务层期望的数据,而只是当前的调用状态。为了获得完整的数据,应用程序需要反复调用I/O操作来确认是否完成(即轮询)。轮询技术如下:
1.read:通过反复调用检查I/O状态是性能最低的最原始的方式。2 .选择:提高读取,判断文件描述符上的事件状态。缺点是文件描述符的最大数量有限。3.poll:对于select的改进,采用链表来避免最大数量限制,但当描述符较多时,性能仍然很低。4.Epall:如果进入轮询时没有检测到I/O事件,它将休眠,直到事件发生。这是Linux下最高效的I/O事件通知机制
轮询满足了无阻塞I/O的需求,保证了完整的数据采集,但对于应用来说只能算是一种同步,因为还是需要等待I/O完全返回。在等待期间,CPU或者用于遍历文件描述符的状态,或者用于休眠并等待事件发生。
1.2理想与现实之间的异步I/O
一个完美的异步I/O应该是一个应用发起的非阻塞调用,无需轮询就可以直接处理下一个任务,只需要在I/O完成后通过信号或回调将数据传递给应用即可。
现实中,异步I/O在不同的操作系统下有不同的实现,比如*nix平台采用用户自定义线程池,Windows平台采用IOCP模型。Node提供libuv作为抽象封装层来封装平台兼容性判断,保证上下平台之间异步I/O的实现是独立的。另外需要强调的是,我们经常提到Node是单线程的,这仅仅意味着Javascript是在单线程中执行的,实际上Node内部还有另一个用于完成I/O任务的线程池。
2.节点的异步输入输出
2.1事件周期
Node的执行模型实际上是一个事件循环。当进程开始时,Node会创建一个无限循环,每次执行循环体时,它都会变成一个Tick。每个Tick过程都是检查是否有等待处理的事件,如果有,取出事件及其相关的回调函数,如果有关联的回调函数,执行它们,然后进入下一个循环。如果没有更多事件要处理,请退出该过程。
2.2观察员
每个赛事周期都有几个观察员。请这些观察员判断是否有需要处理的事件。事件周期是典型的生产者/消费者模型。在Node中,事件主要来自网络请求、文件I/O等。这些事件有对应的网络I/O观察器、文件I/O观察器等。事件循环从观察器中取出事件并处理它们。
2.3请求对象
在从Javascript调用到内核执行I/O操作的转换过程中,有一个中间产品叫做请求对象。以Windows下最简单的fs.open()方法(打开一个文件,根据指定的路径和参数获取文件描述符)为例。从JS调用到内置模块通过libuv进行系统调用,实际调用的是uv_fs_open()方法。在调用过程中,创建了一个FSReqWrap请求对象,并将JS层传入的参数和方法封装在这个请求对象中,其中我们最关注的回调函数设置在这个对象的oncompete _ sym属性上。包装对象后,将FSReqWrap对象推入线程池等待执行。
此时,JS调用立即返回,JS线程可以继续执行后续操作。当前的输入/输出操作正在线程池中等待执行,这完成了异步调用的第一阶段。
2.4执行回调
回调通知是异步I/O的第二阶段,调用线程池中的I/O操作后,将存储得到的结果,然后通知IOCP当前对象操作已经完成,线程返回线程池。在每次Tick执行中,事件循环的I/O观察器都会调用相关方法来检查线程池中是否有已执行的请求。如果有,请求对象将被添加到输入/输出观察器的队列中,然后被视为事件。
3.无输入输出的异步应用编程接口
还有一些与Node中的I/O无关的异步API,比如定时器setTimeout()、setInterval()、process.nextTick()和setImmdiate()等,可以立即异步执行任务等。
3.1定时器应用编程接口
SetTimeout()和setInterval()具有相同的浏览器API,它们的实现原理类似于异步I/O,只是不需要I/O线程池。通过调用计时器API创建的计时器将被插入到计时器观察器中的红黑树中。每个事件周期的嘀嗒声会迭代地从红黑树中取出计时器对象,并检查是否超过了计时时间。如果超过,将形成一个事件,回调函数将立即执行。定时器的主要问题是其计时时间不是很准确(毫秒级,在容差范围内)。
3.2立即异步执行任务API
在Node出现之前,很多人可能会这样调用它,以便立即异步执行任务:复制代码如下: settimeout(function(){//todo },0);
由于事件周期的特点,定时器的精度不够,需要红黑树采用定时器,各种操作的时间复杂度为O(log(n))。但是process.nextTick()方法只会把回调函数放在队列中,在下一轮Tick中取出来执行,用O(1)的复杂度效率更高。
此外,还有一个类似于上述方法的setImmediate()方法,它延迟了回调函数。然而,前者的优先级高于后者,因为事件循环对观察者的检查是有顺序的。另外,前者的回调函数保存在一个数组中,数组中的所有回调函数都会在每一轮Tick中执行;后者的结果存储在链表中,每轮Tick只执行一个回调函数。
4.事件驱动的高性能服务器
上一节以fs.open()为例说明了如何实现异步I/O.事实上,Node还将异步I/O应用于网络套接字的处理,这也是Node构建Web服务器的基础。经典服务器型号有:
1.同步模式:一次只能处理一个请求,其他请求处于等待状态。2.每个进程/请求:每个请求启动一个进程,但是系统资源有限,不可伸缩。3.每个线程/请求:为每个请求启动一个线程。线程比进程轻,但每个线程都占用一定的内存。当大型并发请求到来时,内存将很快耗尽
著名的Apache采用了按线程/按请求的格式,这就是为什么很难处理高并发的原因。节点以事件驱动的方式处理请求,可以节省创建和销毁线程的开销。同时,当操作系统调度任务时,上下文切换的成本非常低,因为线程更少。即使在大量连接的情况下,节点也可以有序地处理请求。
知名服务器Nginx放弃多线程,采用与Node相同的事件驱动模式。现在Nginx有潜力取代Apache。Nginx是纯C写的,性能很高,但只适合作为反向代理或者负载均衡的Web服务器。Node可以构建与Nginx相同的功能,也可以处理各种特定的服务,自身性能也不错。在实际项目中,我们可以结合各自的点来实现应用的最佳性能。