Skip to content

从Promise来看JavaScript中的Event Loop、Tasks和Microtasks #21

Open
@creeperyang

Description

@creeperyang
Owner

看到过下面这样一道题:

(function test() {
    setTimeout(function() {console.log(4)}, 0);
    new Promise(function executor(resolve) {
        console.log(1);
        for( var i=0 ; i<10000 ; i++ ) {
            i == 9999 && resolve();
        }
        console.log(2);
    }).then(function() {
        console.log(5);
    });
    console.log(3);
})()

为什么输出结果是1,2,3,5,4而非1,2,3,4,5

比较难回答,但我们可以首先说一说可以从输出结果反推出的结论:

  1. Promise.then是异步执行的,而创建Promise实例(executor)是同步执行的。
  2. setTimeout的异步和Promise.then的异步看起来 “不太一样” ——至少是不在同一个队列中。

相关规范摘录

在解答问题前,我们必须先去了解相关的知识。(这部分相当枯燥,想看结论的同学可以跳到最后即可。)

Promise/A+规范

要想找到原因,最自然的做法就是去看规范。我们首先去看看Promise的规范

摘录promise.then相关的部分如下:

promise.then(onFulfilled, onRejected)

2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code. [3.1].

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.

规范要求,onFulfilled必须在 执行上下文栈(execution context stack) 只包含 平台代码(platform code) 后才能执行。平台代码指 引擎,环境,Promise实现代码。实践上来说,这个要求保证了onFulfilled的异步执行(以全新的栈),在then被调用的这个事件循环之后。

规范的实现可以通过 macro-task 机制,比如setTimeoutsetImmediate,或者 micro-task 机制,比如MutationObserver或者process.nextTick。因为promise的实现被认为是平台代码,所以可以自己包涵一个task-scheduling队列或者trampoline

通过对规范的翻译和解读,我们可以确定的是promise.then是异步的,但它的实现又是平台相关的。要继续解答我们的疑问,必须理解下面几个概念:

  1. Event Loop,应该算是一个前置的概念,理解它才能理解浏览器的异步工作流程。
  2. macro-task 机制和 micro-task 机制,这组概念很新,之前根本没听过,但却是解决问题的核心。

Event Loop规范

HTML5规范里有Event loops这一章节(读起来比较晦涩,只关注相关部分即可)。

  1. 每个浏览器环境,至多有一个event loop。
  2. 一个event loop可以有1个或多个task queue。
  3. 一个task queue是一列有序的task,用来做以下工作:Events task,Parsing task, Callbacks task, Using a resource task, Reacting to DOM manipulation task等。

每个task都有自己相关的document,比如一个task在某个element的上下文中进入队列,那么它的document就是这个element的document。

每个task定义时都有一个task source,从同一个task source来的task必须放到同一个task queue,从不同源来的则被添加到不同队列。

每个(task source对应的)task queue都保证自己队列的先进先出的执行顺序,但event loop的每个turn,是由浏览器决定从哪个task source挑选task。这允许浏览器为不同的task source设置不同的优先级,比如为用户交互设置更高优先级来使用户感觉流畅。

Jobs and Job Queues规范

本来应该接着上面Event Loop的话题继续深入,讲macro-task和micro-task,但先不急,我们跳到ES2015规范,看看Jobs and Job Queues这一新增的概念,它有点类似于上面提到的task queue

一个Job Queue是一个先进先出的队列。一个ECMAScript实现必须至少包含以下两个Job Queue

Name Purpose
ScriptJobs Jobs that validate and evaluate ECMAScript Script and Module source text. See clauses 10 and 15.
PromiseJobs Jobs that are responses to the settlement of a Promise (see 25.4).

单个Job Queue中的PendingJob总是按序(先进先出)执行,但多个Job Queue可能会交错执行。

跟随PromiseJobs到25.4章节,可以看到PerformPromiseThen ( promise, onFulfilled, onRejected, resultCapability )

这里我们看到,promise.then的执行其实是向PromiseJobs添加Job。

event loop怎么处理tasks和microtasks?

好了,现在可以让我们真正来深入task(macro-task)和micro-task。

认真说,规范并没有包括macro-task 和 micro-task这部分概念的描述,但阅读一些大神的博文以及从规范相关概念推测,以下所提到的在我看来,是合理的解释。但是请看文章的同学辩证和批判地看。

首先,micro-task在ES2015规范中称为Job。 其次,macro-task代指task。

哇,所以我们可以结合前面的规范,来讲一讲Event Loop(事件循环)是怎么来处理task和microtask的了。

  1. 每个线程有自己的事件循环,所以每个web worker有自己的,所以它才可以独立执行。然而,所有同属一个origin的windows共享一个事件循环,所以它们可以同步交流。
  2. 事件循环不间断在跑,执行任何进入队列的task。
  3. 一个事件循环可以有多个task source,每个task source保证自己的任务列表的执行顺序,但由浏览器在(事件循环的)每轮中挑选某个task source的task。
  4. tasks are scheduled,所以浏览器可以从内部到JS/DOM,保证动作按序发生。在tasks之间,浏览器可能会render updates。从鼠标点击到事件回调需要schedule task,解析html,setTimeout这些都需要。
  5. microtasks are scheduled,经常是为需要直接在当前脚本执行完后立即发生的事,比如async某些动作但不必承担新开task的弊端。microtask queue在回调之后执行,只要没有其它JS在执行中,并且在每个task的结尾。microtask中添加的microtask也被添加到microtask queue的末尾并处理。microtask包括mutation observer callbackspromise callbacks

结论

定位到开头的题目,流程如下:

  1. 当前task运行,执行代码。首先setTimeout的callback被添加到tasks queue中;
  2. 实例化promise,输出 1; promise resolved;输出 2;
  3. promise.then的callback被添加到microtasks queue中;
  4. 输出 3;
  5. 已到当前task的end,执行microtasks,输出 5;
  6. 执行下一个task,输出4

Activity

heartAndRain

heartAndRain commented on Oct 31, 2016

@heartAndRain

有一个问题就是,I/O, UI render 究竟是不是当做一个task来执行,看有的文章说是,有的说是在每个task之间进行的

creeperyang

creeperyang commented on Oct 31, 2016

@creeperyang
OwnerAuthor

@cendylee 就我看到的资料来说,好像是在task之间进行的。如果有更详细可靠的资料进行补充或更正当然更好。

hyj1991

hyj1991 commented on Feb 8, 2017

@hyj1991

Promise的then原型方法注册的回调确实是在microtask中注册执行的,但是我很好奇,node实现的process.nextTick,看源码似乎并不是由microtask驱动的,为啥网上到处都说process.nextTick也是属于microtask的一部分呢?

creeperyang

creeperyang commented on Feb 13, 2017

@creeperyang
OwnerAuthor

@hyj1991 从源码来看,process.nextTick 属于 microtask。

https://github.com/nodejs/node/blob/v7.x/src/node.cc#L4381

inline int Start(Isolate* isolate, IsolateData* isolate_data,
                 int argc, const char* const* argv,
                 int exec_argc, const char* const* exec_argv) {

...
  {
    Environment::AsyncCallbackScope callback_scope(&env);
    LoadEnvironment(&env);
  }

https://github.com/nodejs/node/blob/v7.x/src/node.cc#L3406

void LoadEnvironment(Environment* env) {
...
  Local<String> script_name = FIXED_ONE_BYTE_STRING(env->isolate(),
                                                    "bootstrap_node.js");
  Local<Value> f_value = ExecuteString(env, MainSource(env), script_name);

https://github.com/nodejs/node/blob/v7.x/lib/internal/bootstrap_node.js#L12

function startup() {
...
NativeModule.require('internal/process/next_tick').setup();

https://github.com/nodejs/node/blob/v7.x/lib/internal/process/next_tick.js#L49

  function scheduleMicrotasks() {
    if (microtasksScheduled)
      return;

    nextTickQueue.push({
      callback: runMicrotasksCallback,
      domain: null
    });

    tickInfo[kLength]++;
    microtasksScheduled = true;
  }

  ...

  function nextTick(callback) {
    if (typeof callback !== 'function')
      throw new TypeError('callback is not a function');
    // on the way out, don't bother. it won't get fired anyway.
    if (process._exiting)
      return;

    var args;
    if (arguments.length > 1) {
      args = new Array(arguments.length - 1);
      for (var i = 1; i < arguments.length; i++)
        args[i - 1] = arguments[i];
    }

    nextTickQueue.push({
      callback,
      domain: process.domain || null,
      args
    });
    tickInfo[kLength]++;
  }

可以看到,安排microtask就是向nextTickQueue队列压入callback,而nextTick同样是向nextTickQueue队列压入callback,可见,process.nextTick 属于 microtask

20170629 更新代码路径

Ma63d

Ma63d commented on Mar 3, 2017

@Ma63d

很清晰的文章!
我也是有同样的疑惑:到底UI render是在哪执行的?
博主您给出的HTML规范链接里面的这部分内容我反复读了,但是Processing model的第5点Update the rendering之后说的 run the resize steps run the scroll steps这些步骤是什么意思呢?不可能每次microtask执行完就resize和scroll吧,所以请问博主你看懂这块了吗?

59 remaining items

xianshenglu

xianshenglu commented on May 26, 2018

@xianshenglu
watsonnnnn

watsonnnnn commented on Aug 31, 2018

@watsonnnnn

楼主能说下 tick和loop是啥区别吗?

wroy1005

wroy1005 commented on Oct 24, 2018

@wroy1005
<script type="text/javascript">
	setTimeout(function(){
		console.log(1);
	})
	Promise.resolve().then(function(){
		console.log(2);
	})
	console.log(3);
	document.write('<script type="text/javascript">console.log(4)<\/script>');
</script>
<script type="text/javascript">
	document.write('<script type="text/javascript">console.log(5)<\/script>');
	console.log(6)
</script>

结果是什么?

342561

AdvancedCat

AdvancedCat commented on Jun 12, 2019

@AdvancedCat

楼主好,根据最近一次的新闻,WHATWG全权接管了HTML和DOM的标准制定权(链接)。
您在博文中HTML5标准的引用就有点过时了。而且根据最新标准制定Task Queue不是一个队列,它是一个任务集合(A task queue is a set of tasks)。
与时俱进一下。

NotCoderJack

NotCoderJack commented on Oct 30, 2020

@NotCoderJack

单个Job Queue中的PendingJob总是按序(先进先出)执行,但多个Job Queue可能会交错执行。

终于解答了最近被一小段代码的困扰:

var c1 = (r) => {
    console.log(r)
}
var c2 = () => {
    console.log(3)
}

console.log('promise1')
var p3
var p1 = Promise.resolve()
.then(function() {
    console.log(0)
    p3 = Promise.resolve(6)
    return p3
})
.then(c1)


console.log('promise2')
var p2 = Promise.resolve()
.then(function() {
    console.log(1)
})
.then(function() {
    console.log(2)
})
.then(function(){
    console.log('before 3')
    c2()
    console.log('after 3')
}).then(function() {
    console.log(5)
})
/**
log:
promise1
promise2
0
1
2
6
before 3
3
after 3
5
*/

在低版本Node中,输出结果如上,而高版本我用的V12,则6会在after 3后面输出。

但是规范里并没有说,多个Job Queue如何调度(可能有交叉调度),这也导致没法正确推导代码输出结果。哪位有了解多个Job Queue的调度方式,欢迎告知或提供学习资料,感谢!

creeperyang

creeperyang commented on Mar 16, 2021

@creeperyang
OwnerAuthor

浏览器 event loop 简易归纳:

关于 taskQueue

  • 首先浏览器可以拥有不止一个 taskQueue。易于理解的一个解释是,不同 taskQueue 可以放不同类型的 task,比如处理鼠标/键盘事件的可以放一个 taskQueue,便于优先响应用户操作。(但不能饿死其它 taskQueue)。
  • taskQueue 不是 queue,而是 set。每次取最先可以运行的 task,而不是队列头部的 task。

关于运行流程(已简化)

  1. 取一个 taskQueue,并且 taskQueue 有 runnable 的 task(没有则跳到 microtask 那一步);
  2. 从 taskQueue 取出当前要运行的 task 并执行;
  3. 执行完检查 microtask queue 是否不为空,不为空则 dequeue microtask 执行,直到 microtask queue 为空;
  4. 执行 UI 更新(Update the rendering):
    • 理解"Rendering opportunities",比如 60HZ 刷新率下,那么1秒内最多有 60次 Rendering opportunities;如果浏览器没法满足60Hz,那么可能就会降级到30Hz(30次opportunities)而不是降几次;如果浏览器tab页不可见,那么甚至会降到4Hz。
    • rendering 的顺序:处理autofocus-->resize-->scroll-->media query-->update animation-->fullscreen-->animation frame callback --> intersection observation --> mark painting timing
  5. 更新完UI后,看看是不是有机会触发 Idle Callback。

一些重要的执行顺序

Promise --> requestAnimationFrame --> Paint --> requestIdleCallback

一个有意思的实例

Vue.nextTick 为什么能保证在DOM更新后执行? 具体情况见异步更新队列

  1. watcher 代码来看,vue 的数据绑定机制可以监听 vm 数据的更新,但不会同步更新DOM,而是内部调用 queueWatcher,把数据对应的 watcher 塞入队列,并调用 nextTick(仅一次,保证队列后面会被处理,即更新DOM,而后面vm再变动,只是塞入队列);
  2. 所以vm的更新也是调用 Vue.nextTick ,都是通过 Promise 或其它API 生成 microtask;
  3. 因为microtask 队列的先后关系,vm 的更新导致的 DOM 更新被先处理,所以可以通过 Vue.nextTick 保证访问到最新的 DOM 状态;
  4. 最后明确:DOM 更新,和屏幕上的UI更新不是一个概念。DOM 更新可以理解为 JS 的执行,真正的 Paint 时机仍然是浏览器按规则执行——最终符合上一节的执行顺序。
fashen007

fashen007 commented on Mar 24, 2021

@fashen007

image
image
这两个执行结果bing'bu'y并不一样,大佬能解释一下原理吗

creeperyang

creeperyang commented on Mar 25, 2021

@creeperyang
OwnerAuthor

@spademan 我实际测了下,直接btn.click() 和 页面点击按钮输出不一样。

image

可以看到,btn.click() 时,两个 handler 显然合并在同一个 event loop 执行了;而在页面里点击按钮则不一样。这个是浏览器自身实现细节,我没有查到相关文档,估计要去看相关代码才能知道原因了。

我的猜测是:用户点击,是属于用户交互,浏览器认为在UI上需要立即反馈,于是每处理完一个handler,立即更新页面UI。(看看就行,别当真 😸 有官方解释可告知我)

zlv2s

zlv2s commented on Mar 27, 2021

@zlv2s

@spademan 用户点击,事件是异步触发,点击之后

  1. Promise.resolve(), 然后往微任务队列里面放一个任务(log('M1'))
  2. 打印 'L1'
  3. 这时候 call stack 是空闲的,会执行微任务队列里的任务,打印 'M1'
  4. 第二个事件处理程序也是同样如此

用程序触发 click, 事件是同步触发

  1. btn.click() 进入 call stack
  2. Promise.resolve(), 然后往微任务队列里面放一个任务
  3. 打印 'L1'
  4. 此时,call stack 并没有空闲,click 函数还在里面,所以不会执行微任务队列里的任务
  5. 执行第二个事件处理程序,Promise.resolve(),然后往微任务队列里面放一个任务(log('M2'))
  6. 打印 'L2'
  7. 事件处理程序结束,click 函数移出 call stack
  8. 开始执行微任务队列里的任务,打印 'M1', 'M2'

不晓得对不对。。。。

fashen007

fashen007 commented on Apr 1, 2021

@fashen007

image
image
这两个执行结果bing'bu'y并不一样,大佬能解释一下原理吗
@creeperyang @zlv2s 找到一个相对深入一点的解释
https://stackoverflow.com/questions/55709512/why-is-there-a-difference-in-the-task-microtask-execution-order-when-a-button-is

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @googya@1msoft@mrhack@peze@titanew

        Issue actions

          从Promise来看JavaScript中的Event Loop、Tasks和Microtasks · Issue #21 · creeperyang/blog