异步与单线程

114 阅读8分钟

前言

大家好,我是 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 引入异步模式的原因有两点:

  1. JavaScript 是单线程的
  2. 提高 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. 执行栈:

  • 所有的代码都是在主线程执行的,主线程维护着一个执行栈(也叫调用栈),用于管理所有的同步任务

接下来,我们来看看事件循环的基本流程:

当一段代码进入执行栈,会先执行调用栈中的代码,同步任务先执行,再执行微任务,最后执行宏任务,但是这里每执行完一个宏任务,会到微任务队列中去检查是否有事件,有的话会优先去执行所有的微任务。(也就是说宏任务执行完一个,会去清空微任务中的任务(如果有的话),这里的微任务也有可能是在宏任务中产生的)

大家再看图好好理解下:

事件循环.png

好了,大家只要搞清楚上面事件循环的基本流程,那么下面这段代码执行顺序那就是秒杀题:

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

再来看一下时序图: 事件循环时序图.png

执行顺序:

  1. 第一个宏任务(整体 script 代码):

    • 遇到 Promise 构造函数,同步执行
    • 执行 console.log('1') -> 输出 1
    • Promise.resolve 后,将 then 回调放入微任务队列中
    • 执行 console.log('2') -> 输出 2
    • 等待 1s 后,将 setTimeout 对应回调放入宏任务队列中
    • 执行 console.log('5') -> 输出 5
  2. 第一轮微任务:

    • 执行 Promise.then 的回调
    • 输出 console.log('4') --> 输出 4
  3. 第二个宏任务(setTimeout 的回调):

    • 执行 console.log('3') --> 输出 3

理解了事件循环,也就真正理解了 JavaScript 的异步。

JavaScript 实现异步的方式:Promise、async/await、generator 函数、事件监听、发布订阅模式、Web Workers、定时器函数等。

以上就是本篇的所有内容了,欢迎留言,对你有帮助的话就点个赞嘿嘿,大家一起加油~