你是否真正理解Event Loop

509 阅读12分钟

JavaScript 的单线程

我们经常说 JavaScript 是单线程执行的,这里指的是一个进程内只有一个主线程。

多进程,指的是在同一时间里,同一个计算机系统中允许两个及以上的进程处于运行状态。例如我们可以在编写代码的同时使用软件听音乐。

以浏览器为例,我们在打开一个 Tab 页面时,已经创建了一个进程,在这一个进程中可以包含多个线程,例如 HTTP 请求线程、JS 引擎线程等等。

JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

为了避免复杂性,JavaScript 将单线程作为语言的核心。JS 脚本可以创建出多个子进程,并由主线程控制,且不能够操作 DOM。

Event Loop 是 JavaScript 的执行机制。JavaScript 在不同的环境下,例如 Node、浏览器等等,其执行的方式实际上也是不同的。

任务

划分

所有的任务可以分为同步任务与异步任务。所谓同步任务指的是主进程上排队执行的任务,当一个任务执行完毕后会执行下一个。而异步任务指的是,它不进入主进程,而是在任务队列上,当队列通知进程某个异步可以执行了,该任务就进入主线程执行。

队列

队列是遵循先进先出 (FIFO) 原则的有序集合,队列在尾部添加新元素,并在顶部移除元素,最新添加的元素必须排在队列的末尾。在计算机科学中,最常见的例子就是打印队列。JavaScript 在运行中有一个异步队列,其中的任务按照先后顺序执行,排在头部的任务会率先执行,该队列每次只执行一个任务。当我们的执行栈为空时,JavaScript 引擎便会去检查任务队列,如果任务队列不为空,则将第一个任务压如执行栈中执行。

浏览器中的 Event Loop

任务划分

浏览器端的事件循环异步队列分为两种:宏任务(macro-task)队列和微任务(micro-task)队列。常见的任务如下:

  • 宏任务:script 代码、setTimeout、setInterval、I/O、UI rendering
  • 微任务:Promise.then()、MutationObserver 等

事件循环过程

browser

  • 首先,全局上下文被推入执行栈,同步代码被执行。此时微队列为空。
  • 同步代码执行,在执行中会判断是同步任务还是异步任务,通过 API 的调用也会产生新的宏任务与微任务,它们会分别推入各自的队列中去。
  • 主线程内的任务执行完毕后。会去检查微任务队列是否为空。
  • 开始执行完微任务,去读取宏任务队列里排在最前面的任务。这里与执行宏任务的区别在于,微任务是一整队被执行完,而宏任务是一次一个地执行。执行宏任务的过程中会还会遇到宏微任务,依次将其加入各自的任务队列。
  • 以上的过程会不断地重复,直到两个队列都被清空,这个过程就是我们所谓的事件循环。

总结起来,当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。

我们来看一个例子:

setTimeout(function() {
  console.log('setTimeout')
})

new Promise(function(resolve) {
  console.log('promise')
  resolve()
}).then(function() {
  console.log('then')
})

console.log('console')
  1. 首先整段代码作为宏任务进入主线程
  2. 遇到setTimeout,将其回调函数注册并推入宏任务队列
  3. 遇到new Promise直接执行,打印出promise,再遇到then,将其回调推入微任务队列中。
  4. 遇到console.log,打印出console
  5. 此时第一次循环结束,首先检查微任务队列,发现then,执行后打印出then
  6. 微任务队列为空后,检查宏任务队列,将其第一个任务推入主线程,即setTimeout的回调函数,执行后打印出setTimeout
  7. 此时两个队列都为空,代码执行完毕。

async/await

async/await 语法是 ES7 中的生成器的语法糖,我们可以将其转换为 Promise 的形式来看。

首先在 async 函数中,await 之前的代码可以看做是同步的,可以直接按顺序执行,而 await 后面的函数,可以被转化为Promise.resolve(fn())的形式,它也属于同步任务。await 后面的代码,则可以看做是 then()方法的回调函数。通过这样的转换,我们就可以轻松地理清它们的执行顺序。

async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2')
}
console.log('script start')
setTimeout(function() {
  console.log('setTimeout')
}, 0)
async1()
console.log('end')
  1. 首先,打印出 start
  2. 遇到 setTimeout,将其回调函数注册并推入宏任务队列
  3. 遇到 async1,首先打印出 async1 start,接着执行同步任务async2,打印出async2await后面的代码推入微任务队列
  4. 遇到console.log,打印出end
  5. 此时先去检查微任务队列,打印出async1 end
  6. 检查宏任务队列,打印出setTimeout
  7. 两个任务队列为空,执行完毕

Node 中的 Event Loop

Node11 中的 Event Loop 运行原理发生变化,行为与浏览器保持一致。因此下文讨论的是针对 11 版本以下。

Node 中的 Event Loop 和浏览器中的是完全不相同的东西。Node.js 采用 V8 作为 js 的解析引擎,I/O 处理方面使用了自己设计的 libuv,libuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API。

Node

六个阶段

node event

当 Event Loop 进入某个阶段后,会执行该阶段特定的(任意)操作,然后才会执行这个阶段的队列里的回调。当队列被执行完,或者执行的回调数量达到上限后,event loop 会进入下个阶段。

timers

一个 timer(setTimeout、setInterval)指定一个下限时间,而不会准确时间,达到这个下限时间后会执行回调。在达到指定的时候后,timers 会尽早得去执行,但是系统调度或者其他回调执行可能会延迟它们。这里的延迟后面会提到。

timer 阶段执行 setTimeout 和 setInterval 回调,由 poll 阶段控制。

I/O callbacks

执行一些系统的回调,如 TCP 连接发生错误。

idel,prepare

系统内部一些调用,仅限 Node 内部使用。

poll

这个阶段会有两个功能:

  • 执行下限时间已经到达的 timers 的回调
  • 处理 poll 队列里的事件

当没有 timers,会发生以下两种情况之一。

  • poll 队列不为空的时候,事件循环肯定是先遍历队列并同步执行回调,直到队列清空或执行回调数达到系统上限。
  • poll 队列为空的时候,这里有两种情况。
    • 如果代码已经被 setImmediate()设定了回调,那么事件循环直接结束 poll 阶段进入 check 阶段来执行 check 队列里的回调。
    • 如果代码没有被设定 setImmediate()设定回调:
      • 如果有被设定的 timers,那么此时事件循环会检查 timers,如果有一个或多个 timers 下限时间已经到达,那么事件循环将绕回 timers 阶段,并执行 timers 的有效回调队列。
      • 如果没有被设定 timers,这个时候事件循环是阻塞在 poll 阶段等待回调被加入 poll 队列。

check

这个阶段允许在 poll 阶段结束后立即执行回调。如果 poll 阶段空闲,并且有被 setImmediate()设定的回调,那么事件循环直接跳到 check 执行而不是阻塞在 poll 阶段等待回调被加入。

close callbacks

如果一个 socket 或 handle 被突然关掉(比如 socket.destroy()),close 事件将在这个阶段被触发,否则将通过 process.nextTick()触发。

来看一个例子帮助理解:

var fs = require('fs')

function someAsyncOperation(callback) {
  // 假设这个任务要消耗 95ms
  fs.readFile('/path/to/file', callback)
}

var timeoutScheduled = Date.now()

setTimeout(function() {
  var delay = Date.now() - timeoutScheduled

  console.log(delay + 'ms have passed since I was scheduled')
}, 100)

// someAsyncOperation要消耗 95 ms 才能完成
someAsyncOperation(function() {
  var startCallback = Date.now()

  // 消耗 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
})

此时当 event loop 进入了 poll 阶段,发现队列为空,没有代码被 setImmediate。它将会等待剩下的毫秒数,直到最近的 timer 下限时间的到来。当到 95ms 时,fs.readFile 操作结束,其回调被加入到 poll 队列中执行--该回调耗时 10ms。当这一系列执行完成后,时间已经过了 105ms,达到了最近 timer 的下限时间,则回到 timers 阶段,执行 timer 的回调。

这个例子中,poll 阶段即完成了进入 check 阶段完成被 setImmediate 的回调函数,同时也执行了下限时间已经到达的 timers 的回调。

setTimeout 和 setImmediate

setImmediate 方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次 Event Loop 时执行。

我们来看一个常见的例子:

setTimeout(() => {
  console.log('setTimeout')
}, 0)
setImmediate(() => {
  console.log('setImmediate')
})

上述的代码执行结果实际上是不确定的。原因在于进入 timer 阶段的时间不确定。根据机器的性能不同,如果进入 timer 节点可能已经过了 1ms,那么 setTimeout 回调会首先执行;而如果小于 1ms,则事件循环会来到 poll 阶段,此时队列为空且有代码被 setImmediate,进入 check 阶段执行 setImmediate 回调函数,等到下个循环的 timer 阶段再执行 setTimeout 的回调函数。

在某些情况下,二者的执行顺序是确定的

var fs = require('fs')

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

上述代码的运行结果先执行 setImmediate 回调,再执行 setTimeout 回调。

因为 fs 操作的回调是在 poll 阶段,此时队列为空且有代码被 setImmediate,进入 check 阶段执行 setImmediate 回调函数。

同样地,来看下面的代码:

setTimeout(() => {
  setImmediate(() => {
    console.log('setImmediate')
  })
  setTimeout(() => {
    console.log('setTimeout')
  }, 0)
}, 0)

以上的代码在timers阶段执行外部的setTimeout回调后,内层的setTimeoutsetImmediate入队,之后事件循环继续往后面的阶段走,走到 poll 阶段的时候发现队列为空,此时有代码被setImmedate,所以直接进入check阶段执行响应回调(注意这里没有去检测timers队列中是否有成员到达下限事件,因为setImmediate()优先)。之后在第二个事件循环的timers阶段中再去执行相应的回调。

因此,我们可以得出结论:如果两者都不在主模块调用(被一个异步操作包裹),那么setImmediate的回调永远先执行。

proccess.nextTick 与 Promise.then

这两者可以看作是一个微任务,其中proccess.nextTick是在当前"执行栈"的尾部----下一次 Event Loop(主线程读取"任务队列")之前----触发回调函数。

setTimeout(() => {
  console.log('timeout0')
  process.nextTick(() => {
    console.log('nextTick1')
    process.nextTick(() => {
      console.log('nextTick2')
    })
  })
  process.nextTick(() => {
    console.log('nextTick3')
  })
  console.log('sync')
  setTimeout(() => {
    console.log('timeout2')
  }, 0)
}, 0)

以上代码的执行结果是

// timeout0
// sync
// nextTick1
// nextTick3
// nextTick2
// timeout2

首先是在 timer 阶段执行外层 setTimeout 的回调,执行同步代码,打印出timeout0sync,遇到 process.nextTick,将其加入微任务中。其次是遇到 setTimeout,将其加入宏任务。此时去检查微任务队列,执行打印出nextTick1,并将其内部的 process.nextTick 推入微任务,接着打印出nextTick2,再执行微任务队列中最后一个nextTick2。微任务执行完成,队列为空。执行 setTimeout 回调,输出timeout2

与同样作为微任务的Promise.then相比,process.nextTick会更优先执行。

引用知乎黄一君的说法

process.nextTick 永远大于 promise.then,原因其实很简单。。。在 Node 中,_tickCallback 在每一次执行完 TaskQueue 中的一个任务后被调用,而这个_tickCallback 中实质上干了两件事:

  1. nextTickQueue 中所有任务执行掉(长度最大 1e4,Node 版本 v6.9.1)
  2. 第一步执行完后执行_runMicrotasks 函数,执行 microtask 中的部分(promise.then 注册的回调)

浏览器与 Node 环境中 Event Loop 的差异

在浏览器环境下,微任务队列是在每执行完一个宏任务之后执行的。而在 Node.js 中,微任务队列会在时间循环的六个阶段之间执行,即一个阶段执行完毕,就会去执行微任务队列的任务。

setTimeout(() => {
  setImmediate(() => {
    console.log('setImmediate')
    Promise.resolve().then(function() {
      console.log('promise1')
    })
  })
  setTimeout(() => {
    console.log('setTimeout')
    Promise.resolve().then(function() {
      console.log('promise2')
    })
  }, 0)
}, 0)
  1. 首先,外层 setTimeout 的回调在 timer 阶段被执行
  2. 这时候首先来到了 poll 阶段,发现队列为空且有代码被 setImmediate,于是进入 check 阶段执行回调函数,打印出 setImmediate,将 then 的回调推入微任务队列。
  3. 此时 check 阶段结束,于是去检查微任务队列,执行 then 回调,打印出 promise1
  4. 进入第二轮循环,在 timer 阶段执行了内部 setTimeout 的回调,打印出 setTimeout,同样将 then 方法推入微任务队列
  5. timer 阶段结束,检查微任务队列,执行 then 方法回调,打印出 promise2

参考链接