Node.js Event Loop 的理解 Timers,process.nextTick()
发布于 4 个月前 作者 vincentLiuxiang 1426 次浏览 来自 分享

写node.js有一段时间了,一直在理解event loop这个概念,国内外的文章翻阅了也不少,但是对event loop能讲解清楚的还是不多。 最后还是查阅的nodejs对event loop的官方解释才恍然大悟,如获至宝。

写这篇文章的目的是将自己对该文章的理解做一个记录,官方文档链接The Node.js Event Loop, Timers, and process.nextTick()

文章内容可能有错误理解的地方,希望能和大家探讨一下,欢迎批评指正!

Node.js Event Loop 的理解 Timers,process.nextTick()

Event Loop的解释

英文原文: When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.

当Node.js启动时会初始化event loop, 每一个event loop都会包含按如下顺序六个循环阶段,

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
  • timers 阶段: 这个阶段执行setTimeout(callback) and setInterval(callback)预定的callback;
  • I/O callbacks 阶段: 执行除了 close事件的callbacks、被timers(定时器,setTimeout、setInterval等)设定的callbacks、setImmediate()设定的callbacks之外的callbacks;
  • idle, prepare 阶段: 仅node内部使用;
  • poll 阶段: 获取新的I/O事件, 适当的条件下node将阻塞在这里;
  • check 阶段: 执行setImmediate() 设定的callbacks;
  • close callbacks 阶段: 比如socket.on(‘close’, callback)的callback会在这个阶段执行.

每一个阶段都有一个装有callbacks的fifo queue(队列),当event loop运行到一个指定阶段时, node将执行该阶段的fifo queue(队列),当队列callback执行完或者执行callbacks数量超过该阶段的上限时, event loop会转入下一下阶段.

注意上面六个阶段都不包括 process.nextTick()

poll阶段

poll阶段是衔接整个event loop各个阶段比较重要的阶段,为了便于后续例子的理解,本文和原文的介绍顺序不一样,本文先讲这个阶段;

在node.js里,任何异步方法(除timer,close,setImmediate之外)完成时,都会将其callback加到poll queue里,并立即执行。

poll 阶段有两个主要的功能:

  1. 处理poll队列(poll quenue)的事件(callback);
  2. 执行timers的callback,当到达timers指定的时间时;

如果event loop进入了 poll阶段,且代码未设定timer,将会发生下面情况:

  • 如果poll queue不为空,event loop将同步的执行queue里的callback,直至queue为空,或执行的callback到达系统上限;
  • 如果poll queue为空,将会发生下面情况:
    • 如果代码已经被setImmediate()设定了callback, event loop将结束poll阶段进入check阶段,并执行check阶段的queue (check阶段的queue是 setImmediate设定的)
    • 如果代码没有设定setImmediate(callback),event loop将阻塞在该阶段等待callbacks加入poll queue;

如果event loop进入了 poll阶段,且代码设定了timer:

  • 如果poll queue进入空状态时(即poll 阶段为空闲状态),event loop将检查timers,如果有1个或多个timers时间时间已经到达,event loop将按循环顺序进入 timers 阶段,并执行timer queue.

以上便是整个event loop时间循环的各个阶段运行机制,有了这层理解,我们来看几个例子

  • 注意,例子中给出的时间在不同机器下和同一机器下不同执行时刻,其值都会有差异。

example 1

var fs = require('fs');

function someAsyncOperation (callback) {
  // 花费2毫秒
  fs.readFile(__dirname + '/' + __filename, callback);
}

var timeoutScheduled = Date.now();
var fileReadTime = 0;

setTimeout(function () {
  var delay = Date.now() - timeoutScheduled;
  console.log('setTimeout: ' + (delay) + "ms have passed since I was scheduled");
  console.log('fileReaderTime',fileReadtime - timeoutScheduled);
}, 10);

someAsyncOperation(function () {
  fileReadtime = Date.now();
  while(Date.now() - fileReadtime < 20) {

  }
});

结果: 先执行someAsyncOperation的callback,再执行setTimeout callback

-> node eventloop.js
setTimeout: 22ms have passed since I was scheduled
fileReaderTime 2

解释: 当时程序启动时,event loop初始化:

  1. timer阶段(无callback到达,setTimeout需要10毫秒)
  2. i/o callback阶段,无异步i/o完成
  3. 忽略
  4. poll阶段,阻塞在这里,当运行2ms时,fs.readFile完成,将其callback加入 poll队列,并执行callback, 其中callback要消耗20毫秒,等callback之行完,poll处于空闲状态,由于之前设定了timer,因此检查timers,发现timer设定时间是20ms,当前时间运行超过了该值,因此,立即循环回到timer阶段执行其callback,因此,虽然setTimeout的20毫秒,但实际是22毫秒后执行。

example 2

var fs = require('fs');

function someAsyncOperation (callback) {
  var time = Date.now();
  // 花费9毫秒
  fs.readFile('/path/to/xxxx.pdf', callback);
}

var timeoutScheduled = Date.now();
var fileReadTime = 0;
var delay = 0;

setTimeout(function () {
  delay = Date.now() - timeoutScheduled;
}, 5);

someAsyncOperation(function () {
  fileReadtime = Date.now();
  while(Date.now() - fileReadtime < 20) {

  }
  console.log('setTimeout: ' + (delay) + "ms have passed since I was scheduled");
  console.log('fileReaderTime',fileReadtime - timeoutScheduled);
});

结果:setTimeout callback先执行,someAsyncOperation callback后执行

-> node eventloop.js
setTimeout: 7ms have passed since I was scheduled
fileReaderTime 9

解释: 当时程序启动时,event loop初始化:

  1. timer阶段(无callback到达,setTimeout需要10毫秒)
  2. i/o callback阶段,无异步i/o完成
  3. 忽略
  4. poll阶段,阻塞在这里,当运行5ms时,poll依然空闲,但已设定timer,且时间已到达,因此,event loop需要循环到timer阶段,执行setTimeout callback,由于从poll --> timer中间要经历check,close阶段,这些阶段也会消耗一定时间,因此执行setTimeout callback实际是7毫秒 然后又回到poll阶段等待异步i/o完成,在9毫秒时fs.readFile完成,其callback加入poll queue并执行。

setTimeout 和 setImmediate

二者非常相似,但是二者区别取决于他们什么时候被调用.

  • setImmediate 设计在poll阶段完成时执行,即check阶段;
  • setTimeout 设计在poll阶段为空闲时,且设定时间到达后执行;但其在timer阶段执行

其二者的调用顺序取决于当前event loop的上下文,如果他们在异步i/o callback之外调用,其执行先后顺序是不确定的

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

setImmediate(function immediate () {
  console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

关于这点的原因,笔者目前还未弄清楚。。。其原因读者可去看@hyj1991 的留言,讲解非常清晰

但当二者在异步i/o callback内部调用时,总是先执行setImmediate,再执行setTimeout

var fs = require('fs')

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
})
$ node timeout_vs_immediate.js
immediate
timeout

理解了event loop的各阶段顺序这个例子很好理解: 因为fs.readFile callback执行完后,程序设定了timer 和 setImmediate,因此poll阶段不会被阻塞进而进入check阶段先执行setImmediate,后进入timer阶段执行setTimeout

process.nextTick()

千呼万唤始出来,终于到了讲process.nextTick()的时候,来来来喝口水休息休息。

注意:本文讲解的process.nextTick是基于v0.10及以上版本

process.nextTick()不在event loop的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行。

var fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);
  setImmediate(() => {
    console.log('setImmediate');
    process.nextTick(()=>{
      console.log('nextTick3');
    })
  });
  process.nextTick(()=>{
    console.log('nextTick1');
  })
  process.nextTick(()=>{
    console.log('nextTick2');
  })
});
-> node eventloop.js
nextTick1
nextTick2
setImmediate
nextTick3
setTimeout

从poll —> check阶段,先执行process.nextTick, nextTick1 nextTick2 然后进入check,setImmediate, setImmediate 执行完setImmediate后,出check,进入close callback前,执行process.nextTick nextTick3 最后进入timer执行setTimeout setTimeout

process.nextTick()是node早期版本无setImmediate时的产物,node作者推荐我们尽量使用setImmediate。

example process.nextTick()

当递归调用process.nextTick时,即使fs.readFile完成,其callback无机会执行

var fs = require('fs');
var starttime = Date.now();
var endtime = 0;

fs.readFile(__filename, () => {
  endtime = Date.now();
  console.log('finish reading time:',endtime - starttime);
});

var index = 0;

function nextTick () {
  if (index > 1000) return;
  index++;
  console.log('nextTick');
  process.nextTick(nextTick);
}

nextTick();
-> node eventloop.js
nextTick
nextTick
...
nextTick
nextTick
finish reading time: 246

example setImmediate

将process.nextTick替换成setImmediate后,由于setImmediate只在check阶段执行,那么所有的callback都有机会执行。

var fs = require('fs');

fs.readFile(__filename, () => {
  console.log('finish reading');
});

var index = 0;

function Immediate () {
  if (index > 100) return;
  index++;
  console.log('setImmediate');
  setImmediate(Immediate);
}

Immediate()
-> node eventloop.js
setImmediate
setImmediate
setImmediate
setImmediate
finish reading time: 19
...
setImmediate
setImmediate
12 回复

其实event是由uv_run驱动的,并且是在UV_RUN_ONCE的模式下执行 UV_RUN_ONCE就是你文中描述的

poll 阶段: 获取新的I/O事件, 适当的条件下node将阻塞在这里;

中的“适当条件”,它会阻塞在kqueue的kevent()函数中(这里以mac为例,linux下这一块由epoll_wait()函数处理,windows下则是IOCP) 这里描述UV_RUN_ONCE为适当条件,是因为uv_run还有两种模式,对于UV_RUN_NOWAIT的模式,io_poll不会阻塞,会立即触发超时结束当前的event_loop,进入下一次循环(v4.4.2版本中仅用在进程退出前的最后检查) 实际上,你描述的timer,在一次event loop中可能在两个地方执行:

最开始以及close callbacks结束之后

为什么这么说呢,就算不看源代码,我们也可以思考这样的一个场景: 当你设置了一个超时timer,那么第一次进入uv_run_timer时,超时时间未到;此时进入kevent()阻塞等待 如果此处不提供超时机制,那就会永远阻塞在kevent()的I/O等待中;所以我们在 当前的event loop循环中的某一次时,如果存在timer且为达到设定时间,则会把最近的一个timer剩余超时时间作为参数传入io_poll()中,这样kevent()等待时,如果没有任何I/O事件触发,也会由timerout触发跳出等待的操作,结束本次时间循环 所以呢,在UV_RUN_ONCE的模式下,每次循环结束前,即你说的close callback执行结束后,会再执行一次对timer的超时判断 具体涉及到的代码如下:

//deps/uv/src/unix/core.c
int uv_run(uv_loop_t *loop, uv_run_mode mode) {
	int timeout;
	int r;
	int ran_pending;
	//uv__loop_alive返回的是event loop中是否还有待处理的handle或者request
	//以及closing_handles是否为NULL,如果均没有,则返回0
	r = uv__loop_alive(loop);
	//更新当前event loop的时间戳,单位是ms
	if (!r)
    	uv__update_time(loop);
	while (r != 0 && loop->stop_flag == 0) {
    	//使用Linux下的高精度Timer hrtime更新loop->time,即event loop的时间戳
    	uv__update_time(loop);
    	//执行判断当前loop->time下有无到期的Timer,显然在同一个loop里面timer拥有最高的优先级
    	uv__run_timers(loop);
    	//判断当前的pending_queue是否有事件待处理,并且一次将&loop->pending_queue中的uv__io_t对应的cb全部拿出来执行
    	ran_pending = uv__run_pending(loop);
    	//实现在loop-watcher.c文件中,一次将&loop->idle_handles中的idle_cd全部执行完毕(如果存在的话)
    	uv__run_idle(loop);
    	//实现在loop-watcher.c文件中,一次将&loop->prepare_handles中的prepare_cb全部执行完毕(如果存在的话)
    	uv__run_prepare(loop);

    	timeout = 0;
    	//如果是UV_RUN_ONCE的模式,并且pending_queue队列为空,或者采用UV_RUN_DEFAULT(在一个loop中处理所有事件),则将timeout参数置为
    	//最近的一个定时器的超时时间,防止在uv_io_poll中阻塞住无法进入超时的timer中
    	if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
        	timeout = uv_backend_timeout(loop);
    	//进入I/O处理的函数(重点分析的部分),此处挂载timeout是为了防止在uv_io_poll中陷入阻塞无法执行timers;并且对于mode为
    	//UV_RUN_NOWAIT类型的uv_run执行,timeout为0可以保证其立即跳出uv__io_poll,达到了非阻塞调用的效果
    	uv__io_poll(loop, timeout);
    	//实现在loop-watcher.c文件中,一次将&loop->check_handles中的check_cb全部执行完毕(如果存在的话)
    	uv__run_check(loop);
    	//执行结束时的资源释放,loop->closing_handles指针指向NULL
    	uv__run_closing_handles(loop);

    	if (mode == UV_RUN_ONCE) {
        	//如果是UV_RUN_ONCE模式,继续更新当前event loop的时间戳
        	uv__update_time(loop);
        	//执行timers,判断是否有已经到期的timer
        	uv__run_timers(loop);
    	}
    	r = uv__loop_alive(loop);
    	//在UV_RUN_ONCE和UV_RUN_NOWAIT模式中,跳出当前的循环
    	if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
        	break;
		}
		
	//标记当前的stop_flag为0,表示当前的loop执行完毕
	if (loop->stop_flag != 0)
    	loop->stop_flag = 0;
	//返回r的值
	return r;
}

对于node事件循环的核心驱动函数uv_run写的一些注释,随便看看吧~

@hyj1991 谢谢你的注释,我只是看了官方文档,加了自己的一些理解,

我会结合你的注释去看看源码。 请教个问题

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

setImmediate(function immediate () {
  console.log('immediate');
});

执行的顺序不确定,就是因为每一次loop,最开始和结束时都检查timer的缘故么?

@vincentLiuxiang 是的,在node中,setTimeout(cb, 0) === setTimeout(cb, 1); 而setImmediately属于uv_run_check的部分 确实每次loop进来,都是先检查uv_run_timer的,但是由于cpu工作耗费时间,比如第一次获取的hrtime为0 那么setTimeout(cb, 1),超时时间就是loop->time = 1(ms,node定时器精确到1ms,但是hrtime是精确到纳秒级别的) 所以第一次loop进来的时候就有两种情况:

1.由于第一次loop前的准备耗时超过1ms,当前的loop->time >=1 ,则uv_run_timer生效,timeout先执行
2.由于第一次loop前的准备耗时小于1ms,当前的loop->time = 0,则本次loop中的第一次uv_run_timer不生效,那么io_poll后先执行uv_run_check,即immediate先执行,然后等close cb执行完后,继续执行uv_run_timer

那么你说的为什么在回调中,一定是先immediate执行呢,其实也很容易理解 你可以思考一下你写的场景 由于你的timeout和immediate的事件注册是在readFile的回调执行时,触发的 所以必然的,在readFile的回调执行前的每一次event loop进来的uv_run_timer都不会有超时事件触发 那么当readFile执行完毕,kevent收到监听的fd事件完成后,执行了该回调,此时

1.timeout事件注册
2.immediate事件注册
3.由于readFile的回调执行完毕,那么就会从uv_io_poll中出来,此时立即执行uv_run_check,所以immediate事件被执行掉
4.最后的uv_run_timer检查timeout事件,执行timeout事件

所以你会发现,在I/O回调中注册的两者,永远都是immediately先执行

@hyj1991 当时看文档的时候,这块儿就没太弄明白,太感谢了!写的非常清楚。

@vincentLiuxiang 不客气哈,不过我也蛮佩服你的,看文档就能理清这么多哇。我当时是查文档看的晕,资料也少,无奈下直接去读源代码的。。。

感谢分享,精彩的讨论。

楼主真是真知灼见啊

mark

来自酷炫的 CNodeMD

先收藏一波~~~

@JacksonTian 大佬觉得这篇文章说的对不对

回到顶部