文章
2026年7月9日 - 8 MIN READ
I/O 密集 vs CPU 密集:執行緒數該開多少?

I/O 密集 vs CPU 密集:執行緒數該開多少?

拆解 I/O 為什麼是瓶頸、CPU/RAM 與外部裝置的分界線、為什麼 CPU 密集任務用不上 async,以及執行緒數該對齊物理核心還是邏輯核心。

Gary

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 密集則該貼近核心數(甚至是物理核心數),開太多不會更快,只會讓執行緒排隊搶用有限的硬體資源。

Gary Portfolio • © 2026