2019-4-01更新:采纳JSC引擎的术语,我们把宿主(浏览器、Node环境)发起的任务称为宏任务(如SetTimeout),把JavaScript引擎发起的任务称为微观任务(如Promise)。
我们在接触到JavaScript语言的时候就经常听到别人介绍JavaScript 是单线程、异步、非阻塞、解释型脚本语言。
究竟应该如何理解这句话呢?
确切的说,对于开发者的开发过程来说,js确实只有一个线程(由JS引擎维护),这个线程用来负责解释和执行JavaScript代码,我们可以称其为主线程。例如在控制台输入如下代码:
console.log("a");
console.log("b");
console.log("c");
//依次输出a,b,c
可以看出,这段代码在主线程上是按照顺序执行的。但是我们平时的任务处理可能并不会直接获取到结果,这种情况下如果仍然使用同步方法,例如发起一个ajax请求,大概500ms后受到响应,在这个过程中,后面的任务就会被阻塞,浏览器页面就会阻塞所有用户交互,呈“卡死”状态。
console.log("a");
$.ajax({
url:"xxx",
async:false, //同步请求ajax
success:function(){
console.log("b");
}
})
console.log("c");
这种同步的方式对于用户操作非常不友好,所以大部分耗时的任务在JS中都会通过异步的方式实现。虽然js引擎只维护一个主线程用来解释执行JS代码,但实际上浏览器环境中还存在其他的线程,例如处理AJAX,DOM,定时器等,我们可以称他们为工作线程。同时浏览器中还维护了一个消息队列,主线程会将执行过程中遇到的异步请求发送给这个消息队列,等到主线程空闲时再来执行消息队列中的任务。
同步任务的缺点是阻塞,异步任务的缺点是会使代码执行顺序难以判断。
两者比较一下我们还是更倾向于后者。
到目前为止,我们已经涉及到了几个名词,主线程,js引擎,事件循环,消息队列等。接下来会对这些名词一一进行解释。
js引擎
我们所熟悉的引擎是chrome浏览器中和node.js中使用的V8引擎。它的大致组成如图:
v8引擎
这个引擎主要由两个部分组成,内存堆和调用栈。(只负责取消息,不负责生产消息)
内存堆:进行内存分配。如变量赋值。
调用栈:这是代码在栈帧中执行的地方。调用栈中顺序执行主线程的代码,当调用栈中为空时,js引擎会去消息队列取消息。取到后就执行。JavaScript是单线程的编程语言,意味着它有一个单一的调用栈。因此它只能在同一时间做一件事情。调用栈是一种数据结构,它基本上记录了我们在程序中的什么位置。如果我们步入一个函数中,我们会把这些数据放在堆栈的顶部。如果我们从一个函数中返回,这些数据将会从栈顶弹出。这就是堆栈的用途。调用栈中的每个条目叫做栈帧。当我们在chrome调试窗口中看到抛出的错误时,就能够看到大致的调用顺序。
js运行时
运行时我们经常使用的一些API并不是js引擎中提供的,例如setTimeout。它们其实是在浏览器中提供的,也就是运行时提供的,因此,实际上除了JavaScript引擎以外,还有其他的组件。其中有个组件就是由浏览器提供的,叫Web APIs,像DOM,AJAX,setTimeout等等。
然后还有就是非常受欢迎的事件循环和回调队列。
运行时负责给引擎线程发送消息,只负责生产消息,不负责取消息。
消息队列和事件循环
主线程在执行过程中遇到了异步任务,就发起函数或者称为注册函数,通过event loop线程通知相应的工作线程(如ajax,dom,setTimout等),同时主线程继续向后执行,不会等待。等到工作线程完成了任务,eventloop线程会将消息添加到消息队列中,如果此时主线程上调用栈为空就执行消息队列中排在最前面的消息,依次执行。
新的消息进入队列的时候,会自动排在队列的尾端。
单线程意味着js任务需要排队,如果前一个任务出现大量的耗时操作,后面的任务得不到执行,任务的积累会导致页面的“假死”。这也是js编程一直在强调需要回避的“坑”。
主线程会循环上述步骤,事件循环就是主线程重复从消息队列中取消息、执行的过程。
需要注意的是 GUI渲染线程与JS引擎是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。因此页面渲染都是在js引擎主线程调用栈为空时进行的。
其实 事件循环 机制和 消息队列 的维护是由事件触发线程控制的。
事件触发线程 同样是浏览器渲染引擎提供的,它会维护一个 消息队列。
JS引擎线程遇到异步(DOM事件监听、网络请求、setTimeout计时器等...),会交给相应的线程单独去维护异步任务,等待某个时机(计时器结束、网络请求成功、用户点击DOM),然后由 事件触发线程 将异步对应的 回调函数 加入到消息队列中,消息队列中的回调函数等待被执行。
同时,JS引擎线程会维护一个 执行栈,同步代码会依次加入执行栈然后执行,结束会退出执行栈。
如果执行栈里的任务执行完成,即执行栈为空的时候(即JS引擎线程空闲),事件触发线程才会从消息队列取出一个任务(即异步的回调函数)放入执行栈中执行。
执行顺序
了解了事件循环和消息队列之后,接下来就是弄清楚当同步任务和异步任务都存在时,代码执行的顺序究竟是怎么样的。
举个例子:
console.log("a");
setTimeout(function(){
console.log("b")},1000
);
console.log("c");
相信所有人都知道执行顺序是 a, c , b。
如果变化一下:
console.log("a");
setTimeout(function(){
console.log("b")},0
);
console.log("c");
相信通过上面的内容,大部分人也都知道执行顺序还是a,c,b。setTimeout在主线程执行时被添加到了消息队列中,等待主线程调用栈为空时,再从消息队列中取出执行。因此setTimeout中的延时时间并非确切的执行时间,实际上应该理解为添加到消息队列中的延迟时间。以上述代码为例,如果console.log("c")处是一个计算量很大的任务,或者消息队列中已经存在了若干个等待处理的消息。setTimeout都将延迟都将大于设置的延迟时间。
ES6 Promise
以上的内容在ES6之前就基本cover了执行顺序的问题,但是在ES6引入了promise后,产生了一个新的名词”微任务(microtask)“。微任务的执行顺序与之前我们所说的任务(我们可以称之为”宏任务“)是不同的。
console.log('script start')
setTimeout(function() {
console.log('timer over')
}, 0)
Promise.resolve().then(function() {
console.log('promise1')
}).then(function() {
console.log('promise2')
})
console.log('script end')
输出的结果是:
script start
script end
promise1
promise2
timer over
你答对了吗?
我猜这里让你困惑的一定是为什么promise1和promise2在timer over之前输出了。下面我们来解释一下微任务这个概念。
- 一个线程中,事件循环是唯一的,但是任务队列可以拥有多个。
- 任务队列又分为macro-task(宏任务)与micro-task(微任务),在最新标准中,它们被分别称为task与jobs。
- macro-task大概包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering。
- micro-task大概包括: process.nextTick, Promise, Object.observe(已废弃), MutationObserver(H5新特性)
- setTimeout/Promise等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务。
- 来自不同任务源的任务会进入到不同的任务队列。其中setTimeout与setInterval是同源的。
- 事件循环的顺序,决定了JavaScript代码的执行顺序。它从script(整体代码)开始第一次循环。之后全局上下文进入函数调用栈。直到调用栈清空(只剩全局),然后执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次从macro-task开始,找到其中一个任务队列执行完毕,然后再执行所有的micro-task,这样一直循环下去。
- 其中每一个任务的执行,无论是macro-task还是micro-task,都是借助函数调用栈来完成。
举个例子:
setTimeout(function() {
console.log('timeout1');
})
new Promise(function(resolve) {
console.log('promise1');
for(var i = 0; i < 1000; i++) {
i == 99 && resolve();
}
console.log('promise2');
}).then(function() {
console.log('then1');
})
console.log('global1');
执行结果为:
promise1
promise2
global1
then1
timeout1
分析一下代码,首先程序开始执行,遇到setTimeout时将它添加到消息队列,等待后续处理,遇到Promise时会创建微任务(.then()里面的回调),注意此时new promise构造函数中的代码还是同步执行的,只有.then中的回调会被添加到微任务队列。因此会连续输出promise1和promise2。继续执行到console.log('global1')输出global1,到此调用栈中已经为空。此时微任务队列里有一个任务.then,宏任务队列里也有一个任务setTimout。
microtask必然是在某个宏任务执行的时候创建的,而在下一个宏任务开始之前,浏览器会对页面重新渲染(task >> 渲染 >> 下一个task(从任务队列中取一个))。同时,在上一个宏任务执行完成后,渲染页面之前,会执行当前微任务队列中的所有微任务。也就是说,在某一个宏任务执行完后,在重新渲染与开始下一个宏任务之前,就会将在它执行期间产生的所有微任务都执行完毕(在渲染前)。因此会执行.then输出then1,然后进行下一轮事件循环,取出任务队列中的setTimeout输出timeout1。
总结一下执行机制:
-
执行一个宏任务(栈中没有就从事件队列中获取)
-
执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
-
宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
-
当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
-
渲染完毕后,JS引擎线程继续,开始下一个宏任务(从宏任务队列中获取)
参考文章:
https://www.cnblogs.com/jymz/p/7900439.html
https://juejin.im/post/5a6547d0f265da3e283a1df7#heading-6
https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
网友评论