
I/O 密集 vs CPU 密集:執行緒數該開多少?
拆解 I/O 為什麼是瓶頸、CPU/RAM 與外部裝置的分界線、為什麼 CPU 密集任務用不上 async,以及執行緒數該對齊物理核心還是邏輯核心。
Gary
什麼算 I/O
I/O(Input/Output) 泛指程式跟外部世界交換資料的動作。任何資料要離開程式本身、跟外部裝置打交道,都算 I/O:
| I/O 類型 | 例子 |
|---|---|
| 網路 I/O | 發 HTTP 請求、收資料 |
| 磁碟 I/O | 讀寫檔案、資料庫存取 |
| 輸入裝置 I/O | 鍵盤、滑鼠、觸控 |
| 輸出裝置 I/O | 螢幕顯示、印表機 |
| 其他裝置 I/O | 相機、藍牙、感測器 |
I/O 密集(I/O-bound) 不是指這些動作本身,而是指一個任務的耗時瓶頸主要卡在「等這些動作完成」,不是卡在運算。
為什麼 I/O 是瓶頸:速度差距是天文數字
CPU 運算的速度,跟 I/O 裝置的速度,差距懸殊:
| 操作 | 大約耗時 | 相對 CPU 指令慢幾倍 |
|---|---|---|
| CPU 執行一個指令週期 | ~0.3 奈秒 | 1x(基準) |
| CPU Cache(L1) | ~1 奈秒 | 3x |
| RAM(記憶體) | 50~100 奈秒 | 150~300x |
| 讀寫 SSD | ~100 微秒 | 約 30 萬倍 |
| 讀寫傳統硬碟(HDD) | 5~10 毫秒 | 1500 萬倍以上 |
| 網路請求(同機房) | ~0.5 毫秒 | 約 50 萬倍 |
| 網路請求(跨國) | 100~300 毫秒 | 1~3 億倍 |
把 CPU 執行一個指令的時間放大成人類感受得到的「1 秒」:
- 存取 RAM ≈ 2.5~5 分鐘
- 讀寫 SSD ≈ 3.8 天
- 讀寫傳統硬碟 ≈ 1 年
- 網路請求(跨國)≈ 3~9 年
如果 CPU 傻傻等網路資料回來,等於讓一個工作能力超強的人站在原地,只為了等一封要好幾年才送到的信——這就是「I/O 密集」瓶頸的真正含義:時間主要卡在 I/O,不是卡在運算。
反過來,如果任務幾乎沒有等待外部資源,全部時間都是 CPU 在計算(加密、排序、影像處理),這種叫 CPU-bound(CPU 密集),瓶頸在運算能力,而不是等待。
分界線畫在哪:CPU + RAM 不算 I/O
「I/O」的分界線不是「只要在 CPU 晶片外面就算」,而是看存取方式:
CPU + RAM(核心運算資源,不算 I/O)
├─ 暫存器 / Cache
└─ RAM:CPU 直接透過記憶體匯流排定址,不需要 OS 的 I/O 子系統介入
│
│ 分界線
▼
外部裝置(才算 I/O)
├─ 硬碟 / SSD、網路卡、鍵盤滑鼠、螢幕、USB 裝置……
└─ 都要透過 device driver、I/O 控制器、系統呼叫才能存取
| RAM | 硬碟/網路(真正的 I/O) | |
|---|---|---|
| 怎麼存取 | CPU 直接定址,是每條指令自然的一部分 | 要透過 driver、系統呼叫、I/O 控制器 |
| 需不需要 OS 介入 | 不用 | 要,經過 kernel 的 I/O 子系統 |
| 能不能非同步處理 | 不行,是指令執行的內建延遲 | 可以,這正是 event loop/中斷機制發揮作用的地方 |
RAM 存取雖然比 CPU 運算慢 100~300 倍,但這個延遲是每條指令的正常開銷,程式無法、也不需要對它做非同步處理——沒有 await ram.read() 這種東西。真正需要 async 介入的,是慢上幾十萬倍、且時間不可預期的硬碟/網路 I/O。
「等待」不只 I/O 一種類型,計時器、使用者互動、執行緒同步、硬體運算等待都算,完整分類可參考 Async 到底在解決什麼問題? 一文。
CPU 密集型任務:async 幫不上忙
Async 的本質是「反正在等外部東西,不如先做別的事」。但 CPU 密集型任務從頭到尾都是 CPU 自己在算,沒有等待的空檔可以利用:
async function heavyCompute() {
let result = 0
for (let i = 0; i < 10_000_000_000; i++) {
result += Math.sqrt(i) // 每一步都是 CPU 在算,沒有一刻在「等」
}
return result
}
就算包成 async,執行時依然會獨占 CPU、卡住整條執行緒——不像 fetch 有個「送出去後、還沒回來」的空檔可以利用。
真正的解法是動用平行運算,讓計算在別的執行緒/行程跑:
// Web Worker:丟給另一條真正的執行緒去算,主執行緒完全不受影響
const worker = new Worker('heavy-compute.js')
worker.postMessage('start')
worker.onmessage = (e) => {
console.log('結果:', e.data)
}
# Python:multiprocessing 開真正的行程平行運算
from multiprocessing import Pool
with Pool(4) as p:
results = p.map(heavy_compute, data_list)
| I/O 密集 | CPU 密集 | |
|---|---|---|
| 瓶頸 | 等外部裝置回應 | CPU 自己在算 |
| 解法 | Async / 非阻塞 I/O(單執行緒就夠) | 真正的平行運算(多執行緒/多核心) |
| 能不能加速 | Async 不會讓等待變快,但能讓 CPU 別閒置浪費 | 平行運算是真的能加速 |
物理核心 vs 邏輯核心
多數程式語言 API(如 os.cpus().length)回傳的是邏輯核心數,跟真正的物理核心數不一定相等。
1 個物理核心
└─ 若支援 Hyper-Threading / SMT
→ 偽裝成 2 個邏輯核心,讓 OS 誤以為有 2 個可排程單位
→ 底層運算資源(ALU 等)仍是同一份,只是共用
例如「4 核 8 緒」的 CPU:物理核心數 = 4,邏輯核心數 = 8。
為什麼要搞邏輯核心:一條吃不滿,多開幾條榨乾資源
說白了:物理核心的運算資源,常常被一條執行緒吃不滿(因為它會等記憶體、等分支預測,中間有空檔),與其浪費,不如用 Hyper-Threading 多開一條邏輯核心,塞第二條執行緒進來填空檔,榨乾原本閒置的運算資源,換取約 15~30% 的額外效能。
什麼時候邏輯核心沒有意義,甚至會拖慢
如果一條執行緒的工作本身就把核心資源用到滿(高度最佳化、緊密的數值運算迴圈),第一條執行緒就沒留下任何空檔,第二條邏輯執行緒插不進去。更糟的是,兩條邏輯執行緒共用同一份 Cache,如果都在瘋狂用 Cache,會互相把對方的內容擠掉(cache thrashing),實測效能可能反而下降 5~15%。這也是為什麼高效能運算(HPC)領域常直接在 BIOS 關掉 Hyper-Threading,讓執行緒數對齊物理核心數。
| 任務類型 | 建議執行緒數 |
|---|---|
| 純 CPU 密集運算(緊密迴圈、數值計算) | 貼近物理核心數 |
| 一般應用程式(有分支、記憶體停頓) | 邏輯核心數通常可以接受 |
| 想要最保險 | 兩者都測試比較 |
執行緒該開幾條:對齊核心數,不是越多越好
開多條執行緒的目的,是讓原本閒置的其他核心也動起來。但這裡有個常見誤解:
| CPU 密集型任務 | I/O 密集型任務 | |
|---|---|---|
| 執行緒在做什麼 | 一直在運算,真的佔用核心 | 大部分時間在等待(阻塞),不佔用 CPU |
| 建議執行緒數 | 接近 CPU 核心數 | 可以遠超過核心數(開上千條也沒問題) |
| 開太多會怎樣 | 執行緒輪流搶核心(context switch),效能反而變差 | 大部分執行緒在「睡覺」等待,不會真的搶 CPU,開多一點沒差 |
邏輯核心數就是硬體「同一瞬間」真正能平行執行的指令流上限。超過這個數字的執行緒,並不會拿到更多平行運算資源,只是被作業系統用時間切片輪流分配,還多了 context switch 的額外成本——對 CPU 密集型任務是負優化,不是加分。
// Node.js:常見寫法是開跟 CPU 核心數一樣多的 Worker
const os = require('os')
const numCPUs = os.cpus().length
for (let i = 0; i < numCPUs; i++) {
new Worker('heavy-compute.js')
}
結論
I/O 之所以是瓶頸,是因為它跟 CPU 運算的速度差了好幾個數量級;分界線畫在 CPU + RAM 這個「核心運算資源」之外,才是 async 真正能發揮作用的範圍。CPU 密集型任務沒有這種等待空檔,async 幫不上忙,只能靠平行運算換取加速——而執行緒數該開多少,要看任務性質是在「等」還是在「算」:I/O 密集可以遠超過核心數,CPU 密集則該貼近核心數(甚至是物理核心數),開太多不會更快,只會讓執行緒排隊搶用有限的硬體資源。