引言
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...