Node-异步I/O

Node 异步I/O

在浏览器中JavaScript是在单线程执行,而且它和UI渲染公用一个线程。如果网页获取一个网页资源,通过同步的方式获取,那么JavaScript则需要等待资源完全从服务器获取后才能继续执行,这期间UI将停顿,不响应用户的交互行为。可以想象这样的用户体验有多差。而采用异步请求,JavaScript和UI的执行都不会处于等待状态,可以继续响应用户的交互行为。

为什么要用异步I/O

  • 用户体验
    前端通过异步可以消除UI阻塞的现象,但是前端获取资源的速度取决于后端的响应速度。假如一个资源来自于两个不同位置的数据的返回,第一个资源需要M毫秒的耗时,第二个资源需要N毫秒的耗时。如果采用同步的方式,代码大致如下:

    1
    2
    3
    4
    // 消费时间为M
    getData('from_db');
    // 消费时间为N
    getData('from_remote_api');

    但是如果采用异步方式,第一个资源的获取并不会阻塞第二个资源,也即第二个资源也不依赖第一个资源的结束。相关代码如下:

    1
    2
    3
    4
    5
    6
    getData('from_db', function() {
    // 消费时间为M
    });
    getData('from_remote_api', function() {
    // 消费时间为N
    });

    对比两者的时间总消耗, 发现:

    如果是同步,需要耗时(M+N);反之若异步,需要耗时max(M, N)

    随着应用复杂性的增加,情景将会变成M+N+…和max(M, N, …), 此时同步与异步的优劣将会凸显出来。另一方面,随着网站或应用不断膨胀,数据将会分布到多态服务器上,分布式将会是常态。而分布也就意味着M与N的值会线性增长,这样会放大异步和同步在性能方面的差异。总之I/O是昂贵的,分布式I/O是更昂贵

  • 资源分配
    假设业务场景中有一组互不相关的任务需要完成,现行的主流方法有一下两种:

    • 单线程串行依次执行

      会因阻塞I/O导致硬件资源得不到更优的利用。

    • 多线程并行完成

      优点:可以利用多核CPU有效提升CPU的利用率
      缺点:编程中的死锁、状态同步等问题让开发人员很头疼

阻塞I/O与非阻塞I/O

  • 阻塞I/O:发起I/O操作,线程需要等待I/O完成才返回结果,这期间CPU得不到充分利用。
  • 非阻塞I/O:发起I/O操作,通过事件轮训或者事件通知机制,不断查询I/O操作是否完成,或者主线程进入休眠状态等待事件通知I/O结束,然后继续向下执行代码,实际上非阻塞I/O期间,CPU要不用来轮询要不用来休眠,CPU依然得不到有效利用,依旧是同步I/O

Node的异步I/O

Node采用的是异步I/O,利用单线程,远离多线程死锁、状态同步;利用异步I/O,让单线程原理阻塞,以更好的利用CPU。

Node的异步I/O实际上采用的是线程池技术,发起异步I/O时,将I/O操作放入线程池中执行,然后主线程继续执行其它操作,I/O执行完毕通过进程间通信通知主线程,主线程执行回调

Node的I/O部分是由libuv(linux下由libeio具体实现, Windows则由IOCP具体实现)实现的,本质上是多线程,即采用线程池和阻塞I/O模拟异步I/O。

为了弥补单线程无法利用多核CPU的缺点,Node提供了子进程’child_progress‘、’cluster‘模块,将一些多余的任务放入到子进程进行高效的运算。进程管理这里不再多说,请参阅node-process一文。

Node完成整个异步I/O环节的有事件循环、观察者、请求对象等。

  • 事件循环
    在进程启动时,Node便会创建一个类似while(true)的循环,每执行1次循环体的过程我们成为Tick。每个Tick的过程就是查看是否有事件等待去处理,若有,就取出事件及其相关的回调函数。若存在关联的回调函数,就执行它们。然后进入下一个循环,若不再有事件要处理,就退出进程。

  • 观察者
    在每个Tick的过程中,如何判断是否有事件需要处理呢?这里必须引入的概念就是观察者。每个事件循环中有一个或多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。

    在Node中,事件主要来源于网络请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等。

  • 请求对象
    从JavaScript发起调用到内核执行完I/O操作的过渡过程中,会存在一种中间产物,它叫请求对象。以打开文件为例,

    1. 首先JavsScript调用Node的核心模块
    2. 核心模块调用c++内建模块
    3. 内建模块通过libuv进行系统调用,分平台实现,实质上调用了uv_fs_open方法
    4. 在uv_fs_open调用的过程中,会创建一个FSReqWrap请求对象。从js层传入的参数和当前方法都被封装到这个请求对象中了
    5. 在对象创建、设置完毕后,就会送入I/O线程池等待执行
    6. js线程继续执行当前任务的后续操作,当前的I/O操作在线程池中等待执行,不管它是否阻塞I/O,都不会影响js线程的后续执行,如此就达到了异步的目的
  • 执行回调
    组装好请求对象,送入I/O线程池等待执行,实际上完成了异步I/O的第一部分,回调通知是第二部分。

    线程池中的I/O操作调用完毕之后。,会把结果放在请求对象中,然后通知IOCP,告知当前对象操作已完成,并将请求对象加入到I/O观察者的队列中,当做事件处理,然后通过事件循环执行回调函数。

Node 非I/O的异步API

  • 定时器
    setTimeout()和setInterval()与浏览器中的API是一致的,分别用于单次和多次定时执行任务。实现原理与异步I/O比较类似,只是不需要线程池的参与。

    调用setTimeout或者setInterval创建的定时器会被插入到定时器观察者内部的一个红黑树中。每次Tick执行时,会从该红黑树中迭代去除定时器对象,检查是否超过定时时间,若超过,就形成一个事件,回调函数执行

    setTimeout和setInterval主要的区别在于后者是重复性的检测和执行。

  • process.nextTick()
    在未了解process.nextTick()之前,立即异步执行一个任务,很多人可能会调用setTimeout来达到所需的效果:

    1
    2
    3
    setTimeout(function() {
    // TODO
    }, delay);

    由于事件循环自身的特点,定时器的精确度不够。另外,采用定时器需要动用红黑树,创建定时器和迭代等操作,故setTimeout(fn, delay)的方式比较浪费性能。 而,process.nextTick()操作相对较为轻量,具体代码如下:

    1
    2
    3
    process.nextTick(function() {
    // TODO
    });

    定时器采用红黑树的操作时间复杂度为o(log(n)), nextTick的时间复杂度为o(1).

  • setImmediate()
    setImmediate与process.nextTick十分类似,都是将回调函数延迟执行。我们试将它们放在一起时,看看有什么区别呢?具体代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    setImmediate(function () {
    console.log('setImmediate 1', new Date().getTime());
    });

    process.nextTick(function () {
    console.log('process.nextTick 1', new Date().getTime());
    setImmediate(function () {
    console.log('process.nextTick - setImmediate 1', new Date().getTime());
    });
    });

    process.nextTick(function () {
    console.log('process.nextTick 2', new Date().getTime());
    setImmediate(function () {
    console.log('process.nextTick - setImmediate 2', new Date().getTime());
    });
    });

    setImmediate(function () {
    console.log('setImmediate 4', new Date().getTime());
    process.nextTick(function () {
    console.log('setImmediate - process.nextTick', new Date().getTime());
    });
    });

    setImmediate(function () {
    console.log('setImmediate 3', new Date().getTime());
    });

    输出结果如下

    1
    2
    3
    4
    5
    6
    7
    8
    process.nextTick 1 1578295796741
    process.nextTick 2 1578295796746
    setImmediate 1 1578295796746
    setImmediate 4 1578295796747
    setImmediate - process.nextTick 1578295796747
    setImmediate 3 1578295796747
    process.nextTick - setImmediate 1 1578295796747
    process.nextTick - setImmediate 2 1578295796747

    从结果可以看出,nextTick的回调函数执行的优先级高于setImmediate。这里的主要原因在于事件循环观察者的检查是有先后顺序的,process.nextTick属于idle观察者,setImmediate属于check观察者。在每一个轮询检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者。之所以这样设计,是为了保证每轮循环能够较快的结束,防止CPU占用过多而阻塞后续I/O。