JavaScript的单线程模型 常年唱衰却经久不衰 为了扬长避短它做了哪些操作?

引言

JavaScript 作为一门单线程语言,在执行时只能处理一个任务,这使得它在设计上更为简单,但某些方面也被人们批评,比如阻塞操作、处理复杂计算出现性能问题、回调地狱使代码难以维护等等。但是JS通过使用异步编程Web WorkersWeb 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)

异步操作将被分发到宏任务队列和微任务队列中,然后由事件循环按照特定的顺序执行:

  1. 执行同步代码(宏任务):事件循环的执行过程始于同步代码的执行。从调用栈中执行同步任务,这其实就相当于开启第一轮的宏任务。

  2. 查询异步代码并执行微任:当执行栈为空时,事件循环会查询是否有异步代码需要执行。如果有,将执行微任务队列中的任务。

  3. 渲染页面:在执行微任务后,如果需要,会渲染页面。这一步通常发生在宏任务执行前,以确保用户界面的及时响应。

  4. 执行宏任务:最后,事件循环会执行宏任务队列中的任务。这个过程就是事件循环的一个完整轮回。执行完宏任务后,再次查询是否有微任务需要执行,依此类推。

解析异步编程执行过程

在了解了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');
})
  1. 同步任务的执行 :代码开始执行,首先是同步任务。console.log('start') 输出 'start'

  2. 宏任务的处理 :接下来,遇到了一个定时器 setTimeout,它是一个宏任务。它会被放入宏任务队列,并在当前宏任务执行完成后,等待 1000 毫秒后执行。

  3. 微任务的处理 :然后,遇到了一个 Promise 构造函数,因为它是一个同步任务,所以会先执行console.log('Promise') 输出 'Promise',然后 resolve() 触发了两个 .then() 方法,它们都是微任务,被放入微任务队列。

  4. 执行微任务 :到这里第一轮宏任务中的同步任务就执行完了,于是开始执行微任务。执行微任务队列中的任务有两个 .then() 方法分别输出 'then1''then2'

  5. 执行定时器的回调 :经过一段时间(1000 毫秒),定时器的回调函数执行,也就是开启第二轮宏任务。console.log('setTimeout') 输出 'setTimeout',接着设置了一个嵌套的 setTimeout,这个定时器也被推入宏队伍队列中,然后输出 'end'

  6. 执行嵌套的 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()
  1. foo 函数的调用

    • foo 函数被调用,进入执行栈。
  2. await A()

    • 遇到 await A()A() 返回的 Promise 进入等待状态。
    • await 会将后续代码(console.log(1); await B(); await C();)封装成微任务,推入微任务队列。
  3. 控制权交还给事件循环

    • 当前宏任务(执行 foo 函数)执行完毕,控制权交还给事件循环。
  4. 执行微任务队列

    • 微任务队列中有 console.log(1); await B(); await C();
    • 执行 console.log(1) 输出 '1'
  5. 执行宏任务队列中的 foo 函数的下一部分

    • await B():遇到 await B()B() 返回的 Promise 进入等待状态。
    • await 会将后续代码(await C();)封装成微任务,推入微任务队列。
  6. 控制权交还给事件循环

    • 当前宏任务(执行 foo 函数的一部分)执行完毕,控制权交还给事件循环。
  7. 执行微任务队列

    • 微任务队列中有 await C();
    • 执行 await C():遇到 await C()C() 返回的 Promise 进入等待状态。
  8. 控制权交还给事件循环

    • 当前宏任务(执行 foo 函数的一部分)执行完毕,控制权交还给事件循环。
  9. 执行微任务队列

    • 微任务队列中没有任务了。
  10. 执行宏任务队列中的 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...

相关推荐
柏箱几秒前
使用JavaScript写一个网页端的四则运算器
前端·javascript·css
TU^2 分钟前
C语言习题~day16
c语言·前端·算法
一颗花生米。3 小时前
深入理解JavaScript 的原型继承
java·开发语言·javascript·原型模式
学习使我快乐013 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19953 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
勿语&4 小时前
Element-UI Plus 暗黑主题切换及自定义主题色
开发语言·javascript·ui
黄尚圈圈4 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水5 小时前
简洁之道 - React Hook Form
前端
正小安7 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序