js 事件循环

事件循环基本

js 是一门单线程非阻塞脚本语言。即便浏览器推出的 web worker 也只允许子线程承担计算任务,而不能操纵 dom 节点(因为主、子线程同时操纵 dom 会造成不一致)。

执行栈可用于解释 js 引擎对同步脚本的处理。即当函数首次被调用时,js 引擎就会为该过程创建执行上下文,并将该执行上下文压入执行栈中。执行上下文也称为执行环境,包含函数的私有作用域、父级作用域、参数、作用域中的变量以及 this 指向。当函数执行完毕后,js 引擎会在执行栈中销毁该执行上下文,并将执行栈定位到前一个执行上下文。执行上下文典型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
executionContextObj = {
scopeChain: {// 作用域链,对象形式,包含所有变量对象、所有父级执行上下文中的变量对象
/* variableObject + all parent execution context's variableObject */
},
variableObject: {// 变量对象,对象形式,包含函数形参、函数内部的变量以及函数声明(但不包含表达式)
/* function arguments / parameters, inner variable and function declarations */
arguments: {
0: 22,// 实参
length: 1
},
realArg: 22,// 调用期间获得的实参
afunction: pointer to function afunction(),// 内部声明的函数,以指针形式引用
aVariable: undefined,// 内部变量 aVariable,创建阶段声明上移,为 undefined
functionExp: undefined,// 内部以变量 functionExp 声明的函数变量,创建阶段声明上移,为 undefined
},
this: {}// this 关键字
}

js 引擎对异步任务的处理则借助 callback queue 事件队列实现。即当主线程中的同步任务执行完毕时,js 引擎会去检查事件队列,并取出执行。异步任务有其优先级,在浏览器环境中,有 micro task 微任务、macro task 宏任务两类。微任务包含 Promise、MutaionObserver;宏任务包含 setTimeout、setInterval、setImmediate、requestAnimationFrame。浏览器先执行微任务,再执行宏任务。交互行为作为特殊的异步任务,通过收发消息的方式实现,详情可参考 深入理解javascript中的事件循环event-loop

node 中的事件循环

在 node 环境中,node 使用 v8 引擎解释 js 脚本,然后使用 libuv 引擎处理事件循环以及其他异步行为。在启动阶段,node 就会初始化 event loop 事件循环。下图描绘了 node 处理事件循环的各阶段,每个阶段都有一个先进先出的回调队列待执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
  • poll:I/O 事件完毕后,会进入 poll 阶段(同步模式会阻塞在 poll 阶段)。它首先会计算阻塞时间,然后执行 poll 队列,直到队列耗尽或超过阈值。然后检查脚本中是否有 setImmediate,如果有,进入 check 阶段。最后检查是否达到了定时器的指定时间,如果是,进入 timers 阶段。
  • check:执行 setImmediate 回调。其特殊意义在于,如果 poll 阶段队列为空,可以使用 setImmediate 使程序进入 check 阶段,而不至于阻塞在 poll 阶段。
  • close callbacks:调用如 socket.on(‘close’, …) 等回调。
  • timers:调用 setTimeout、setInterval 等回调。定时器通常会滞后于用户设定时间,因为它需要等待 timers 阶段之前的任务执行完成。技术上,定时器的执行时机由 poll 阶段控制。
  • pending callbacks:调用如某些系统操作(如TCP错误类型)的回调。
  • idle, prepare:node 内部使用。

需注意,在非 I/O 循环中调用 setImmediate、setTimeout,两者的执行时机是不确定的;在 I/O 循环中,setImmediate 总是先于 setTimeout。

process.nextTick 在事件循环算法之外,它将在事件循环的任意阶段继续执行前得到调用。无限递归的 process.nextTick 有可能会饿死 I/O,无法使事件循环达到 loop 阶段。node 为什么将 process.nextTick 设计为当前操作之后、事件循环之前呢?其目的是这样设计能使同步执行的回调通过 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
// 同步脚本
let bar;

function someAsyncApiCall(callback) { callback(); }

someAsyncApiCall(() => {
console.log('bar', bar); // undefined
});

bar = 1;

// 借助 process.nextTick 实现异步
let bar;

function someAsyncApiCall(callback) {
process.nextTick(callback);
}

someAsyncApiCall(() => {
console.log('bar', bar); // 1
});

bar = 1;

参考

JavaScript中执行环境和栈
详解JavaScript中的Event Loop(事件循环)机制
The Node.js Event Loop, Timers, and process.nextTick()