Skip to content

并发与并行

今天我们来解决一个很多同学经常搞混的概念对:并发和并行。

这两个词在日常交流中常常被混用,但在编程领域,它们指的是完全不同的执行模式。理解它们的区别,不仅能帮你正确选型,还能在调优性能时少走弯路。

先抛一个问题:

你一边刷手机一边等外卖,这是并发还是并行?

如果你下意识觉得“反正就是同时干两件事”,那这篇文章你一定要看完。我会用简单的比喻和直观的例子让你彻底搞清这两个概念,并且一起看一下 JS 中哪些特性和这两个概念相关。

在计算机领域,“并发”和“并行”并不是同义词,虽然它们都能让你在同一时间段内处理多个任务,但实现方式、依赖条件和结果体验都不一样。

并发

英语为 Concurrency,指的是在同一时间段内,多个任务交替进行。这些任务没有真正同时运行,而是通过任务切换来营造“同时进行”的效果。

并行

英语为 Parallelism,指的是在同一时刻,多个任务真正同时运行。这通常依赖于多核 CPU多台机器的同时执行。

两者具体的对比如下表:

维度并发(Concurrency)并行(Parallelism)
定义多个任务在同一时间段交替执行多个任务在同一时刻真正同时执行
实现方式单线程任务切换、事件循环、调度器多线程、多进程、多核 CPU 同时执行
硬件依赖无需多核,可在单核 CPU 上实现通常需要多核 CPU 或多台机器
类比一个服务员轮流服务多桌客人多个服务员同时服务多桌客人
优势节省资源、实现简单性能强、适合 CPU 密集型任务
劣势CPU 密集任务下切换开销大实现复杂、线程/进程通信开销大

理解清楚核心的概念后,接下来我们就需要看一下两者在 JS 中的实现方式了。

并发#

JavaScript 是一门单线程语言,同一时刻只有一个任务在运行。那遇到耗时长的任务怎么办?——如果阻塞在原地等待,整个页面就会“卡死”,用户无法进行任何操作。

解决办法

把这些耗时任务交给环境中的异步机制去处理(例如 I/O 操作、定时器、网络请求等),等任务完成后再通过事件循环(Event Loop)将回调推回主线程继续执行。

因此,在 JS 中,执行异步任务其实就是一种并发的表现:多个任务在同一时间段内交替推进(本质是时间片切换),看起来就像“同时”在进行。

JS 常见的异步写法演进

下面的 4 个阶段,是多数开发者在学习和使用异步时常见的写法演进(实际发布时间线有部分重叠):

  1. 回调函数

最早的异步模式,通过将逻辑写在回调函数中实现任务完成后的操作。

console.log('开始')
setTimeout(() => {
console.log('任务完成')
}, 1000)
console.log('结束')
  1. Promise

Promise 让异步代码更可读,避免了“回调地狱”。

new Promise((resolve) => {
setTimeout(() => resolve('任务完成'), 1000)
}).then(console.log)
console.log('继续执行其他任务')
  1. 生成器

生成器可以通过 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))
  1. async/await

async/await 是 Promise 的语法糖,让异步代码看起来像同步代码。

async function run() {
await new Promise((resolve) => setTimeout(resolve, 1000))
console.log('任务完成')
}
run()

并发操作的常用 API

在实际开发中,并发操作更多是结合以下 Promise API 来实现(这些方法在前文已有详细讲解,这里仅列出名称与核心特性):

  1. Promise.all:需要所有任务成功才能继续(典型场景:并行请求多个接口)
  2. Promise.allSettled:不在乎成败,只想收集所有结果
  3. Promise.race:获取最先完成的任务(可用于实现超时控制)
  4. Promise.any:容错性强,只要有一个成功即可

总结一下,在 JS 中,并发是通过异步调度实现的,本质上是一个线程在不同任务之间交替执行,利用事件循环在空闲时处理等待完成的任务。这种方式虽然看起来像是“同时”进行,但在任何一个时间点,主线程实际上只在执行一个任务。

如果我们希望多个任务能够真正地同时运行,而不是依靠时间片切换,这则是我们接下来要讨论的重点:并行

并行#

前面已经解释了并行的概念,JS 作为一门单线程的语言,本身是不能直接并行执行代码的,但它可以借助额外的线程来实现并行,比如:

这类 API 的本质,是通过在后台开辟新的线程去运行代码,从而让多个计算任务真正同时进行,并且通过消息机制与主线程通信。

注意:无论是 Web Worker 还是 Worker Threads,涉及到的细节非常非常多,随便哪一个单独领出来,都可以写成一个新的系列文章。所以这里只需要了解这两者是实现并行的手段即可。

Web Worker

主线程只有一个,CPU 密集型任务(图像处理、加密、路径规划、压缩/解压等)会阻塞 UI。Web Worker 把重计算挪到后台线程执行,主线程继续保持交互与渲染,实现真正的并行(与异步并发的时间片切换不同)。

Worker 是一个独立的 JS 线程,没有 DOM、windowdocument;与主线程通过 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 })
}

上面的示例中:

零拷贝的意思是不再复制一份数据,而是把“这块内存的使用权”直接交给对方,或双方直接共享同一块内存。

在 JS 里的两种典型方式:

  1. Transferable(转移所有权):把 ArrayBuffer(或 MessagePortImageBitmapOffscreenCanvas 等)放进 postMessage 的第二个参数(transfer list)里。发送后,原端的缓冲区会被“剥离”(detached),对端拿到同一块字节的控制权,避免再做一份拷贝。
  2. SharedArrayBuffer(共享内存):双方拿到的是同一块内存的视图(再也不用传来传去),配合 Atomics 做同步。浏览器环境里要满足 cross-origin isolation;Node.js 环境里直接可用。

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) })
}
})

在上面的代码示例中:

写在最后#

并发和并行是现代 JS 工程里反复出现的主题,总结一下:

两者不冲突,绝大多数前端/服务端场景用并发异步就够了,只有当计算真把 CPU 吃满、拖慢交互或吞吐时,再让 Worker 家族登场。