JavaScript的单线程模型与浏览器的多进程引擎
探究为何 JavaScript 在单线程上运行,而浏览器却能通过事件循环机制同时处理网络请求、页面渲染和定时器任务。
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 能实现什么以及其局限性在哪里。
关键要点
请牢记以下三点:
- JavaScript 本身是单线程的。你的代码会在主线程上依次逐步骤执行。
- 浏览器是一个更复杂的并发环境。除了运行 JavaScript 外,它还会并行处理网络、定时器、渲染、输入以及媒体相关操作。
- 异步并不意味着回调函数会以多线程方式执行。实际等待工作由浏览器完成,一旦主线程有空闲资源,才会将控制权交还给 JavaScript。
一种有助于理解的表述方式是:
Browser
│
├── JavaScript execution
├── Network activity
├── Timers
├── User input
├── Rendering
├── Media processing
└── Other browser systems
JavaScript 只是那个更大系统中的其中一个组件,而非整个系统本身。
有了这样的框架,事件循环、异步 API、UI 卡顿以及浏览器级并发等概念就变得容易理解多了。
相关阅读
- process.nextTick() 如何悄悄扼杀 Node.js 事件循环 —— 解释了为何递归调用 process.nextTick() 会完全阻塞 libuv 的轮询阶段,以及如何使用 setImmediate() 解决事件循环堵塞问题。
- 理解 JavaScript 闭包与事件循环 —— 通过实际代码示例,了解闭包如何保留外部变量,以及事件循环如何排序调用栈、微任务和宏任务。