Skip to main content

event-loop

Overview

宿主环境(Hosting Environment),比如浏览器、Node.js,提供 WEB APIs。当这些 WEB API 触发之时,将 Task 加入 Callback Queue
Call Stack 为空时,Event LoopCallback Queue 中的 Task,加入到 Call Stack中。每次加入的Task,称之为一个Tick
JS 引擎(JS Engine),执行 Call Stack 中的Tasks

event-loop-overview.png

Job Queue

由上文可以看出,Event Loop,是Hosting Environment提供的功能。ES6中,由于Promise机制引入标准,Event Loop成为JS engine的一部分,而不再仅仅是 Hosting Environment 的职责。
JS EngineJob Queue 加到当前 Tick 的最后。优先于下一个Tick,也就是Callback Queue中的 code。"later, but as soon as possible."
很多文章,将此类任务,称之为微任务(MicroTask)。为了区分,Event Loop 中的Task,成为宏任务(MacroTask)。

WEB APIs

事件(event),用户交互(user interaction),脚本(script),渲染(rendering),网络(networking)等。

MacroTask
script(整体代码), setTimeout, setInterval, setImmediate(node 独有), I/O, UI rendering

MicroTask
process.nextTick(node 独有), Promises, Object.observe(废弃), MutationObserver

实现

根据上下文的不同,Event loop也有不同的实现:

  • Node.js使用了 libuv 库来实现 Event loop;
  • 浏览器中,html规范定义了Event loop,具体的实现则交给不同的厂商去完成。

Browser

同一个 context 中,总的执行顺序为:同步代码—>microTask—>macroTask。
浏览器中,一个事件循环里有很多个来自不同任务源的任务队列(task queues),每一个任务队列里的任务是严格按照先进先出的顺序执行的。但是,因为浏览器自己调度的关系,不同任务队列的任务的执行顺序是不确定的。大体步骤如下:

  1. 浏览器会不断从Task队列(Callback Queue)中按顺序取Task执行
  2. 每执行完一个Task都会检查Microtask队列是否为空(执行完一个Task的具体标志是Call Stack为空)
  3. 如果不为空则会一次性执行完所有Microtask
  4. 然后再进入下一个循环去 task 队列中取下一个Task执行,以此类推。

event-loop-browser.png

Node.js

Node.jsEvent Loop分为 6 个阶段,它们会按照顺序反复运行,分别如下:

  1. timers:执行setTimeout()setInterval()中到期的Callback
  2. I/O callbacks:上一轮循环中有少数的I/O callback会被延迟到这一轮的这一阶段执行
  3. idle, prepare:队列的移动,仅内部使用
  4. poll:最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
  5. check:执行setImmediatecallback
  6. close callbacks:执行close事件的callback,例如socket.on("close", func)
    不同于浏览器的是,在每个阶段完成后,而不是MacroTask任务完成后,microTask队列就会被执行。这就导致了同样的代码在不同的上下文环境下会出现不同的结果。

另外需要注意的是,如果在timers阶段执行时创建了setImmediate则会在此轮循环的check阶段执行,如果在timers阶段创建了setTimeout,由于timers已取出完毕,则会进入下轮循环,check阶段创建timers任务同理。

event-loop-node.png

执行顺序差别

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

浏览器输出:
time1
promise1
time2
promise2

Node输出:
time1
time2
promise1
promise2

浏览器:两个setTimeout作为两个MacroTask, 所以先输出timer1, promise1,再输出timer2,promise2
Node:最初timer1timer2就在timers阶段中。开始时首先进入timers阶段,执行timer1的回调函数,打印timer1,并将promise1.then回调放入microtask队列,同样的步骤执行timer2,打印timer2
至此,timer阶段执行结束,event loop进入下一个阶段之前,执行microtask队列的所有任务,依次打印promise1、promise2

setImmediate(() => {
console.log('timer1')
Promise.resolve().then(function () {
console.log('promise1')
})
})
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function () {
console.log('promise2')
})
}, 0)
Node输出:
timer1 timer2
promise1 或者 promise2
timer2 timer1
promise2 promise1

按理说setTimeout(fn,0)应该比setImmediate(fn)快,应该只有第二种结果,为什么会出现两种结果呢?
这是因为Node做不到 0 毫秒,最少也需要 1 毫秒。实际执行的时候,进入事件循环以后,有可能到了 1 毫秒,也可能还没到 1 毫秒,取决于系统当时的状况。如果没到 1 毫秒,那么 timers 阶段就会跳过,进入 check 阶段,先执行setImmediate的回调函数。
另外,如果已经过了Timer阶段,那么setImmediate会比setTimeout更快,例如:

const fs = require('fs');
fs.readFile('test.js', () => {
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
});

上面代码会先进入 I/O callbacks 阶段,然后是 check 阶段,最后才是 timers 阶段。因此,setImmediate 才会早于 setTimeout 执行。

process.nextTick优先于Promise

setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
Promise.resolve().then(() => console.log(3));
process.nextTick(() => console.log(4));
输出结果:4 3 1 2或者4 3 2 1

microTask优于macroTask运行,所以先输出下面两个;
而在Nodeprocess.nextTickPromise更加优先,所以 4 在 3 前;
而根据我们之前所说的Node没有绝对意义上的 0ms,所以 1, 2 的顺序不固定。

并发

通过单线程事件循环实现
等待条件以及竞态条件实现:

// gate
if (a && b) {
// do something here
}
if (!a) {
// do something here
}

不要使用 Synchronous Ajax Request。
async function expression,用于匿名函数,立即执行函数。