Aaron
  • 3.5k

Node.js事件循环

说到Node.js的事件循环网上已经有了很多形形色色的文章来讲述其中的原理,说的大概都是一个意思,学习了一段时间,对Node.js事件循环有了一定的了解之后写一篇博客总结一下自己的学习成果。

事件循环

在笔者看来事件与循环本身就是两个概念,事件是可以被控件识别的操作,如按下确定按钮,选择某个单选按钮或者复选框。每一种控件有自己可以识别的事件,如窗体的加载、单击、双击等事件,编辑框(文本框)的文本改变事件。

然而循环则是在GUI线程中包含有一个循环,然而这个循环对于开发者和用户来讲是看不见的,只有关闭了程序之后该循环才会结束。当用户触发了一个按钮事件之后,就会产生响应的事件,这些时间被加入到一个队列中,用户在前台不断的产生事件,然而后台也在不断的处理这些时间,在处理的时候被加入到一个队列中,由于主循环中循环的存在会挨个处理这些对应的事件。

image

而对于JavaScript来讲的话由于JavaScript是单线程的,对于一个比较耗时的操作则是使用异步的方法解决(Ajax...)。对于不同的异步事件来也是由不同的线程各司其职来处理的。

Node.js中的事件循环

Node.js的事件循环与浏览器的事件循环还是有很大的区别的,当Node.js启动后,它会初始化事件轮询;处理已提供的输入脚本(或丢入REPL,本文不涉及到),它可能会调用一些异步的API函数调用,安排任务处理事件,或者调用process.nextTick(),然后开始处理事件循环。

有一点是非常明确的,事件循环同样运行在单线程环境下,JavaScript的事件循环是依靠于浏览器来实现的,然而Node.js则是依赖于Libuv来实现的。

根据Node.js官方介绍,每次事件循环都包含了6个阶段,对应到Libuv源码中的实现,如下图所示,图中显示了事件循环的概述以及执行顺序。

image

  1. timersj阶段:这个阶段执行timer(setTimeout、setInterval)的回调
  2. I/O callbacks:执行一些系统调用错误,比如网络通信的错误回调
  3. idle,prepare:仅node内部使用
  4. poll:获取新的I/O事件, 适当的条件下node将阻塞在这里
  5. check:执行 setImmediate() 的回调
  6. close callbacks:执行 socket 的 close 事件回调

下面是Node.js事件循环源代码:

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;
  r = uv__loop_alive(loop);
  if (!r)
    uv__update_time(loop);
  while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop);
    // timers阶段
    uv__run_timers(loop);
    // I/O callbacks阶段
    ran_pending = uv__run_pending(loop);
    // idle阶段
    uv__run_idle(loop);
    // prepare阶段
    uv__run_prepare(loop);
    timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);
    // poll阶段
    uv__io_poll(loop, timeout);
    // check阶段
    uv__run_check(loop);
    // close callbacks阶段
    uv__run_closing_handles(loop);
    if (mode == UV_RUN_ONCE) {
      uv__update_time(loop);
      uv__run_timers(loop);
    }
    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }
  if (loop->stop_flag != 0)
    loop->stop_flag = 0;
  return r;
}

假设事件循环进入到某一个阶段,及时在这期间其他队列中的事件已经准备就绪,也会先将当前阶段对应队列中所有的回调方法执行完毕之后才会继续向下执行,结合代码也是能够很好的理解的。不难可以得出在事件循环系统中回调的执行顺序是有迹可循的,同样也会造成事件阻塞。

var fs = require("fs");
fs.readFile('input.txt', function (err, data) {
   if (err){
      console.log(err.stack);
      return;
   }
   console.log(data.toString());
});
fs.readFile('test.txt', function (err, data) {
   if (err){
      console.log(err.stack);
      return;
   }
   console.log(data.toString());
});
console.log("程序执行完毕");

对于整个事件循环有个一个大概的认知之后,接下来针对每个阶段进行详细的说明。

timers

该阶段主要用来处理定时器相关的回调方法,当一个定时器超市后一个事件就会加入到该阶段的队列中,事件循环会跳转至这个阶段执行对应的回调方法。

定时器的回调会在触发后尽可能早的被调用,为什么要说尽可能早的呢?因为实际的触发事件可能要比预先设置的时间要长。Node.js并不能保证timer在预设时间到了就会立即执行,因为Node.jstimer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。

I/O callbacks

在这个阶段中除了timers、setImmediate,以及close操作之外的大多数的回调方法都位于这个阶段执行。例一个TCP socket执行出现了一些错误,那么这个回调函数会在I/O callbacks阶段来执行。名字会让人误解为执行I/O回调处理程序,然而一些常见的回调则会再poll阶段进行处理。

I/O callbacks阶段主要经过如下过程:

  1. 检查是否有pending的I/O回调。如果有,执行回调。如果没有,退出该阶段。
  2. 检查是否有process.nextTick任务,如果有,全部执行。
  3. 检查是否有microtask,如果有,全部执行。
  4. 退出该阶段。

poll

对于Poll阶段其主要的功能主要有两点:

  1. 处理 poll 队列的事件
  2. 当有已超时的 timer,执行它的回调函数

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

  1. 如果poll队列不为空,则事件循环会按照顺序便利执行队列中的回调方法,这个过程是同步的。
  2. 如果poll队列为空则会再次进行判断

    • 若有预设的setImmediate(),事件循环将结束poll阶段进入check阶段,并执行check阶段的任务队列
    • 若没有预设的setImmediate(),那么事件循环可能会进入等待状态,并等待新事件的产生,这也是该阶段为什么被命名为poll的原因。出了这些意外,该阶段还会不断的检查是否有相关的定时器超市,如果有就会跳转到timers阶段,然后执行对应的回调方法

check

该阶段执行setImmediate()的回调函数。关于setImmediate是一个比较特殊的定时器方法,setImmediate的回调则会加入到check队列中,从事件循环的阶段图可以知道,check阶段的执行顺序是在poll之后的。

一般情况下,事件循环到达poll阶段后,就会检查当前代码是否调用了setImmediate方法,这个在叙述poll阶段的时候已经有提及了,如果一个回调函数是被setImmediate方法调用的,事件循环则会跳出poll阶段从而进入到check阶段。(这一段有点重复...)

close

close阶段是用来管理关闭事件,用于清理应用程序的状态。如程序中的socket关闭等都会加入到close队列中,当本轮事件结束后则会进入下一轮循环。

小结

对于事件循环来说每个阶段都有一个任务队列,当事件循环到达某个阶段的时候,讲执行该阶段的任务队列,知道队列清空或执行的对调到达系统上限后,才会转入到下一个阶段。当所有的阶段被执行一次后,事件循环则就完成了一个tick

process.nextTick

这是Node.js特有的方法,它不存在于任何浏览器(以及进程对象)中,process.nextTick是一个异步的动作,并且让这个动作在事件循环中当前阶段执行完之后立即执行,也就是上面所说的tick

process.nextTick(() => {
    console.log("1")   
})
console.log("2")
//  2
//  1

官方对于process.nextTick有一段很有意思的解释:从语义角度看,setImmediate(稍后会说到)应该比process.nextTick先执行才对,而事实相反,命名是历史原因也很难再变。

然而对于process.nextTick来说该方法并不是事件循环中的一部分,但是它的回调方法确是由事件循环调用的,该方法定义的回调方法会被加入到nextTickQueue的队列中。相反地,nextTickQueue将会在当前操作完成之后立即被处理,而不管当前处于事件循环的哪个阶段。

Node.jsprocess.nextTick进行了限制,若递归调用process.nextTick当倒带nextTickQueue最大限制之后则会抛出一个错误。

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

//  Maxmum call stack size exceeded
nextTick(0);

既然说process.nextTick也是存在于队列中,那么其执行顺序也是根据程序所编写顺序执行的。

process.nextTick(() => {
    console.log(1)
});
process.nextTick(() => {
    console.log(2)
});

//  1
//  2

和其它回调函数一样,process.nextTick定义的回调也是由事件循环执行的,如果process.nextTick的回调方法中出现了阻塞操作,后面的要执行的回调函数同样会被阻塞。process.nextTick会在各个事件阶段之间执行,一旦执行,要直到nextTickQueue被清空,才会进入到下一个事件阶段,所以如果递归调用process.nextTick,会导致出现I/O starving的问题,比如下面例子的readFile已经完成,但它的回调一直无法执行。

const fs = require('fs')
const starttime = Date.now()
let endtime;
fs.readFile('text.txt', () => {
  endtime = Date.now()
  console.log('finish reading time: ', endtime - starttime)
})
let index = 0
function handler () {
  if (index++ >= 1000) return
  console.log(`nextTick ${index}`)
  process.nextTick(handler)
}
handler();

//  nextTick 1
//  nextTick 2
//  ......
//  nextTick 999
//  nextTick 1000
//  finish reading time: 170

process.nextTick() vs setImmediate()

seImmediate方法不属于ECMAScript标准,而是Node.js提出的新方法,它同样将一个回调函数加入到事件队列中,不同于setTimeoutsetIntervalsetImmediate并不接受一个时间作为参数,setImmediate的事件会在当前事件循环的结尾触发,对应的回调方法会在当前事件循环的末尾(check)执行。虽然它确实存在于某些浏览器中,但并未在所有浏览器中达到一致的行为,因此在浏览器中使用时,您需要非常小心。它类似于setTimeout(fn,0)代码,但有时会优先于它。这里的命名也不是最好的。

  1. process.nextTick中的回调在事件循环的当前阶段中被立即执行。
  2. setImmediate中的回调在事件循环的下一次迭代或tick中被执行

本质上,它们两个的名字应该互相调换一下。process.nextTick()的执行时机比setImmediate()要更及时(上面有提过)。实施这项改变将导致很多npm包无法使用。每天都有很多新模块被加入,这意味着每等待一天,就会有更多潜在的破坏发生。虽然他们的名字相互混淆,但将它们调换名字这种事是不会发生的(建议开发者在所有地方使用setImmediate,这样程序更容易让人理解)。

仍然使用上述例子,若把nextTick替换成setImmediate会怎样呢?

const fs = require('fs')
const starttime = Date.now()
let endtime;
fs.readFile('text.txt', () => {
  endtime = Date.now()
  console.log('finish reading time: ', endtime - starttime)
})
let index = 0
function handler () {
  if (index++ >= 1000) return
  console.log(`setImmediate ${index}`)
  setImmediate(handler)
}
handler();

// setImmediate 1
// setImmediate 2
// finish reading time: 80
// ......
// setImmediate 999
// setImmediate 1000

这是因为嵌套调用的setImmediate()回调,被排到了下一次事件循环才执行,所以不会出现阻塞。

setImmediate vs setTimeout

定时器在Node.js和浏览器中的表现形式是相同的。关于定时器的一个重要的事情是,我们提供的延迟不代表在这个时间之后回调就会被执行。它的真正含义是,一旦主线程完成所有操作(包括微任务)并且没有其它具有更高优先级的定时器,Node.js将在此时间之后执行回调。

  1. setImmediate()被设计在poll阶段结束后立即执行回调
  2. setTimeout()被设计在指定下限时间到达后执行回调
setTimeout(function timeout () {
  console.log('timeout');
},0);

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

//  结果一
//  timeout
//  immediate
/**--------华丽的分割线--------**/
//  结果二
//  immediate
//  timeout

why?为什么会有两个结果,笔者在研究这里的时候也是有些不太明白,于是又做了第二个例子:

var fs = require('fs')
fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
});
//  运行N次
//  immediate
//  timeout
  1. 如果两者都在主模块调用,那么执行先后取决于进程性能,即随机。
  2. 如果两者都不在主模块调用,那么setImmediate的回调永远先执行。

虽然结论得出来了,但是这又是为啥呢?回想一下文章上半段所叙述的事件循环。首先进入timer阶段,如果我们的机器性能一般,那么进入timer阶段时,1毫秒可能已经过去了(setTimeout(fn,0)等价于setTimeout(fn,1)),那么setTimeout的回调会首先执行。如果没到一毫秒,那么我们可以知道,在check阶段,setImmediate的回调会先执行。为什么fs.readFile回调里设置的,setImmediate始终先执行?因为fs.readFile的回调执行是在poll阶段,所以,接下来的check阶段会先执行setImmediate的回调。我们可以注意到,UV_RUN_ONCE模式下,事件循环会在开始和结束都去执行timer

练习题

阅读完本文章有什么收获呢?不如看下下面的代码,预测一下输出结果是什么样的。先不要急着看答案额...

const fs = require('fs');
console.log('beginning of the program');
const promise = new Promise(resolve => {
    console.log('I am in the promise function!');
    resolve('resolved message');
});
promise.then(() => {
    console.log('I am in the first resolved promise');
}).then(() => {
    console.log('I am in the second resolved promise');
});
process.nextTick(() => {
    console.log('I am in the process next tick now');
});
fs.readFile('index.html', () => {
    console.log('==================');
    setTimeout(() => {
        console.log('I am in the callback from setTimeout with 0ms delay');
    }, 0);
    setImmediate(() => {
        console.log('I am from setImmediate callback');
    });
});
setTimeout(() => {
    console.log('I am in the callback from setTimeout with 0ms delay');
}, 0);
setImmediate(() => {
    console.log('I am from setImmediate callback');
});





// 输出结果
// beginning of the program
// I am in the promise function!
// I am in the process next tick now
// I am in the first resolved promise
// I am in the second resolved promise
// I am in the callback from setTimeout with 0ms delay
// I am from setImmediate callback
// ==================
// I am from setImmediate callback
// I am in the callback from setTimeout with 0ms delay

总结

对于本文中一些知识点任然有些模糊,懵懵懂懂,一直都在学习中,通过学习事件循环也看了一些文献,在其中看到了这一句话:除了你的代码,一切都是同步的,我觉得很有道理,对于理解事件循环很有帮助。

  1. Node.js的事件循环分为6个阶段
  2. process.nextTick不属于事件循环,但是产生的回调会加入到nextTickQueue
  3. setImmediatesetTimeout的执行顺序会受到环境所影响

文章略长若文章中有哪些错误,请在评论区指出,我会尽快做出修正。大家可以踊跃发言共同进步,交流。

阅读 922

推荐阅读
Web邦邦堂
用户专栏

欢迎订阅前端邦邦堂专栏前端邦邦堂是一群初入IT编程的人共同组成。用意是互帮互助,共同成长。Qq群号:1...

1221 人关注
19 篇文章
专栏主页