详解“Node.js环境”中的event loop机制

2,596 阅读11分钟

目前,我们常见的JavaScript运行时(runtime)有两个,一个是浏览器环境,一个是Node.js环境,既然是两个不同的运行时,那么JavaScript在他们中的执行机制自然不一样。本文先讲述JavaScript在Node.js环境中的执行机制,在下一篇博文中我会详细给大家介绍JavaScript在浏览器环境中的执行机制。

首先,我们着重强调一下Node自身的执行模型——事件循环event loop),他也是我们今天的主角。正是它使得Node中的回调函数十分普遍。在进程启动时,Node便会创建一个类似于while(true){...}的循环,每执行一次循环体的过程我们称为Tick。每个Tick的过程就是查看是否有事件待处理,如果有,就取事件及其相关的回调函数。如果存在关联的回调函数,就执行他们。然后进入下一个循环,如果不再有事件要处理,就退出进程。

1.The Node.js System

先来看一张图,如下:

Node.js

                                                   图1.1 The Node.js System

根据上图,总结Node.js的运行机制如下:

(1)V8引擎解析JavaScript脚本。

(2)解析后的代码,调用Node API。

(3)libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。

(4)V8引擎再将结果返回给用户。

由此,我们可以知道,Node中的事件循环是由底层的libuv库负责执行的,关于libuv库,简单介绍一下。起初,Node只可以在Linux平台上运行,如果想在Windows平台上学习和使用Node,则必须通过Cygwin或者MinGW。随着Node的发展,微软注意到了它的存在,并投入了一个团队实现Windows平台的兼容。兼容Windows和*nix平台主要得益于Node在架构层面的改动,它在操作系统与Node上层模块系统之间构建了一层平台架构,即libuv。

                                         

                                  图1.2  Node基于libuv实现跨平台的架构示意图

2.Node.js中的事件循环

首先要明确的是,Node中的事件循环是运行在单线程的环境下(JavaScript在Node环境中的主线程是单线程的,事件循环的线程也是单线程的,这两个不是一个线程)。Node作为一种运行时,它的事件循环是由底层的libuv库实现的。下面如图2.1所示,描述了Node中事件循环的具体流程:

             

                                               图2.1 Node中事件循环流程

上面的图例中,将Node事件循环分成了6个不同的阶段,其中每个阶段都维护着一个回调函数的队列,在不同的“阶段”(我们使用阶段来描述事件循环,它并没有任何特别之处,本质上就是不同方法的顺序调用),事件循环会处理不同类型的事件,其代表的含义分别为:

  • Timers:用来处理setTimeOut()和setInterval()的回调。
  • pending callbacks(I/O callbacks):大多数的回调方法在这个阶段执行,除了timers、close和setImmediate事件的回调函数。
  • idle,prepare:仅仅在内部使用,我们不管它。
  • poll:轮询,不断检查有没有新的I/O事件,事件环可能会在这里阻塞。
  • check:处理setImmediate()事件的回调。
  • close:处理一些close相关的事件,例如socket.on('close',...)等。

假设事件循环现在进入了某个阶段,即使在这期间有其它队列中的事件就绪,也会先将当前阶段队列里的全部回调方法执行完毕后,再进入到下个阶段。

下面,我们来针对Node事件循环的每个阶段进行详细说明。

  1. timers 阶段

    这个阶段主要用来处理定时器相关的回调,当一个定时器超时后,一个事件就会加入到队列中,事件循环跳转至这个阶段执行对应的回调函数。定时器的回调会在触发后尽可能早地被调用,这表示实际的延时可能会比定时器规定的时间要长。如果事件循环,此时正在执行一个比较耗时的callback,例如处理一个比较耗时的循环,那么定时器的回调只能等到当前回调执行结束了才能被执行,即被阻塞。事实上,timers阶段的执行受到poll阶段的控制,后面会讲到。

  2. IO callbacks 阶段

    Nodejs官网文档对这个阶段的解释为:除了timers、setImmediate,以及close操作之外的大多数的回调方法都位于这个阶段执行。但是,一些常见的回调,例如fs.readFile的回调是放在poll阶段来执行的。根据libuv的文档,一些应该在上轮事件循环poll阶段执行的callback,因为某些原因不能执行,就会被延迟到这一轮的事件循环的I/O callbacks阶段执行。换句话说这个阶段执行的callbacks是上轮残留的。

  3. poll 阶段

    poll阶段的主要任务是等待新的事件的出现(该阶段使用epoll来获取新的事件),如果没有,事件循环可能会在此阻塞。这些事件对应的回调方法可能位于timers阶段(如果定义了定时器),也可能是check阶段(如果设置了setImmediate方法)。

    poll阶段主要有两个步骤如下:

    (1)如果有到期的定时器,那么就执行定时器的回调方法。

    (2)处理poll阶段对应的事件队列(以下简称poll队列)里的事件。

    当事件循环到达poll阶段时,如果这时没有要处理的定时器的回调方法,则会进行下面的判断:

    (1)如果poll队列不为空,则事件循环会按照顺序遍历执行队列中的回调函数,这个过程是同步的。

    (2)如果poll队列为空,会接着进行如下的判断:①如果当前代码定义了setImmediate方法,事件循环会离开poll阶段,然后进入check阶段去执行setImmediate方法定义的回调方法。②如果当前代码并没有定义setImmediate方法,那么事件循环可能会进入等待状态,并等待新的事件出现,这也是该阶段为什么会被命名为poll(轮询)的原因。此外,还会不断检查是否有相关的定时器超时,如果有,就会跳转到timers阶段,然后执行对应的回调。
  4. check 阶段

    setImmediate是一个特殊的定时器方法,它占据了事件循环的一个阶段,整个check阶段就是为setImmediate方法而设置的。一般情况下,当事件循环到达poll阶段后,就会检查当前代码是否调用了setImmediate,但如果一个回调函数是被setImmediate方法调用的,事件循环就会跳出poll阶段而进入check阶段。

  5. close 阶段

    如果一个socket或者一个句柄被关闭,那么就会产生一个close事件,该事件会被加入到对应的队列中。close阶段执行完毕后,本轮事件循环结束,循环进入到下一轮。

看完了上面的描述,我们明白了Node中的事件循环是分阶段处理的,对于每一个阶段来说,处理事件队列中的事件就是执行对应的回调方法,每一个阶段的事件循环都对应着不同的队列。

在Node中,事件队列不止一个,定时器相关的事件和磁盘IO产生的事件需要不同的处理方式,如果把所有的事件都放到一个队列里,势必要增加许多类似switch/case的代码。那样的话,倒不如将不同类型的事件归类到不同的事件队列里,然后一个个的遍历下来,如果当中出现了新的事件,就进行相应的处理。

event loop的每一次循环都需要依次经过上述的阶段。 每个阶段都有自己的callback队列,每当进入某个阶段,都会从所属的队列中取出callback来执行,当队列为空或者被执行callback的数量达到系统的最大数量时,进入下一阶段。这六个阶段都执行完毕称为一轮循环。

3.process.nextTick

process.nextTick的意思就是定义出一个异步动作,并且让这个动作在事件循环当前阶段结束后执行。

process.nextTick其实并不是事件循环的一部分,但它的回调方法也是由事件循环调用的,该方法定义的回调方法会被加入到一个名为nextTickQueue的队列中。在事件循环的任何阶段,如果nextTickQueue不为空,都会在当前阶段操作结束后优先执行nextTickQueue中的回调函数,当nextTickQueue中的回调方法被执行完毕后,事件循环才会继续向下执行。

Node限制了nextTickQueue的大小,如果递归调用了process.nextTick,那么当nextTickQueue达到最大限制后会抛出一个错误,我们验证一下:

function recurse(i){
    while(i<9999){
        process.nextTick(recurse(i++))
    }
}

recurse(0);

//运行结果如下:
//RangeError:Maximum call stack size exceeded

既然nextTickQueue也是一个队列,那么先被加入队列的回调会优先执行,我们验证一下:

process.nextTick(function(){
    console.log('one')
})
process.nextTick(function(){
    console.log('tow')
})
console.log('three');

//运行结果如下:
//three 
//one 
//two

和其它回调函数一样,nextTick定义的回调也是由事件循环执行的,如果nextTick的回调方法中出现了阻塞操作,后面的要执行的回调函数同样会被阻塞,我们验证一下:

process.nextTick(function(){
    console.log('one');
    //由于死循环的存在,之后的事件被阻塞
    while(true){}
});
process.nextTick(function(){
    console.log('tow') //不会被打印
});
console.log('three');
//运行结果如下:
//three 
//one

4.nextTick与setImmediate

seImmediate方法不属于ECMAScript标准,而是Node提出的新方法,它同样将一个回调函数加入到事件队列中,不同于setTimeout和setInterval,setImmediate并不接受一个时间作为参数,setImmediate的事件会在当前事件循环的结尾触发,对应的回调方法会在当前事件循环的末尾(check)执行。

setImmediate方法和process.nextTick方法很相似,二者经常被拿来放在一起比较,由于process.nextTick会在当前操作完成后立刻执行,因此总会在setImmediate之前执行,我们验证一下:

setImmediate(function(param){
    console.log("执行"+param);
},"setImmediate");

process.nextTick(function(){
    console.log("执行next Tick");
});
//运行结果如下:
//执行next Tick
//执行setImmediate

此外,当有递归的异步操作时只能使用setImmediate,不能使用process.nextTick,前面讲process.nextTick时已经验证过这个问题了,就是递归调用process.nextTck会出现call stack溢出的情况。关于递归调用setImmediate,我们验证一下:

function recurse(i,end){
    if(i>end){
        console.log('done!')
    } else {
        console.log(i);
        setImmediate(recurse,i+1,end)
    }
}
recurse(0,999999999999);

运行上面的代码完全没有问题,因为setImmediate不会生成call stack。

5.setImmediate和setTimeout

通过上面的内容,我们已经知道了setImmediate方法会在poll阶段结束后执行,而setTimeout会在规定的时间到期后执行,由于无法预测执行代码时事件循环当前处于哪个阶段,因此当代码中同时存在这两个方法时,回调函数的执行顺序是不固定的,我们验证一下:

setTimeout(function(){
    console.log('timeout');
},0)
setImmediate(function(){
    console.log('immediate');
})

//在node环境中,多次执行上面的代码,就会发现如下两种结果:

(1)先输出timeout,后输出immediate

(2)先输出immediate,后输出timeout

通过上面的分析,我们知道这种情况是正常的。

但是如果将二者放在一个IO操作的callback中,则永远是setImmediate先执行,我们验证一下:

require('fs').readFile('foo.txt',function(){
    setTimeout(function(){
        console.log('timeout');
    },0)
    setImmediate(function(){
        console.log('immediate');
    })
})

//多次执行上面的代码,始终是先输出immediate,后输出timeout

这是因为readFile的回调函数执行时,事件循环位于poll阶段,因此事件循环会先进入check阶段执行setImmediate的回调,然后再进入timers阶段执行setTimeout的回调。

文章有些内容直接参考node.js官网文档来写的,想了解原汁原味的Node.js event loop,请点击查看。在libuv官方文档中也有对event loop的介绍,这里介绍的更细腻一些,请点击查看。