[转] 并发与并行
作者:谢杰
该文章是并发异步操作系列文章第四篇。
今天我们来解决一个很多同学经常搞混的概念对:并发和并行。
这两个词在日常交流中常常被混用,但在编程领域,它们指的是完全不同的执行模式。理解它们的区别,不仅能帮你正确选型,还能在调优性能时少走弯路。
先抛一个问题:
你一边刷手机一边等外卖,这是并发还是并行?
如果你下意识觉得“反正就是同时干两件事”,那这篇文章你一定要看完。我会用简单的比喻和直观的例子让你彻底搞清这两个概念,并且一起看一下 JS 中哪些特性和这两个概念相关。
在计算机领域,“并发”和“并行”并不是同义词,虽然它们都能让你在同一时间段内处理多个任务,但实现方式、依赖条件和结果体验都不一样。
并发
英语为 Concurrency,指的是在同一时间段内,多个任务交替进行。这些任务没有真正同时运行,而是通过任务切换来营造“同时进行”的效果。
- 类比:一个服务员同时负责 3 桌客人,他会先给 A 桌上菜,再去 B 桌点单,然后回到 C 桌加水……看起来好像在同时照顾三桌,其实是快速切换任务。
- 特点:一个执行单元(单线程)通过任务调度来处理多个任务。
并行
英语为 Parallelism,指的是在同一时刻,多个任务真正同时运行。这通常依赖于多核 CPU 或多台机器的同时执行。
- 类比:3 个服务员分别负责 3 桌客人,大家同时干活,互不干扰,这就是真正的同时进行。
- 特点:需要多个执行单元(多线程、多进程、多核硬件)共同工作。
两者具体的对比如下表:
| 维度 | 并发(Concurrency) | 并行(Parallelism) |
|---|---|---|
| 定义 | 多个任务在同一时间段交替执行 | 多个任务在同一时刻真正同时执行 |
| 实现方式 | 单线程任务切换、事件循环、调度器 | 多线程、多进程、多核 CPU 同时执行 |
| 硬件依赖 | 无需多核,可在单核 CPU 上实现 | 通常需要多核 CPU 或多台机器 |
| 类比 | 一个服务员轮流服务多桌客人 | 多个服务员同时服务多桌客人 |
| 优势 | 节省资源、实现简单 | 性能强、适合 CPU 密集型任务 |
| 劣势 | CPU 密集任务下切换开销大 | 实现复杂、线程/进程通信开销大 |
理解清楚核心的概念后,接下来我们就需要看一下两者在 JS 中的实现方式了。
并发
JavaScript 是一门单线程语言,同一时刻只有一个任务在运行。那遇到耗时长的任务怎么办?——如果阻塞在原地等待,整个页面就会“卡死”,用户无法进行任何操作。
解决办法
把这些耗时任务交给环境中的异步机制去处理(例如 I/O 操作、定时器、网络请求等),等任务完成后再通过事件循环(Event Loop)将回调推回主线程继续执行。
因此,在 JS 中,执行异步任务其实就是一种并发的表现:多个任务在同一时间段内交替推进(本质是时间片切换),看起来就像“同时”在进行。
JS 常见的异步写法演进
下面的 4 个阶段,是多数开发者在学习和使用异步时常见的写法演进(实际发布时间线有部分重叠):
- 回调函数
最早的异步模式,通过将逻辑写在回调函数中实现任务完成后的操作。
console.log("开始");
setTimeout(() => {
console.log("任务完成");
}, 1000);
console.log("结束");
- Promise
Promise 让异步代码更可读,避免了“回调地狱”。
new Promise((resolve) => {
setTimeout(() => resolve("任务完成"), 1000);
}).then(console.log);
console.log("继续执行其他任务");
- 生成器
生成器可以通过 yield 暂停执行,并与异步逻辑结合。通常需要配合调度器(如 co 库)自动迭代,否则需要手动调用 next()。
function* task() {
const result = yield new Promise((resolve) =>
setTimeout(() => resolve("任务完成"), 1000)
);
console.log(result);
}
const iterator = task();
iterator.next().value.then((res) => iterator.next(res));
- async/await
async/await 是 Promise 的语法糖,让异步代码看起来像同步代码。
async function run() {
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("任务完成");
}
run();
并发操作的常用 API
在实际开发中,并发操作更多是结合以下 Promise API 来实现(这些方法在前文已有详细讲解,这里仅列出名称与核心特性):
Promise.all:需要所有任务成功才能继续(典型场景:并行请求多个接口)Promise.allSettled:不在乎成败,只想收集所有结果Promise.race:获取最先完成的任务(可用于实现超时控制)Promise.any:容错性强,只要有一个成功即可
总结一下,在 JS 中,并发是通过异步调度实现的,本质上是一个线程在不同任务之间交替执行,利用事件循环在空闲时处理等待完成的任务。这种方式虽然看起来像是“同时”进行,但在任何一个时间点,主线程实际上只在执行一个任务。
如果我们希望多个任务能够真正地同时运行,而不是依靠时间片切换,这则是我们接下来要讨论的重点:并行
并行
前面已经解释了并行的概念,JS 作为一门单线程的语言,本身是不能直接并行执行代码的,但它可以借助额外的线程来实现并行,比如:
- 浏览器环境:
Web Worker - Node.js 环境:
Worker Threads
这类 API 的本质,是通过在后台开辟新的线程去运行代码,从而让多个计算任务真正同时进行,并且通过消息机制与主线程通信。
注意:无论是 Web Worker 还是 Worker Threads,涉及到的细节非常非常多,随便哪一个单独领出来,都可以写成一个新的系列文章。所以这里只需要了解这两者是实现并行的手段即可。
Web Worker
主线程只有一个,CPU 密集型任务(图像处理、加密、路径规划、压缩/解压等)会阻塞 UI。Web Worker 把重计算挪到后台线程执行,主线程继续保持交互与渲染,实现真正的并行(与异步并发的时间片切换不同)。
Worker 是一个独立的 JS 线程,没有 DOM、window、document;与主线程通过 postMessage/onmessage 传递消息。适合“计算重、输入输出轻”的场景;I/O 为主的任务通常不必用 Worker。
下面来看一个 Dedicated Worker 的最小可用示例:
Dedicated Worker 意思是“专用 Web Worker”,只服务于创建它的那一个页面(或脚本)的 Worker 线程。这个 Worker 不会被其他页面/标签复用或共享。
除了 Dedicated Worker 以外,常见的还有:
SharedWorker(共享 Worker):可被同源的多个页面/ iframe 共享,一个实例多处连接;用
new SharedWorker('./shared.js'),通过MessagePort通信、onconnect事件接入。适合跨标签共享连接/缓存/池化。Service Worker:不是计算线程,而是网络代理层(离线缓存、请求拦截、推送),用
navigator.serviceWorker.register()注册,按生命周期事件运行,不是拿来做重计算的。
// main.js
// 使用 ESM worker 便于打包器处理依赖
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
worker.onmessage = (e) => {
const { id, result } = e.data;
console.log(`任务 ${id} 完成:`, result);
};
worker.onerror = (err) => console.error('Worker 出错:', err.message);
// 发送计算任务(演示 Transferable:零拷贝转移 ArrayBuffer)
let seq = 0;
export function sumLargeArray(ints) {
const id = ++seq;
const buf = new ArrayBuffer(ints.length * 4);
new Int32Array(buf).set(ints);
worker.postMessage({ id, op: 'sum', buf }, [buf]); // 发送并转移所有权
}
// worker.js
self.onmessage = (e) => {
const { id, op, buf } = e.data;
if (op !== 'sum') return;
const view = new Int32Array(buf);
let s = 0;
for (let i = 0; i < view.length; i++) s += view[i];
// 回传结果
postMessage({ id, result: s });
};
上面的示例中:
-
new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })这写法,是让打包器找得到入口、按 ESM 处理依赖,避免构建后路径翻车。 -
每个任务都有一个
id,这样信息的“发送”和“回包”能对上号,高并发也不串台。 -
postMessage({ id, op: 'sum', buf }, [buf])里的第二个参数是 transfer list:把ArrayBuffer的所有权直接过户给 worker,零拷贝更快。注意:过户后主线程的buf就是空壳了,别再用。
零拷贝的意思是不再复制一份数据,而是把“这块内存的使用权”直接交给对方,或双方直接共享同一块内存。
在 JS 里的两种典型方式:
- Transferable(转移所有权):把
ArrayBuffer(或MessagePort、ImageBitmap、OffscreenCanvas等)放进postMessage的第二个参数(transfer list)里。发送后,原端的缓冲区会被“剥离”(detached),对端拿到同一块字节的控制权,避免再做一份拷贝。- SharedArrayBuffer(共享内存):双方拿到的是同一块内存的视图(再也不用传来传去),配合
Atomics做同步。浏览器环境里要满足 cross-origin isolation;Node.js 环境里直接可用。
-
worker.onerror兜底,脚本加载失败、运行时未捕获异常等都会触发;onmessageerror负责消息反序列化失败这类问题。 -
可以使用
worker.terminate()释放线程;如果是长驻后台计算,可以复用同一个 worker 来批量处理任务。
Worker Threads
浏览器里是 Web Workers,到了 Node.js 环境,并行就交给 Worker Threads。它把CPU 密集型计算从主线程(事件循环)里剥离到真实的操作系统线程里跑,避免把整台服务“卡住”。和 child_process 不同的是:线程共享进程内存,可以用 SharedArrayBuffer/Atomics,也能把 ArrayBuffer 作为 Transferable 零拷贝传递。
下面是一个最小可用示例(ESM):
// main.mjs
import { Worker } from 'node:worker_threads';
// 和浏览器那段保持同样的“发任务→回包”模式
const worker = new Worker(new URL('./worker.mjs', import.meta.url), {
type: 'module',
});
let seq = 0;
function sumLargeArray(ints) {
const id = ++seq;
const buf = new ArrayBuffer(ints.length * 4);
new Int32Array(buf).set(ints);
// Node 里的 worker.postMessage 同样支持 transferList
worker.postMessage({ id, op: 'sum', buf }, [buf]);
}
worker.on('message', ({ id, result, error }) => {
if (error) return console.error(`任务 ${id} 失败:`, error);
console.log(`任务 ${id} 完成:`, result);
});
worker.on('error', (err) => {
console.error('Worker 线程错误:', err);
});
worker.on('exit', (code) => {
if (code !== 0) console.warn('Worker 非正常退出,code =', code);
});
// demo:丢一个大数组过去
sumLargeArray(Int32Array.from({ length: 1e6 }, (_, i) => i));
// worker.mjs
import { parentPort } from 'node:worker_threads';
parentPort.on('message', ({ id, op, buf }) => {
try {
if (op !== 'sum') return;
const view = new Int32Array(buf);
let s = 0;
for (let i = 0; i < view.length; i++) s += view[i];
parentPort.postMessage({ id, result: s });
} catch (e) {
parentPort.postMessage({ id, error: String(e) });
}
});
在上面的代码示例中:
- 同样的心智模型:主线程
postMessage → { id }发任务;worker 里计算后postMessage回来,用id对号入座。 - 零拷贝传输:把
ArrayBuffer放进transferList,直接“过户”到 worker,避免复制。 - 线程而非进程:对比
child_process,线程更轻、更易共享内存,但也要注意不要破坏共享状态(必要时用Atomics做同步)。 - 生命周期:
worker.terminate()会返回一个 Promise;同时监听error/exit,方便回收与自愈。 - 路径与模块:和浏览器那段一样,用
new URL('./worker.mjs', import.meta.url)+{ type: 'module' },避免构建/部署后路径失效。
写在最后
并发和并行是现代 JS 工程里反复出现的主题,总结一下:
- 并发(Concurrency):一个线程在任务间切换;适合 I/O、等待型工作(网络/磁盘/定时器)。JS 的异步任务处理,采用的就是并发模型。并发像一个人来回切活儿。
- 并行(Parallelism):多线程/多核真正同时运行;适合 CPU 密集(图像处理、加解密、路径规划、压缩等)。JS 中实现并行,浏览器环境用 Web Workers;Node.js 环境用 Worker Threads。并行是请来更多人同时干。
两者不冲突,绝大多数前端/服务端场景用并发异步就够了,只有当计算真把 CPU 吃满、拖慢交互或吞吐时,再让 Worker 家族登场。
-EOF-

浙公网安备 33010602011771号