NodeJS异步IO

November 17, 2017

提到Node,我们很容易想到异步IO,但是什么是异步IO?为什么要用异步IO?异步IO执行流程是怎么样的?你真的了解Node中的异步IO吗?

导读

异步IO其实并不是一个新鲜的概念,它早就存在于操作系统中,但是由于使用异步的编程模式不太好理解,以至于一些高级语言典型如PHP,在语言层面屏蔽了异步甚至是多线程,PHP是以同步阻塞的方式来运行的,这种设计的优点在于,程序员同步编写代码,代码很好理解。缺点在于阻塞导致其不能很好的并发。Node的设计理念是事件驱动,异步I/O。

为什么需要异步I/O

用户体验

首先我们要知道的是浏览器中的JavaScript是在单线程上执行,而且它还是与UI渲染共用一个线程。这就意味着JavaScript在执行过程的时候UI渲染和响应处于停滞状态(所以脚本执行时间不能过长)。需要注意的是B/S模型总,网络速度会给用户体验带来很大麻烦。如果以同步方式获取服务器上的资源,JavaScript会停住等待,这期间UI会停顿,交互不能响应。

同步和异步运行时间比较

//同步方式运行,总共耗时M+N
getDate(url1) //耗时M
getDate(url2) //耗时N

//异步方式运行,总共耗时max(M,N)
getDate(url1,function(result) {})//耗时M
getDate(url2,function(result) {})//耗时N

资源分配

需要了解的是,如果有一组互不相关的任务需要完成,主流的方法有两个,单线程串行执行和多线程并行的区别。多线程的代价在于执行期线程上下文切换开销比较大。在业务复杂的时候,还经常面临锁,状态同步的问题。优点在于在多核cpu上能够有效提升cpu利用率。单线程优点在于编程方式比较符合人的思维习惯,易于表达。缺点在于其性能不是很好,一个略慢的任务会导致后续执行代码被阻塞。在计算机资源中,通常I/O与CPU计算之间是可以并行进行的。但是同步编程却使得I/O操作让后续任务等待,造成资源不能很好的利用。

Node在单线程同步和多线程并行之间选择的方案是:利用单线程,远离死锁,状态同步的问题,利用异步,远离单线程阻塞的问题,更好利用cpu。

另外Node为了弥补单线程无法利用多核CPU的缺点,也提供了类似前端web workers的子进程。该子进程可以通过工作进程高效利用CPU和I/O。

异步I/O与非阻塞I/O

异步I/O ≠ 非阻塞I/O

同步/异步和阻塞/非阻塞是两回事。对操作系统内核I/O来说,只有阻塞I/O和非阻塞I/O。

  • 阻塞I/O:调用之后一定要等到系统内核层面完成所有操作之后,调用才结束。以读文件为例,磁盘寻道,读取数据,复制数据到内存中,调用结束
  • 非阻塞I/O:调用之后立即返回,返回的不是业务层期望的数据,而是当前调用的状态,如果要获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成。(重复确认->轮询),其麻烦的地方在于需要采用轮询技术去确认是否完成数据获取。cpu判断,耗费cpu资源。轮询技术满足了非阻塞I/O确保获取完整数据的需求,但是对于应用程序来说,这只能算是一种同步,因为应用程序仍然需要等待I/O完全返回,依旧会需要很多时间来等待。理想情况下的非阻塞异步I/O应该是应用程序发起非阻塞调用,无需通过遍历或者时间唤醒等待方式轮询,可以直接处理下一个任务,只需在I/O完成后通过信号或者回调将数据传递给应用程序即可。

现实的异步I/O

通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让另一个线程进行计算处理,通过线程之间的通信将I/O得到得数据进行传递,这种便是典型的线程池模拟异步I/O。典型的*nix下的libeio和windows下的IOCP都是使用这种方式,而Node提供了libuv作为抽象封装层,使得Node支持跨平台异步I/O。

需要注意的是,我们常说Node是单线程的,指的是JavaScript执行在单线程中,在Node中,无论在什么平台,内部完成I/O任务都另有线程池。除了用户代码无法并行执行外,所有的I/O则是可以并行起来的

Node中的异步I/O

事件循环(event loop)

整个Node执行都在一个事件循环中

观察者

在每个循环中,怎么判断是否有事件需要处理呢?这里就要引入观察者了。每个事件循环中都有一个或多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。 事件循环是一个典型的生产者/消费者模型,异步IO,网络请求等是事件的生产者,源源不断的为Node提供不同类型的事件,这些事件被传递到观察者那里,事件循环则从观察者那里取出事件并处理。

请求对象

对于Node中的异步IO调用而言,回调函数不由开发者来调用,从JS发起调用到IO操作完成,存在一个中间产物,叫请求对象。 在JS发起调用后,JS调用Node的核心模块,核心模块调用C++内建模块,內建模块通过libuv判断平台并进行系统调用。在进行系统调用时,从JS层传入的方法和参数都被封装在一个请求对象中,请求对象被放在线程池中等待执行。JS立即返回继续下面的操作。

执行回调

在线程可用时,线程会取出请求对象来执行IO操作,执行完后将结果放在请求对象中,并归还线程。 在事件循环中,IO观察者会不断的找到线程池中已经完成的请求对象,从中取出回调函数和数据并执行此处输入图片的描述

Node中一些非I/O的异步API

定时器setTimeout(),setInterval()

不需要I/O线程池的作用,创建的定时器对象会被插入到观察者内部的一个红黑树中,每次Tick(一次 循环)执行时,会从该红黑树中迭代取出定时器对象,检查是否超时,如果超过,就形成一个事件,回调函数将立即执行。定时器的问题在于不精确,如果某一次循环占用的时间较多,那么下次循环时,可能就超出时间了。

process.nextTick()

将回调函数放入队列中,在下一轮Tick时取出执行。与setTimeout(fn,0)相比,nextTick更加轻量高效,而且定时器精度也不高。

setImmediate()

和process.nextTick()很类似。process.nextTick()中的回调函数优先级要高于setImmediate(),原因是事件循环对观察者的检查是有先后顺序的,process.nextTick()属于idle观察者,setImmediate()属于check对象。在每一轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者。具体实现上,process.nextTick()的回调函数保存在一个数组(每轮循环会将数组中的回调函数全部执行完)中,而setimmediate()结果则是保存在链表(在每轮循环中执行链表中的一个回调函数)中。

事件驱动的高性能服务器

几种经典服务器模型

  • 同步式:一次只能处理一个请求,其余请求都处于等待状态
  • 每进程/每请求:可以处理多个请求,但是不具备扩展性,因为系统资源有限
  • 每线程/每请求:线程比进程轻量,每个线程都占用一定的内存,大并发,内存仍会用完。(Apacher)

事件驱动的服务器

Node和nginx都采用这种事件驱动的服务器。无须为每个请求创建一个额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换代价低,从而实现高性能。


Profile picture

Written by Colgin who lives and works in China, focus on web development. You can comment on github