前言
大家好,我是 luckyCover,今天带来 JavaScript 三座大山中的异步与单线程。在了解异步前,我们先来看看 JavaScript 单线程的机制,知道它为什么要设计成单线程。
单线程
单线程就像只有一条车道的公路,想要通行的车只能一辆接一辆排在后边,同一时间仅能通行一辆车。
在计算机科学中:
线程是程序执行流的最小单位,你可以把它想象成一个“工人”。
单线程意味着只有一个“工人”,这个工人只能一件一件地完成任务,他不能“分身”去同时做多件事。
优点:简单和可预测
- 开发简单:程序员不需要考虑“并发控制”问题,比如两个任务同时争夺一份数据怎么办(这称为“资源竞争”或“死锁”)。
- 可预测性:代码的执行顺序和编写顺序高度一致,容易理解和调试。
缺点:性能瓶颈
- 效率低下(在 CPU 密型任务中):如果一个任务耗时很长(比如一个复杂计算),并且它不需要等待外部资源(如文件读取、网络请求),那么它就会阻塞整个线程。后面的所有任务都必须等它完成,导致程序“卡住”或响应缓慢。
- 无法利用多核 CPU:现代 CPU 有多个核心,就像一个有多个车道的高速公路。但单线程程序只能使用其中一个核心,其他核心都处于闲置状态,造成了资源浪费。
JavaScript 单线程
根据单线程性质,我们可知道 JavaScript 同一时间仅能执行一个任务。
所以JavaScript 为什么要引入单线程呢?我们先往下看:
- 浏览器需要渲染 DOM
- JavaScript 可以修改 DOM
- JavaScript 执行时,浏览器 DOM 渲染暂停
如果 JavaScript 是多线程的,那么同一时间可以执行多段 JavaScript 脚本,如果这多段 JavaScript 脚本中对相同的 DOM 进行修改了,那么此时应该听谁的?这就造成了 DOM 冲突。
你可能听说过 HTML5 引入的 web Worker,可以开启一个额外的线程,用来缓解复杂计算带来的性能瓶颈。但是 web worker 不能访问 window 对象、document 对象等。
如果你学过 java,你可能会知道锁的机制,可以避免同一时间对相同内容的修改。为浏览器引入锁的机制虽然能解决这些冲突,但是这大大提高了操作的复杂性,所以 JavaScript 从诞生开始就选择了单线程执行。
我们举个常见的例子:
let sum = 0
for(let i=0;i<1000;i++) {
sum += i
}
console.log(sum)
JavaScript 单线程机制,上边代码计算从 0 到 1000 总和需要消耗很长时间,这会阻塞主线程执行,必须等待 for 循环执行完毕后才能打印 sum。
大家想想如果一个网站因为某段复杂的计算,而导致页面迟迟处于一个白屏状态,在用户的感知下会觉得这个页面卡死了,这样的体验是非常差的。
为了解决这个问题,JavaScript 将任务的执行模式分为两种:同步与异步。
同步与异步
同步
我们借着上边例子:
let sum = 0
for(let i=0;i<1000;i++) {
sum += i
}
console.log(sum) // 499550
在代码同步执行下,上边代码会按顺序逐行执行,结果如预期所至,打印的就是 0-100 相加的结果。
异步
还是同样的代码,这时候我们给这个 for 循环外层套上 setTimeout,这时候便开启了异步执行模式:
let sum = 0
setTimeout(() => {
for(let i=0;i<1000;i++) {
sum += i
}
}, 1000)
console.log(sum) // 0
发现结果变为 0 了,说明开启异步模式后遇到异步任务后暂且跳过它(即不会阻塞主线程执行),继续往下执行,此时 for 循环还没开始执行,打印 sum 的结果自然为 0。那么上边这个异步任务啥时候执行呢?下边将事件循环时会提到。
总结:
JavaScript 引入异步模式的原因有两点:
- JavaScript 是单线程的
- 提高 CPU 利用率
事件队列与事件循环
事件队列
通过前边我们知道 JavaScript 是单线程的,主线程一次仅能执行一个任务,如果有一段很耗时的计算任务,那么很可能造成页面卡顿白屏等现象。为了解决这个问题,JavaScript 引入了异步回调的机制,而事件队列就是这个机制的核心。
事件队列用来存储“成功”的异步任务,敲重点,这里指的是成功的异步回调,也就是只有这个异步回调到执行时机了,那么事件循环会去事件队列中取任务。我们举个例子看看啥是成功的事件回调。
let p = new Promise((resolve, reject) => {
console.log('1')
resolve()
console.log('2')
})
setTimeout(() => {
console.log('3')
}, 1000)
p.then(() => {
console.log('4')
})
console.log('5')
看了上边代码,大家先别管输出结果,等你搞清楚事件循环就知道执行顺序了。比如我们上边的 setTimeout 包裹的回调函数,我们知道它会开启一个计时器,等指定时间过后才会执行,但其实到时间后也会立即执行,而是先将该回调推入到事件队列中,等待合适时机再拿到主线程上执行。你可以想象在事件队列中也是需要排队的,先进去的会先执行。
说了这么多,事件队列就是用来存储一个个的成功回调,队列中的回调任务会按顺序排列,以先进先出的规则来进行。
事件循环
在讨论事件循环之前,我们先来明确下微任务、宏任务和执行栈的概念:
1. 宏任务:
- 包括 setTimeout 、setInterval 、整体的 script 代码、I/O 操作等
- 宏任务按照队列顺序执行,每次执行一个
2. 微任务(Microtask):
- 包括 Promise.then / catch / finally 、process.nextTick() 、MutationObserver 等
- 当同步任务执行完毕,会立即执行微任务队列中的所有任务
3. 执行栈:
- 所有的代码都是在主线程执行的,主线程维护着一个执行栈(也叫调用栈),用于管理所有的同步任务
接下来,我们来看看事件循环的基本流程:
当一段代码进入执行栈,会先执行调用栈中的代码,同步任务先执行,再执行微任务,最后执行宏任务,但是这里每执行完一个宏任务,会到微任务队列中去检查是否有事件,有的话会优先去执行所有的微任务。(也就是说宏任务执行完一个,会去清空微任务中的任务(如果有的话),这里的微任务也有可能是在宏任务中产生的)
大家再看图好好理解下:
好了,大家只要搞清楚上面事件循环的基本流程,那么下面这段代码执行顺序那就是秒杀题:
let p = new Promise((resolve, reject) => {
console.log('1')
resolve()
console.log('2')
})
setTimeout(() => {
console.log('3')
}, 1000)
p.then(() => {
console.log('4')
})
console.log('5')
我们先来拆分,看看哪些属于同步代码,哪些属于微任务和宏任务。
首先,整个 script 会以一个宏任务进入执行栈,此时开始执行调用栈中的内容,我们来看下执行过程:
- 执行栈
- console.log('1')
- console.log('2')
- console.log('5')
- 微任务
- console.log('4')
- 宏任务
- console.log('3')
new Promise 的代码是同步的,所以会往下执行,先打印 1 ,随后执行 resolve(),我们知道 resolve 函数执行完后会执行 then 回调,Promise.then 属于微任务,会被放到微任务队列中,接着同步代码继续执行,打印 2 ;接下来遇到 setTimeout,浏览器会开启一个计时器,等待计时完成后会将对应回调推入到宏任务队列中;同步任务继续往下执行,打印 5,此时同步任务执行完毕,开始检查微任务中是否有任务,把刚才的 then 回调拿出来执行,打印 4,微任务为空,最后检查宏任务,发现 setTimeout 对应回调已在一秒后放入宏任务队列,从宏任务队列中取出任务执行,最后打印 3。
所以它的执行顺序是:1、2、5、4、3
再来看一下时序图:
执行顺序:
-
第一个宏任务(整体 script 代码):
- 遇到 Promise 构造函数,同步执行
- 执行
console.log('1')-> 输出 1 - Promise.resolve 后,将 then 回调放入微任务队列中
- 执行
console.log('2')-> 输出 2 - 等待 1s 后,将 setTimeout 对应回调放入宏任务队列中
- 执行
console.log('5')-> 输出 5
-
第一轮微任务:
- 执行 Promise.then 的回调
- 输出
console.log('4')--> 输出 4
-
第二个宏任务(setTimeout 的回调):
- 执行
console.log('3')--> 输出 3
- 执行
理解了事件循环,也就真正理解了 JavaScript 的异步。
JavaScript 实现异步的方式:Promise、async/await、generator 函数、事件监听、发布订阅模式、Web Workers、定时器函数等。
以上就是本篇的所有内容了,欢迎留言,对你有帮助的话就点个赞嘿嘿,大家一起加油~