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 模型。

撞到的牆

  1. CPU 密集任務直接卡死:Event loop 是單 thread。如果你在 callback 裡做了一個需要 500ms 的 CPU 計算,這 500ms 期間整個 event loop 被占住,沒有任何其他連線能被處理。
  2. 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
end

WhatsApp 用 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 LoopI/O 密集極高效CPU 密集致命,單點瓶頸Node.js, Nginx
async/await可讀性好傳染性,底層還是 event loopPython asyncio, JS
M:N / Goroutine兼顧 I/O 和 CPU需要設計並行安全Go, Kotlin coroutine
Actor沒有共享狀態訊息 overhead,生態小Erlang, Elixir, Akka

你在看一個語言的並行模型時,值得問:它在解什麼極限?它的弱點會在哪種場景爆發?

下一篇:型別系統演進