引言
JavaScript 作为一门单线程语言,在执行时只能处理一个任务,这使得它在设计上更为简单,但某些方面也被人们批评,比如阻塞操作、处理复杂计算出现性能问题、回调地狱使代码难以维护等等。但是JS通过使用异步编程、Web Workers、Web Assembly 、EventLoop等技术,让开发者们可以充分利用 JavaScript 在各种应用场景中的优势,同时降低其弱点的影响。那么今天我们就来深入探讨JavaScript中的异步编程和事件循环模型,来看看它为了处理阻塞操作和回调地狱下了那些功夫?
开头唠唠
在深入理解 JavaScript 中的线程、进程和异步编程之前,我们先来聊聊单线程的基本概念和异步编程的动机。
JavaScript 单线程特性的基础概念
JavaScript 之所以被设计成单线程,有着一些基础的概念比如:
1. 单线程执行模型
JavaScript 在执行代码时,按照从上到下的顺序逐行执行。这种执行模型称为单线程执行模型,意味着在任何给定的时刻,只能执行一个任务。这有助于避免复杂的同步问题,简化了编程模型。
2. 事件驱动的设计
JavaScript 的单线程执行模型与事件驱动的设计相结合。通过事件监听器和回调函数,JavaScript 能够处理用户输入、网络请求等异步事件,而不会阻塞主线程的执行。这种非阻塞的设计使得 JavaScript 能够更好地处理并发操作。
异步编程的出现和背景
1. 处理网络请求和I/O
在 Web 开发中,经常需要进行网络请求和I/O 操作。如果在请求数据的过程中阻塞主线程,用户界面就会出现卡顿,影响用户体验。异步编程的引入解决了这一问题,使得 JavaScript 能够在等待数据的同时继续执行其他任务。
2. 提高程序性能
通过异步编程,JavaScript 能够更有效地利用系统资源,提高程序的性能。例如,可以同时处理多个异步任务,而不必等待一个任务完成后再处理下一个任务。
3. 事件循环的诞生
为了实现异步编程,JavaScript 引入了事件循环机制。事件循环允许 JavaScript 在等待异步任务完成的同时执行其他任务,从而更好地响应用户操作和处理复杂的应用逻辑。
现在我们理解了JavaScript 单线程的基础特性以及异步编程的动机。现在就让我们开始深入探讨线程、进程和异步编程的机制吧!
一、 进程和线程
理解 JavaScript 中的线程和进程是深入学习异步编程的关键。在 JavaScript 中,这些概念与浏览器中的标签页密切相关。
进程
1. 进程是操作系统资源分配的最小单位
在计算机中,进程是操作系统分配资源的最小单位。每个进程都有独立的内存空间、文件句柄等。这种隔离确保了一个进程的崩溃不会影响其他进程的稳定性。
通俗来讲:进程就是CPU
运行指令和保存上下文所需要的时间。
2. 进程的特性和优势
- 资源隔离: 每个进程有
自己的内存空间,不同进程之间不会直接共享数据。这种隔离提高了系统的稳定性。 - 独立执行: 进程之间是
独立执行的,一个进程的崩溃不会影响其他进程。 - 文件共享: 进程可以通过
文件进行通信,实现数据的共享。
在浏览器中,每个标签页通常运行在独立的进程中,这种多进程架构提高了浏览器的稳定性和安全性。每个进程中包含多个线程,分工合作以完成页面的加载和渲染。
线程
1. 线程是进程中的执行单元
线程是进程中的执行单元,一个进程可以包含多个线程。线程共享进程的资源,包括内存空间和文件句柄等。这使得线程之间的通信相对容易,但也需要注意同步和互斥的问题。
举个栗子:在浏览器中,每个标签页通常运行在一个独立的进程中。这个进程中包含多个线程,每个线程负责不同的任务,例如:
- 渲染线程(GPU): 负责将 HTML、CSS 和 JavaScript 转换成可视化的页面,利用 GPU 加速渲染。
- HTTP 请求线程: 负责处理网络请求,从服务器获取资源。
- JavaScript 引擎线程: 负责执行 JavaScript 代码。
通俗来讲:线程就是进程中更小的单位,描述了一段指令执行所需要的时间
2. 线程之间的通信和共享资源
线程之间的通信和资源共享是多线程编程中需要特别关注的问题。由于线程共享相同的地址空间,因此需要使用同步机制来确保数据的一致性。常见的同步机制包括锁、信号量等。
但是在 JavaScript 中,由于单线程的特性,不存在线程之间的竞争条件,所以单线程就没有🔒(LOCK)的概念,这样虽然节省了上下文切换的时间,但是却丧失了同步机制来确保数据的一致性。然而,在 Web Workers 中,JavaScript 提供了一种多线程的解决方案,允许在后台执行脚本以进行并行计算。
二、 异步编程
在 JavaScript 中,异步编程是处理非阻塞操作的关键。通过引入宏任务和微任务的概念,以及事件循环机制,JavaScript 可以在等待异步操作完成的同时继续执行其他任务。那么现在就让我们深入了解异步编程的核心机制吧。
宏任务与微任务
在事件循环中,任务分为两种:宏任务(Macrotask)和微任务(Microtask)。
宏任务包括: script 标签中的代码、定时器、I/O 操作等
微任务包括: promise.then 方法、MutationObserver、Process.nextTick(在 Node.js 中)等。
1. 宏任务(Macrotask)
宏任务是由浏览器提供的异步任务,包括 script 标签中的代码、定时器(setTimeout)、I/O 操作和 UI 渲染等。宏任务会被依次推入宏任务队列,按照顺序执行。
2. 微任务(Microtask)
微任务是在当前任务执行完成后立即执行的任务,包括 promise.then 方法、MutationObserver 和 Process.nextTick(在 Node.js 中)。微任务会在宏任务执行前被处理。
事件循环(Event Loop)
异步操作将被分发到宏任务队列和微任务队列中,然后由事件循环按照特定的顺序执行:

执行同步代码(宏任务):事件循环的执行过程始于同步代码的执行。从调用栈中执行同步任务,这其实就相当于开启第一轮的宏任务。
查询异步代码并执行微任:当执行栈为空时,事件循环会查询是否有异步代码需要执行。如果有,将执行微任务队列中的任务。
渲染页面:在执行微任务后,如果需要,会渲染页面。这一步通常发生在宏任务执行前,以确保用户界面的及时响应。
执行宏任务:最后,事件循环会执行宏任务队列中的任务。这个过程就是事件循环的一个完整轮回。执行完宏任务后,再次查询是否有微任务需要执行,依此类推。
解析异步编程执行过程
在了解了JavaScript中异步编程的核心机制:宏任务、微任务、事件循环机制后,让我们通过解析下面的代码,深入了解宏任务、微任务以及事件循环的工作原理。
Promise/Then
js
console.log('start');
setTimeout(() => {
console.log('setTimeout');
setTimeout(() => {
console.log('inner');
});
console.log('end');
}, 1000);
new Promise((resolve, reject) => {
console.log('Promise');
resolve()
})
.then(() => {
console.log('then1');
})
.then(() => {
console.log('then2');
})
同步任务的执行 :代码开始执行,首先是同步任务。
console.log('start')输出'start'。宏任务的处理 :接下来,遇到了一个定时器
setTimeout,它是一个宏任务。它会被放入宏任务队列,并在当前宏任务执行完成后,等待 1000 毫秒后执行。微任务的处理 :然后,遇到了一个
Promise构造函数,因为它是一个同步任务,所以会先执行console.log('Promise')输出'Promise',然后resolve()触发了两个.then()方法,它们都是微任务,被放入微任务队列。执行微任务 :到这里第一轮宏任务中的同步任务就执行完了,于是开始执行微任务。执行微任务队列中的任务有两个
.then()方法分别输出'then1'和'then2'。执行定时器的回调 :经过一段时间(1000 毫秒),定时器的回调函数执行,也就是开启第二轮宏任务。
console.log('setTimeout')输出'setTimeout',接着设置了一个嵌套的setTimeout,这个定时器也被推入宏队伍队列中,然后输出'end'。执行嵌套的 setTimeout 回调 :嵌套的
setTimeout回调是一个宏任务,会在下一个事件循环中执行,也就是是开启了第三轮宏任务,最后执行console.log('inner')输出'inner'。
最终的输出顺序如下:
node
start
Promise
then1
then2
setTimeout
end
inner
Async/Await
虽然JavaScript更新了Promie承诺语句来处理回调地狱,但是一连串的.then执行起来也略微显得不是很完美,所以在大家的强烈建议下后续官方又新推出了一种新的语法糖------------Async/Await。那么话不多嗦,让我们借助下面的例子,深入理解 Async/Await 在事件循环中的执行流程。
js
function A (){
return new Promise((resolve,reject) =>{
setTimeout(() => {
console.log('异步A完成');
resolve()
}, 1000);
})
}
function B (){
return new Promise((resolve,reject) =>{
setTimeout(() => {
console.log('异步B完成');
resolve()
}, 500);
})
}
function C (){
return new Promise((resolve,reject) =>{
setTimeout(() => {
console.log('异步C完成');
resolve()
}, 100);
})
}
async function foo(){
await A() //await 会阻塞后续代码,将后续代码推入微任务队列
console.log(1);
await B()
await C()
}
foo()
-
foo 函数的调用
foo函数被调用,进入执行栈。
-
await A()
- 遇到
await A(),A()返回的 Promise 进入等待状态。 await会将后续代码(console.log(1); await B(); await C();)封装成微任务,推入微任务队列。
- 遇到
-
控制权交还给事件循环
- 当前宏任务(执行
foo函数)执行完毕,控制权交还给事件循环。
- 当前宏任务(执行
-
执行微任务队列
- 微任务队列中有
console.log(1); await B(); await C();。 - 执行
console.log(1)输出'1'。
- 微任务队列中有
-
执行宏任务队列中的 foo 函数的下一部分
await B():遇到await B(),B()返回的 Promise 进入等待状态。await会将后续代码(await C();)封装成微任务,推入微任务队列。
-
控制权交还给事件循环
- 当前宏任务(执行
foo函数的一部分)执行完毕,控制权交还给事件循环。
- 当前宏任务(执行
-
执行微任务队列
- 微任务队列中有
await C();。 - 执行
await C():遇到await C(),C()返回的 Promise 进入等待状态。
- 微任务队列中有
-
控制权交还给事件循环
- 当前宏任务(执行
foo函数的一部分)执行完毕,控制权交还给事件循环。
- 当前宏任务(执行
-
执行微任务队列
- 微任务队列中没有任务了。
-
执行宏任务队列中的 foo 函数的下一部分
- 执行完
await C()后续的代码。
- 执行完
最终输出:
node
异步A完成
1
异步B完成
异步C完成
终极面试题来啦
在经历过前面风雨的洗刷后,让我们来看下面这个面试题,你觉得结果会是输出什么?
js
console.log('script start') //A
async function async1() {
await async2()
console.log('async1 end')//B
}
async function async2() {
console.log('async2 end')//C
}
async1()
setTimeout(function () {
console.log('setTimeout')//D
}, 0)
new Promise(resolve => {
console.log('Promise')//E
resolve()
})
.then(function () {
console.log('promise1')//F
})
.then(function () {
console.log('promise2')//G
})
console.log('script end')//H
需要注意的是,按照我们之前的思路推导出来的结果和实际运行的结果却不同,这是为什么呢? 其实我们的思路并没有错误,按以前老版本的await语法糖来分析输出结果确实是和我们想的一样,但是现在新版本的await可以说是提速了,总结来说就是 await代码又成同步代码了 ,不过await依旧还会阻塞后续代码,让它后面的代码进入微任务队列。
那么下面让我们一起来分析一下await更新后这段代码的执行过程是怎么样的吧~
首先执行A打印出script start,然后调用async1(),在async1()函数里面await async2()会被直接当成同步代码执行,所以直接调用async2()然后打印出 async2 end,但是await async2()后面的代码会被推入到微任务队列中,接着执行到定时器,于是定时器里面的这个函数会被推入到宏任务队列,再接着就是一个Promise同步函数的执行,直接打印出Promise,后面就是两个.then语句依次被推入到微任务队列中,最后同步任务中只剩下H,于是打印出script end,然后再执行微任务队列:
JS
G=>F=>B
依次打印出
JS
async1 end
promise1
promise2
微任务队列执行完后,再来到宏任务队列:
D
于是开启新一轮宏任务,最后执行打印出setTimeout
所以最终的打印结果就是

总结来说,JS中的事件循环机制其实就是先执行同步代码,如果有异步代码,就会将其分类成异步宏任务和异步微任务,并将其入入其相应的队列中,当同步代码全部执行完毕就会去执行微任务队列,接着执行宏任务队列并开启新一轮宏任务。需要注意的是,面试官有可能会问你这样一个问题:微任务一定在宏任务之前执行吗?
答案是并不是这样的,因为例如当浏览器读到这个标签时就会开启一轮宏任务,这个时候宏任务就比微任务先执行。
结语
那么到了这里我们今天的文章就结束啦~
创作不易,如果感觉这个文章对你有帮助的话,点个赞吧♥
更多内容面试经典头疼问题:JavaScript中的类型转换[1]---基本数据类型转换
博主的开源Git仓库: gitee.com/cheng-bingw...