阅读 815

浏览器和 node 中的 Event Loop

前言

众所周知,JavaScript 这门语言是单线程的。那也就是说,JavaScript 在同一时间只能做一件事,后面的事情必须等前面的事情做完之后才能得到执行。

任务队列

JavaScript 单线程这件事乍一看好像没毛病,代码本来就是需要按顺序执行的嘛,先来后到,后面的你就先等着。如果是计算量导致的排队,那没办法,老老实实排吧。但如果是因为 I/O 很慢(比如发一个 Ajax 请求,需要 200ms 才能返回结果),那这个等待时间就没太必要了,完全可以先执行后面其他的任务,等你请求的数据回来了再执行 Ajax 后面的操作嘛。

由此,JavaScript 中的任务分成了两种,第一种是同步任务,指的是在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;第二种是异步任务,指的是不进入主线程、而进入“任务队列”的任务,只有“任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

其执行过程如下:

  1. JavaScript 引擎运行 JavaScript 时,有一个主线程和一个任务队列。
  2. 同步任务跑在主线程上面,异步任务扔进任务队列中进行等待。
  3. 主线程中的任务执行完毕之后,回去看看任务队列中有没有异步任务到了需要触发的时机。如果有,那就开始执行异步任务。
  4. 重复的执行主线程的任务和轮询任务队列。

Event Loop

这种主线程不断地从任务队列中读取任务的机制称为 Event Loop(事件循环)。

在讲 Event Loop 之前,我们先来了解一下 macrotask(宏任务)和 microtask(微任务)。

宏任务

包括 setTimeoutsetIntervalsetImmediate (浏览器仅 IE10 支持)、I/OUI Rendering

微任务

包括 process.nextTicknode 独有)、PromiseObject.observe(已废弃)、MutatinObserver

这里多说一句,Promise 的执行函数(也就是 new Promise(fn) 中的 fn)是同步任务。

浏览器中的 Event Loop

Event Loop 的实现在浏览器和 node 中是不一样的,我们先看浏览器。

  1. 开始执行主线程的任务
  2. 主线程的任务执行完毕之后去检查 microtask 队列,将已经到了触发时机的任务放进主线程。
  3. 主线程开始执行任务
  4. 主线程的任务执行完毕之后去检查 macrotask 队列,将已经到了触发时机的任务放进主线程。
  5. 主线程开始执行任务
  6. 轮询 microtaskmicrotask

看一下例子

好,讲完了流程,来看下🌰。

console.log('script start'); // 同步任务

setTimeout(function() {
  console.log('setTimeout'); // 放入 宏任务 队列
}, 0);

new Promise((resolve, reject) => {
  console.log('promise'); // 同步任务
  resolve();
})
  .then(function() {
    console.log('promise1'); // 放进 微任务 队列
  })
  .then(function() {
    console.log('promise2'); // 放进 微任务 队列
  });
console.log('script end'); // 同步任务
复制代码

根据上面的标识,先执行同步任务,打印出 “script start” 、 “promise” 、 “script end”,然后开始检查 microtask 队列,打印出 “promise1” 和 “promise2”,然后去检查 macrotask 队列,打印出 “setTimeout”。

这里 setTimeout 虽然它的延迟时间为 0,但它是个宏任务,所以必须等同步任务和微任务执行完毕之后才轮到它。

在看一个🌰。

console.log('script start'); // 同步任务

async function async1() {
  await async2();
  console.log('async1 end'); // 这里就是 then 里面的代码,放入 微任务 队列
}
async function async2() {
  console.log('async2 end'); // 同步任务
}
async1();

setTimeout(function() {
  console.log('setTimeout'); // 放入 宏任务 队列
}, 0);

new Promise((resolve) => {
  console.log('Promise'); // 同步任务
  setTimeout(() => {
    console.log('setTimeout promise'); // 放入 宏任务 队列
    resolve();
  });
})
  .then(function() {
    console.log('promise1'); // 放入 微任务 队列
  })
  .then(function() {
    console.log('promise2'); // 放入 微任务 队列
  });

console.log('script end'); // 同步任务
复制代码

这里的 asyncawait 就是 Promise 的语法糖,要懂得转换,其实上述 asyncawait 代码等价于:

new Promise((resolve) => {
  new Promise((resolve) => {
    console.log('async2 end');
    resolve();
  });
  resolve();
}).then(() => {
  console.log('async1 end');
});
复制代码

所以执行顺序为

script start
async2 end
promise
script end
async1 end
setTimeout
setTimeout promise
promise1
promise2
复制代码

有人可能会有疑惑了,打印 “promise1” 和 “promise2” 是微任务,怎么还晚于 setTimeout 宏任务呢?

虽然它们是微任务,但是由于触发它们的 resolve() 处于 setTimeout 宏任务之中,所以它们其实是在第二轮微任务的轮询中被触发的。

好了,浏览器的 Event Loop 就说到这个,接下来讲一下 nodeEvent Loop

node 中的 Event Loop

node 中的 Event Loop 就比较复杂了,英语好的可以去看官方文档

引用官文档中的一张图,了解一下 Event Loop 的六个阶段。

每个阶段都有自己的任务队列。

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
复制代码

6 个阶段

  • timer:执行setTimeoutsetInterval中到期的 callback。

  • pending callback:上一轮循环中少数的 callback 会放在这一阶段执行。

  • idle, prepare:仅在内部使用。

  • poll:最重要的阶段,执行 pending callback,在适当的情况下回阻塞在这个阶段。

  • check:执行setImmediate(setImmediate()是将事件插入到事件队列尾部,主线程和事件队列的函数执行完成之后立即执行setImmediate指定的回调函数)的 callback。

  • close callbacks:执行 close 事件的 callback,例如socket.on('close'[,fn])或者http.server.on('close, fn)

我们重点关注 timerpollcheck 这三个阶段。

timer

这个阶段执行该阶段任务队列中 setTimeoutsetInterval 到期的回调,这两者需要设置一个时间。按规则来说,是到了设定的时间之后就应该执行回调,但在实际情况中,回调函数并不是一到设定的时间就能得到执行的,有可能被其他的任务阻塞了,需要等其他任务执行完成之后回调才能得到执行。

比如你设定一个 100ms 之后的 setTimeout A 回调,但是在 95ms 时执行了一个其他的 B 任务,需要耗时 10ms,那么在时间来到 100ms 的时候,B 任务还在执行当中,那么此时并不会立即执行 A 回调,而是会再等 5ms,等 B 回调完成之后,然后系统发现 A 回调的触发时机已经到了,那赶紧去执行 A 回调。也就是说在这种情况下,A 回调会在 105ms 的时间被执行。

poll

poll 阶段主要有两个事情要做:

  1. 执行 I/O 回调。
  2. 处理轮询队列中的任务。

当事件循环到达 poll 阶段时,会有下面两种情况:

  1. poll 队列不为空,那就开始执行队列中的任务,直到队列为空或者达到系统限制。
  2. poll 队列为空,那么这种情况又分两种情况;
    1. 如果 check 阶段有 setImmediate 任务需要执行,那么就立即结束当前阶段,转到 check 阶段执行该阶段队列中的回调。
    2. 如果 check 阶段没有 setImmediate 任务需要执行,那么此时会停留在 poll 阶段进行等待,等待有任务进到任务队列中进行执行。

在 2.2 的情况中,还会去检查 timer 阶段有没有任务到了执行时间,如果有,那么转入 timer 阶段执行队列中到期的任务。

check

此阶段会执行 setImmediate 回调,一旦此阶段的任务队列中有了 setImmediate 回调任务,且 poll 阶段的任务执行完了,处于空闲状态,那么就会立即转到 check 阶段执行此阶段任务队列中的任务。

转入此阶段的条件check 任务队列中有了任务,poll 阶段处于闲置状态,或者 poll 阶段等待超时。

setTimeout 和 setImmediate

这两者很相似,也有些不同。

  • setImmediate设计用于在当前poll阶段完成后 check 阶段执行脚本 。
  • setTimeout 安排在经过最小设定时间后运行的脚本,在timers阶段执行。

大部分时间 setImmediate 会比 setTimeout 先执行,但也有例外。比如下列代码:

setImmediate(() => {
  console.log('setImmediate');
});
setTimeout(() => {
  console.log('setTimeout');
});
复制代码

如果这两个任务是在 check 之后 timer 之前加入到各自阶段的任务队列中的,那么会先执行 setTimeout,其他情况会先执行 setImmediate

总的来说,setImmediate 在大部分的情况下会比 setTimeout 先执行。

process.nextTick

从技术上来说,process.nextTick 并不属于 Event Loop 的一部分,它会在每个阶段执行完毕转入下一个阶段的之前执行。如果有多个 process.nextTick 语句(不管它们是否嵌套),都会在当前阶段结束之后全部执行。

比如:

process.nextTick(function A() {
  console.log(1);
  process.nextTick(function B() {
    console.log(2);
  });
});

setTimeout(() => {
  console.log('setTimeout');
}, 0);
复制代码

这段代码会输出:“1 => 2 => setTimeout”

再来看下 setImmediate

setImmediate(function A() {
  console.log(1);
  setImmediate(function B() {
    console.log(2);
  });
});

setTimeout(() => {
  console.log('setTimeout');
}, 0);
复制代码

这段代码的总是在最后输出 2,说明 setImmediate 会将它里面的事件注册到下一个循环中。

由于 process.nextTick 里面的 process.nextTick 也会在当前阶段执行,那么如果 process.nextTick 发生了嵌套,那么就会产生无限循环,再也不会转入其他阶段。

process.nextTick(function foo() {
  process.nextTick(foo);
});
复制代码

promise

node 中的 promiseprocess.nextTick 都属于微任务,它也会在每个阶段执行完毕之后调用,但是它的优先级会比 process.nextTick 低。

参考

JavaScript 运行机制详解:再谈Event Loop

The Node.js Event Loop, Timers, and process.nextTick()

文章分类
前端
文章标签