New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从一道题浅说 JavaScript 的事件循环 #61

Open
dwqs opened this Issue Feb 19, 2018 · 22 comments

Comments

Projects
None yet
@dwqs
Owner

dwqs commented Feb 19, 2018

阮老师在其推特上放了一道题:

new Promise(resolve => {
    resolve(1);
    Promise.resolve().then(() => console.log(2));
    console.log(4)
}).then(t => console.log(t));
console.log(3);

看到此处的你可以先猜测下其答案,然后再在浏览器的控制台运行这段代码,看看运行结果是否和你的猜测一致。

事件循环

众所周知,JavaScript 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。根据 HTML 规范

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.

为了协调事件、用户交互、脚本、UI 渲染和网络处理等行为,防止主线程的不阻塞,Event Loop 的方案应用而生。Event Loop 包含两类:一类是基于 Browsing Context,一种是基于 Worker。二者的运行是独立的,也就是说,每一个 JavaScript 运行的"线程环境"都有一个独立的 Event Loop,每一个 Web Worker 也有一个独立的 Event Loop。

本文所涉及到的事件循环是基于 Browsing Context。

那么在事件循环机制中,又通过什么方式进行函数调用或者任务的调度呢?

任务队列

根据规范,事件循环是通过任务队列的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。

在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务处理模型是比较复杂的,但关键步骤如下:

  • 在此次 tick 中选择最先进入队列的任务(oldest task),如果有则执行(一次)
  • 检查是否存在 Microtasks,如果存在则不停地执行,直至清空 Microtasks Queue
  • 更新 render
  • 主线程重复执行上述步骤

仔细查阅规范可知,异步任务可分为 taskmicrotask 两类,不同的API注册的异步任务会依次进入自身对应的队列中,然后等待 Event Loop 将它们依次压入执行栈中执行。

查阅了网上比较多关于事件循环介绍的文章,均会提到 macrotask(宏任务) 和 microtask(微任务) 两个概念,但规范中并没有提到 macrotask,因而一个比较合理的解释是 task 即为其它文章中的 macrotask。另外在 ES2015 规范中称为 microtask 又被称为 Job。

(macro)task主要包含:script(整体代码)、setTimeout、setInterval、I/O、UI交互事件、postMessage、MessageChannel、setImmediate(Node.js 环境)

microtask主要包含:Promise.then、MutaionObserver、process.nextTick(Node.js 环境)

在 Node 中,会优先清空 next tick queue,即通过process.nextTick 注册的函数,再清空 other queue,常见的如Promise;此外,timers(setTimeout/setInterval) 会优先于 setImmediate 执行,因为前者在 timer 阶段执行,后者在 check 阶段执行。

多谢 @yaodingyd 纠正:requestAnimationFrame 既不属于 macrotask, 也不属于 microtask: https://stackoverflow.com/questions/43050448/when-will-requestanimationframe-be-executed

setTimeout/Promise 等API便是任务源,而进入任务队列的是他们指定的具体执行任务。来自不同任务源的任务会进入到不同的任务队列。其中setTimeout与setInterval是同源的。

event loop

示例

纯文字表述确实有点干涩,这一节通过一个示例来逐步理解:

console.log('script start');

setTimeout(function() {
  console.log('timeout1');
}, 10);

new Promise(resolve => {
    console.log('promise1');
    resolve();
    setTimeout(() => console.log('timeout2'), 10);
}).then(function() {
    console.log('then1')
})

console.log('script end');

首先,事件循环从宏任务(macrotask)队列开始,这个时候,宏任务队列中,只有一个script(整体代码)任务;当遇到任务源(task source)时,则会先分发任务到对应的任务队列中去。所以,上面例子的第一步执行如下图所示:

step1

然后遇到了 console 语句,直接输出 script start。输出之后,script 任务继续往下执行,遇到 setTimeout,其作为一个宏任务源,则会先将其任务分发到对应的队列中:

step2

script 任务继续往下执行,遇到 Promise 实例。Promise 构造函数中的第一个参数,是在 new 的时候执行,构造函数执行时,里面的参数进入执行栈执行;而后续的 .then 则会被分发到 microtask 的 Promise 队列中去。所以会先输出 promise1,然后执行 resolve,将 then1 分配到对应队列。

构造函数继续往下执行,又碰到 setTimeout,然后将对应的任务分配到对应队列:

step3

script任务继续往下执行,最后只有一句输出了 script end,至此,全局任务就执行完毕了。

根据上述,每次执行完一个宏任务之后,会去检查是否存在 Microtasks;如果有,则执行 Microtasks 直至清空 Microtask Queue。

因而在script任务执行完毕之后,开始查找清空微任务队列。此时,微任务中,只有 Promise 队列中的一个任务 then1,因此直接执行就行了,执行结果输出 then1。当所有的 microtast 执行完毕之后,表示第一轮的循环就结束了。

step4

这个时候就得开始第二轮的循环。第二轮循环仍然从宏任务 macrotask开始。此时,有两个宏任务:timeout1timeout2

取出 timeout1 执行,输出 timeout1。此时微任务队列中已经没有可执行的任务了,直接开始第三轮循环:

step5

第三轮循环依旧从宏任务队列开始。此时宏任务中只有一个 timeout2,取出直接输出即可。

这个时候宏任务队列与微任务队列中都没有任务了,所以代码就不会再输出其他东西了。那么例子的输出结果就显而易见:

script start
promise1
script end
then1
timeout1
timeout2

总结

在回头看本文最初的题目:

new Promise(resolve => {
    resolve(1);
    
    Promise.resolve().then(() => {
    	// t2
    	console.log(2)
    });
    console.log(4)
}).then(t => {
	// t1
	console.log(t)
});
console.log(3);

这段代码的流程大致如下:

  1. script 任务先运行。首先遇到 Promise 实例,构造函数首先执行,所以首先输出了 4。此时 microtask 的任务有 t2t1
  2. script 任务继续运行,输出 3。至此,第一个宏任务执行完成。
  3. 执行所有的微任务,先后取出 t2t1,分别输出 2 和 1
  4. 代码执行完毕

综上,上述代码的输出是:4321

为什么 t2 会先执行呢?理由如下:

实践中要确保 onFulfilled 和 onRejected 方法异步执行,且应该在 then 方法被调用的那一轮事件循环之后的新执行栈中执行

  • Promise.resolve 方法允许调用时不带参数,直接返回一个resolved 状态的 Promise 对象。立即 resolvedPromise 对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。

http://es6.ruanyifeng.com/#docs/promise#Promise-resolve

所以,t2t1 会先进入 microtask 的 Promise 队列。

相关链接

@zhongdeming428

This comment has been minimized.

zhongdeming428 commented Feb 20, 2018

笔记总结

微任务与宏任务

  • 宏任务主要包含:script(整体代码)、setTimeout、setInterval、I/O、UI交互事件、setImmediate(Node.js 环境)

  • 微任务主要包含:Promise、MutaionObserver、process.nextTick(Node.js 环境)

我们的JavaScript的执行过程是单线程的,所有的任务可以看做存放在两个队列中——执行队列和事件队列。

执行队列里面是所有同步代码的任务,事件队列里面是所有异步代码的宏任务,而我们的微任务,是处在两个队列之间。

当JavaScript执行时,优先执行完所有同步代码,遇到对应的异步代码,就会根据其任务类型存到对应队列(宏任务放入事件队列,微任务放入执行队列之后,事件队列之前);当执行完同步代码之后,就会执行位于执行队列和事件队列之间的微任务,然后再执行事件队列中的宏任务。

实例

console.log('script start');

setTimeout(function() {
  console.log('timeout1');
}, 10);

new Promise(resolve => {
    console.log('promise1');
    resolve();
    setTimeout(() => console.log('timeout2'), 10);
}).then(function() {
    console.log('then1')
})

console.log('script end');

以上代码来源于博主的该篇博客,比较具有代表性,因为包含了宏任务、微任务、同步代码。在我们分析输出时,可以给所有的任务划分归类,结果如下:

console.log('script start');   //同步输出——1

setTimeout(function() {
  console.log('timeout1');   //异步宏任务,推入事件队列——5
}, 10);

new Promise(resolve => {
    console.log('promise1');   //同步输出——2
    resolve();   //同步执行 
    setTimeout(() => console.log('timeout2'), 10);   //异步宏任务,推入事件队列——6
}).then(function() {
    console.log('then1')     //异步微任务, 在执行队列之后,事件队列之前执行——4
})

console.log('script end');   //同步输出——3

结尾

这是我在阅读博主的博文之后,根据自己的想法理解所写出来的,难免存在错误,敬请大家指出!

@woshixiaoqianbi

This comment has been minimized.

woshixiaoqianbi commented Feb 22, 2018

试了下,setTimout和setImmediate却不是同源的,

new Promise(()=>{
	setTimeout(()=>{
		console.log(1);
	});
	setImmediate(()=>{
		console.log(2);
	})
	resolve(1);
}).then(()=>{
	setTimeout(()=>{
		console.log(3);
	});
	setImmediate(()=>{
		console.log(4);
	})
}).then(()=>{
	setTimeout(()=>{
		console.log(5);
	});
	setImmediate(()=>{
		console.log(6);
	})
})

setImmediate和setInterval有自己的任务队列

@harry-liu

This comment has been minimized.

harry-liu commented Feb 22, 2018

console.log('script start');

setTimeout(function() {
  console.log('timeout1');
  new Promise(resolve => {
        console.log('promise2');
        resolve();
    }).then(function() {
        console.log('then2')
    })
}, 10);

new Promise(resolve => {
    console.log('promise1');
    setTimeout(() => console.log('timeout2'), 10);
    resolve();
}).then(function() {
    console.log('then1')
})

console.log('script end');

感谢博主
稍微再改变一下,感觉理解更好一些

@njleonzhang

This comment has been minimized.

njleonzhang commented Feb 22, 2018

Promise.resolve 方法允许调用时不带参数,直接返回一个resolved 状态的 Promise 对象。立即 resolved 的 Promise 对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。

new Promise(resolve => {
    resolve(1);
    
    Promise.resolve(1).then(() => { // 结果并不会不一样啊
    	// t2
    	console.log(2)
    });
    console.log(4)
}).then(t => {
	// t1
	console.log(t)
});
console.log(3);

按你这个解释似乎有点矛盾。不过感觉上就是t2的then的代码先执行到了, 所以t2的then被先加入了队列。😂

@dwqs

This comment has been minimized.

Owner

dwqs commented Feb 22, 2018

@njleonzhang 你看下阮老师对 Promise.resolve 的表述:

如果参数是个非 thenable 对象或者不是一个对象,也是返回一个 resolved 状态的 Promise

示例如下:

let thenable = {
  then: function(resolve, reject) {
    resolve(42);
  }
};
new Promise(resolve => {
    resolve(1);
    
    Promise.resolve(thenable).then((t) => { 
    	// t2
    	console.log(t)
    });
    console.log(4)
}).then(t => {
	// t1
	console.log(t)
});
console.log(3);

//输出 4 3 1 42

@njleonzhang

This comment has been minimized.

njleonzhang commented Feb 22, 2018

@dwqs 学到了。

@dwqs

This comment has been minimized.

Owner

dwqs commented Mar 12, 2018

自己动手实现 ES6 Promise 一文可以看到部分相关实现:

// ...
// 根据 x 值,解析 promise 状态
 resolveProcedure(promise, x)function resolveProcedure({ resolve, reject, promise2 }, x) {
    // 2.3.1 If promise and x refer to the same object, reject promise with a TypeError as the reason.
    if (promise2 === x) {
        reject(new TypeError(x));
    }

    if (x instanceof ES6Promise) {    // 2.3.2 If x is a promise, adopt its state
        x.then(value => resolveProcedure({resolve, reject, promise2}, value), reason => reject(reason));
    } else if ((typeof x === 'object' && x !== null) || (typeof x === 'function')) {  // 2.3.3 
        let resolvedOrRejected = false;
        try {
            let then = x.then;      // 2.3.3.1 Let then be x.then
            if (typeof then === 'function') {   // 2.3.3 If then is a function, call it with x as this, first argument resolvePromise, and second argument rejectPromise, where:
                then.call(x, value => {
                    if (!resolvedOrRejected) {
                        resolveProcedure({ resolve, reject, promise2 }, value); // 2.3.3.3.1 If/when resolvePromise is called with a value y, run [[Resolve]](promise, y).
                        resolvedOrRejected = true;
                    }
                    // 2.3.3.3.3 If both resolvePromise and rejectPromise are called, or multiple calls to the same argument are made, the first call takes precedence, and any further calls are ignored.
                }, reason => {
                    if (!resolvedOrRejected) {
                        reject(reason);             // 2.3.3.3.2 If/when rejectPromise is called with a reason r, reject promise with r.
                        resolvedOrRejected = true;
                    }
                    // 2.3.3.3.3 If both resolvePromise and rejectPromise are called, or multiple calls to the same argument are made, the first call takes precedence, and any further calls are ignored.
                });
            } else {                // 2.3.3.4 If then is not a function, fulfill promise with x.
                resolve(x);
            }
        } catch (e) {
            if (!resolvedOrRejected) {
                // 2.3.3.2 If retrieving the property x.then results in a thrown exception e, reject promise with e as the reason.
                // 2.3.3.4 If calling then throws an exception e
                reject(e);
            }
        }
    } else {
        resolve(x);     // 2.3.4 If x is not an object or function, fulfill promise with x.
    }
}
// ...

@njleonzhang

@yaodingyd

This comment has been minimized.

yaodingyd commented Mar 16, 2018

requestAnimationFrame不属于task,它是浏览器渲染过程的一步,和task/microtask的执行是分离的

@dwqs

This comment has been minimized.

Owner

dwqs commented Mar 17, 2018

已修改 @yaodingyd

@sideFlower

This comment has been minimized.

sideFlower commented Mar 22, 2018

楼上的这个

console.log('script start');

setTimeout(function() {
console.log('timeout1');
new Promise(resolve => {
console.log('promise2');
resolve();
}).then(function() {
console.log('then2')
})
}, 10);

new Promise(resolve => {
console.log('promise1');
setTimeout(() => console.log('timeout2'), 10);
resolve();
}).then(function() {
console.log('then1')
})

console.log('script end');

在浏览器中执行的结果 和在node环境中执行的结果,并不一致。 有大佬解释下吗

@sunyongjian

This comment has been minimized.

sunyongjian commented Mar 29, 2018

@sideFlower 浏览器和 Node.js 的事件循环机制是有区别的。单这道题来说,区别在于浏览器是把 macro task 1, 2 加入队列,挨个执行,并把 macro 中的 micro 执行。也就是 timer1(macro) 先执行,其中的 promise then(micro) 再执行,完毕后再跑 timer2。而 node 中的 micro task 是在 node 事件循环的各个阶段之间执行,也就是主线程跑完后,会把 micro 池中的事件清空。然后是 timers 阶段,也就是定时器这种,按照加入事件池的先后顺序执行,此时 micro task 加入新的 promise.then,直到所有的定时器执行完,才会从 micro task 取出任务,挨个执行,所以才会最后调用 then2。

这是从这道题来说的不同,具体你可以从网上搜一下 浏览器和 node 环境事件循环的不同。

@Huooo

This comment has been minimized.

Huooo commented May 20, 2018

非常感谢这篇文章的详解以及讨论,学习到了Event Loop的很多细节。但是还有一处不明,还请帮忙解释一下。非常感谢你的时间。

// example 1

new Promise(resolve => {
    resolve(1)
    Promise.resolve().then(() => console.log(42)) // then1
    console.log(4)
}).then(t => console.log(t)) // then2
console.log(3)

// 输出结果依此为:4, 3, 42, 1
// example 2

let thenable = {
  then: (resolve, reject) => {
    resolve(42)
  }
}
new Promise(resolve => {
    resolve(1)
    Promise.resolve(thenable).then((t) => console.log(t)) // then1
    console.log(4)
}).then(t => console.log(t)) // then2
console.log(3)

// 输出结果依此为:4, 3, 1, 42

问题是:不明白在Promise.resolve()的参数是“无参数”和“thenable对象”时,没搞明白Event Loop中这两者的执行顺序??

  1. example 1是阮一峰老师在Promise.resolve()的四种参数情况中的“无参数”,直接返回一个resolved状态的 Promise 对象,此时resolve的 Promise 对象是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。我理解到的是执行到‘Promise.resolve().then(() => console.log(42))’的回调函数then()被立即放入Microtask Queue中记为then1,然后继续执行外层回调函数then(),放入Microtask Queue中记为then2,所以then1优先于then2执行,先输出42后输出1
  2. example 1是传入了“thenable对象”,那么按照作者文章解释的,此时‘Promise.resolve(thenable).then((t) => console.log(t))’的回调函数也应该是放入Microtask Queue中记为then1,外层的回调函数then(),放入Microtask Queue中记为then2,所以我认为的then1优先于then2执行,先输出42后输出1,但是实际上输出结果是1, 42
    上面的讨论感觉并没有解答我真正的疑惑,所以还请解释详细一些,非常感谢。 @dwqs
@dwqs

This comment has been minimized.

Owner

dwqs commented May 21, 2018

@Huooo 第二个问题 你可以看下上面「部分相关实现」我贴出的代码

@Huooo

This comment has been minimized.

Huooo commented May 22, 2018

Event Loop的理解有点磕磕绊绊的,主要担心自己理解的不是正确的,但是关于Promise.resolve()源码的部分已经看明白了,非常感谢 @dwqs

@WangBiaoxuan

This comment has been minimized.

WangBiaoxuan commented Jun 9, 2018

@dwqs 怎么MessageChannel既在microTask又在macTask中呀?

@dwqs

This comment has been minimized.

Owner

dwqs commented Jun 9, 2018

@WangBiaoxuan 已纠正 谢谢指出

@fi3ework

This comment has been minimized.

fi3ework commented Jul 21, 2018

MessageChannel 是属于 microTask 啊

@fi3ework

This comment has been minimized.

fi3ework commented Jul 25, 2018

另外

new Promise(resolve => {
    resolve(1);
    Promise.resolve().then(() => console.log(2));
    console.log(4)
}).then(t => console.log(t));
console.log(3);

之所以 2 比 1 要早,是因为 then(() => console.log(2)); 的执行在 .then(t => console.log(t)); console.log(3); 之前,所以在 Promise 的实现中,输出 2 对应的事件更早的注册到了 microtask queue 中。

@sfsoul

This comment has been minimized.

sfsoul commented Aug 6, 2018

@fi3ework hey,man!you are right

@nicewahson

This comment has been minimized.

nicewahson commented Aug 31, 2018

tick有些疑问,什么叫一次tick?如果一次循环叫一次tick,那为什么不直接叫nextLoop呢?一次循环指的是清空队列,还是取队列里的一个event并执行?

@qingtianiii

This comment has been minimized.

qingtianiii commented Oct 20, 2018

非常感谢!!讲的很详细,大致都明白了!

不过我还有一点疑惑,希望大佬能解答一下

setTimeout(() => {
  console.log(0)
})

new Promise(resolve => {
  resolve(1)
  Promise.resolve().then(t => {
    console.log(2)
    Promise.resolve().then(t => {
      console.log(4)
    })
  })
  console.log(3)
})
.then(t => {
  console.log(t)
})

console.log(5)

输出顺序是 3 5 2 1 4 0,为什么4会比1先输出?不是说 Promise.resolve().then 会更先执行吗?
@dwqs

@cuiyongjian

This comment has been minimized.

cuiyongjian commented Nov 4, 2018

@qingtianiii 看 then的注册顺序:首先注册的是 console.log(2) 这个then,接下来注册 console.log(t) 这个then,所以先输出2 (在2执行时又注册了一个then),再输出1. 1输出结束之后,发现microtask队列还有一个新的task,则执行它输出 4.

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