Javascript 是单线程语言,那么异步代码是如何执行的呢?
1. 基本概念
- 任务
可以理解为一段可执行的代码 - 同步任务/异步任务
同步任务:会立即执行的任务
异步任务:不会立即执行的任务 - 堆 / 栈 / 队列
堆:一棵完全二叉树,可看做一种树状存储空间,在js中为对象(或称为引用类型,而基本类型存储在栈中)动态分配存储内存。
栈:一种操作受限的线性表,仅允许在表的同一端插入和删除,可以类比向被子里装乒乓球。特性 —— 先进后出。函数调用形成栈帧。
队列:一种操作受限的线性表,仅允许在表尾插入,在表头删除,可以类比排队下车。特性 —— 先进先出。例如任务事件队列。 - 进程 / 线程
进程:操作系统资源分配的基本单位,可简单理解为每个运行的应用程序就是一个(主)进程。
线程:任务调度和执行的基本单位,可简单理解为进程中用于执行任务的基本单位。
对应到浏览器:- 浏览器是多进程的,当打开浏览器就创建了主进程,每打开一个tab页就会创建一个渲染进程;
- 渲染进程是多线程的,主要包括:GUI渲染线程、JS引擎线程(只能有一个JS线程执行JS代码)、异步执行模块线程组(定时触发器线程、http请求线程、事件触发线程等)。GUI渲染线程与JS引擎线程互斥,不能同时执行。
参考 🔗链接,加入了个人理解。
2. 单线程
Javascript 是一门单线程编程语言,即便事件循环、ajax、Promise以及web-worker等赋予了Javascript异步编程或“多线程”的外在表现,但依然无法改变其单线程的本质。因为上述特性都是在单线程和异步原理基础上模拟出来的。
Javascript 设计成单线程的原因:JS 可以操作 dom,若多线程同时操作一个 dom 元素,将会出现 dom 渲染冲突的情况。所以,为了避免dom渲染冲突,JS 必须是单线程语言。
3. 事件循环机制
通常来说,执行多任务的方式有:多进程、单进程多线程。
由于JS是单线程语言,所以JS引擎设计成只能同时有一个JS线程执行JS代码,那么如何实现执行多任务呢?
单靠JS引擎显然是无法实现的,解决方法是JS引擎结合异步执行模块线程实现事件循环机制。先看一段代码:
let data = [];
$.ajax({
url: www.javascript.com,
data: data,
success: () => {
console.log('ajax请求成功');
}
})
console.log('代码执行结束');
我们都知道,执行结果为: 代码执行结束、 ajax请求成功。
主程序执行过程中并未阻塞ajax请求的执行,也就是说,这两个任务是同时执行的。而输出顺序的先后说明ajax的回调函数success是在主程序之后才执行的。
这段代码的实际执行流程可概括如下图:

- 任务进入JS线程的执行栈,判断该任务为同步任务或异步任务;
- 同步任务继续在JS线程中执行直至全部执行完毕;
- 浏览器渲染进程为异步任务创建异步执行模块线程(定时触发器线程、http请求线程、事件触发线程等中的一种),并在此完成回调函数注册,异步任务完成后将回调函数压入事件队列,异步线程和JS线程独立运行,互不干扰;
- JS线程不断的检查执行栈是否为空,若为空,则读取事件队列中的回调函数并压入执行栈进行执行。执行结束后,再次检查执行栈是否为空,再次读取事件队列中的回调函数并压入执行栈进行执行,依次循环,即为JS的事件循环机制。
4.宏任务和微任务
事件循环约束了JS单线程模式下多任务的执行机制,当然,多任务是对于异步任务而言的。 我们知道JS中有多种类型的异步任务,那么当不同类型的异步任务存在是,执行机制又是怎样的呢?看一段代码:
setTimeout(function() {
console.log('1');
}, 0)
new Promise(function(resolve) {
console.log('2');
resolve();
}).then(function() {
console.log('3');
})
console.log('4');
基于 3 中的讨论,若异步任务不加以区分,输出结果大概为:2 4 1 3,但实际输出结果为:2 4 3 1。
显然,除同步任务异步任务外,事件循环机制对任务做了其它区分。
任务又可进一步区分为:
- 宏任务(Macro-task):整体代码(同步任务)、setTimeout、setInterva、ajax
- 微任务(Micro-task):Promise、process.nextTick(nodejs)
由此以来,事件循环的更详细表述就成了这样:
- 任务进入JS线程的执行栈,判断该任务为同步任务或异步任务,若为异步任务则进一步判断是宏任务还是微任务;
- 同步任务继续在JS线程中执行直至全部执行完毕(整体代码为宏任务);
- 浏览器渲染进程为异步任务创建异步执行模块线程(定时触发器线程、http请求线程、事件触发线程等类型中的一种),并在此完成回调函数注册,异步任务完成后将宏任务的回调函数压入宏任务事件队列,将微任务的回调函数压入微任务事件队列,异步线程和JS线程独立运行,互不干扰;
- JS线程不断的检查执行栈是否为空,若为空,则读取事件队列中的回调函数并压入执行栈进行执行。事件循环的顺序为:先执行宏任务(整体代码或回调),再执行当前宏任务回调中出现过的微任务,接着执行宏任务事件队列中的下一个宏任务,接着再执行当前宏任务回调中出现过的微任务,依次循环。(可结合流程图及代码理解这个过程)

console.log('1'); // 整体代码
setTimeout(function() { // 宏任务1
console.log('2');
new Promise(function(resolve) { // 微任务1-1
resolve();
}).then(function() {
console.log('3')
})
new Promise(function(resolve) { // 微任务1-2
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
new Promise(function(resolve) { // 微任务1
resolve();
}).then(function() {
console.log('6')
})
new Promise(function(resolve) { // 微任务2
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() { // 宏任务2
console.log('9');
setTimeout(function() { // 宏任务2-1
console.log('10')
})
new Promise(function(resolve) { // 微任务2-1
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
执行:(数组模拟事件队列,数组头象征队列尾,数组尾象征队列头,队列特征:队尾压入队头取出,先进先出)
- 执行
整体代码(宏任务),得到 - 宏任务事件队列:[宏任务2回调,宏任务1回调],微任务事件队列:[微任务2回调,微任务1回调]。输出:1、7 - 执行
微任务1回调,此时 - 宏任务事件队列:[宏任务2回调,宏任务1回调],微任务事件队列:[微任务2回调]。输出:6 - 执行
微任务2回调,此时 - 宏任务事件队列:[宏任务2回调,宏任务1回调],微任务事件队列:[]。输出:8 - 执行
宏任务1回调,此时 - 宏任务事件队列:[宏任务2回调],微任务事件队列:[微任务1-2回调,微任务1-1回调]。输出:2、4 - 执行
微任务1-1回调,此时 - 宏任务事件队列:[宏任务2回调],微任务事件队列:[微任务1-2回调]。输出:3 - 执行
微任务1-2回调,此时 - 宏任务事件队列:[宏任务2回调],微任务事件队列:[]。输出:5 - 执行
宏任务2回调,此时 - 宏任务事件队列:[宏任务2-1回调],微任务事件队列:[微任务2-1回调]。输出:9、11 - 执行
微任务2-1回调,此时 - 宏任务事件队列:[宏任务2-1回调],微任务事件队列:[]。输出:12 - 执行
宏任务2-1回调,此时 - 宏任务事件队列:[],微任务事件队列:[]。输出:10
综上最终输出为:1、7、6、8、2、4、3、5、9、11、10
本文参考:
大致总结事件循环机制,很多内容为自我理解,所以很多细节经不起推敲,我也会随着理解的加深,逐步纠正、补充本文,如有发现错误请不吝指教~
其它
我将学习、工作中的积累做成了开源项目:Blog
欢迎关注并一起讨论学习。