JavaScript – 单线程 与 执行机制 (event loop)

前言

因为在写 RxJS 系列,有一篇要介绍 Scheduler。它需要对 JS 执行机制有点了解,于是就有了这里篇。

 

参考

知乎 – 详解JavaScript中的Event Loop(事件循环)机制

掘金 – 彻底搞懂JavaScript事件循环

关于JavaScript单线程的一些事

 

游览器与 JavaScript 的线程

游览器是多线程的

但是呢,负责执行 JavaScript 的却只有一条 JS 线程。

UI 渲染则是 GUI 线程负责,虽然是分开两条线程,但是它们关系又很密切。

JS 阻塞渲染

document.querySelector('h1')!.textContent = 'Hello World';
for (let i = 0; i < 5_000_000_000; i++) {} // 耗时 6 秒

游览器渲染是有周期的,第一句代码虽然更新了 DOM,但游览器并不会马上去渲染 UI。

它会等到所有 JS 执行完毕才去渲染。所以下面的 for loop 执行了 6 秒,那么 6 秒后用户才会看见 h1 变成 'Hello World'。

这就是 JS 阻塞渲染。

JS 不阻塞渲染

h1 {
  animation: moving 1s ease infinite;
}
@keyframes moving {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(100%);
  }
}

JS

for (let i = 0; i < 5_000_000_000; i++) {} // 耗时 6 秒

在这 6 秒中,CSS animation 依然可以跑的顺顺,这是因为 JS 和 UI 是不同线程负责的,它们是可以同时工作的。

 

JavaScript 的执行机制

first JS code

游览器从 URL 下载到 HTML 后,开始解析。当遇到 <script> 标签后去获取 JS 代码 (inline or src),

然后依据 defer or async 决定什么时候执行。 

JS code to 执行栈 (execution stack)

当要执行时,游览器会把 JS 代码放入 exec stack (想象它是一个 box),

exec stack 接获代码后就开始执行,我们先用简单的同步代码为例子

const value = '';
for (const number of [1, 2, 3, 4, 5]) {
  console.log(number);
}

执行完以后,JS 线程休息,轮到 UI 线程去渲染。这样就算完成了一个周期 (执行 JS + 渲染 = 1 周期)

执行异步代码

上面我们以同步代码为例,这里我们换成异步代码 (Ajax)。

执行 JS...遇到 Ajax...发送请求....这时就遇到一个等待的问题。

请求发送以后,需要等待 server response,这可能是一个漫长的过程。如果 JS 线程就傻傻的等,那么就有可能阻塞 UI 渲染 (为了不阻塞, JS 线程一定要尽快的执行完, 完成一个周期),

于是就有了异步这个概念,我们把要等待 response 才能继续执行的代码叫 callback,不需要等待 response 依然能继续执行的代码叫同步代码。

当 exec stack 遇到异步代码后,它会把 callback 存起来,然后继续执行后续的同步代码。这样 JS 线程就不需要傻傻等了。

执行完同步代码后,就渲染 UI 这样一个周期就结束了。

callback to event queue

当 response 回来以后,游览器会找出刚才保存的 callback 代码。然后把它放进 event queue (想象它是另一个 box)。

然后等待 exec stack 完成当前的周期,再把 event queue 的代码放进 exec stack,然后周而复始。

其它异步代码

除了上面提到的 Ajax 以外,SetTimeout,Event Listenner,Promise 这些都是异步代码,都有 callback。

Macro Task vs Micro Task

异步代码中还有细分 Macro Task 和 Micro Task。

SetTimeout,Event Listenner, ResizeObserver, IntersectionObserver 属于 Macro Task

Promise.resolve,queueMicrotask,MutaionObserver 属于 Micro Task

它们的执行时机不同。

exec stack 执行完 JS 代码后,会先看 event queue 有没有 Micro Task 可以执行。如果有就立刻执行 (before UI render)。没有的话就完成这次周期。

然后去看 event queue 有没有 Macro Task 可以执行 (after UI render)

Promise, SetTimeout, requestAnimationFrame 触发时机

requestAnimationFrame(() => {
  console.log('4. async requestAnimationFrame – next next cycle');
});

setTimeout(() => {
  console.log('3. async setTimeout – next cycle');
}, 4);

Promise.resolve().then(() => {
  console.log('2. async Promise – this cycle');
});

console.log('1. sync console – this cycle');

结果

Console 是同步代码,在当前周期执行。

Promise / queueMicrotask 是异步代码,callback 进 event queue,同时它是 Micro Task。exec stack 执行完后会马上去执行 Micro Task 才结束周期,所以它依然是当前周期。

SetTimeout 是异步代码 Macro Task,它的默认值是 4ms 后执行,所以它会进入 event queue,肯定不是当前周期执行。

requestAnimationFrame 是异步代码 Macro Task,它大约是 60ms 后执行 (这个不一定哦,有时候甚至不到 4ms 它就执行了,游览器有它的算法)。它也是进入 event queue,肯定不是当前周期执行。

上面 4 行代码,Macro Task 就会产生新的周期,所以一定会有 3 个周期。

为什么 SetTimeout 触发时机不精准?

timeout 的计数不是 JS 线程负责的,游览器有一条计数的线程,时间到的时候,callback 会被放入 event queue。

但是并不一定马上执行。如果 exec stack 正忙着,时间自然就被耽搁了,就不精准了。

所以,不管有多少线程帮忙分工,执行 JS 的始终只有一条 JS 线程,还是有许多局限的。

耗时的 CPU 操作导致阻塞

异步的解决思路是靠 callback,JS 线程不等待就不会阻塞。

但是如果我跑 for loop 100亿次呢?JS 线程忙不过来,还是会阻塞 UI 渲染。

所以就有了 Web Worker。Web Worker 和 SetTimeout 是一样的概念 (setTimeout 游览器会给予多一条线程来帮忙计数,于是 JS 主线程就 free 了)

当开启 Web Worker 后,游览器会创建一条线程去处理 JS 代码 (比如 for loop 100亿次),这时 JS 主线程就 free 了。

然后就等 callback 咯。

 

执行机制 FAQ

1. 游览器是单线程吗?

不是,游览器有很多线程。比如 JS 线程,UI 线程,Timer 线程 等等等。

 

2. JS 会影响 UI 渲染吗?

有些时候会,虽然它们是不同的线程,但是游览器渲染是有周期的。JS 一旦运行,就必须等到它结束后才更新 DOM 做 UI 渲染 (独立的 CSS animation,hover effect 这些就不受影响,可以并行)

 

3. JS 是单线程吗?

是也不是,JS 只有一条主线程。主线程阻塞就会导致 UI 无法渲染。但是 JS 可以通过 Web Worker 开多几条子线程来帮忙处理耗时的 CPU 计算。

这样就可以减轻主线程的负担,它就不会阻塞了。另外,Web Worker 是不能直接访问 DOM 的,它只能透过主线程间接访问 DOM,从这一点就看得出来它俩的职责是不一样的了。

 

4. 同步和异步代码是什么?有什么区别?

同步就是一镜到底,没有中断的执行。

异步就是跑了个开头,然后等待 (可以等另一个线程,等 IO,等 Network,等谁不重要),等到时机到了,再运行后续的代码 (callback)。

 

5. 为什么需要异步?

因为 JS 主线程不能一直跑,一直跑 UI 就一直不渲染,用户就感觉死机了。所以要分段。

另一点是,在等 IO, Network 时,JS 线程本来就没事干。瞎等干什么呢,倒不如去做点别的。

 

6. Promise,queueMicrotask,SetTimeout,requestAnimationFrame,UI render 谁先触发?

sync JS > async Micro Task > UI Render > async Macro Task

首先三个都是异步,一定后于同步代码

Promise / queueMicrotask 是 Micro Task 先于 UI render,timeout 和 animation (Macro Task)

UI render 先于 timeout 和 animation

SetTimeout 和 requestAnimationFrame 谁先不好说,因为是游览器控制的。一个是默认是 4ms,另一个大约在 60ms。

 

7. queueMicrotask 和 Promise 谁先触发?

它们是同级,你先调用哪个,哪个的 callback 就先跑

 

8. queueMicrotask 里面又调用了 queueMicrotask 什么时候执行?

window.setTimeout(() => console.log('timeout'));     // 第四
queueMicrotask(() => {
  console.log('micro1');   // 第一
  queueMicrotask(() => {
    console.log('micro2'); // 第三
  });
});
Promise.resolve().then(() => console.log('promise')); // 第二

同步执行完,会拿出 micro1 和 Promise

执行 micro1 时会创建 micro2 放进 queue,接着执行 Promise

接着拿出 micro2 执行

接着拿出 setTimeout 执行

 

9. 为什么 interval, timeout 时间不精准?

timer 是准的,只是到点的时候,游览器只把 callback 放入了 event queue。

而 event queue 必须等到 exec stack 完成当前周期 (执行 + 渲染) 后,才会把 callback 交给 exec stack。

如果这时碰巧 exec stack 在忙,那么自然就耽误了。

 

10. 什么时候需要用 Web Worker?

当你需要处理耗时的 CPU 操作时。

开启 Web Worker 主要的目的不是为了开更多线程分工,从而提高计算速度. (这是目的, 但不一定时主要目的),

更重要的原因是不要让 JS 主线程阻塞影响到 UI 渲染。

 

posted @ 2022-10-30 00:23  兴杰  阅读(269)  评论(0编辑  收藏  举报