[轉(zhuǎn)] 并發(fā)與并行
作者:謝杰
該文章是并發(fā)異步操作系列文章第四篇。
今天我們來(lái)解決一個(gè)很多同學(xué)經(jīng)常搞混的概念對(duì):并發(fā)和并行。
這兩個(gè)詞在日常交流中常常被混用,但在編程領(lǐng)域,它們指的是完全不同的執(zhí)行模式。理解它們的區(qū)別,不僅能幫你正確選型,還能在調(diào)優(yōu)性能時(shí)少走彎路。
先拋一個(gè)問(wèn)題:
你一邊刷手機(jī)一邊等外賣,這是并發(fā)還是并行?
如果你下意識(shí)覺得“反正就是同時(shí)干兩件事”,那這篇文章你一定要看完。我會(huì)用簡(jiǎn)單的比喻和直觀的例子讓你徹底搞清這兩個(gè)概念,并且一起看一下 JS 中哪些特性和這兩個(gè)概念相關(guān)。
在計(jì)算機(jī)領(lǐng)域,“并發(fā)”和“并行”并不是同義詞,雖然它們都能讓你在同一時(shí)間段內(nèi)處理多個(gè)任務(wù),但實(shí)現(xiàn)方式、依賴條件和結(jié)果體驗(yàn)都不一樣。
并發(fā)
英語(yǔ)為 Concurrency,指的是在同一時(shí)間段內(nèi),多個(gè)任務(wù)交替進(jìn)行。這些任務(wù)沒(méi)有真正同時(shí)運(yùn)行,而是通過(guò)任務(wù)切換來(lái)營(yíng)造“同時(shí)進(jìn)行”的效果。
- 類比:一個(gè)服務(wù)員同時(shí)負(fù)責(zé) 3 桌客人,他會(huì)先給 A 桌上菜,再去 B 桌點(diǎn)單,然后回到 C 桌加水……看起來(lái)好像在同時(shí)照顧三桌,其實(shí)是快速切換任務(wù)。
- 特點(diǎn):一個(gè)執(zhí)行單元(單線程)通過(guò)任務(wù)調(diào)度來(lái)處理多個(gè)任務(wù)。
并行
英語(yǔ)為 Parallelism,指的是在同一時(shí)刻,多個(gè)任務(wù)真正同時(shí)運(yùn)行。這通常依賴于多核 CPU 或多臺(tái)機(jī)器的同時(shí)執(zhí)行。
- 類比:3 個(gè)服務(wù)員分別負(fù)責(zé) 3 桌客人,大家同時(shí)干活,互不干擾,這就是真正的同時(shí)進(jìn)行。
- 特點(diǎn):需要多個(gè)執(zhí)行單元(多線程、多進(jìn)程、多核硬件)共同工作。
兩者具體的對(duì)比如下表:
| 維度 | 并發(fā)(Concurrency) | 并行(Parallelism) |
|---|---|---|
| 定義 | 多個(gè)任務(wù)在同一時(shí)間段交替執(zhí)行 | 多個(gè)任務(wù)在同一時(shí)刻真正同時(shí)執(zhí)行 |
| 實(shí)現(xiàn)方式 | 單線程任務(wù)切換、事件循環(huán)、調(diào)度器 | 多線程、多進(jìn)程、多核 CPU 同時(shí)執(zhí)行 |
| 硬件依賴 | 無(wú)需多核,可在單核 CPU 上實(shí)現(xiàn) | 通常需要多核 CPU 或多臺(tái)機(jī)器 |
| 類比 | 一個(gè)服務(wù)員輪流服務(wù)多桌客人 | 多個(gè)服務(wù)員同時(shí)服務(wù)多桌客人 |
| 優(yōu)勢(shì) | 節(jié)省資源、實(shí)現(xiàn)簡(jiǎn)單 | 性能強(qiáng)、適合 CPU 密集型任務(wù) |
| 劣勢(shì) | CPU 密集任務(wù)下切換開銷大 | 實(shí)現(xiàn)復(fù)雜、線程/進(jìn)程通信開銷大 |
理解清楚核心的概念后,接下來(lái)我們就需要看一下兩者在 JS 中的實(shí)現(xiàn)方式了。
并發(fā)
JavaScript 是一門單線程語(yǔ)言,同一時(shí)刻只有一個(gè)任務(wù)在運(yùn)行。那遇到耗時(shí)長(zhǎng)的任務(wù)怎么辦?——如果阻塞在原地等待,整個(gè)頁(yè)面就會(huì)“卡死”,用戶無(wú)法進(jìn)行任何操作。
解決辦法
把這些耗時(shí)任務(wù)交給環(huán)境中的異步機(jī)制去處理(例如 I/O 操作、定時(shí)器、網(wǎng)絡(luò)請(qǐng)求等),等任務(wù)完成后再通過(guò)事件循環(huán)(Event Loop)將回調(diào)推回主線程繼續(xù)執(zhí)行。
因此,在 JS 中,執(zhí)行異步任務(wù)其實(shí)就是一種并發(fā)的表現(xiàn):多個(gè)任務(wù)在同一時(shí)間段內(nèi)交替推進(jìn)(本質(zhì)是時(shí)間片切換),看起來(lái)就像“同時(shí)”在進(jìn)行。
JS 常見的異步寫法演進(jìn)
下面的 4 個(gè)階段,是多數(shù)開發(fā)者在學(xué)習(xí)和使用異步時(shí)常見的寫法演進(jìn)(實(shí)際發(fā)布時(shí)間線有部分重疊):
- 回調(diào)函數(shù)
最早的異步模式,通過(guò)將邏輯寫在回調(diào)函數(shù)中實(shí)現(xiàn)任務(wù)完成后的操作。
console.log("開始");
setTimeout(() => {
console.log("任務(wù)完成");
}, 1000);
console.log("結(jié)束");
- Promise
Promise 讓異步代碼更可讀,避免了“回調(diào)地獄”。
new Promise((resolve) => {
setTimeout(() => resolve("任務(wù)完成"), 1000);
}).then(console.log);
console.log("繼續(xù)執(zhí)行其他任務(wù)");
- 生成器
生成器可以通過(guò) yield 暫停執(zhí)行,并與異步邏輯結(jié)合。通常需要配合調(diào)度器(如 co 庫(kù))自動(dòng)迭代,否則需要手動(dòng)調(diào)用 next()。
function* task() {
const result = yield new Promise((resolve) =>
setTimeout(() => resolve("任務(wù)完成"), 1000)
);
console.log(result);
}
const iterator = task();
iterator.next().value.then((res) => iterator.next(res));
- async/await
async/await 是 Promise 的語(yǔ)法糖,讓異步代碼看起來(lái)像同步代碼。
async function run() {
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("任務(wù)完成");
}
run();
并發(fā)操作的常用 API
在實(shí)際開發(fā)中,并發(fā)操作更多是結(jié)合以下 Promise API 來(lái)實(shí)現(xiàn)(這些方法在前文已有詳細(xì)講解,這里僅列出名稱與核心特性):
Promise.all:需要所有任務(wù)成功才能繼續(xù)(典型場(chǎng)景:并行請(qǐng)求多個(gè)接口)Promise.allSettled:不在乎成敗,只想收集所有結(jié)果Promise.race:獲取最先完成的任務(wù)(可用于實(shí)現(xiàn)超時(shí)控制)Promise.any:容錯(cuò)性強(qiáng),只要有一個(gè)成功即可
總結(jié)一下,在 JS 中,并發(fā)是通過(guò)異步調(diào)度實(shí)現(xiàn)的,本質(zhì)上是一個(gè)線程在不同任務(wù)之間交替執(zhí)行,利用事件循環(huán)在空閑時(shí)處理等待完成的任務(wù)。這種方式雖然看起來(lái)像是“同時(shí)”進(jìn)行,但在任何一個(gè)時(shí)間點(diǎn),主線程實(shí)際上只在執(zhí)行一個(gè)任務(wù)。
如果我們希望多個(gè)任務(wù)能夠真正地同時(shí)運(yùn)行,而不是依靠時(shí)間片切換,這則是我們接下來(lái)要討論的重點(diǎn):并行
并行
前面已經(jīng)解釋了并行的概念,JS 作為一門單線程的語(yǔ)言,本身是不能直接并行執(zhí)行代碼的,但它可以借助額外的線程來(lái)實(shí)現(xiàn)并行,比如:
- 瀏覽器環(huán)境:
Web Worker - Node.js 環(huán)境:
Worker Threads
這類 API 的本質(zhì),是通過(guò)在后臺(tái)開辟新的線程去運(yùn)行代碼,從而讓多個(gè)計(jì)算任務(wù)真正同時(shí)進(jìn)行,并且通過(guò)消息機(jī)制與主線程通信。
注意:無(wú)論是 Web Worker 還是 Worker Threads,涉及到的細(xì)節(jié)非常非常多,隨便哪一個(gè)單獨(dú)領(lǐng)出來(lái),都可以寫成一個(gè)新的系列文章。所以這里只需要了解這兩者是實(shí)現(xiàn)并行的手段即可。
Web Worker
主線程只有一個(gè),CPU 密集型任務(wù)(圖像處理、加密、路徑規(guī)劃、壓縮/解壓等)會(huì)阻塞 UI。Web Worker 把重計(jì)算挪到后臺(tái)線程執(zhí)行,主線程繼續(xù)保持交互與渲染,實(shí)現(xiàn)真正的并行(與異步并發(fā)的時(shí)間片切換不同)。
Worker 是一個(gè)獨(dú)立的 JS 線程,沒(méi)有 DOM、window、document;與主線程通過(guò) postMessage/onmessage 傳遞消息。適合“計(jì)算重、輸入輸出輕”的場(chǎng)景;I/O 為主的任務(wù)通常不必用 Worker。
下面來(lái)看一個(gè) Dedicated Worker 的最小可用示例:
Dedicated Worker 意思是“專用 Web Worker”,只服務(wù)于創(chuàng)建它的那一個(gè)頁(yè)面(或腳本)的 Worker 線程。這個(gè) Worker 不會(huì)被其他頁(yè)面/標(biāo)簽復(fù)用或共享。
除了 Dedicated Worker 以外,常見的還有:
SharedWorker(共享 Worker):可被同源的多個(gè)頁(yè)面/ iframe 共享,一個(gè)實(shí)例多處連接;用
new SharedWorker('./shared.js'),通過(guò)MessagePort通信、onconnect事件接入。適合跨標(biāo)簽共享連接/緩存/池化。Service Worker:不是計(jì)算線程,而是網(wǎng)絡(luò)代理層(離線緩存、請(qǐng)求攔截、推送),用
navigator.serviceWorker.register()注冊(cè),按生命周期事件運(yùn)行,不是拿來(lái)做重計(jì)算的。
// 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(`任務(wù) ${id} 完成:`, result);
};
worker.onerror = (err) => console.error('Worker 出錯(cuò):', err.message);
// 發(fā)送計(jì)算任務(wù)(演示 Transferable:零拷貝轉(zhuǎn)移 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]); // 發(fā)送并轉(zhuǎn)移所有權(quán)
}
// 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];
// 回傳結(jié)果
postMessage({ id, result: s });
};
上面的示例中:
-
new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })這寫法,是讓打包器找得到入口、按 ESM 處理依賴,避免構(gòu)建后路徑翻車。 -
每個(gè)任務(wù)都有一個(gè)
id,這樣信息的“發(fā)送”和“回包”能對(duì)上號(hào),高并發(fā)也不串臺(tái)。 -
postMessage({ id, op: 'sum', buf }, [buf])里的第二個(gè)參數(shù)是 transfer list:把ArrayBuffer的所有權(quán)直接過(guò)戶給 worker,零拷貝更快。注意:過(guò)戶后主線程的buf就是空殼了,別再用。
零拷貝的意思是不再?gòu)?fù)制一份數(shù)據(jù),而是把“這塊內(nèi)存的使用權(quán)”直接交給對(duì)方,或雙方直接共享同一塊內(nèi)存。
在 JS 里的兩種典型方式:
- Transferable(轉(zhuǎn)移所有權(quán)):把
ArrayBuffer(或MessagePort、ImageBitmap、OffscreenCanvas等)放進(jìn)postMessage的第二個(gè)參數(shù)(transfer list)里。發(fā)送后,原端的緩沖區(qū)會(huì)被“剝離”(detached),對(duì)端拿到同一塊字節(jié)的控制權(quán),避免再做一份拷貝。- SharedArrayBuffer(共享內(nèi)存):雙方拿到的是同一塊內(nèi)存的視圖(再也不用傳來(lái)傳去),配合
Atomics做同步。瀏覽器環(huán)境里要滿足 cross-origin isolation;Node.js 環(huán)境里直接可用。
-
worker.onerror兜底,腳本加載失敗、運(yùn)行時(shí)未捕獲異常等都會(huì)觸發(fā);onmessageerror負(fù)責(zé)消息反序列化失敗這類問(wèn)題。 -
可以使用
worker.terminate()釋放線程;如果是長(zhǎng)駐后臺(tái)計(jì)算,可以復(fù)用同一個(gè) worker 來(lái)批量處理任務(wù)。
Worker Threads
瀏覽器里是 Web Workers,到了 Node.js 環(huán)境,并行就交給 Worker Threads。它把CPU 密集型計(jì)算從主線程(事件循環(huán))里剝離到真實(shí)的操作系統(tǒng)線程里跑,避免把整臺(tái)服務(wù)“卡住”。和 child_process 不同的是:線程共享進(jìn)程內(nèi)存,可以用 SharedArrayBuffer/Atomics,也能把 ArrayBuffer 作為 Transferable 零拷貝傳遞。
下面是一個(gè)最小可用示例(ESM):
// main.mjs
import { Worker } from 'node:worker_threads';
// 和瀏覽器那段保持同樣的“發(fā)任務(wù)→回包”模式
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(`任務(wù) ${id} 失敗:`, error);
console.log(`任務(wù) ${id} 完成:`, result);
});
worker.on('error', (err) => {
console.error('Worker 線程錯(cuò)誤:', err);
});
worker.on('exit', (code) => {
if (code !== 0) console.warn('Worker 非正常退出,code =', code);
});
// demo:丟一個(gè)大數(shù)組過(guò)去
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 }發(fā)任務(wù);worker 里計(jì)算后postMessage回來(lái),用id對(duì)號(hào)入座。 - 零拷貝傳輸:把
ArrayBuffer放進(jìn)transferList,直接“過(guò)戶”到 worker,避免復(fù)制。 - 線程而非進(jìn)程:對(duì)比
child_process,線程更輕、更易共享內(nèi)存,但也要注意不要破壞共享狀態(tài)(必要時(shí)用Atomics做同步)。 - 生命周期:
worker.terminate()會(huì)返回一個(gè) Promise;同時(shí)監(jiān)聽error/exit,方便回收與自愈。 - 路徑與模塊:和瀏覽器那段一樣,用
new URL('./worker.mjs', import.meta.url)+{ type: 'module' },避免構(gòu)建/部署后路徑失效。
寫在最后
并發(fā)和并行是現(xiàn)代 JS 工程里反復(fù)出現(xiàn)的主題,總結(jié)一下:
- 并發(fā)(Concurrency):一個(gè)線程在任務(wù)間切換;適合 I/O、等待型工作(網(wǎng)絡(luò)/磁盤/定時(shí)器)。JS 的異步任務(wù)處理,采用的就是并發(fā)模型。并發(fā)像一個(gè)人來(lái)回切活兒。
- 并行(Parallelism):多線程/多核真正同時(shí)運(yùn)行;適合 CPU 密集(圖像處理、加解密、路徑規(guī)劃、壓縮等)。JS 中實(shí)現(xiàn)并行,瀏覽器環(huán)境用 Web Workers;Node.js 環(huán)境用 Worker Threads。并行是請(qǐng)來(lái)更多人同時(shí)干。
兩者不沖突,絕大多數(shù)前端/服務(wù)端場(chǎng)景用并發(fā)異步就夠了,只有當(dāng)計(jì)算真把 CPU 吃滿、拖慢交互或吞吐時(shí),再讓 Worker 家族登場(chǎng)。
-EOF-

浙公網(wǎng)安備 33010602011771號(hào)