孙世吉

10 天前

我与Microtasks的前世今生之一眼望穿千年

本文作者:IMWeb 孙世吉 原文出处:IMWeb社区 未经同意,禁止转载

本文有标题党之嫌,内含大量Microtaks相关总结性信息,请谨慎服用。

Google Developer Day China 2018 by Jake Archibald

2018年9月21日,虽然没有参加该场GDD,但是也有幸拜读了百度@小蘑菇小哥总结的文章深入浏览器的事件循环(GDD@2018),配注的说明插图形象生动,文终的click代码也很有意思,推荐大家阅读。这里就先恬不知耻的将该文的精华以及一些自己的总结陈列如下:

异步任务 特点 常见产生处
Tasks (Macrotasks) - 当次事件循环执行队列内的一个任务
- 当次事件循环产生的新任务会在指定时机加入任务队列等待执行
- setTimeout
- setInterval
- setImmediate
- I/O
Animation callbacks - 渲染过程(Structure-Layout-Paint)前执行
- 当次事件循环执行队列里的所有任务
- 当次事件循环产生的新任务会在下一次循环执行
- rAF
Microtasks - 当次事件循环的结尾立即执行的任务
- 当次事件循环执行队列里的所有任务
- 当次事件循环产生的新任务会立即执行
- Promise
- Object.observe
- MutationObserver
- process.nextTick

直观的感受一下Macrotasks和Microtasks

看过一篇公众号文章下面的留言:

那个所谓的mtask和task的区别我并不认同...,我认为事件对列只有一个,就是task。

特别是对于JS异步编程思维还不太熟悉的同学,比如两年前从java转成javascript后的我,对于这种异步的调用顺序其实很难理解。

不过有一个特别能说明Macrotasks和Microtasks的例子:

// 普通的递归, 造成死循环, 页面无响应
function callback() {
    console.log('callback');
    callback();
}
callback();

上面的代码相信大家非常好理解,一个很简单的递归,由于事件循环得不到释放,UI渲染无法进行导致页面无响应。

通常我们可以使用setTimeout来进行改造,我们把下一次执行放到异步队列里面,不会持久的占用计算资源,这就是我们说的Macrotasks:

// Macrotasks,不会造成死循环
function callback() {
  console.log('callback');
  setTimeout(callback,0);
}

callback();

但是Promise回调产生的Microtasks呢,如下代码,同样会造成死循环。

通过上文我们也可以知道当次事件循环产生的新Microtasks会立即执行,同时当次事件循环要等到所有Microtasks队列执行完毕后才会结束。所以当我们的Microtasks在产生新的任务的同时,会导致Microtasks队列一直有任务等待执行,这次事件循环永远不会退出,也就导致了我们的死循环。

// Microtasks,同样会造成死循环,页面无响应
function callback() {
  console.log('callback');
  Promise.resolve().then(callback);
}
callback();

Microtasks 与 Promise A+

当然,上文解决了本人关于Microtasks的相关疑虑 (特别是有人拿出一段参杂setTimeout和Promise的代码让你看代码输出顺序时) 的同时,也让我回忆起似乎曾几何时也在哪里看到过关于Microtask的字眼。

经过多日的寻找,终于在以前写过的一片关于Promise的总结文章 打开Promise的正确姿势 里找到了。该文通过一个实例说明了新建Promise的代码是会立即执行的,并不会放到异步队列里:

var d = new Date();

// 创建一个promise实例,该实例在2秒后进入fulfilled状态
var promise1 = new Promise(function (resolve,reject) {
  setTimeout(resolve,2000'resolve from promise 1');
});

// 创建一个promise实例,该实例在1秒后进入fulfilled状态
var promise2 = new Promise(function (resolve,reject) {
  setTimeout(resolve,1000,promise1); // resolve(promise1)
});

promise2.then(
  result => console.log('result:',result,new Date() - d),
  error => console.log('error:',error)
)

上面的代码输出

result: resolve from promise 1 2002

我们得到两点结论:

  • 验证了Promise/A+中的2.3.2规范
  • 新建Promise的代码时会立即执行的 (运行时间是2秒而不是3秒)

但是当时本人忽略了Promise/A+的相关注解内容:

Here “platform code” means engine,environment,and promise implementation code. In practice,this requirement ensures that onFulfilled and onRejected execute asynchronously,after the event loop turn in which then is called,and with a fresh stack. This can be implemented with either a “macro-task” mechanism such as setTimeout or setImmediate,or with a “micro-task” mechanism such as MutationObserver or process.nextTick. Since the promise implementation is considered platform code,it may itself contain a task-scheduling queue or “trampoline” in which the handlers are called.

是的,这就是本人与MicroTasks的第一次相遇,没有一见钟情还真是非常抱歉啊。

该注解说明了Promise的 onFulfilledonRejected 回调的执行只要确保是在 then被调用后异步执行就可以了。具体实现成 setTimeout 似的 macrotasks 机制或者 process.nextTick 似的microtasks机制都可以,具体视平台代码而定。

为什么需要Microtasks

搜索引擎能找到的相关文章基本都指向了一篇《Tasks,microtasks,queues and schedules》,也许这就是传说中原罪的发源之地吧。

Microtasks are usually scheduled for things that should happen straight after the currently executing script,such as reacting to a batch of actions,or to make something async without taking the penalty of a whole new task.

简单来说,就是希望对一系列的任务做出回应或者执行异步操作,但是又不想额外付出一整个异步任务的代价。在这种情况下,Microtasks就可以用来调度这些应当在当前执行脚本结束后立马执行的任务

The microtask queue is processed after callbacks as long as no other JavaScript is mid-execution,and at the end of each task. Any additional microtasks queued during microtasks are added to the end of the queue and also processed.

单独看Macrotasks和 Microtasks,执行顺序可以总结如下:

  • 取出Macrotasks任务队列的一个任务,执行;
  • 取出Microtasks任务队列的所有任务,依次执行;
  • 本次事件循环结束,等待下次事件循环;

从这个方面我们也可以理解为什么Promise.then要被实现成Microtasks,回调在实现Promise/A+规范 (必须是异步执行)的基础上,也保证能够更快的被执行,而不是跟Macrotasks一样必须等到下次事件循环才能执行。大家可以重新执行一下上文对比Macrotasks和Microtasks时举的例子,也会发现他们两的单位时间内的执行次数是不一样的。

可以试想一些综合了异步任务和同步任务的的Promise实例,Microtasks可以保证它们更快的得到执行资源,例如:

new Promise((resolve) => {
  if(/* 检查资源是否需要异步加载 */) {
    return asyncAction().then(resolve);
  }
  // 直接返回加载好的异步资源
  return syncResource;
});

如果上面的代码是为了加载远程的资源,那么只有第一次需要执行异步加载,后面的所有执行都可以直接同步读取缓存内容。如果使用Microtasks,我们也就不用每次都等待多一次的事件循环来获取该资源,Promise实例的新建过程是立即执行的,同时onFulfilled回调也是在本次事件循环中全部执行完毕的,减少了切换上下文的成本,提高了性能。

但是呢,从上文关于Promise/A+规范的引用中我们已经知道不同浏览器对于该实现是不一致的。部分浏览器 (越来越少) 将Promise的回调函数实现成了Macrotasks,原因就在于Promise的定义来自ECMAScript而不是HTML。

A Job is an abstract operation that initiates an ECMAScript computation when no other ECMAScript computation is currently in progress. A Job abstract operation may be defined to accept an arbitrary set of job parameters.

按照ECMAScript的规范,是没有Microtasks的相关定义的,类似的有一个jobs 的概念,和Microtasks很相似.

相关应用

Vue - src/core/utils/next-tick.js 中也有相关Macrotask和Microtask的实现

let microTimerFunc
let macroTimerFunc
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks,0)
  }
}
// Determine microtask defer implementation.
/* istanbul ignore next,$flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    // in problematic UIWebViews,Promise.then doesn't completely break,but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed,until the browser
    // needs to do some other work,e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}

推荐阅读

浏览器的 Event Loop

打开Promise的正确姿势

Promise/A+

Tasks,microtasks,queues and schedules

1条评论

    您需要 注册 一个IMWeb账号或者 才能进行评论。