先看一段代码。页面上有个按钮,点击后执行:
button.addEventListener('click', () => {
const start = Date.now();
while (Date.now() - start < 3000) {}
console.log('done');
});
点击之后,页面会卡住三秒。按钮点不动,动画停住,输入框也打不出字。三秒后,done 才打印出来。
这段代码暴露了一个常被忽略的事实:JavaScript 的主线程既要执行你的代码,也要负责渲染页面和响应交互。它一旦被占住,整页都得等。
要理解这件事,得先回到一个更基础的问题:线程到底是什么?
线程:CPU 调度的最小单位
进程可以理解为一个正在运行的程序,拥有独立的内存空间。线程则是进程内部的执行单元,是 CPU 调度和分派的基本单位。
一个进程至少有一个线程。同一个进程里的多个线程共享进程的内存和资源,包括堆、全局变量、打开的文件句柄等。它们各自拥有独立的栈和寄存器状态,因此可以同时执行不同的代码路径。
可以这样类比:
- 进程像一栋房子,有独立地址和门牌号。
- 线程像房子里的人,共用厨房、客厅和冰箱。
- 多线程就是多个人同时在同一栋房子里干活。
共用空间带来了效率,也带来了冲突。两个人同时改同一张购物清单,最后写下的内容可能互相覆盖。这就是多线程编程里最麻烦的部分:共享状态。
单线程和多线程,到底差在哪
单线程意味着程序在任意时刻只有一个执行流。代码从上往下执行,前一件事没做完,后一件事只能等着。
多线程则允许一个进程内同时存在多个执行流。操作系统会在多个线程之间切换 CPU 时间片,让它们看起来在同时运行。在多核 CPU 上,多个线程还可以真正并行执行。
两者的差异不只是“快慢”,更在于复杂度:
| 维度 | 单线程 | 多线程 |
|---|---|---|
| 执行方式 | 一次只做一件事 | 多个执行流交替或并行 |
| 共享数据 | 天然安全,几乎没有竞态 | 需要锁、原子操作等同步机制 |
| 上下文切换 | 少 | 多,切换有开销 |
| 调试难度 | 相对简单 | 死锁、竞态、时序问题难复现 |
| CPU 利用 | 单核为主 | 可利用多核 |
| IO 密集型任务 | 依赖异步机制 | 可用线程池处理 |
这里有个常见误解:多线程一定比单线程快。
不一定。线程创建、销毁和上下文切换都要成本。如果任务本身很小,或者大量时间花在等待网络和磁盘上,多线程带来的收益可能抵不过同步开销。反过来,如果任务需要大量 CPU 计算,而且能拆分,多线程才有明显优势。
为什么 JavaScript 是单线程的
JavaScript 诞生于浏览器,最初的任务是操作 DOM、响应用户点击、提交表单。这个场景决定了它不能轻易拥抱多线程。
原因可以归结为三点。
1. 共享 DOM 会让并发变得危险
假设 JavaScript 有两个线程,同时操作同一个 DOM 节点:
// 线程 A
const el = document.querySelector('#title');
el.textContent = 'Hello';
// 线程 B
const sameEl = document.querySelector('#title');
sameEl.remove();
线程 A 刚拿到元素引用,线程 B 就把它删了。线程 A 接下来该改什么?如果再加锁,开发者每次操作 DOM 都要考虑同步,Web 开发会变得非常沉重。
单线程从源头上避免了这个问题:同一时刻只有一段 JavaScript 在跑,DOM 不会被两个执行流同时修改。
2. 单线程更符合浏览器的交互模型
浏览器需要处理点击、滚动、输入、定时器、动画和网络回调。如果这些事件由多个线程并发修改页面状态,开发者很难推理“页面为什么变成了现在这样”。
单线程加事件循环,把并发事件排成队列,一次处理一个。执行顺序清晰,状态变化也可预测。
3. 异步机制把等待交给了宿主环境
JavaScript 是单线程,不代表浏览器或 Node.js 只有一个线程。
网络请求、文件读取、定时器等耗时操作,通常由宿主环境处理。浏览器会把网络请求交给浏览器内部的线程,Node.js 会通过 libuv 的线程池处理部分文件系统和 DNS 操作。完成后,回调被放进任务队列,等主线程空闲时再执行。
这就是事件循环的核心:
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
// 输出:
// 1
// 3
// 2
setTimeout 没有阻塞主线程。它只是注册了一个回调,然后继续往下执行。主线程空下来后,再从队列里取出回调运行。
所以更准确的说法是:JavaScript 的执行是单线程的,但它的运行时并不是单线程的。
单线程带来了什么优势
没有锁,也没有死锁
在多线程语言里,共享数据通常需要加锁:
synchronized (list) {
list.add(item);
}
JavaScript 的主线程模型让绝大多数代码天然不需要考虑锁。你不需要担心两个回调同时修改同一个变量,也不需要调试死锁。对于业务代码来说,这是巨大的心智负担减免。
状态变化更容易推理
单线程意味着代码执行顺序基本确定。事件回调按队列顺序执行,前一个任务完成后才会开始下一个。排查问题时,可以沿着调用栈和事件队列还原过程,而不是面对多个线程交错执行的日志。
事件循环适合 IO 密集型场景
Web 应用和 Node.js 服务的大量时间都花在等待上:等数据库、等接口、等文件。单线程加非阻塞 IO,可以用较少的资源同时处理大量连接。
一个 Node.js 进程处理上千个并发请求并不罕见。它并不是同时执行上千段 JavaScript,而是把等待时间利用起来,在连接就绪时快速处理回调。
更简单的心智模型
单线程把“并发”从代码层面转移到了事件调度层面。开发者只需要理解事件循环、任务队列和异步 API,不需要掌握线程池、锁、条件变量和内存可见性。
这也是 JavaScript 能成为 Web 通用语言的重要原因:入门门槛低,常见业务逻辑写起来直接。
单线程的代价
单线程不是免费午餐。
最直接的问题是:一段耗时的同步代码会阻塞整个主线程。前面那个三秒的 while 循环就是例子。在浏览器里,这会导致页面卡顿;在 Node.js 里,这会导致所有请求排队等待。
第二个问题是 CPU 密集型任务无法充分利用多核。图片处理、视频转码、复杂加密、大规模计算,这些任务如果全放在主线程,性能会很差。
第三个问题是错误处理更集中。一个未捕获异常可能影响整个进程,因为所有请求共享同一个主线程。
JavaScript 如何弥补单线程的不足
单线程是默认模型,但不是唯一手段。
Web Worker
浏览器提供了 Web Worker,可以把耗时任务放到后台线程:
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ numbers: [1, 2, 3, 4, 5] });
worker.onmessage = (event) => {
console.log('result:', event.data);
};
// worker.js
self.onmessage = (event) => {
const result = event.data.numbers.reduce((sum, n) => sum + n, 0);
self.postMessage(result);
};
Worker 有独立线程和独立全局环境,不能直接操作 DOM。主线程和 Worker 通过消息通信,从而避免共享内存带来的竞态。
Node.js 的 Worker Threads
Node.js 也提供了 worker_threads 模块,用于把 CPU 密集型任务移出主线程:
const { Worker } = require('worker_threads');
const worker = new Worker('./heavy-task.js');
worker.on('message', (result) => {
console.log(result);
});
异步 IO 和任务拆分
对于 IO 密集型任务,优先使用异步 API。对于必须同步完成的大任务,可以拆成多个小任务,借助 setTimeout、setImmediate 或 requestIdleCallback 让出主线程,避免长时间阻塞。
最后
线程是程序内部的执行单元。多线程能利用多核、提升吞吐,但也带来共享状态、锁和竞态的复杂度。
JavaScript 选择单线程,不是因为它做不到多线程,而是因为浏览器场景需要一个简单、可预测、不会让多个执行流同时改 DOM 的模型。异步 IO 和事件循环补上了等待时的效率,Web Worker 和 Worker Threads 则补上了 CPU 密集型任务的缺口。
理解这一点后,很多 JavaScript 行为就顺了:为什么 setTimeout 不精确,为什么长循环会卡页面,为什么 Promise 回调总在当前任务之后执行,以及为什么真正的计算密集型任务不该硬塞进主线程。