任务、微任务、队列以及调度

原文地址(英):https://jakearchibald.com/201...

当我告诉Matt Gaunt(作者的同事),我正在谋划写一篇关于在浏览器事件循环(event loop)体系中微任务( microtask )的队列和执行的文章时,他说:“实话告诉你Jake,我对这篇文章是不会感兴趣的”。好吧,不管怎样,既然我已经写了那就让我们坐下来好好享受它,好吗?

事实上,如果视频更符合你的胃口,那么Philips Roberts 在JSConf上关于event loop的演讲会是很好的参考(该演讲不涉及微任务(microtask),但是对事件循环的其他部分都讲得非常好),闲话少说,开始我们的内容。

    以下是一小段JavaScript:
    
    console.log('script start');
    
    setTimeout(function() {
      console.log('setTimeout');
    }, 0);
    
    Promise.resolve().then(function() {
      console.log('promise1');
    }).then(function() {
      console.log('promise2');
    });
    
    console.log('script end');

想想看控制台会按照什么样的顺序打印结果呢?

正确的答案是:script start, script end, promise1, promise2, setTimeout,但是在不同的浏览器上结果可能会有所不同。

Microsoft Edge, Firefox 40, iOS Safari和桌面版Safari 8.0.8会在promise1,promise2之前打印出setTimeout,虽然这可能是浏览器厂商间各自竞争的结果,但这未免有些奇怪,因为Firefox 39和Safari 8.0.7得到的结果始终是正确的。

为什么会是这样

为了搞清楚缘由,你需要明白事件循环(event loop)是如何处理任务(tasks)和微任务(microtasks)的.当这些名词第一次出现的时候,你可能会感到头疼,没关系,深呼吸...

每一个"线程"都拥有属于自己的事件循环(event loop),也就意味着每一个web worker都会存在自有的事件循环并独立运行互不干扰。然而所有同源窗口之间共享一个事件循环(event loop),这样它们就可以同步通信了(译者注:根据HTML5.2规范,事件循环分两种,一种是浏览器上下文的,一种是web worker的)。事件循环(event loop)总是不断的运行,执行队列中的任务(task)。一个事件循环存在多个任务源,这确保了任务在特定任务源的执行顺序(译者注:同一个任务源的任务将被添加到相同任务队列,不同任务源的任务可能被添加到不同任务队列),但是在每一次的循环中,浏览器会自主选择哪个源的任务优先执行,这确保了一些性能敏感的任务的优先级,比如用户输入。

任务(tasks,译者注:也叫macro-task)被放到任务源中,浏览器内部执行转移到JavaScript/DOM领域,并且确保这些 tasks按序执行。在tasks执行期间,浏览器可能更新渲染。来自鼠标点击的事件回调需要安排一个task,解析HTML和setTimeout同样需要。

setTimeout等待了给定的延迟时间之后就会为它的回调创建一个新的任务。这就是为什么setTimeout在script end之后打印script start,因为script end是归属于第一个任务,而setTimeout对应的是另一个任务,至此,我们快要搞清楚了,我需要你们有足够的耐心看完下一个部分

微任务(Microtasks)队列通常用于存放一些任务,这些任务应该在正在执行的脚本之后立即执行,比如对一批动作作出反应,或者操作异步执行避免创建整个新任务造成的性能浪费。每次事件循环中,如果没有其他JavaScript运行并且任务(task)都执行完毕了,那么微任务就会在回调之后被执行。在微任务中排队的任何其他微任务将被添加到队列的末尾并进行处理。微任务包括 MutationObserverPromise的回调(译者注:微任务包括:process.nextTick(Nodejs), Promises, Object.observe, MutationObserver;任务(tasks)包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering)。

一个settled状态的promise(直接调用resolve或者reject)或者已经变成settled状态(异步请求被settled)的promise,会立刻将它的callback(then)放到microtask队列里面。这就能保证promise的回调是异步的,即便promise已经变为settled状态。因此一个已settledpromise调用.then(yey,nay)时将立即把一个microtask任务加入microtasks任务队列。这就是为什么 promise1promise2script end 之后打印,因为正在运行的代码必须在处理 microtasks 之前完成。promise1promise2setTimeout 之前打印,因为 microtasks 总是在下一个 task 之前执行。

让我们一步一步分析,(译者注:跳转到原文step by step示例,这对理解本文非常有用

为什么在一些浏览器上的结果会有不同呢?

一些浏览器打印出来的结果是:script startscript endsetTimeoutpromise1promise2。这些浏览器在promise回调之前调用了setTimeout。这很可能是浏览器把promise回调当做是新任务(task )的一部分而不是微任务(microtask)。

这种错误某种程度上是可以被原谅的,因为promises规范来源于ECMAScript而不是HTML。ECMAScript定义了类似微任务的“jobs”概念,但是除了一些模糊的邮件讨论之外,这种关系(jobs和microtasks)并不明确。但promises应该作为微任务的一部分这是普遍的共识。

把promise当做是任务将会导致一些性能问题,回调可能没有必要因为某些相关任务(比如渲染)而被延迟。由于与其他任务源的交互这也会导致一些不确定性,并且会中断与其他Api的交互。

把promise归类为微任务已经是很急迫的事情了。Webkit(Safari内核)一直都在做正确的事情,我想Safari最终会解决这和问题,事实上,Firefox43已经修复了这个问题。

真正有趣的是,Safari和Firefox在这里都经历了一次回归,从那以后问题就被修复了。我想知道这是不是一个巧合。

译者注

  1. task -> microtask -> ui render
  2. 对于promise而言,决议后(resolve或reject)才会把then回调推入microtaks队列

参考

  1. 从Promise来看JavaScript中的Event Loop、Tasks和Microtasks
阅读 2.1k

推荐阅读