首页 / 文章 / JavaScript的单线程模型与浏览器的多进程引擎

JavaScript的单线程模型与浏览器的多进程引擎

探究为何 JavaScript 在单线程上运行,而浏览器却能通过事件循环机制同时处理网络请求、页面渲染和定时器任务。

1692 词

JavaScript在单线程上运行。

你很可能已经读过这句话无数次了。

然而当你加载一个现代网页时,会同时看到以下所有操作正在发生:

网络请求正在传输中。

图片正在被获取并解码。

CSS动画正在流畅播放。

页面能立即响应点击操作。

内容正在被渲染到屏幕上。

与此同时,你的JavaScript代码仍在持续执行。

那么到底底层发生了什么?

如果JavaScript只有单线程,那其他所有操作是由谁处理的?

回答这个问题就能揭示关于浏览器实际运行机制的最重要概念之一:

JavaScript与浏览器并非同一事物。

JavaScript拥有主线程

当开发者称 JavaScript 为单线程时,具体指的是 JavaScript 代码的执行方式。

你的代码在单个主 JavaScript 线程上运行。

以这段代码为例:

console.log("One");
console.log("Two");
console.log("Three");

每一行代码都会严格地在前一行之后执行。

JavaScript 不会在同一线程上同时运行这三条语句。

只有一个调用栈可供使用。

在任何给定时刻,只有一段 JavaScript 代码在执行。

这就是此语境下单线程的本质。

但问题在这里会变得更加复杂。

浏览器本身的职责远不止运行脚本而已。

浏览器的任务远不止运行 JavaScript

浏览器是一台比单纯的 JavaScript 引擎复杂得多的机器。

它必须管理诸如以下任务:

  • 通过网络获取数据
  • 调度计时器
  • 捕获键盘和鼠标输入
  • 在屏幕上生成帧
  • 解码图像
  • 播放声音
  • 处理视频播放
  • 将数据持久化到磁盘
  • 计算页面布局
  • 在绘图时绘制像素
  • 在合成过程中合并图层
  • 浏览器和操作系统负责的其他各类任务

你的 JavaScript 代码并不会直接执行所有这些操作。

相反,JavaScript 可以将任务委托给浏览器,让浏览器处理具体细节。

例如:

fetch("/api/users");

你的脚本负责发起请求。

但你的 JavaScript 并不会手动打开套接字或从服务器传输单个字节。

这一责任由浏览器及其底层的系统承担。

一旦收到响应,浏览器就会安排你的回调函数或 Promise 的继续执行在 JavaScript 线程上运行。

这与以下想法有很大不同:

“JavaScript 能处理所有事情。”

将 JavaScript 视为更大系统中的一个工作进程

可以用餐厅作为思维模型。

JavaScript 就是负责接单的单一服务器。

浏览器则代表整个餐厅的运营流程。

背后还有许多其他工作进程负责不同的任务。

JavaScript 可能会这样说:

“我需要从服务器获取这些数据。”

此时,浏览器就会接管网络操作。

JavaScript无需暂停并等待所有数据字节到达。

它可以继续执行其他任务。

一旦操作完成并准备好由JavaScript处理,相应的任务就会被放入JavaScript线程的队列中。

这就是异步浏览器API运作的基础。

那么fetch()又会怎样呢?

以这个例子为例:

console.log("Start");
fetch("/api/users")
  .then(() => {
    console.log("Users received");
  });
console.log("End");

初学者通常会这样想象:

Start
   ↓
fetch()
   ↓
wait for server
   ↓
Users received
   ↓
End

但这并非实际发生情况的准确描述。

JavaScript的执行不会在调用fetch()时暂停以等待响应返回。

更准确的情景是这样的:

JavaScript
   │
   ├── Start fetch
   │
   ▼
Browser handles network work
   │
   │
   └───────────────┐
                   │
JavaScript         │
continues          │
                   │
   ▼               │
console.log("End") │
                   │
                   ▼
        Response becomes available
                   │
                   ▼
        Promise continuation
        gets scheduled
                   │
                   ▼
        JavaScript runs it

按照这样的流程,控制台输出通常会是这样:

Start
End
Users received

这里的关键点并非仅仅在于fetch()是异步运行的。

最重要的是究竟是谁在等待

JavaScript的线程在等待网络响应时永远不会被阻塞。

事件循环将各部分联系起来

这就是事件循环发挥作用的地方。

你可以把JavaScript环境想象成一个代码执行的位置,再加上一些协调机制来决定何时允许继续进行异步操作。

该系统的简化版本如下:

Browser
                │
     ┌──────────┼───────────┐
     │          │           │
 Network     Timers      User Input
     │          │           │
     └──────────┼───────────┘
                │
                ▼
         Scheduling queues
                │
                ▼
           Event Loop
                │
                ▼
          Call Stack
                │
                ▼
           JavaScript

请记住,此图只是简化后的版本。

实际的浏览器内部结构要复杂得多,不同的引擎也会以各自的方式实现这些机制。

不过它仍然能够体现核心思想:

运行 JavaScript 只是一个更大系统中的其中一部分。

计时器并不会在后台秘密运行你的代码

来看这个例子:

setTimeout(() => {
  console.log("Done");
}, 1000);

一种容易理解的描述方式是:

“JavaScript 会启动一个独立的线程来倒计时一秒。”

但这种思维模式会让你走入歧途。

实际上实现计时器功能的其实是浏览器,而非 JavaScript 本身。只有当指定的延迟时间结束之后,回调函数才会被列入调度候选列表。此时 JavaScript 才会真正执行它——而且只有在主线程有空时才会执行。

因此,像这样的代码是个糟糕的主意:

setTimeout(() => {
  console.log("Done");
}, 1000);

while (true) {}

为什么这会导致问题?

一旦延迟结束,回调函数就会被标记为可执行状态,但主线程却陷入无限循环中而无法释放。回调函数无法强行介入并中断当前正在执行的 JavaScript 代码。

浏览器完全可以知道:

“计时器已经触发。”

但仅知道这一点还不够——JavaScript 仍需要一个机会才能真正执行:

Main JavaScript thread

while (true) {
   // never finishes
}
        ↓
Timer becomes ready
        ↓
Callback waits
        ↓
JavaScript never becomes available

这是值得牢记的核心区别之一。

异步并不意味着回调函数会在其他线程中执行。

那为什么页面不会一直卡住呢?

因为在任何给定时刻,JavaScript 的执行只是浏览器需要处理的众多任务之一。

不过这里有一个需要注意的要点。

决定页面响应速度的许多工作——包括执行 JavaScript 代码以及渲染流程中的部分步骤——都在同一条主线程上完成。

正因如此,如下代码仍会导致页面卡住:

const start = performance.now();

while (performance.now() - start < 5000) {
  // expensive work
}

主线程会花费整整五秒钟来运行那个循环。在此期间,任何也需要使用主线程的交互处理或渲染操作都不得不等待。

这正是人们说以下内容时所指的情况:

“JavaScript 是单线程的。”

浏览器层面支持并发,JavaScript 层面为单线程

这是需要牢记的关键区别。

浏览器作为一个整体进程,可以将不同类型的工作分配到多个线程中执行。具体实现因浏览器和操作系统的不同而有所差异,但从底层来看,现代浏览器都是为同时处理大量任务而设计的。

大致来说,任务的分配方式可能如下:

Browser
│
├── JavaScript execution
├── Network activity
├── Rendering-related work
├── Image/media processing
├── Browser services
└── Other internal tasks

然而,这些机制都无法让你写出类似这样的代码:

runThisFunctionOnAnotherBrowserThread();

并让任意的 JavaScript 代码跳转到其他线程上执行。你的普通 JavaScript 仍然会按照其一贯的单线程执行模型来运行。

如果你确实需要将 JavaScript 中的繁重计算任务从主线程中移开,那就是 Web Workers 的作用。

结论

单线程 JavaScript 并不意味着整个浏览器只能运行在单个线程上。

你的代码在单个主线程上执行,而浏览器则通过自身的内部机制处理各种其他任务。

诸如网络请求、定时器、用户输入处理、渲染以及媒体解码等功能都可以独立于JavaScript的执行而进行。

一旦这些后台任务准备好继续执行,浏览器就会安排相应的回调或Promise处理机制,以便JavaScript能够接手。

尽管如此,主线程仍然承担着重要职责。

运行时间较长的JavaScript代码会延迟所有争夺该线程的其他任务——包括用户交互、渲染以及其他待处理任务。

因此,我们看到的浏览器整体上具备较强的并发能力,但JavaScript的执行却始终严格遵循单线程模式。

理解这种分离有助于明白基于浏览器的 JavaScript 能实现什么以及其局限性在哪里。

关键要点

请牢记以下三点:

  1. JavaScript 本身是单线程的。你的代码会在主线程上依次逐步骤执行。
  2. 浏览器是一个更复杂的并发环境。除了运行 JavaScript 外,它还会并行处理网络、定时器、渲染、输入以及媒体相关操作。
  3. 异步并不意味着回调函数会以多线程方式执行。实际等待工作由浏览器完成,一旦主线程有空闲资源,才会将控制权交还给 JavaScript。

一种有助于理解的表述方式是:

Browser
│
├── JavaScript execution
├── Network activity
├── Timers
├── User input
├── Rendering
├── Media processing
└── Other browser systems

JavaScript 只是那个更大系统中的其中一个组件,而非整个系统本身。

有了这样的框架,事件循环、异步 API、UI 卡顿以及浏览器级并发等概念就变得容易理解多了。

相关阅读