创见博客
什么是线程?为什么 JavaScript 选择了单线程
七崽爱吃小饼干2026/10/19阅读 0

先看一段代码。页面上有个按钮,点击后执行:

js
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 节点:

js
// 线程 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 操作。完成后,回调被放进任务队列,等主线程空闲时再执行。

这就是事件循环的核心:

js
console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

console.log('3');

// 输出:
// 1
// 3
// 2

setTimeout 没有阻塞主线程。它只是注册了一个回调,然后继续往下执行。主线程空下来后,再从队列里取出回调运行。

所以更准确的说法是:JavaScript 的执行是单线程的,但它的运行时并不是单线程的。

单线程带来了什么优势

没有锁,也没有死锁

在多线程语言里,共享数据通常需要加锁:

java
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,可以把耗时任务放到后台线程:

js
// 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 密集型任务移出主线程:

js
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 回调总在当前任务之后执行,以及为什么真正的计算密集型任务不该硬塞进主线程。

评论
0/100