Event Loop(事件循环)

提到JavaScript,大家都知道它是单线程的,那么在执行异步请求的时候,它内部的事件处理机制是怎样的呢?这里就来谈谈事件循环 —— Event loop.

浏览器端的事件循环

先来看这段经典的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
setTimeout(function() {
console.log(1)
}, 0);

new Promise(function executor(resolve) {
console.log(2);
for( var i=0 ; i<10000 ; i++ ) {
i == 9999 && resolve();
}
console.log(3);
}).then(function() {
console.log(4);
});

console.log(5);

输出结果:

1
2
3
4
5
2
3
5
4
1

但是其中的原因是什么呢?

先来了解几个概念:

  • 执行栈(stack):执行栈是一个栈结构,函数调用会形成一个栈帧,帧中包含了当前执行函数的参数和局部变量等上下文信息,函数执行完后,它的执行上下文会从栈中弹出。栈空才会去读取任务队列。
  • 任务队列(task queue):任务队列是一个存储着待执行任务的队列,其中的任务严格按照时间先后顺序执行,排在队头的任务将会率先执行,而排在队尾的任务会最后执行。任务队列每次仅执行一个任务,在该任务执行完毕之后,再执行下一个任务。当执行栈为空时,JS 引擎便检查任务队列,如果不为空的话,任务队列便将第一个任务压入执行栈中运行。

其中任务队列中的任务分为2种:
macrotasks: script(整体代码),setTimeout, setInterval, setImmediate, I/O, UI rendering
microtasks: process.nextTick, Promises, Object.observe, MutationObserver

检查执行栈是否为空,以及确定把哪个task加入执行栈的这个过程就是事件循环,而js实现异步的核心就是事件循环。(注意:同步任务总是比异步任务先执行)

事件循环的步骤包括:

  1. JavaScript引擎首先从macrotask queue中取出第一个任务,即从script(整体代码)开始循环。
  2. 执行完毕后,将microtask queue中的所有任务取出,按顺序全部执行。
  3. 然后再从macrotask queue中取下一个任务执行。
  4. 执行完毕后,再次将microtask queue中的全部取出执行
  5. UI render(由于浏览器有自己的优化策略,不是每轮事件循环都会执行视图更新)
  6. 循环往复。直到两个queue中的任务都取完。

简单的说一次事件循环就是:

  1. 在 macrotask 队列中执行最早的那个 task ,然后移出
  2. 执行 microtask 队列中所有可用的任务,然后移出
  3. 下一个循环,执行下一个 macrotask 中的任务

现在再回去看那段代码,就很好理解了。
先执行整体代码(promise的构造executors是同步执行的)依次输出2、3、5,接着检查microtask queue,执行所有的任务,这里是promise的回调函数then,输出4,接着再次检查macrotask queue,执行一个任务,这里是setTimeout的回调函数,于是输出1

Node.js中的事件循环

再来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)

setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)

实际在node环境下输出的是

1
2
3
4
timer1
timer2
promise1
promise2

根据Node.js官方介绍,每次事件循环都包含了6个阶段,对应到 libuv 源码中的实现,如下图所示

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 │
└───────────────────────────┘

每个阶段的任务:

Phases Overview

  • timers: this phase executes callbacks scheduled by setTimeout() and setInterval().
  • pending callbacks: executes I/O callbacks deferred to the next loop iteration.
  • idle, prepare: only used internally.
  • poll: retrieve new I/O events; execute I/O related callbacks (almost all with the exception of close callbacks, the ones scheduled by timers, and setImmediate()); node will block here when appropriate.
  • check: setImmediate() callbacks are invoked here.
  • close callbacks: some close callbacks, e.g. socket.on('close', ...).

我们重点关注timers, poll 和check
timers执行setTimeout、setInterval的回调,poll获取新的I/O事件, 执行与I / O相关的回调(几乎是除了定时器和setImmediate()的close事件回调之外的全部回调),check执行 setImmediate() 的回调

每个事件循环中的阶段的间隔 ,会处理完所有microtask

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────────┐
┌─>│ timers | ┌───────────────┐
│ └─────────────┬─────────────┘<-----┤ microtasks │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ pending callbacks │ ┌───────────────┐
│ └─────────────┬─────────────┘<-----┤ microtasks │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ idle, prepare │ ┌───────────────┐
│ └─────────────┬─────────────┘<-----┤ microtasks │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ poll │ ┌───────────────┐
│ └─────────────┬─────────────┘<-----┤ microtasks │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │ ┌───────────────┐
│ └─────────────┬─────────────┘<-----┤ microtasks │
│ ┌─────────────┴─────────────┐ └───────────────┘
└──┤ close callbacks │
└───────────────────────────┘

关于 setTimeout(), setImmediate(), process.nextTick():

  • setTimeout() 在某个时间值过后尽快执行回调函数;
  • setImmediate() 。 一旦轮询阶段完成就执行回调函数;
  • process.nextTick() 在当前调用栈结束后就立即处理,这时也必然是“事件循环继续进行之前” ;

process.nextTick() 会在各个事件阶段之间执行,一旦执行,要直到nextTick队列被清空,才会进入到下一个事件阶段,所以如果递归调用 process.nextTick(),会导致出现I/O starving(饥饿)的问题。官方推荐用户使用setImmediate()