1970 年代,Unix 伺服器處理連線的方式是這樣的:
新連線進來 → fork() 一個子行程 → 子行程處理這個連線 → 連線結束、子行程退出
每個連線一個行程,完全隔離,不共享記憶體,一個連線崩了不影響其他連線。
Apache 早期版本用的就是這個模型(prefork MPM)。1995 年,這個模型跑得很好。2000 年,一個 Linux 行程佔 4-8 MB 記憶體,你有 1 GB RAM,大概能開 200 個並行連線。
2005 年,一個普通的 Web 應用已經需要撐幾千個並行連線。fork() 模型直接撞牆了。
第一代:OS Thread(1990 年代末)
替代方案:不 fork 行程,改用 thread。Thread 在同一行程裡共享記憶體,建立成本比行程低 10-100 倍,切換成本(context switch)也低。
Apache 的 worker MPM:一個行程裡跑多個 thread,每個 thread 服務一個連線。
解決了什麼:行程太重(記憶體太貴)的問題。
撞到的牆:
Thread 不是免費的——一個 OS thread 預設有 1-8 MB 的 stack。一萬個連線,就是 10-80 GB 的 stack 記憶體,沒有任何一台合理的伺服器能承受。
更根本的問題:Thread 在等 I/O 的時候,什麼都不做,但資源還占著。你的 thread 打了一個資料庫查詢,然後等待 50ms,這 50ms 它完全 idle,但 OS 還是要調度它、還是要保留它的 stack。
這不是 thread 數量的問題,是 I/O 等待模型的問題。
第二代:Thread Pool(2000 年代初)
修正:不是每個連線一個 thread,而是預先建一個 thread pool,連線來了就從 pool 裡借一個 thread 服務,服務完放回去。
Java 的 ExecutorService、Python 的 concurrent.futures.ThreadPoolExecutor——這個模型解決了「無限 thread 建立」的問題,thread 數量有上限。
解決了什麼:避免 thread 爆炸。
撞到的牆:
Thread pool 只是限制了問題的規模,沒有解決根本問題。如果你的 thread pool 有 100 個 thread,而每個 thread 平均要等 90ms 的資料庫 I/O,那你同時只能服務 100 個連線——剩下的請求要排隊。
你的 CPU 使用率只有 10%,但你的服務已經滿載了。資源在等待,不在工作。
第三代:Event Loop(2000 年代中期)
顛覆性的想法:與其讓 thread 等 I/O,不如讓 I/O 等好了通知你。
這是 non-blocking I/O 的核心概念,配上一個 event loop:
發出 I/O 請求 → 不等,繼續做下一件事
I/O 完成 → OS 通知你 (epoll/kqueue) → 執行 callback
一個 thread,處理幾千個並行 I/O,因為這個 thread 從不阻塞——它只在真正有事情要做的時候才工作。
Nginx 在 2004 年用這個模型直接擊敗了 Apache。Node.js 在 2009 年把這個模型帶給了應用層開發者。
解決了什麼:I/O 等待時 thread 資源浪費的問題。一個 Node.js 行程能撐數萬個並行連線,記憶體消耗遠低於 thread pool 模型。
撞到的牆:
- CPU 密集任務直接卡死:Event loop 是單 thread。如果你在 callback 裡做了一個需要 500ms 的 CPU 計算,這 500ms 期間整個 event loop 被占住,沒有任何其他連線能被處理。
- Callback hell:非同步邏輯一旦有多個步驟,就變成 callback 嵌套 callback:
db.query('SELECT * FROM users', function(err, users) {
cache.set('users', users, function(err) {
mailer.send(users, function(err) {
// 三層之後已經沒有人知道這在做什麼
});
});
});第四代:async/await(2015 年後)
修正:Promise 和 async/await 是 event loop 的語法糖——底層還是同一個 event loop,但寫法變成了像同步程式碼的樣子:
const users = await db.query('SELECT * FROM users');
await cache.set('users', users);
await mailer.send(users);Python 的 asyncio(3.5+)、JavaScript 的 async/await(ES2017)、Kotlin 的 coroutine、Dart 的 async——這一代語言全部加入了這個機制。
解決了什麼:Callback hell 的可讀性問題。
還是沒解決的問題:
async/await 只是讓 event loop 的寫法變好看,它的本質限制還在——
- 還是單 thread(或者說,I/O 多工還是一個 scheduler 在統籌)
- CPU 密集任務還是要另外處理(worker thread、process pool)
- 「傳染性」:一個函式用了
await,呼叫它的所有函式也要變成async——整個 call stack 都被感染
Python 的 asyncio 和同步的 threading 無法直接混用,就是這個傳染性的具體表現。
第五代:Goroutine 與 M:N Scheduling(2012–)
Go 提出了一個不一樣的問題框架:
如果 thread 太重,event loop 又有 CPU 限制,有沒有辦法做到寫起來像同步程式碼,但底層自動非同步?
答案是 M:N scheduling(又叫 user-space thread 或 green thread):
- 你建立的
goroutine不是 OS thread,是 Go runtime 管理的輕量執行單元,stack 從 2KB 開始(可動態擴展) - Go runtime 有自己的 scheduler,把 M 個 goroutine 映射到 N 個 OS thread 上
- 當一個 goroutine 等 I/O,scheduler 自動把 OS thread 讓給其他 goroutine——你不需要寫任何 async/await
go func() {
data, err := db.Query("SELECT ...") // 會等 I/O,但不阻塞 OS thread
process(data)
}()一百萬個 goroutine 是完全可行的(1M * 2KB = 2GB)。一百萬個 OS thread 根本不可能。
解決了什麼:
- 寫法簡單(像同步)、底層非同步(不阻塞 OS thread)
- CPU 密集任務可以分散到多個 OS thread(真正的多核並行)
- 沒有 async/await 的傳染性
撞到的牆:共享記憶體的並行安全問題——多個 goroutine 同時讀寫同一個變數,競態條件(race condition)在任何 M:N 模型裡都存在。Go 的 channel 是推薦解法,但需要開發者主動設計。
第六代:Actor Model(Erlang/Elixir)
Actor model 的主張更激進:根本不要共享記憶體。
Actor model 是 Carl Hewitt 在 1973 年提出的概念,Erlang 從 1980 年代就在用,不是新技術。近年在討論分散式系統和高可用架構時重新被關注,不是因為它變新了,是因為它解決的問題(共享狀態的並行安全、跨節點的狀態隔離)在微服務時代變得更普遍。
每個 Actor 是獨立的行程(Erlang 的輕量行程,不是 OS 行程),Actor 之間只透過 message passing 溝通,沒有共享狀態,沒有鎖,沒有競態條件。
send(pid, {:request, data}) # 發訊息
receive do
{:response, result} -> result
endWhatsApp 用 Erlang,每台伺服器撐兩百萬個連線。Erlang 的 actor 可以熱更新(不停機部署),系統崩了可以自動重啟(let it crash 哲學)。
解決了什麼:共享記憶體的並行安全問題、分散式系統的狀態一致性。
代價:message passing 的 overhead 比共享記憶體高;Erlang/Elixir 的生態系相對小;這個心智模型的學習曲線不低。
模型的選擇,不是優劣,是取捨
| 模型 | 強項 | 弱點 | 代表語言/平台 |
|---|---|---|---|
| OS Thread | 簡單直觀,CPU 密集有優勢 | 記憶體貴,I/O 等待浪費 | Java, Python (threading) |
| Thread Pool | 控制資源上限 | 還是 I/O 等待問題 | Java ExecutorService |
| Event Loop | I/O 密集極高效 | CPU 密集致命,單點瓶頸 | Node.js, Nginx |
| async/await | 可讀性好 | 傳染性,底層還是 event loop | Python asyncio, JS |
| M:N / Goroutine | 兼顧 I/O 和 CPU | 需要設計並行安全 | Go, Kotlin coroutine |
| Actor | 沒有共享狀態 | 訊息 overhead,生態小 | Erlang, Elixir, Akka |
你在看一個語言的並行模型時,值得問:它在解什麼極限?它的弱點會在哪種場景爆發?