JavaScript任务队列的顺序机制(事件循环)

同步与异步模式简介

我们知道,Javascript语言的执行环境是单线程(single thread)的。

所谓"单线程",就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务,以此类推。

这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。

为了解决这个问题,Javascript语言将任务的执行模式分成两种:同步(Synchronous)和异步(Asynchronous)。

同步模式就是后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的;

异步模式则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行队列上的后一个任务,而是执行回调函数;后一个任务则是不等前一个任务的回调函数的执行而执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。

"异步模式"非常重要。在浏览器端,耗时很长的操作都应该异步执行,避免浏览器失去响应,最好的例子就是Ajax操作。在服务器端,"异步模式"甚至是唯一的模式,因为执行环境是单线程的,如果允许同步执行所有http请求,服务器性能会急剧下降,很快就会失去响应。

异步任务队列

可能有人告诉你,Javascript内部存在着先进先出的异步任务队列,仅仅用以存储异步任务,与同步任务分开管理。进程执行完全部同步代码后,每当进程空闲、触发回调或定时器到达规定的时间,Javascript会从队列中顺序取出符合条件的异步任务并执行之。

我们简单验证一下,

var timeout1 = setTimeout(function() {
  console.log(2);
}, 0);

console.log(1);

var timeout2 =setTimeout(function() {
  console.log(3);
}, 0);

上面的代码我们都知道输出是,1 2 3,因为setTimeout是异步任务,而timeout1又比timeout2先注册,所以最终输出了这个结果。

然而,仅仅通过以上代码我们确定不了同步任务究竟是不是会优先于异步任务执行,因为setTimeout有一个最小的时间间隔限制,在这个时间间隔里语句console.log(1)完全可以执行完毕,我们要想办法让同步代码占用更长时间。

定时器最小时间间隔:在苹果机上的最小时间间隔是10ms,在Windows系统上的最小时间间隔大约是15ms。Firefox中定义的最小时间间隔是10ms,而HTML5规范中定义的最小时间间隔是4ms

再阅读下面代码,

setTimeout(function() {
  console.log(1);
}, 0);

console.log(2);

let end = Date.now() + 1000*5;

while (Date.now() < end) {
}

console.log(3);

end = Date.now() + 1000*5;

while (Date.now() < end) {
}

console.log(4);

输出顺序:2 3 4 1 从上面的输出结果我们可以确定,异步代码是在所有同步代码执行完毕以后才开始执行的。并且,两段代码的行为也没有跟我们上述的理解有对不上的地方。

那我们刚刚对js异步任务队列的理解方式是对的吗?底层机制会是这样的吗?

事实上,我们上述对于异步队列的理解和解释都是非常浅层和感性的(并且是错误的),虽然跟着上述的理解方式我们可以解释很多代码行为,但实际的机制却远没有这么简单,异步模式作为Javascript的重中之重,有很多设计细节是我们未知的,我们应当更加理性和学术地去探究学习。

再看一段比较复杂的代码,说出它的输出顺序:

setTimeout(function(){
    console.log(2);
},0);

new Promise(function(resolve){
    console.log(3);
    resolve();
    console.log(4);
}).then(function(){
    console.log(5);
});

console.log(6);

setTimeout(function(){
    console.log(7);
},0);

console.log(8);

你认为上述代码输出结果是什么呢?讲出理由。

以下为浏览器环境输出结果: 输出顺序为,3 4 6 8 5 2 7,跟你事先认为的结果一样吗?为什么结果会这样?

除了注册顺序以外,还有什么因素影响着每个异步任务在异步队列中的顺序呢?

我们先一起了解下事件循环任务队列两个概念,再回来解答这个问题。

线程、事件循环和任务队列

Javascript是单线程的,但是却能执行异步任务,这主要是因为 JS 中存在事件循环(Event Loop)和任务队列(Task Queue)。

事件循环:JS 会创建一个类似于 while (true) 的循环,每执行一次循环体的过程称之为Tick。每次Tick的过程就是查看是否有待处理事件,如果有则取出相关事件及回调函数放入执行栈中由主线程执行。待处理的事件会存储在一个任务队列中,也就是每次Tick会查看任务队列中是否有需要执行的任务。

任务队列:异步操作会将相关回调添加到任务队列中。而不同的异步操作添加到任务队列的时机也不同,如onclick, setTimeout,ajax处理的方式都不同,这些异步操作是由浏览器内核的webcore来执行的,webcore包含下图中的3种 webAPI,分别是DOM Bindingnetworktimer模块。

主线程:JS 只有一个线程,称之为主线程。而事件循环是主线程中执行栈里的代码执行完毕之后,才开始执行的。所以,主线程中要执行的代码时间过长,会阻塞事件循环的执行,也就会阻塞异步操作的执行。只有当主线程中执行栈为空的时候(即同步代码执行完后),才会进行事件循环来观察要执行的事件回调,当事件循环检测到任务队列中有事件就取出相关回调放入执行栈中由主线程执行。

ES5规范中对于事件循环的定义

翻开规范《ECMAScript® 2015 Language Specification》,找到事件循环 6.1.4 Event loops

规范中中提到,一个浏览器环境,只能有一个事件循环,而一个事件循环可以多个任务队列,每个任务都有一个任务源(Task source)。

相同任务源的任务,只能放到一个任务队列中。

不同任务源的任务,可以放到不同任务队列中。

又举了一个例子说,客户端可能实现了一个包含鼠标键盘事件的任务队列,还有其他的任务队列,而给鼠标键盘事件的任务队列更高优先级,例如75%的可能性执行它。这样就能保证流畅的交互性,而且别的任务也能执行到了。同一个任务队列中的任务必须按先进先出的顺序执行,但是不保证多个任务队列中的任务优先级,具体实现可能会交叉执行。

结论:一个事件循环可以有多个任务队列,队列之间可有不同的优先级,同一队列中的任务按先进先出的顺序执行,但是不保证多个任务队列中的任务优先级,具体实现可能会交叉执行。

重新看回开始的代码:

setTimeout(function(){
    console.log(2);
},0);

new Promise(function(resolve){
    console.log(3);
    resolve();
    console.log(4);
}).then(function(){
    console.log(5);
});

console.log(6);

setTimeout(function(){
    console.log(7);
},0);

console.log(8);

输出结果是,3 4 6 8 5 2 7。为什么setTimeout会后于promise.then执行呢,原因或许就是它所处的任务队列优先级较低。

不同任务队列的优先级

那么接下来,我们探究一下不同任务队列的优先级。

实际上,对于任务队列的优先级的定义,Promise/A+ 规范中有作详细的解释。

图灵社区 : 阅读 : 【翻译】Promises/A+规范

我们都知道,一个Promise的当前状态必须为以下三种状态中的一种:等待态(Pending)、执行态(Fulfilled)和拒绝态(Rejected)。

而上面的Promises规范就规定了,实践中要确保onFulfilledonRejected异步执行,且应该在then方法被调用的那一轮事件循环以后的新执行栈中执行

意思就是,当我们调用resolve()reject()的时候,触发promise.then(...)实际上是一个异步操作,这个promise.then(...)并不是在resolve()reject()的时候就立刻执行的,而也是要重新进入任务队列排队的,不过能直接在当前的事件循环新的执行栈中被取出执行(不用等下次事件循环)。

知道这个以后,我们再看一段代码,这个代码包含常用的大部分异步操作,我们将借此得出不同任务队列的优先顺序:

(其中setImmediate()process.nextTick()是node的语句)

setImmediate(function(){
    console.log(1);
},0);
setTimeout(function(){
    console.log(2);
},0);
new Promise(function(resolve){
    console.log(3);
    resolve();
    console.log(4);
}).then(function(){
    console.log(5);
});
console.log(6);
process.nextTick(function(){
    console.log(7);
});
console.log(8);

NodeJs环境输出

其中3 4 6 8是同步输出的。 因为注册顺序:1 > 2 > 5 > 7,而输出顺序是7 > 5 > 2 > 1

所以可以很容易得到,优先级 :process.nextTick > promise.then > setTimeout > setImmediate

而实际上,上述的Promises规范早已提到异步队列优先级规定的详细定义和解释了,并不需要我们一个一个去测试。

在js引擎中,我们可以按性质把任务分为两类,macrotask(宏任务)和 microtask(微任务)。

所以,js执行任务的流程是这样的:

  1. 第一个事件循环,先执行script中的所有同步代码(即 macrotask 中的第一项任务)
  2. 再取出 microtask 中的全部任务执行
  3. 下一个事件循环,再回到 macrotask 取其中的下一项任务
  4. 再取出 microtask 中的全部任务执行
  5. 反复执行事件循环…

现在,你可以根据这个流程再看回前面的代码,其实一切都很容易理解了…

以上,就是Javascript任务队列的顺序机制。

小结

事件循环:JS 会创建一个类似于 while (true) 的循环,每执行一次循环体的过程称之为Tick。每次Tick的过程就是查看是否有待处理事件,如果有则取出相关事件及回调函数放入执行栈中由主线程执行。待处理的事件会存储在一个任务队列中,也就是每次Tick会查看任务队列中是否有需要执行的任务。

任务队列:异步操作会将相关回调添加到任务队列中。而不同的异步操作添加到任务队列的时机也不同,如onclick, setTimeout,ajax处理的方式都不同,这些异步操作是由浏览器内核的webcore来执行的,webcore包含下图中的3种 webAPI,分别是DOM Bindingnetworktimer模块。

主线程:JS 只有一个线程,称之为主线程。而事件循环是主线程中执行栈里的代码执行完毕之后,才开始执行的。所以,主线程中要执行的代码时间过长,会阻塞事件循环的执行,也就会阻塞异步操作的执行。只有当主线程中执行栈为空的时候(即同步代码执行完后),才会进行事件循环来观察要执行的事件回调,当事件循环检测到任务队列中有事件就取出相关回调放入执行栈中由主线程执行。

一个事件循环可以有多个任务队列,队列之间可有不同的优先级,同一队列中的任务按先进先出的顺序执行,但是不保证多个任务队列中的任务优先级,具体实现可能会交叉执行。

在js引擎中,我们可以按性质把任务分为两类,macrotask(宏任务)和 microtask(微任务)。