前端进击笔记第七节 单线程的 JavaScript 如何管理任务?

287 篇文章 100 订阅

如果说 JavaScript 代码运行过程中的语法分析阶段、编译阶段和执行阶段属于微观层面的运行逻辑,那么今天我来介绍下宏观角度下的 JavaScript 运行过程,包括 JavaScript 的单线程设计、事件循环的并发模型设计。

要怎么理解 JavaScript 是单线程这个概念呢?大概需要从浏览器来说起。

JavaScript 最初被设计为浏览器脚本语言,主要用途包括对页面的操作、与浏览器的交互、与用户的交互、页面逻辑处理等。如果将 JavaScript 设计为多线程,那当多个线程同时对同一个 DOM 节点进行操作时,线程间的同步问题会变得很复杂。

因此,为了避免复杂性,JavaScript 被设计为单线程。

这样一个单线程的 JavaScript,意味着任务需要一个接一个地处理。如果有一个任务是等待用户输入,那在用户进行操作前,所有其他任务都处于等待状态,页面会进入假死状态,用户体验会很糟糕。

那么,为了高效进行页面的交互和渲染处理,我们围绕着任务执行是否阻塞 JavaScript 主线程,将 JavaScript 中的任务分为同步任务和异步任务。

同步任务与异步任务

  • 同步任务:在主线程上排队执行的任务,前一个任务完整地执行完成后,后一个任务才会被执行。

  • 异步任务:不会阻塞主线程,在其任务执行完成之后,会再根据一定的规则去执行相关的回调。

我们先来看一下同步任务在浏览器中的是怎样执行的。

同步任务与函数调用栈

在 JavaScript 中,同步任务基本上可以认为是执行 JavaScript 代码。在上一讲内容中,我们提到 JavaScript 在执行过程中每进入一个不同的运行环境时,都会创建一个相应的执行上下文。那么,当我们执行一段 JavaScript 代码时,通常会创建多个执行上下文。

而 JavaScript 解释器会以栈的方式管理这些执行上下文、以及函数之间的调用关系,形成函数调用栈(call stack)(调用栈可理解为一个存储函数调用的栈结构,遵循 FILO(先进后出)的原则)。

我们来看一下 JavaScript 中代码执行的过程:

  1. 首先进入全局环境,全局执行上下文被创建并添加进栈中;

  2. 每调用一个函数,该函数执行上下文会被添加进调用栈,并开始执行;

  3. 如果正在调用栈中执行的 A 函数还调用了 B 函数,那么 B 函数也将会被添加进调用栈;

  4. 一旦 B 函数被调用,便会立即执行;

  5. 当前函数执行完毕后,JavaScript 解释器将其清出调用栈,继续执行当前执行环境下的剩余的代码。

由此可见,JavaScript 代码执行过程中,函数调用栈栈底永远是全局执行上下文,栈顶永远是当前执行上下文

在不考虑全局执行上下文时,我们可以理解为刚开始的时候调用栈是空的,每当有函数被调用,相应的执行上下文都会被添加到调用栈中。执行完函数中相关代码后,该执行上下文又会自动被调用栈移除,最后调用栈又回到了空的状态(同样不考虑全局执行上下文)。

由于栈的容量是有限制的,所以当我们没有合理调用函数的时候,可能会导致爆栈异常,此时控制台便会抛出错误:

Drawing 0.png

这样的一个函数调用栈结构,可以理解为 JavaScript 中同步任务的执行环境,同步任务也可以理解为 JavaScript 代码片段的执行。

同步任务的执行会阻塞主线程,也就是说,一个函数执行的时候不会被抢占,只有在它执行完毕之后,才会去执行任何其他的代码。这意味着如果我们一个任务执行的时间过长,浏览器就无法处理与用户的交互,例如点击或滚动

因此,我们还需要用到异步任务。

异步任务与回调队列

异步任务包括一些需要等待响应的任务,包括用户交互、HTTP 请求、定时器等。

我们知道,I/O 类型的任务会有较长的等待时间,对于这类无法立刻得到结果的事件,可以使用异步任务的方式。这个过程中 JavaScript 线程就不用处于等待状态,CPU 也可以处理其他任务。

异步任务需要提供回调函数,当异步任务有了运行结果之后,该任务则会被添加到回调队列中,主线程在适当的时候会从回调队列中取出相应的回调函数并执行。

这里提到的回调队列又是什么呢?

实际上,JavaScript 在运行的时候,除了函数调用栈之外,还包含了一个待处理的回调队列。在回调队列中的都是已经有了运行结果的异步任务,每一个异步任务都会关联着一个回调函数。

回调队列则遵循 FIFO(先进先出)的原则,JavaScript 执行代码过程中,会进行以下的处理:

  • 运行时,会从最先进入队列的任务开始,处理队列中的任务;

  • 被处理的任务会被移出队列,该任务的运行结果会作为输入参数,并调用与之关联的函数,此时会产生一个函数调用栈;

  • 函数会一直处理到调用栈再次为空,然后 Event Loop 将会处理队列中的下一个任务。

这里我们提到了 Event Loop,它主要是用来管理单线程的 JavaScript 中同步任务和异步任务的执行问题。

单线程的 JavaScript 是如何管理任务的

我们知道,单线程的设计会存在阻塞问题,为此 JavaScript 中任务被分为同步和异步任务。那么,同步任务和异步任务之间是按照什么顺序来执行的呢?

JavaScript 有一个基于事件循环的并发模型,称为事件循环(Event Loop),它的设计解决了同步任务和异步任务的管理问题。

根据 JavaScript 运行环境的不同,Event Loop 也会被分成浏览器的 Event Loop 和 Node.js 中的 Event Loop。

浏览器的 Event Loop

在浏览器里,每当一个被监听的事件发生时,事件监听器绑定的相关任务就会被添加进回调队列。通过事件产生的任务是异步任务,常见的事件任务包括:

  • 用户交互事件产生的事件任务,比如输入操作;

  • 计时器产生的事件任务,比如setTimeout

  • 异步请求产生的事件任务,比如 HTTP 请求。

JavaScript 的运行过程,可以借用 Philip Roberts 演讲《Help, I'm stuck in an event-loop》中经典的一张图来描述:

Drawing 1.png

如图,主线程运行的时候,会产生堆(heap)和栈(stack),其中堆为内存、栈为函数调用栈。我们能看到,Event Loop 负责执行代码、收集和处理事件以及执行队列中的子任务,具体包括以下过程。

  1. JavaScript 有一个主线程和调用栈,所有的任务最终都会被放到调用栈等待主线程执行。

  2. 同步任务会被放在调用栈中,按照顺序等待主线程依次执行。

  3. 主线程之外存在一个回调队列,回调队列中的异步任务最终会在主线程中以调用栈的方式运行。

  4. 同步任务都在主线程上执行,栈中代码在执行的时候会调用浏览器的 API,此时会产生一些异步任务。

  5. 异步任务会在有了结果(比如被监听的事件发生时)后,将异步任务以及关联的回调函数放入回调队列中。

  6. 调用栈中任务执行完毕后,此时主线程处于空闲状态,会从回调队列中获取任务进行处理。

上述过程会不断重复,这就是 JavaScript 的运行机制,称为事件循环机制(Event Loop)。

Event Loop 的设计会带来一些问题,比如setTimeoutsetInterval的时间精确性。这两个方法会设置一个计时器,当计时器计时完成,需要执行回调函数,此时才把回调函数放入回调队列中。

如果当回调函数放入队列时,假设队列中还有大量的回调函数在等待执行,此时就会造成任务执行时间不精确。

要优化这个问题,可以使用系统时钟来补偿计时器的不准确性,从而提升精确度。举个例子,如果你的计时器会在回调时触发二次计时,可以在每次回调任务结束的时候,根据最初的系统时间和该任务的执行时间进行差值比较,来修正后续的计时器时间。

Node.js 中的 Event Loop

除了浏览器,Node.js 中同样存在 Event Loop。由于 JavaScript 是单线程的,Event Loop 的设计使 Node.js 可以通过将操作转移到系统内核中,来执行非阻塞 I/O 操作。

Node.js 中的事件循环执行过程为:

  1. 当 Node.js 启动时将初始化事件循环,处理提供的输入脚本;

  2. 提供的输入脚本可以进行异步 API 调用,然后开始处理事件循环;

  3. 在事件循环的每次运行之间,Node.js 会检查它是否正在等待任何异步 I/O 或计时器,如果没有,则将其干净地关闭。

与浏览器不一样,Node.js 中事件循环分成不同的阶段:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤               |
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

图片1.png
由于事件循环阶段划分不一致,Node.js 和浏览器在对宏任务和微任务的处理上也不一样。

宏任务和微任务

事件循环中的异步回调队列有两种:宏任务(MacroTask)和微任务(MicroTask)队列。

什么是宏任务和微任务呢?

  • 宏任务:包括 script 全部代码、setTimeoutsetIntervalsetImmediate(Node.js)、requestAnimationFrame(浏览器)、I/O 操作、UI 渲染(浏览器),这些代码执行便是宏任务。

  • 微任务:包括process.nextTick(Node.js)、PromiseMutationObserver,这些代码执行便是微任务。

为什么要将异步任务分为宏任务和微任务呢?这是为了避免回调队列中等待执行的异步任务(宏任务)过多,导致某些异步任务(微任务)的等待时间过长。在每个宏任务执行完成之后,会先将微任务队列中的任务执行完毕,再执行下一个宏任务。

因此,前面我们所说的回调队列可以理解为宏任务队列,同时还有另外一个任务队列为微任务队列。

在浏览器的异步回调队列中,宏任务和微任务的执行过程如下:

  1. 宏任务队列一次只从队列中取一个任务执行,执行完后就去执行微任务队列中的任务。

  2. 微任务队列中所有的任务都会被依次取出来执行,直到微任务队列为空。

  3. 在执行完所有的微任务之后,执行下一个宏任务之前,浏览器会执行 UI 渲染操作、更新界面。

我们能看到,在浏览器中每个宏任务执行完成后,会执行微任务队列中的任务。而在 Node.js 中,事件循环分为 6 个阶段,微任务会在事件循环的各个阶段之间执行。也就是说,每当一个阶段执行完毕,就会去执行微任务队列的任务。

宏任务和微任务的执行顺序,常常会被用作面试题,比如下面这道考察PromisesetTimeoutasync/await等 API 执行顺序的题目:

console.log("script start");

setTimeout(() => {
 console.log(“setTimeout”);
}, 1000);

Promise.resolve()
.then(function () {
   console.log(“promise1”);
})
.then(function () {
   console.log(“promise2”);
});

async function errorFunc() {
 try {
   await Promise.reject(“error!!!”);
} catch (e) {
   console.log(“error caught”); // 微1-3
}
 console.log(“errorFunc”);
 return Promise.resolve(“errorFunc success”);
}
errorFunc().then((res) => console.log(“errorFunc then res”));

console.log(“script end”);

你知道这道题的答案是什么吗?欢迎在留言区写下你的解题过程。

小结

今天我介绍了 JavaScript 的单线程设计,它的设计初衷是为了让用户获得更好的交互体验。同时,为了避免单线程的任务执行过程中发生阻塞,事件循环(Event Loop)机制便出现了。

在浏览器和 Node.js 中,都存在单线程的 Event Loop 设计,它们之间的不一致主要表现为 Event Loop 阶段划分以及宏任务和微任务的处理。

或许你会感到疑惑,除了应对面试以外,掌握 JavaScript 的事件循环、宏任务和微任务相关机制,对我们有什么用处呢?

要知道,浏览器中在执行 JavaScript 代码的时候不会进行页面渲染,如果一项任务花费的时间太长,浏览器将无法执行其他任务(例如处理用户事件)。因此,当存在大量复杂的计算、或导致了死循环的编程错误时,甚至会使页面终止。

我们可以更合理地利用这些机制来拆分任务,比如考虑将多次触发的数据变更通过微任务收集起来,再一起进行 UI 的更新和渲染,便可以降低浏览器渲染的频率,提升浏览器的性能,给到用户更好的体验。


精选评论

**洲:
  1. 脚本先执行同步代码, 宏任务, 顺序是 “script start”, setTimeout, “script end”, 由于 setTimeout 是异步任务, 所以程序不会等待它完成, 所以 setTimeout 的回调函数会被挂起, 在将来等待时间完成之后就会把它重新调入回调队列, 第一轮执行完成之后, 此时微任务有 Promise.resolve(), errorFunc(), 它们会被加入回调队列, 顺序是 Promise.resolve() = errorFunc()2. 此时主线程处于空闲状态, 需要从回调队列中提取任务, 队列是先进先出, 所以取出来的是 Promise.resolve(), 此时它就会进入调用栈, 接着主线程就从调用栈中取出它运行, 所以现在就会输出 promise, 与此同时, 产生了下一个微任务, 这个微任务接着也会被加入回调队列, 此时回调队列的顺序是 errorFunc() = Promise.resolve() 产生的微任务(PR)3. 接下来同理, errorFunc() 会被处理, 因为 await 会阻塞异步操作, 所以这个 await 后面的 Promise 不会去回调队列排队, 而是等待完成, 所以 “error caught” 就会被输出, 接着是一段同步代码, 所以就会输出 “errorFunc”, 同理, 异步函数返回的 Promise 会被加入回调队列中排队, 此时回调队列是 PR = errorFunc 返回的回调4. 同理, 此时会执行 “promise 2”, 接着就会执行 "errorFunc then res"5. 接着就是 setTimeout 的等待时间到了, 其回调函数加入回调队列, 执行 “setTimeout”, 因为宏任务一次只执行一次, 然后是执行所有的微任务, 所有微任务执行完之后, 再执行下一次宏任务, 所以就算 setTimeout 计时时间为 0, 也是最后执行6. 最后的运行结果为 “script start”, “script end”, “promise 1”, “error caught”, “errorFunc”, “promise 2”, “errorFunc then res”, “setTimeout”
    讲师回复:

    分析得很好,还可以考虑下在 Node.js 环境下,是否会是同样的结果呢~~

*振:

node11以后的事件循环,执行结果与浏览器是一样的吧

    讲师回复:

    没错~在 node 11 之后的版本,的确是浏览器保持一致了~
以 timers 阶段为例,在 node 11 版本之前,只有全部执行了 timers 阶段队列的全部任务才执行微任务队列;在 node 11 版本开始,timer 阶段的 setTimeout、setInterval 被修改为,执行一个任务就立刻执行微任务队列,与浏览器趋同了~

*伟:

感觉node那块的event loop 没有讲明白啊?有点懵

    讲师回复:

    在 Node.js 中,事件循环分为 6 个阶段,微任务会在事件循环的各个阶段之间执行。也就是说,每当一个阶段执行完毕,就会去执行微任务队列的任务。
可以以文中的例子来试试看,在浏览器和 Node.js 环境中的执行结果有什么不一样(当然,Node.js 11 版本之后,两个结果已经一致了,可以参考下其他评论)

**新:

宏观任务和微观任务的案例还是不太懂

    讲师回复:

    具体哪里不懂呢?能详细描述下吗?

**哈:

系统时钟指的是屏幕的刷新频率吧

    讲师回复:

    指的是 Date.prototype.getTime(),可以手动计算每次定时器中回调执行的时间差,然后调整下一次定时器的时间,从而缓解多个定时器累加后导致的时间差距越来越大的问题

856:

老是 能解释下I/O 操作吗?

    讲师回复:

    I/O,即 input/output 输入输出操作。对网页来说,有用户的交互(点击、拖动等)、存储和 DB 的读取等等。这些操作都需要进行等待,比如等待用户的操作才会触发对应的事件,等待存储读写完成等。

*龙:

有个问题,既然主线程执行完之后,先去宏任务取一个,执行之后,再清空微任务队列,那不是定时器先执行吗

    讲师回复:

    定时器有延时噢,会在延时完成后才将回调任务添加到队列

**东:

script全部代码是异步任务下的宏任务,那同步任务指javacript代码片段,script不就包括javascript片段了,这不懂

    讲师回复:

    这个问题有点没看明白。同步任务可任务是执行 JavaScript 代码片段这个没错,但是 JavaScript 代码在执行过程中会产生异步任务噢,比如 setTimeout,异步任务的回调可以理解为另外的 JavaScript 代码片段,会在异步任务队列等待主线程获取并执行,只不过异步任务队列也分为宏任务和微任务两种而已

*浩:

注意了:浏览器环境下的EventLoop与Node.js环境下的EventLoop模型还是有不同的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值