异步编程这是一个很难用一两句话解释的事情,但千言万语总得有个开头啊! 我们就先从单线程开始讲起。
我们都知道JavaScript是单线程的,即JavaScript的代码只能在一个线程上运行,也就说,JavaScript同时只能执行一个js任务,但是为什么要这样呢?这与浏览器的用途有关,JavaScript的主要用途是与用户互动和操作DOM。设想一段JavaScript代码,分发到两个并行互不相关的线程上运行,一个线程在DOM上添加内容,另一个线程在删除DOM,那么会发生什么?以哪个为准?所以为了避免复杂性,JavaScript从一开始就是单线程的,且以后也不会变。
我们知道了,一段JavaScript代码只能在一个线程从上到下的执行,但是我们遇到setTimeout或者ajax异步时,也没有等待那么诸如onclick回调,setTimeout,Ajax这些都是怎么实现的呢?
var a = 1;
setTimeout(function(){}...);
ajax(function(){}...);
console.log(2);
这里首先我们要知道JavaScript是单线程 , 但是浏览器是多线程的。浏览器开设了其他线程去辅助JavaScript线程的运行。
浏览器有很多线程,例如:
- GUI 渲染线程
- JavaScript引擎线程
- 定时器触发线程 (setTimeout)
- 浏览器事件线程 (onclick)
- http 异步线程
- EventLoop轮询处理线程
- …
其中,1、2、4为常驻线程。
接下来我们就来讨论下这些线程。
JavaScript 引擎线程,我们把它称为 主线程 ,它是干嘛的?即运行JavaScript 代码的那个线程(不包括异步的那些代码),比如:
var a = 1;
setTimeout(function(){}...);
ajax(function(){}...);
console.log(2);
第1、4行代码是同步代码,直接在主线程中运行;第2、3行代码交给其他线程运行。
主线程运行 JavaScript 代码时,会生成个 执行栈 ,可以处理函数的嵌套,通过出栈进栈进行控制。接下来我们从最简单的代码,来分析主线程和调用栈。
function wait(){
//code
}
wait();
function foo(){
//code
}
foo();
当 V8 准备执行这段代码时,会先将全局执行上下文压入到调用栈中,如下图所示:
然后 V8 便开始在主线程上执行 wait 函数,首先它会创建 wait 函数的执行上下文,并将其压入栈中,那么此时执行栈、主线程的关系如下图所示:
等 wait 函数执行结束,V8 就会从栈中弹出 wait 函数的执行上下文,此时的效果如下所示:
foo函数的执行以此类推。等 foo 函数执行结束,V8 就会结束当前的宏任务,调用栈也会被清空,调用栈被清空后状态如下图所示:
执行主线程上的JavaScript同步代码(我们也会把这个任务当做是一个宏任务)。
**
引擎在执行JavaScript代码的过程中遇到了异步代码怎么办?
消息队列(任务队列)
任务队列可以理解为一个静态的队列存储结构,非线程,只做存储,里面存的是一堆异步成功后的回调函数,先成功的异步的回调函数在队列的前面,后成功的在后面。
注意:是异步成功后,才把其回调函数扔进队列中,而不是一开始就把所有异步的回调函数扔进队列。比如setTimeout 3秒后执行一个函数,那么这个函数是在3秒后才进队列的。
**
JavaScript代码中,碰到异步代码,就被放入相对应的线程中去执行,比如:
var a = 2;
setTimeout(fun A)
ajax(fun B)
console.log()
dom.onclick(func C)
主线程在运行这段代码时,碰到setTimeout(fun A),把这行代码交给 定时器触发线程 去执行,碰到 ajax(fun B)把这行代码交给 http 异步线程去执行 ,碰到 dom.onclick(func C) ,把这行代码交给 浏览器事件线程 去执行。
注意:这几个异步代码的回调函数fun A,fun B,fun C,各自的线程都会保存着的,因为需要在未来执行。
所以,这几个线程主要干两件事:
1、执行主线程扔过来的异步代码,并执行代码
2、保存着回调函数,异步代码执行成功后,通知 EventLoop 轮询处理线程 过来取相应的回调函数。
EventLoop轮询处理线程
什么是事件循环? 通过刚刚的讲解我们知道了有 JavaScript 主线程处理同步代码,有定时器线程HTTP异步线程、浏览器事件线程等等处理异步代码。有消息队列,存储着异步成功后的回调函数。
那这三者是通过事件循环这个中介来进行交流沟通的,所以事件循环本身扮演的就是一个沟通协调的角色。
注意:整个的流程是循环往复的。
注意:只有主线程的同步代码都执行完了,才会去队列里看看还有啥要执行的没。
消息队列中事件又被称为宏任务,宏任务很简单,就是指消息队列中的等待被主线程执行的事件。每个宏任务在执行时,V8 都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化,最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务。
但是这里有一个问题由于主线程执行消息队列中宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景什么意思?举个例子假设在消息队列中某些宏任务的执行时间过久,它会影响到消息队列后面的宏任务的执行,而且这个影响是不可控的,因为你无法知道前面的宏任务需要多久才能执行完成。
于是 JavaScript 中又引入了微任务 微任务可以在实时性和效率之间做一个有效的权衡。
通俗地理解,V8 会为每个宏任务维护一个微任务队列。当 V8 执行一段 JavaScript 时,会为这段代码创建一个环境对象,微任务队列就是存放在该环境对象中的。当你通过 Promise.resolve 生成一个微任务,该微任务会被 V8 自动添加进微任务队列,等整段代码快要执行结束时,该环境对象也随之被销毁,但是在销毁之前,V8 会先处理微任务队列中的微任务。
微任务之所以能实现这样的效果,主要取决于微任务的执行时机,微任务其实是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
接下来,我们就来详细分析微任务跟宏任务执行过程。
var count = 1;
console.log(count);
function foo() {
var n = 2;
console.log(n);
Promise.resolve().then(function() {
console.log(3);
})
}
setTimeout(function() {
console.log(4)
}, 1000);
foo();
首先,当 V8 执行这段代码时,会将全局执行上下文压入调用栈中,并在执行上下文中创建一个空的微任务队列。
那么此时:
- 调用栈中包含了全局执行上下文;
- 微任务队列为空。
此时的消息队列、主线程、调用栈的状态图如下所示:
执行 setTimeout 方法,该方法会触发了一个macro-global 宏任务,V8 会将该宏任务添加进消息队列。
然后,执行 foo 函数的调用,V8 会先创建 foo 函数的执行上下文,并将其压入到栈中。
接着执行 Promise.resolve,这会触发一个微任务,V8 会将该微任务添加进微任务队列。
那么此时:
- 调用栈中包含了全局执行上下文、foo 函数的执行上下文;
- 微任务队列有了一个 micro-foo 微任务;
- 消息队列中存放了一个通过 setTimeout 设置的宏任务 macro-global ;
接下来,foo 函数执行结束并退出执行上下文也会从栈中弹出。
这时候 V8 会检查微任务队列,如果微任务队列中存在微任务,那么 V8 会依次取出微任务,并按照顺行执行。因为微任务队列中的任务分别是:micro-foo, 调用回调输出3。
等微任务队列中的所有微任务都执行完成之后,当前的宏任务也就执行结束了,接下来主线程会继续去消息队列中取出任务 macro-global 调用回调输出4 。
等所有的任务执行完成之后,消息队列、主线程和调用栈的状态图如下所示。
总结这节课我们主要从调用栈、主线程、消息队列这三者关联的角度来分析了微任务。
消息队列中事件又被称为宏任务,不过,宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,而微任务可以在实时性和效率之间做有效的权衡。
微任务之所以能实现这样的效果,主要取决于微任务的执行时机,微任务其实是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。