你不知道的 JS 单线程

1,125 阅读18分钟

Event Loop 就像过山车

前言

本来是完全不想写这篇文章的,奈何 JS 单线程、异步、事件循环经常被拿来面试,而这些其实是个连环、整体。

好了言归正传,大家都知道 JS 是一门单线程的语言,类似的语言还有 Python、Ruby 都属于单线程,不过它们各自都有多线程的库来实现多线程编程。JS 的单线程意味着在同一个 JS 引擎上一次只能做一件事,就像这样:

console.time();
console.log(1);
console.log(2);
console.log(3);
console.timeEnd();

简单代码的执行时间

而单线程最大的问题是当一段代码执行特别耗时,用户就要等待很久才能看到结果,也就是阻塞。模拟如下:

function executeTask() {
  for (let i = 0; i < 1000000; i++) {
    if (i === 1000000 - 1) console.log('task over');
  }
}

console.log('Start!');
console.time('very slow');
executeTask();
console.timeEnd('very slow');
console.log('Done!');

image.png

当 JS 引擎解析并运行代码时发生了什么?要想知道发生了什么,首先要知道它处在什么样的环境中。

浏览器架构

过去的浏览器采用单进程架构,在打开一个浏览器应用时,系统就分配了一块供数据使用的内存以及多个线程,线程之间允许通信。比如网络线程、插件线程、渲染线程、GPU 线程,所有标签页都共用一个进程中的所有线程。这样的架构设计意味着一旦其中一个线程崩溃,那么整个进程就会跟着崩溃。这就是为什么当你玩 4399 卡顿时,整个浏览器就疯狂转圈圈的原因,于是不得不重新启动浏览器。

而现代的浏览器大多采用多进程架构,因此在打开一个浏览器时,浏览器进程、网络进程、GPU 进程和插件进程都准备好了,在打开新的标签页时,浏览器会为该标签页创建一个新的渲染进程,它将负责处理该标签页的内容渲染和交互。这些彼此独立的进程让浏览器更加稳定。为了进一步保证数据安全,每一个页面的渲染进程和插件进程会被放入沙箱内。

渲染进程中的组件和功能则与我们息息相关。

组件/功能概述备注
渲染引擎负责解析 HTML、CSS 和 JavaScript,并将其转换为可视化的网页内容排版引擎:WebKit、Blink
布局引擎负责计算和确定网页元素的位置和大小,以便正确显示在浏览器窗口中CSS 盒模型以及其他布局规则来处理网页布局
JavaScript 引擎解析和执行网页中的 JavaScript 代码Chrome 的 V8 引擎。将 JavaScript 代码转换为可执行的指令,并与其他组件进行交互,实现网页的动态功能和交互性
GPU 加速利用计算机的图形处理单元(GPU)来加速图形渲染并行的 GPU 渲染线程
网络栈负责处理网络请求和响应与浏览器的网络进程进行通信,获取网页所需的资源,如 HTML、CSS、JavaScript 文件、图像和视频等
事件处理负责处理用户的交互事件,如鼠标点击、键盘输入和滚动等将这些事件传递给适当的组件和 JavaScript 代码,以触发相应的行为和功能

从表格中不难发现,上面的阻塞代码就发生在渲染进程中。在渲染进程拿到网络进程中传过来的 HTTP 文档后,渲染引擎就会对 HTTP 文档中的标记进行解析,从而创建 DOM 树,背后则是利用栈的这种数据结构,比如当遇到开闭标签时,完成一组节点的构建,如此这般创建出 DOM 树。

然而当解析到 script 标签时,HTML 解析器则会暂停,因为 JS 作为用户交互和实现动态网页的关键,必然会对 DOM 元素进行改动,此时,JS 引擎对代码进行下载、解析和执行,执行完成后才会恢复 HTML 解析器的运行。这就是为什么我们要把 JS 代码放在 HTML 文档的最下方的根本原因(defer 和 async 属性不在讨论范围),总不能都不存在这个 DOM 元素还去操作吧,比如下面这个 HTML 文档,在打印 body 元素时,body 都不在 DOM 树上呢,显然结果为 null

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>single thread in js</title>
    <script>
	  console.log(document.body);
    </script>
  </head>
  <body>
	 Hello world
  </body>
</html>

那如果我们将最开始的阻塞代码放到这份文档的 script 部分又会发生什么呢?那就是长达 1.5s 的白屏。如果按照 CSR 架构方式进行代码研发,页面内容由 JS 动态渲染形成,最大的问题就是,大型的前端项目的 JS 文件体积很大或者 JS 文件特别多而又不做优化,可想而知,白屏将会对用户体验产生多么大的影响以及对服务器的压力,当然还有 SEO 的影响,这就要考虑使用 SSR 了。

JS 单线程

回到那个问题——当 JS 引擎解析并运行代码时发生了什么?我把步长改为 10,下面是在 JS 调用栈中的可视化结果(红色箭头表示下一步要执行的代码,绿色箭头表示刚刚执行过的代码):

executeTask() 进入全局调用栈

executeTask 函数被放进全局上下文,它的值是一个引用类型,引用堆中某个对象。

全局上下文在 JS 引擎开始解析代码时被创建的,是代码执行的起点(可以理解为一种环境,比如你在厨房这个环境下,有锅碗瓢盆、油盐酱醋,然后就能做出美味佳肴),其中放了全局对象(在浏览器环境下是 window 对象、在 node.js 环境下是 global 对象)、全局变量、全局函数定义(例如这里的 executeTask 函数声明,如果是函数表达式则属于全局变量)、this 关键词(在全局上下文中,this关键字引用全局对象)。

当有函数被调用时,就会创建函数执行上下文,然后推入全局上下文的执行调用栈的顶部,如果嵌套函数,那么会创建新的函数执行上下文,然后继续往栈里推,而这些函数执行上下文就形成了作用域链,从而决定了变量和函数的可访问性。在执行完成后,函数执行上下文就会被弹出调用栈,作用域也随之销毁(闭包除外),这样就保证了代码的执行顺序。

console.log('Start!') 执行后打印出 Start!

console.log('Start!') 执行后打印 "Start!",就像上面解释的,这里是有所简化的。

console 属于浏览器提供的全局对象(window.console),logconsole 对象的一个方法。在执行 console.log 方法时,会创建一个新的函数执行上下文(包含了函数的局部变量、参数和函数的作用域链),然后被推入调用栈。之后,解析器会把字符串 "Start!" 作为参数传递给 log 方法,在打印完成后,console.log 的上下文就会从栈中弹出(此时内存将会得到释放),而全局执行上下文继续执行其他代码,直到程序结束。

console.time('very slow') 执行后记录开始时间

console.time('very slow') 执行后记录开始时间。

调用 executeTask 函数,创建函数执行上下文,入栈。

调用 executeTask 函数,创建函数执行上下文,入栈,扫描函数作用域下的变量,此时 i 为 undefined。

执行 i 的初始化

执行 i 的初始化

因为使用了 let,块级作用域下,i 初始化为 0。

初始化完成

初始化完成,i 为 0。

if 条件判断

if 条件判断

判断条件。

执行 i++

新一轮迭代,i< 10,i 的值增加。

i 的值为 1

i 的值为 1。

if 条件判断完毕,准备下一次迭代

if 条件判断完毕,准备下一次迭代。以此类推。

i === 9 ?

判断 i 是否等于 9。

打印 task over

打印 task over。

判断是否还能进入下一次迭代

判断是否还能进入下一次迭代,此时 i 为 10,退出 for 循环。

函数返回 undefined

函数返回 undefined。

executeTask 执行完毕,出栈。

executeTask 执行完毕,函数执行上下文销毁,弹出执行调用栈。

打印出 very slow 的耗时

打印出 very slow 的耗时。

打印 Done!

打印出 Done!,程序运行结束。

在关闭当前 Tab 标签页后,渲染进程也随之关闭,全局上下文自然也没有了。

最后,用一张图来总结:

single thread in js

需要补充的一点是,CSS 的下载和解析也会占用主线程,毕竟 DOM 元素需要用 CSS 来布局和上色。不过,在一般情况下,渲染引擎会在解析 HTML 时提前开一个预解析线程扫描 CSS 和 JS 文件引用,提前下载好这些文件。

异步编程 —— callback

在常见的应用场景中,假设所有代码只能通过同步的方式编写和运行,那将是一场灾难。好在 JS 支持异步编程模型,我们通过使用回调函数、Promise、async/await 等机制,实现非阻塞的异步操作。

在起初,我们使用回调函数处理异步任务,这里使用定时器来模拟异步的网络请求:

callback

console.log('其他代码1');
console.log('其他代码2');
// 选择相片
function getPhoto(cb) {
  setTimeout(() => {
    console.log('1. 选取相片');
    const name = '天上有朵云';
    cb(name);
  }, 1000);
}
// 设置滤镜效果
function setFilters(data, cb) {
  setTimeout(() => {
    console.log('2. 设置滤镜效果');
    cb({
      photo: data,
      filters: '自然美',
    });
  }, 1000);
}
// 添加标题
function addTitle(data, cb) {
  setTimeout(() => {
    console.log('3. 添加标题');
    cb(data.filters + '-' + data.photo + '.png');
  }, 1000);
}
// 发布作品
function publish(data) {
  setTimeout(() => {
    console.log('4. 作品发布成功!', data);
  }, 2000);
}

function main() {
  console.log('execute main');
  getPhoto(function (photo) {
    setFilters(photo, function (filteredData) {
      addTitle(filteredData, function (finalData) {
        publish(finalData);
      });
    });
  });
}
main();
console.log('其他代码3');
console.log('其他代码4');
console.log('其他代码5');
console.log('其他代码6');

显然,main 函数的执行并不会阻塞其他代码的执行。

result

但 main 函数还是有一个问题,回调函数一旦有很多层级,那么代码的逻辑就会变得难以理解和维护,这就是回调地狱。

异步编程 —— Promise

ES6 中的 Promise 对象解决了这个问题。一个 Promise 实例对象表示异步操作最终的完成(或失败)以及其结果值。它有三种状态,初始状态是 pending,当请求成功就调用 resolve,状态从 pending 到 fulfilled,结果值会放在 Promise#then 中处理;失败的话调用 reject,状态从 pending 到 rejected,结果值会放在 Promise#catch 中处理。两种状态的转换只能是单向的。

Promise

一开始接触 Promise 是很难理解的,可以看到各种翻译,例如期约、承诺。其实它的关键,在我看来不在 Promise,而在 then,then 这个词有“到时候”的意思,所以 Promise 对象就变成了一个别人对我的承诺,承诺当然不会立即兑现,等过了一段时间之后,才能看到是否兑现了诺言以及拿到结果,也就是“你讲了你的承诺,咱们到时候拿到结果再怎么怎么样”。

function getPhoto() {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log('1. 选取相片');
      const name = '天上有朵云';
      resolve(name);
    }, 1000);
  });
}

function setFilters(data) {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log('2. 设置滤镜效果');
      resolve({
        photo: data,
        filters: '自然美',
      });
    }, 1000);
  });
}

function addTitle(data) {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log('3. 添加标题');
      resolve(data.filters + '-' + data.photo + '.png');
    }, 1000);
  });
}

function publish(data) {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log('4. 作品发布成功!', data);
      resolve();
    }, 2000);
  });
}

function main() {
  console.log('execute main');
  getPhoto()
    .then(function (photo) {
      return setFilters(photo);
    })
    .then(function (filteredData) {
      return addTitle(filteredData);
    })
    .then(function (finalData) {
      return publish(finalData);
    })
    .catch(function (error) {
      console.error('Error:', error);
    });
}

console.log('其他代码1');
console.log('其他代码2');
main();
console.log('其他代码3');
console.log('其他代码4');
console.log('其他代码5');
console.log('其他代码6');

从上面的例子中就可以理解为,先选取照片,成功选完通过 resolve 把状态和结果给出,后面接上 then 方法,到时候再来处理这个结果,在处理结果的回调函数中再次返回新的 Promise 对象,调用链式的 then,这样就形成了调用顺序,代码变得更加直观可控。

异步编程 —— async/await

除了 Promise,还有 ES7 中的替代方案——async/await,这是一种语法糖,让异步代码写起来像在写同步代码。上面的代码就可以改写成:

// ...

async function main() {
  console.log('execute main');
  try {
    const photo = await getPhoto();
    const filteredData = await setFilters(photo);
    const finalData = await addTitle(filteredData);
    await publish(finalData);
  } catch (error) {
    console.error('Error:', error);
  }
}

console.log('其他代码1');
console.log('其他代码2');
main();
console.log('其他代码3');
console.log('其他代码4');
console.log('其他代码5');
console.log('其他代码6');

main 函数中,不需要再写链式调用了。

而且,async 函数的返回值一定是一个 Promise 对象,如果不是,会隐式地包装在一个 promise 中。就比如:

async function foo() {
  return 1;
}
// 等价于
// function foo() {
//   return Promise.resolve(1);
// }
const res = foo();
console.log(res);

async functions return a promise object

因此,当看到 async 函数就要第一反应出它返回的是一个 Promise 对象!

并发请求

尽管如此,也并不是所有页面的异步请求只有一个。假设在一个低代码的页面中需要两组数据,数据间彼此独立。一个是画布数据,一个是节点数据:

low code

// 获取节点列表
const fetchNodes = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(['node1', 'node2', 'node3']);
    }, 2000);
  });
};

// 获取画布数据
const fetchGraph = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        id: 1,
        start: 'node1',
        end: 'node2',
      });
    }, 1000);
  });
};

const init = async () => {
  const res1 = await fetchNodes(); // 2s
  const res2 = await fetchGraph(); // 1s

  console.log(res1);
  console.log(res2);
  return [res1, res2];
};
init();

在上面的代码中,执行 init 函数总计花了 3s,其中 fetchNodes 花了 2s,fetchGraph 花了 1s。这是一种按照先后顺序发送请求的方式。然而,从业务上讲,左侧的节点数据和右侧的画布数据是互相独立的。显然,使用并发的方式去请求数据更加适合这样的业务场景。

// ...
const init = async () => {
  const res1 = fetchNodes(); // 2s
  const res2 = fetchGraph(); // 1s

  console.log(await res1);
  console.log(await res2);
  return [await res1, await res2];
};
init();

在上面的代码中,fetchNodesfetchGraph 即时发送,并没有通过 await 来控制顺序,到了后面通过 await 分别把 Promise 实例中的结果拿到,然后返回。这就实现了请求并发,执行 init 函数的时间为 2s(较慢的那个)。

不过在并发的场景下,使用 Promise.all 方法更加直观,也不容易忘记 await。

// ...
const init = () => {
  const promises = Promise.all([fetchNodes(), fetchGraph()]);
  promises.then((res) => {
    console.log(res);
    return res;
  });
};
init();

result

Event Loop

不管是定时器、AJAX 还是 DOM 的事件监听,都是异步任务,这些 Web API 操作会在各自的线程上执行,然后,注册回调函数进入任务队列等待,当调用栈空了的时候,就会执行任务队列中的回调函数,从而保证 JS 代码的非阻塞执行(在其他语言中,也有类似的机制)。

这种流转的机制就是 Event Loop。在 Event Loop 中,有三种数据结构必须了解:

  • stack 栈,在函数执行时会产生一个栈,即函数调用栈。

  • heap 堆,堆是一个用来表示一大块(通常是非结构化的)内存区域,所有的对象被分配在堆中。

  • queue 队列,一个 JavaScript 运行时包含了一个待处理的任务队列。每一个任务都关联着一个用以处理这个任务的回调函数。

    浏览器中的 event loop 简易图

在任务队列的区分上,主要分为任务(宏任务)和微任务队列。

  1. 任务队列(Task Queue) :也称为宏任务队列(Macro Task Queue) ,用于存放宏任务。宏任务包括整体的 script 代码块、setTimeout、setInterval、setImmediate(在 Node.js 环境中可用)、I/O 操作、UI 渲染等。当这些宏任务中的某个任务完成时,会被添加到任务队列中等待执行。
  2. 微任务队列(Microtask Queue) :也称为微任务队列(Micro Task Queue) ,用于存放微任务。微任务包括 **Promise 的回调函数、MutationObserver 的回调函数、process.nextTick(在 Node.js 环境中可用)**等。当微任务队列为空时,会立即执行微任务队列中的所有任务。

其中,任务来自宿主环境提供的任务,而微任务来自 JS 引擎提供的任务。

浏览器环境下的 Event Loop

https://serhiikoziy.medium.com/event-loop-in-chrome-browser-72bd6c8db033

简单来说,在浏览器环境下,Event Loop 的运行方式如下:

  1. 当异步任务(如 setTimeout,Promise 等)被调用时,他们会被添加到任务队列(Task Queue)或微任务队列(Microtask Queue)中。
  2. 当主线程空闲时,Event Loop 从任务队列中取出一个任务执行。
  3. 如果存在微任务,那么在每个 task 执行完成后,所有的微任务(microtasks)都会被执行,直到微任务队列为空。
  4. 上述步骤重复。

用代码来表示浏览器环境下的 Event Loop:

while (true) {
    queue = getQueue();
    task = queue.pop();
    execute(task);

    // 如果存在微任务,执行微任务
    while (microtaskQueue.hasTasks()) {
        doMircotask();
        // 是否需要重绘页面
        if (isRepaintTime()) {
            animationTasks = animationQueue.copyTasks();
            for (task in animationTasks) {
                doAnimationTask(task)
            }
            repaint();
        }
    }
}

上面的代码中有一个 isRepaintTime 函数,用来模拟浏览器以一定的频率进行的 UI 渲染。这个频率通常与显示器的刷新率相匹配,一般为每秒 60 次(60 Hz)。这意味着浏览器每秒会尝试进行 60 次渲染,以更新屏幕上的内容。那么渲染什么呢?就是在浏览器原理中提到的布局(Layout)、绘制(Paint)和合成(Composite)等步骤。当浏览器需要渲染新的内容时,它会触发渲染流水线,按照一定的顺序执行这些步骤,最终将内容呈现在屏幕上。

Node.js 环境下的 Event Loop

event loop in Node.js

Node.js 环境与浏览器环境不同,没有 script 标签、用户交互、动画帧的回调、渲染流水线。它的 Event Loop 包含以下几个阶段:

  1. timers:此阶段执行 setTimeoutsetInterval 回调。
  2. pending callbacks:执行延迟到下一个循环迭代的 I/O 回调。
  3. idle, prepare:仅系统内部使用。
  4. poll:检索新的 I/O 事件; 执行与 I/O 相关的回调(除了关闭回调,定时器,和 setImmediate); node 会在适当的时候阻塞在这里。
  5. checksetImmediate() 回调在这里执行。
  6. close callbacks:一些关闭的回调函数,如:socket.on('close', ...)

在每个阶段之间,Node.js 会执行微任务队列中的任务,包括 Promise 回调和其他微任务。 用代码来表示 Node.js 环境下的 Event Loop:

while (tasksAreWaiting()) {
    queue = getNextQueue();
    if (queue.hasTasks()) {
        task = queue.pop();
        execute(task);

        // 如果存在 process.nextTick,执行该微任务
        while (nextTickQueue.hasTasks()) {
            doNextTickTask();
        }
        // 如果存在 promise,执行该微任务
        while (promiseQueue.hasTasks()) {
            doPromiseTask();
        }
    }
}

setImmediate()process.nextTick() 是比较奇葩的两个函数,第一个叫做立即执行,第二个叫做在下一个 tick 执行,但实际的表现上却相反,process.nextTick() 会立即执行,而setImmediate() 会在下一个 tick 执行。

浏览器和 Node.js 下的 Event Loop 的不同:

  1. 微任务(Microtasks)处理的位置不同:在浏览器中,微任务在每个任务(MacroTask)后面执行。而在 Node.js 中,微任务在 Event Loop 的每个阶段之间执行。
  2. 任务队列(Task Queues)的结构不同:浏览器中通常有一个任务队列,而 Node.js 有多个任务队列,每个队列对应 Event Loop 的一个阶段。
  3. I/O 处理方式不同:Node.js 的 Event Loop 专门有一个阶段来处理 I/O 操作。

Event Loop 可视化

网上有一个不错的可视化工具(JavaScript Visualizer 9000),可以更好地理解 Event Loop,大家可以玩玩看:www.jsv9000.app/ ,有JS 代码、任务队列、微任务队列、函数调用栈、Event Loop。在 Event Loop 中可以看到执行机制,与“浏览器环境下的 Event Loop”一节不谋而合。

JavaScript Visualizer 9000

面试题

有点绕的一道题。

console.log('A stack');
queueMicrotask(function () {
  console.log('B microtask');
});
requestAnimationFrame(function () {
  console.log('C rAF');
});
console.log('D stack');
setTimeout(function () {
  console.log('E task');
  queueMicrotask(function () {
    console.log('Inside E microtask');
  });
}, 0);
console.log('F stack');
Promise.resolve()
  .then(function () {
    console.log('G microtask');
  })
  .then(function () {
    console.log('H microtask');
  });
requestAnimationFrame(function () {
  console.log('I rAF');
});
console.log('J stack');
setTimeout(function () {
  console.log('K task');
}, 0);
queueMicrotask(function () {
  console.log('L microtask');
});
console.log('M stack');

我写的执行结果(有误)

实际的执行顺序,但不完全正确:

较为正确的执行顺序

按照 Event Loop 的执行机制,自己写的时候还是出现了错误。在 L 打印后,微任务队列中还有微任务,所以应该先执行刚刚创建的打印 H 的微任务的回调函数,再往后执行……

修改

然而, 因为屏幕刷新次数的随机性,加上 requestAnimationFrame 以后,答案就充满了不确定性,C 和 I 随机地插入在打印顺序中。所以,并没有什么正确答案。

总结

到这里,想必大家对JS 单线程、异步、事件循环都可以做到心中有数了。

以上,如有谬误,还请斧正,感谢您的阅读。

👏 对了,如果你还没有我的好友,加我微信:with_his_x,备注 「掘金」 ,即有机会加入高质量前端交流群,在这里你将会认识更多的朋友;也欢迎关注我的公众号 见嘉 Being Dev,并设置星标,以便第一时间收到更新。

参考: