Event Loop

事件循环

我们都知道JS是一门单线程的非阻塞的脚本语言,他最初的定位就是用来与浏览器DOM交互的,所以决定了他是单线程,防止多个线程操作同一个DOM引起冲突。非阻塞则是JS在执行异步任务的时候不会阻塞整个JS主线程的执行,而是会挂起任务,等待执行完成之后再回调。

有人说web worker不是多线程吗,其实web worker严格来讲是浏览器JS主线程的子线程,而且这些子线程也没有I/O的权限,不能操作DOM。web worker的主要作用是替主线程分担一些耗时的计算任务。

上面说到了非阻塞的特性会让JS挂起异步任务,那么这些被挂起的异步任务需要一个时机去执行,这种执行的策略在JS中被称作是“事件循环”(Event Loop)。由于浏览器和node环境的JS执行环境不同,这也导致了他们的事件循环不一致。下面分别来讲下事件循环在这两种环境下的表现。

宏任务与微任务

在讲之前先说下两个概念一个是微任务,一个是宏任务。

  1. 微任务:microtask在ES6中改名为jobs,微任务包括

    1. process.nextTick(Node中独有)
    2. promise (ES6+支持)
    3. Object.observe (被废弃)
    4. MutationObserver(新API,监听DOM节点变化,MDN文档
  2. 宏任务:macrotask在ES6中改名为task,宏任务包括

    1. script(浏览器)
    2. setTimeout
    3. setInterval
    4. setImmediate(IE和node支持)
    5. I/O
    6. UI render(浏览器)

微任务与宏任务有一个共同点是他们都是异步的任务(script除外,同步任务),不同的是在同一个事件循环中微任务会在宏任务之前执行(script除外)。

浏览器中

在浏览器中事件循环执行顺序是:

  1. 执行script同步任务
  2. 执行并清空微任务列表
  3. UI render
  4. 下一轮Event Loop执行异步宏任务 ... 循环

来段代码遛遛:

console.log('script start')

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

new Promise(resolve => {
  console.log('promise')
  resolve()
})
.then(function() {
  console.log('promise1')
})
.then(function() {
  console.log('promise2')
})

console.log('script end')

根据浏览器中事件循环的规律可以知道以上代码的执行顺序是

// script start => promise => script end => promise1 => promise2 => setTimeout

Node

Node中的事件循环和浏览器中的略有不同,不过大体上是类似的。

Node中的事件循环可以分为6个阶段,下面是我从Node文档找的示例图:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
  1. timer:会执行已经到期的 setTimeout() 和 setInterval() 的回调函数
  2. pending callbacks(I/O callbacks):会执行除了timer,close,setImmediate之外的事件回调
  3. idle, prepare:仅系统内部使用
  4. poll:轮询,不断检查有没有新的I/O事件,事件环可能会在这里阻塞。这里主要分为两个步骤
    1. 先查看poll queue中是否有事件,有任务就按先进先出的顺序依次执行回调
    2. 当queue为空时,会检查是否有setImmediate()的callback,如果有就进入check阶段执行这些callback。但同时也会检查是否有到期的timer,如果有,就把这些到期的timer的callback按照调用顺序放到timer queue中,之后循环会进入timer阶段执行queue中的 callback。
  5. check:执行setImmediate回调
  6. close:执行close事件

这六个阶段好像漏掉了上面提到的一个Node中的微任务process.nextTick()process.nextTick()会先于其他的microtask执行

理解 process.nextTick() 您可能已经注意到 process.nextTick() 在关系图中没有显示,即使它是异步 API 的一部分。这是因为 process.nextTick() 在技术上不是事件循环的一部分。相反,无论事件循环的当前阶段如何,都将在当前操作完成后处理 nextTickQueue。这里的一个操作被视作为一个从 C++ 底层处理开始过渡,并且处理需要执行的 JavaScript 代码。

回顾我们的关系图,任何时候在给定的阶段中调用 process.nextTick(),所有传递到 process.nextTick() 的回调将在事件循环继续之前得到解决。这可能会造成一些糟糕的情况, 因为它允许您通过进行递归 process.nextTick() 来“饿死”您的 I/O 调用,阻止事件循环到达 轮询 阶段。

参考资料

  1. MDN Event Loop
  2. Node Event Loop