mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1877 字
5 分钟
为什么 JavaScript 是单线程的
2023-12-21

JavaScript 是当今最流行的编程语言之一,它有一个独特的设计特点:单线程。在多核 CPU 普及的今天,这个设计看似落后,实则是深思熟虑的结果。理解 JavaScript 为什么是单线程,需要回到浏览器环境的历史背景中寻找答案。

一、历史背景:浏览器沙箱的约束#

1.1 JavaScript 的诞生#

1995 年,Brendan Eich 在 Netscape 公司用 10 天时间设计了 JavaScript。当时的设计目标非常简单:

  • 脚本语言:用于网页交互,不是通用编程语言
  • 轻量级:简单的语法,易于上手
  • 安全沙箱:不能访问文件系统、不能操作网络
timeline title JavaScript 发展时间线 1995 : JavaScript 诞生(Netscape) 1996 : JScript 发布(Microsoft) 1997 : ECMAScript 1 标准化 2006 : jQuery 发布 2009 : Node.js 发布 2015 : ES6 发布

1.2 浏览器环境的限制#

浏览器是一个高度复杂的环境,JavaScript 作为其中唯一的脚本语言,面临严格的约束:

约束类型具体限制设计考量
安全性不能直接访问文件系统防止恶意网站窃取数据
隔离性每个页面独立运行防止跨页面攻击
响应性不能阻塞 UI 渲染保证用户体验
兼容性必须向后兼容网页不能因更新而崩溃
flowchart TB subgraph 浏览器进程 B[浏览器主进程] R[渲染进程] G[GPU 进程] N[网络进程] end subgraph 渲染进程(沙箱) JS[JavaScript 引擎] DOM[DOM 树] CSS[CSS 树] REN[渲染] end B --> R R --> JS JS --> DOM DOM --> REN CSS --> REN style R fill:#f9f,stroke:#333 style JS fill:#ff9,stroke:#333

1.3 当时的多线程技术背景#

在 JavaScript 诞生的 1995 年,多线程编程存在严重的技术障碍:

线程安全问题

// 多线程环境下典型的竞态条件
public class Counter {
private int count = 0;
public void increment() {
count++; // 非原子操作!
}
}

多线程的复杂性:

  • 死锁(Deadlock)
  • 竞态条件(Race Condition)
  • 内存可见性问题
  • 锁的粒度难以控制

二、为什么选择单线程:DOM 操作的考量#

2.1 DOM 操作的特殊性#

JavaScript 最核心的功能是操作 DOM(Document Object Model)。DOM 是一棵树形结构,任何操作都可能影响整棵树的状态。

flowchart TB subgraph DOM 树 HTML[html] HEAD[head] BODY[body] DIV1[div] DIV2[div] P1[p] P2[p] end HTML --> HEAD HTML --> BODY BODY --> DIV1 BODY --> DIV2 DIV1 --> P1 DIV2 --> P2 style HTML fill:#f9f,stroke:#333

假设 JavaScript 支持多线程,考虑以下场景:

// 线程 1:删除节点
document.getElementById("container").remove();
// 线程 2:同时操作同一个节点
document.getElementById("container").appendChild(child);

这种情况下,线程 2 的操作会失败或产生未定义行为。

2.2 多线程操作 DOM 的灾难#

如果允许多线程操作 DOM,会面临以下问题:

sequenceDiagram participant T1 as 线程 1 participant T2 as 线程 2 participant DOM as DOM 树 T1->>DOM: 查找 #container T2->>DOM: 删除 #container T1->>DOM: 操作 #container(已删除!) DOM-->>T1: Error: 节点不存在

解决方案对比

方案实现复杂度性能影响开发难度
单线程 + 事件循环无锁开销简单
多线程 + 细粒度锁极高锁竞争严重困难
多线程 + 事务机制事务开销中等
多线程 + Copy-on-Write中等内存开销中等

2.3 Web Worker 的设计哲学#

HTML5 引入了 Web Worker,允许 JavaScript 创建后台线程,但有一个关键限制:Worker 不能操作 DOM

flowchart LR subgraph 主线程 UI[UI 渲染] DOM[DOM 操作] EVENT[事件处理] end subgraph Web Worker CALC[复杂计算] FETCH[数据获取] PROC[数据处理] end UI <--> EVENT EVENT <--> DOM EVENT <-->|postMessage| CALC CALC <-->|postMessage| EVENT CALC --> FETCH CALC --> PROC style UI fill:#9f9,stroke:#333 style CALC fill:#99f,stroke:#333

这印证了 JavaScript 单线程设计的核心理念:DOM 操作必须是单线程的

三、事件循环机制详解#

3.1 事件循环的基本结构#

JavaScript 通过事件循环(Event Loop)实现单线程的异步处理:

flowchart TB subgraph 调用栈 S1[函数 1] S2[函数 2] S3[函数 3] end subgraph 任务队列 M[宏任务队列] m[微任务队列] end subgraph Web APIs API1[setTimeout] API2[fetch] API3[DOM 事件] end S1 --> S2 --> S3 S3 -->|执行完成| m m -->|微任务清空| M M -->|取出任务| S1 API1 --> M API2 --> M API3 --> M style S1 fill:#f9f,stroke:#333 style m fill:#9f9,stroke:#333 style M fill:#99f,stroke:#333

3.2 调用栈与任务队列#

console.log("1. 开始");
setTimeout(() => {
console.log("2. setTimeout");
}, 0);
Promise.resolve().then(() => {
console.log("3. Promise");
});
console.log("4. 结束");
// 输出顺序:1, 4, 3, 2

执行过程分析:

sequenceDiagram participant S as 调用栈 participant M as 宏任务队列 participant m as 微任务队列 participant C as 控制台 Note over S: 执行全局脚本 S->>C: 输出 "1. 开始" S->>M: 注册 setTimeout 回调 S->>m: 注册 Promise 回调 S->>C: 输出 "4. 结束" Note over S: 调用栈清空 S->>m: 执行微任务 m->>C: 输出 "3. Promise" Note over m: 微任务队列清空 S->>M: 执行下一个宏任务 M->>C: 输出 "2. setTimeout"

3.3 事件循环的完整流程#

flowchart TD A[从宏任务队列取出一个任务] --> B[执行该任务] B --> C{调用栈是否为空?} C -->|否| B C -->|是| D[执行所有微任务] D --> E{微任务队列是否为空?} E -->|否| D E -->|是| F[UI 渲染] F --> G[等待下一个宏任务] G --> A style A fill:#99f,stroke:#333 style D fill:#9f9,stroke:#333 style F fill:#ff9,stroke:#333

四、宏任务与微任务的设计#

4.1 为什么要区分宏任务和微任务?#

宏任务和微任务的区分是为了解决一个关键问题:如何处理需要尽快执行但不应该阻塞当前任务的回调

类型代表 API执行时机用途
宏任务setTimeout, setInterval, I/O每轮事件循环一个延迟执行、IO 操作
微任务Promise.then, queueMicrotask当前宏任务结束后立即执行需要尽快执行的回调

4.2 微任务的设计目的#

微任务的设计是为了让开发者能够在当前任务结束后、下一个任务开始前执行代码:

// 不使用微任务
function processData(data) {
console.log("处理数据");
// 问题是:这会阻塞后续代码
notifyUsers(data);
updateUI(data);
logAnalytics(data);
}
// 使用微任务
function processData(data) {
console.log("处理数据");
// 将非关键操作推迟到微任务
queueMicrotask(() => notifyUsers(data));
queueMicrotask(() => updateUI(data));
queueMicrotask(() => logAnalytics(data));
}

4.3 实际案例:MutationObserver#

sequenceDiagram participant JS as JavaScript 代码 participant DOM as DOM 树 participant MO as MutationObserver participant m as 微任务队列 JS->>DOM: 修改 DOM DOM->>MO: 触发变更记录 MO->>m: 添加微任务回调 Note over JS: 当前代码继续执行 JS->>JS: 代码执行完毕 m->>MO: 执行回调 MO->>JS: 处理 DOM 变更

4.4 宏任务与微任务的执行顺序#

flowchart TB subgraph 事件循环轮次 1 M1[宏任务 1<br/>主脚本] m1_1[微任务 1.1] m1_2[微任务 1.2] end subgraph 事件循环轮次 2 M2[宏任务 2<br/>setTimeout] m2_1[微任务 2.1] end subgraph 事件循环轮次 3 M3[宏任务 3<br/>setInterval] end M1 --> m1_1 --> m1_2 --> M2 --> m2_1 --> M3 style M1 fill:#99f,stroke:#333 style M2 fill:#99f,stroke:#333 style M3 fill:#99f,stroke:#333 style m1_1 fill:#9f9,stroke:#333 style m1_2 fill:#9f9,stroke:#333 style m2_1 fill:#9f9,stroke:#333

五、单线程的性能优化策略#

5.1 异步编程模型#

JavaScript 通过异步编程解决单线程的阻塞问题:

flowchart LR subgraph 同步模式 S1[请求 A] --> S2[等待...] S2 --> S3[处理 A] S3 --> S4[请求 B] S4 --> S5[等待...] S5 --> S6[处理 B] end subgraph 异步模式 A1[请求 A] --> A2[请求 B] A2 --> A3[处理 A 响应] A3 --> A4[处理 B 响应] end style S2 fill:#f66,stroke:#333 style S5 fill:#f66,stroke:#333

5.2 async/await 的实现原理#

async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user;
}
// 等价于
function fetchUser(id) {
return fetch(`/api/users/${id}`)
.then(response => response.json())
.then(user => user);
}
stateDiagram-v2 [*] --> 调用fetchUser 调用fetchUser --> 发起请求 发起请求 --> 暂停执行: await 暂停执行 --> 其他任务: 返回控制权 其他任务 --> 恢复执行: 响应到达 恢复执行 --> 返回结果 返回结果 --> [*]

5.3 Web Workers:真正的并行#

对于 CPU 密集型任务,可以使用 Web Workers:

// 主线程
const worker = new Worker("compute.js");
worker.postMessage({ data: largeData });
worker.onmessage = e => {
console.log("计算结果:", e.data);
};
// compute.js (Worker 线程)
self.onmessage = e => {
const result = heavyComputation(e.data);
self.postMessage(result);
};
flowchart TB subgraph 主线程 UI[UI 交互] DOM[DOM 操作] W1[Worker 通信] end subgraph Worker 线程 COMP[复杂计算] DATA[数据处理] W2[Worker 通信] end UI --> DOM UI --> W1 W1 <-->|postMessage| W2 W2 --> COMP COMP --> DATA style UI fill:#9f9,stroke:#333 style COMP fill:#99f,stroke:#333

5.4 性能优化技术对比#

技术适用场景优点局限
Promise/async-awaitI/O 操作简洁的异步语法不能并行 CPU 任务
setTimeout/setImmediate延迟执行避免阻塞有最小延迟限制
requestAnimationFrame动画与刷新率同步仅用于视觉更新
Web WorkersCPU 密集计算真正并行不能操作 DOM
Service Workers网络请求离线缓存复杂的生命周期

六、与其他语言的多线程对比#

6.1 Java:真正的多线程#

// Java 多线程示例
public class Counter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
}
// 创建多个线程
for (int i = 0; i < 10; i++) {
new Thread(() -> {
counter.increment();
}).start();
}
flowchart LR subgraph JVM 进程 T1[线程 1] T2[线程 2] T3[线程 3] H[堆内存] end T1 --> H T2 --> H T3 --> H style H fill:#f9f,stroke:#333

6.2 Python:GIL 的限制#

Python 有全局解释器锁(GIL),同一时刻只有一个线程可以执行 Python 字节码:

import threading
# 多线程在 CPU 密集任务中无法并行
def cpu_bound():
sum(range(10000000))
threads = [threading.Thread(target=cpu_bound) for _ in range(4)]
# 这不会比单线程更快!
flowchart TB subgraph Python 解释器 GIL[GIL] T1[线程 1] T2[线程 2] T3[线程 3] end GIL -->|持有锁| T1 T2 -->|等待| GIL T3 -->|等待| GIL style GIL fill:#f66,stroke:#333

6.3 Go:Goroutine 的轻量级并发#

Go 使用 goroutine 实现轻量级并发:

func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(i)
}
wg.Wait()
}
flowchart TB subgraph Go 运行时 S[调度器] P1[Processor 1] P2[Processor 2] P3[Processor 3] end subgraph Goroutines G1[Goroutine 1] G2[Goroutine 2] G3[...] G1000[Goroutine 1000] end S --> P1 S --> P2 S --> P3 G1 --> S G2 --> S G3 --> S G1000 --> S style S fill:#f9f,stroke:#333

6.4 语言并发模型对比#

特性JavaScriptJavaPythonGo
并发模型事件循环操作系统线程GIL + 线程Goroutine
内存共享不共享共享,需锁共享,受 GIL 限制CSP 模型
上下文切换成本较高中等极低
线程数量上限1(主线程)数千数百数百万
学习曲线
flowchart TB subgraph JavaScript JS1[单线程] --> JS2[事件循环] JS2 --> JS3[异步 I/O] end subgraph Java J1[多线程] --> J2[共享内存] J2 --> J3[锁机制] end subgraph Python P1[GIL 限制] --> P2[伪并行] P2 --> P3[多进程替代] end subgraph Go G1[Goroutine] --> G2[CSP 通信] G2 --> G3[高效调度] end style JS1 fill:#9f9,stroke:#333 style J1 fill:#f99,stroke:#333 style P1 fill:#ff9,stroke:#333 style G1 fill:#9ff,stroke:#333

七、Node.js 如何利用单线程实现高并发#

7.1 Node.js 的架构#

Node.js 基于 V8 引擎和 libuv 库,实现了高效的异步 I/O:

flowchart TB subgraph Node.js 进程 JS[JavaScript 代码] V8[V8 引擎] NODE[Node.js 绑定] LIBUV[libuv] end subgraph libuv 线程池 T1[Worker 1] T2[Worker 2] T3[Worker 3] T4[Worker 4] end subgraph 系统 FS[文件系统] NET[网络] DNS[DNS] end JS --> V8 V8 --> NODE NODE --> LIBUV LIBUV --> T1 LIBUV --> T2 LIBUV --> T3 LIBUV --> T4 T1 --> FS T2 --> NET T3 --> DNS style LIBUV fill:#f9f,stroke:#333

7.2 libuv 的事件循环#

flowchart TD A[timers] --> B[pending callbacks] B --> C[idle, prepare] C --> D[poll] D --> E[check] E --> F[close callbacks] F --> A subgraph 各阶段说明 timers: setTimeout/setInterval poll: 执行 I/O 回调 check: setImmediate end style A fill:#99f,stroke:#333 style D fill:#9f9,stroke:#333 style E fill:#ff9,stroke:#333

7.3 高并发的秘密:非阻塞 I/O#

Node.js 的高并发来自非阻塞 I/O 设计:

// 阻塞式(传统服务器)
const data = fs.readFileSync("file.txt"); // 阻塞
processData(data);
handleOtherRequests(); // 等待读取完成后才能处理
// 非阻塞式(Node.js)
fs.readFile("file.txt", (err, data) => {
processData(data); // 回调执行
});
handleOtherRequests(); // 立即执行,不等待
sequenceDiagram participant C1 as 客户端 1 participant C2 as 客户端 2 participant N as Node.js participant FS as 文件系统 C1->>N: 请求读取文件 A N->>FS: 异步读取文件 A Note over N: 不等待,继续处理 C2->>N: 请求读取文件 B N->>FS: 异步读取文件 B FS-->>N: 文件 A 读取完成 N-->>C1: 返回文件 A FS-->>N: 文件 B 读取完成 N-->>C2: 返回文件 B

7.4 Node.js 的性能数据#

指标传统多线程服务器Node.js 单线程
并发连接数受线程数限制10万+
内存占用(1000 连接)~1GB~100MB
上下文切换频繁
CPU 利用率多核利用单核,可集群

7.5 Node.js 的集群模式#

为了利用多核 CPU,Node.js 提供了集群模式:

const cluster = require("cluster");
const os = require("os");
if (cluster.isMaster) {
const cpuCount = os.cpus().length;
for (let i = 0; i < cpuCount; i++) {
cluster.fork();
}
} else {
// Worker 进程
require("./server");
}
flowchart TB subgraph Master 进程 LB[负载均衡] end subgraph Worker 进程 W1[Worker 1] W2[Worker 2] W3[Worker 3] W4[Worker 4] end C[客户端] --> LB LB --> W1 LB --> W2 LB --> W3 LB --> W4 style LB fill:#f9f,stroke:#333

八、总结与权衡分析#

8.1 JavaScript 单线程的优势#

优势说明
简单性无需考虑线程安全、锁、竞态条件
确定性代码执行顺序可预测,便于调试
低开销无线程创建、切换的开销
DOM 安全避免 DOM 操作的并发问题
适合 I/O网页和服务器场景主要是 I/O 密集型

8.2 JavaScript 单线程的局限#

局限解决方案
CPU 密集任务阻塞Web Workers
无法利用多核集群模式、Worker Threads
长时间计算影响响应分片执行、requestIdleCallback

8.3 设计哲学#

JavaScript 单线程设计体现了几个重要的工程原则:

mindmap root((JavaScript 单线程)) 简单性 无锁设计 易于理解 快速开发 安全性 DOM 操作安全 无数据竞争 沙箱隔离 实用性 适合 I/O 密集 网页场景优化 良好的用户体验 可扩展 Web Workers 集群模式 WASM 多线程

8.4 未来展望#

随着 WebAssembly 线程和 Node.js Worker Threads 的发展,JavaScript 生态正在获得真正的并行能力:

技术状态用途
WebAssembly Threads可用高性能计算
Node.js Worker Threads稳定服务端并行
SharedArrayBuffer受限(安全)共享内存并行
Atomics可用原子操作

核心洞察:JavaScript 的单线程不是缺陷,而是一个针对特定场景的深思熟虑的设计选择。在 I/O 密集型的网页和服务端场景中,这个设计提供了简单、高效、安全的并发处理能力。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

为什么 JavaScript 是单线程的
https://blog.souloss.com/posts/why-the-design/why-javascript-is-single-threaded/
作者
Souloss
发布于
2023-12-21
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时