寫過多執行緒的人都碰過這種程式碼:
import threading
class Account:
def __init__(self, balance):
self.balance = balance
self.lock = threading.Lock()
def transfer(src, dst, amount):
with src.lock: # 先鎖來源帳戶
with dst.lock: # 再鎖目標帳戶
src.balance -= amount
dst.balance += amount
看起來沒問題。兩個帳戶都鎖住了才動錢,餘額不會算錯。
然後某天半夜,服務卡死,沒有任何錯誤訊息:
T1: transfer(a, b, 100) # 握有 a.lock,等 b.lock
T2: transfer(b, a, 50) # 握有 b.lock,等 a.lock
# 兩邊互相等待,永遠不會醒來
這段程式碼的每一行都是對的,錯的是它們湊在一起的方式。
鎖的問題不是難用,是不能組合
死結的標準解法是規定上鎖順序,例如永遠先鎖 id 較小的帳戶:
def transfer(src, dst, amount):
first, second = sorted([src, dst], key=lambda a: a.id)
with first.lock:
with second.lock:
src.balance -= amount
dst.balance += amount
問題解決了,但你剛剛在專案裡埋了一條全域約定:這個系統裡所有需要同時持有兩把鎖的地方,都必須遵守同一個順序。這條約定不存在於型別系統裡,不存在於介面裡,編譯器不會檢查它。它只存在於某個人腦中,或某份沒人看的 wiki 裡。
半年後有人加了一個功能,要在轉帳的同時記一筆稽核日誌,而稽核模組自己也有一把鎖。他不知道有這條約定。
這就是共享記憶體並行的核心困境:兩段各自正確的程式碼,組合起來可能是錯的。一個函式的正確性不能只看它自己,還要看整個程式有誰在什麼順序下拿了哪些鎖。這種性質叫做不可組合(non-composable),它讓程式碼審查幾乎無法發現問題,因為問題不在任何一段程式碼裡面。
更根本的地方在於預設值。共享記憶體模型的預設是「所有執行緒都能碰所有資料」,安全性靠開發者自己一處一處加回來。漏掉一處不會有編譯錯誤,只會在壓力測試的第七萬次迭代裡偶爾算錯一塊錢。
Actor 的三個約束
Carl Hewitt 在 1973 年提出的 Actor 模型,把預設值反過來:資料預設不共享,要交流只能傳訊息。
actor 是帶著私有狀態的運算單元。它收到一則訊息後,只能做三件事:送訊息給它認得的其他 actor、生出新的 actor、換掉自己接下來處理訊息的行為,也就是更新狀態。就這樣,沒有第四件。
三個約束撐起整個模型:
- 狀態私有:沒有別的 actor 能讀寫你的變數,連讀都不行。想知道我的餘額,送訊息來問。
- 訊息非同步:送出就返回,不等對方處理完。送訊息這個動作本身不會阻塞。
- 一次一則:訊息先進 mailbox(信箱) 排隊,actor 依序取出處理。
第三條是關鍵。actor 內部永遠是單執行緒語意,所以你在 actor 裡面寫的程式碼,可以放心地讀寫自己的狀態,不需要任何鎖。競爭條件在這裡不可能發生,不是因為你小心,而是因為模型不給你機會。
用 Swift 的 actor 寫同一個帳戶:
enum WithdrawError: Error {
case insufficientFunds
}
actor Account {
private var balance: Int // 私有狀態:actor 外面沒有任何人碰得到
init(balance: Int) {
self.balance = balance
}
// 方法本身不必標 async,但從 actor 外面呼叫一律要 await
func withdraw(_ amount: Int) throws -> Int {
guard amount <= balance else {
throw WithdrawError.insufficientFunds
}
balance -= amount // 沒有鎖,也不需要鎖
return amount
}
}
// 呼叫端(在某個 async 情境裡)
let account = Account(balance: 1000)
let got = try await account.withdraw(100) // 跨 actor 存取,await 是編譯器強制的
balance 是這個 actor 的私有狀態,actor 這個關鍵字讓編譯器接管了它:外面的程式碼想直接讀 account.balance 會編譯失敗,只能透過 await 排隊進來。一千條連線同時打進來提款,這些呼叫會排成一列,一次執行一個。
所以 Actor 模型並沒有消除競爭,它把競爭從「同時存取」搬到了「排隊順序」。你的餘額不會算錯,但誰先誰後仍然由排隊決定。差別在於:資料層級的競爭條件消失了,訊息層級的順序問題還在。
訊息不是函式呼叫
await account.withdraw(100) 這行看起來像呼叫函式,這正是危險的地方。它其實是:把請求丟進別人的佇列,然後等一個回覆。中間隔著一段真實的距離,而 Swift 只用一個 await 來提示你。換成 Erlang 的 gen_server:call(Pid, {withdraw, 100}) 也一樣,包在函式外皮底下的仍然是一來一回兩則訊息。
這段距離會在三個地方咬到你。
沒有跨 actor 的原子操作。 前面那筆轉帳,在 Actor 世界裡不能用「同時鎖住兩個帳戶」來解,因為你根本沒有鎖。你只能做成一段協定:先跟來源帳戶說扣款,收到確認後再跟目標帳戶說入帳。中間如果目標帳戶掛了,你得自己補償把錢加回去。分散式交易的那套麻煩,一樣不少地回來了。Actor 沒有免費給你 ACID,它給的是把交易邊界畫在單一 actor 內部的能力——所以真正的設計功夫,在於決定哪些狀態應該住在同一個 actor 裡。
順序保證比你想的弱。 Erlang 保證 A 送給 B 的訊息會依序抵達,但 A 和 C 各自送給 B 的訊息,交錯順序是不確定的。任何依賴「全域事件順序」的邏輯,在這裡都是錯的。
mailbox 會滿。 生產者比消費者快的時候,訊息會堆積。Erlang 的 mailbox 預設無界,積到最後是記憶體耗盡;Akka 允許設定有界 mailbox,滿了就丟訊息或阻塞送方。無論哪種,你都得自己想清楚背壓(backpressure)策略。非同步不會讓慢的元件變快,只會讓它悄悄把問題往後推。
真正的殺手鐧是讓它掛掉
多數介紹 Actor 的文章把重點放在並行,但 Erlang 社群自己更在意的是另一件事:容錯。
因為狀態是私有的,一個 actor 崩潰時,破壞範圍就是它自己。沒有半修改的共享資料結構留給別人,沒有沒釋放的鎖卡住整個系統。這讓「重啟」變成一個便宜到可以當作預設錯誤處理的選項。
於是有了 supervision tree(監督樹):actor 之間形成父子關係,父 actor 的職責不是處理業務,而是在子 actor 掛掉時決定怎麼辦。
-module(bank_sup).
-behaviour(supervisor).
-export([start_link/0, init/1]).
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
init([]) ->
SupFlags = #{strategy => one_for_one, % 誰掛了就重啟誰,不影響手足
intensity => 10, % 10 秒內重啟超過 10 次,
period => 10}, % 監督者自己也放棄,往上一層報
Children = [#{id => account, start => {account, start_link, [1000]}},
#{id => audit_log, start => {audit_log, start_link, []}}],
{ok, {SupFlags, Children}}.
intensity 與 period 那兩行才是這套機制不會失控的關鍵。重啟不是無限供應的:短時間內連續掛太多次,監督者就判定這不是暫時性故障,自己也停掉、往上一層報。錯誤於是沿著監督樹往上冒,直到某一層有能力處理,或者整個系統認賠重來。
這帶出 Erlang 那句常被引用、也常被誤解的口號:let it crash。它的意思不是「不用處理錯誤」,而是把錯誤分成兩類。預期內的錯誤(餘額不足、格式錯誤)當然要在程式碼裡明確處理;預期外的錯誤就不必寫一堆防禦性程式碼去猜怎麼救——依賴的服務回了沒人看過的東西、狀態掉進不該存在的組合——直接崩潰,讓監督者把這個 actor 重置回乾淨狀態。
會這樣設計,是因為多數詭異的錯誤來自狀態被弄髒,而重啟正好是清狀態最可靠的手段。你寫的猜測性復原邏輯,通常比重啟更容易出錯,而且沒有測試覆蓋。
WhatsApp 用 Erlang 撐起單機數百萬條連線,Discord 用 Elixir 處理即時訊息廣播,這些案例常被拿來證明 Actor 效能好。但真正讓它們敢在半夜自動化運行的,是崩潰的影響半徑被限制在單一 process。
同樣叫 actor,隔離的深度不一樣
前面那個 actor Account,用 Erlang 的 gen_server 重寫一次,從外面看幾乎是同一件事:狀態私有、外人只能排隊進來、一次處理一個。但這兩種 actor 的地基完全不同,而地基決定了你被保護到什麼程度。
Erlang 與 Elixir 靠實體隔離。 BEAM 上每個 process 都是獨立的輕量級執行單元,有自己的 heap、自己的 GC。process 之間傳訊息是把資料複製一份過去,不是傳參考。所以「另一條執行緒改到我的物件」在 BEAM 上不是被禁止,是沒有語法可以表達。你手上永遠只有自己 heap 上的那份副本。再加上語言本身資料不可變,連你自己都改不動它。這叫 process-based isolation(行程層級隔離)。
Swift 靠編譯器隔離。 Swift 的 actor 就是普通物件,住在同一個 OS process 裡,共享同一塊位址空間,跑在同一個 thread pool 上。balance 就躺在那塊記憶體裡,沒有任何實體屏障擋著別的執行緒。擋住你的是編譯器:actor 的可變狀態被標記為 actor-isolated,外面想碰就得 await,直接讀寫是編譯錯誤。跨越隔離邊界傳遞的型別必須符合 Sendable,這條規則會把你推向值型別(struct),送出去的是一份副本,不是同一個物件的參考。(Swift 6 的 region-based isolation 與 sending 標註放寬了一點:編譯器若能證明某個 non-Sendable 物件已經完全交接、沒有第二個人握著,也放行。方向沒變,仍然是「要嘛拷貝,要嘛證明獨佔」。)
差別在逃生門。
Swift 給了你 @unchecked Sendable:這個標註的意思是「編譯器你別管,這個型別的執行緒安全我自己負責」。只要有人標了它,那個型別上的靜態保證就失效,回到你自己拿鎖的世界。所以「Swift 6 沒有資料競爭」這句話得補上但書:得啟用 Swift 6 language mode(strict concurrency checking 在這個模式下是預設,資料競爭直接是編譯錯誤),而且團隊裡沒有人為了趕工去打洞。條件成立時它確實很硬,但那是幾個條件疊出來的結果,不是語言送你的。
Erlang 不需要這些但書,因為它不是禁止你共享,是沒有給你共享的語法。
當然,BEAM 也沒有純粹到滴水不漏。ETS 表和 persistent_term 就是明擺著的共享儲存,兩者都不住在 process heap 上。差別在於它們是明確的 API:你得知道自己正在用共享儲存,而且從 ETS 讀出來的 term 會被複製進你自己的 heap,你拿到的仍然是一份副本,不是別人也握著的可變參考。(大於 64 bytes 的 binary 也是共享的,BEAM 用參考計數把它放在 process heap 外,免得大字串每傳一次就複製一次。不過 binary 不可變,共享它不會有人改到你。)
| Erlang / Elixir | Swift | |
|---|---|---|
| 隔離靠什麼 | 執行期:獨立 heap,訊息複製 | 編譯期:actor isolation + Sendable |
| 記憶體 | 每個 actor 各一份,不共享 | 同一個 process,共享位址空間 |
| 資料可變性 | 不可變,想改也改不了 | 可變,靠隔離規則擋住 |
| 逃生門 | ETS、persistent_term,明確的 API | @unchecked Sendable,一標就失效 |
| 崩潰半徑 | 單一 process | 整個 app |
| 監督重啟 | OTP supervisor 內建 | 沒有,錯誤沿著呼叫端往上拋 |
最後兩行是 Swift actor 最少被提到的限制。前面那套 let it crash 在 Swift 上不成立:actor 只是同一個 process 裡的物件,一個 fatalError 或陣列越界會把整個 app 帶走,沒有監督者能把它重置回乾淨狀態。Swift 對應的機制是 structured concurrency——task 形成一棵樹、取消沿著樹往下傳、錯誤沿著 throws 往上拋。這條路要求你在每個可能出錯的地方都想清楚怎麼辦,正好跟「預期外的錯誤就別猜了」相反。所以前面那段監督樹我留著 Erlang 沒改成 Swift:那不是換個語法就能翻譯的東西,它需要底下真的有互相隔離的執行單元。
Swift 逼你用安全的方式寫程式,Erlang 則讓不安全的寫法根本不存在。
Swift 的 actor 會被插隊
前面說 actor「一次一則」。這句話在 Swift 上有個大到會出事的但書。
Swift 的 actor 是 reentrant(可重入) 的。actor 隔離的 async 方法只要遇到 await,就在那裡讓出執行權,actor 可以趁這個空檔開始處理下一個進來的呼叫。等原本那則工作恢復時,它讀到的狀態,可能已經被別人改過了。
actor Account {
private var balance: Int
private let auditLog: AuditLog
func withdraw(_ amount: Int) async throws -> Int {
guard amount <= balance else { // 檢查:此刻餘額確實夠
throw WithdrawError.insufficientFunds
}
try await auditLog.record(amount) // ← 暫停點:別人可以在這裡進來
balance -= amount // 扣款依據的是「檢查當時」的假設
return amount
}
}
guard 通過的那一刻,餘額確實夠。但 await 那行把 actor 讓了出去,第二筆提款進來、通過同一個檢查、也扣了款。等第一筆恢復執行,它不會重新檢查,直接扣。餘額變負數。
SE-0306 對這件事沒有藏。它寫得很白:暫停點會讓 actor 隔離的非同步函式交錯執行,所以每一個暫停點都得檢查過:後面的程式碼要是依賴了某個前提,而那個前提在這期間可能被改掉,那裡就是 bug。
這個錯誤跟本文後面那段 asyncio 的例子是同一個形狀:只要「檢查」跟「動手」中間存在暫停點,就有人插得進來。actor 幫你擋掉的是同一時間兩條執行緒踩同一個變數;它擋不掉跨越 await 的假設失效。
規則一句話:await 之前得到的結論,await 之後就不能再相信。修法有兩種,一種是把暫停點移出臨界區:
func withdraw(_ amount: Int) throws -> Int {
guard amount <= balance else { throw WithdrawError.insufficientFunds }
balance -= amount // 檢查到扣款之間沒有 await,插不進來
Task { try? await auditLog.record(amount) } // 記日誌是副作用,丟出去做
return amount
}
另一種是在 await 之後重新檢查一次。兩種都行,前提是你先看見那個 await 是個缺口。
排隊這個比喻在 Swift 上還要再打一次折:actor 連先進先出都沒有承諾。SE-0306 直說了,等待同一個 actor 的任務,不保證按照它們開始等待的順序執行,這一點跟嚴格 FIFO 的 serial DispatchQueue 不同,因為 actor 的執行器會把任務優先權納入考量。Erlang 的 mailbox 沒有這個模糊地帶,同一個寄件者送來的訊息,保證依序處理。所以前面那句「訊息在 mailbox 裡排成一列」,換到 Swift 只能讀成「一次執行一個」,不能讀成「照送出順序執行」。
Erlang 剛好站在對面。gen_server 的 handle_call 從頭到尾不讓出,它就是一個跑到完的函式,中間沒有暫停點可以插隊。代價是這段期間整個 process 卡住,後面的訊息全部在 mailbox 裡等。所以 OTP 的建議是別在 handle_call 裡做慢事情,要慢就 spawn 一個 process 去做,事後再回覆。
一邊把「不會被插隊」買下來,代價是頭部阻塞;一邊把「可以被插隊」當預設,代價是你得自己盯住每個 await。同一個取捨的兩端,沒有哪邊免費。
它跟 Go 的 channel 不一樣
常有人把 Actor 和 Go 的 goroutine + channel 當成同一種東西,兩者其實是不同的模型。Go 走的是 CSP(Communicating Sequential Processes),差別在於「誰有名字」。
Actor 模型裡,收件者有名字(address 或 pid),管道是匿名的:你拿著 pid 就能送訊息,不需要事先建立連線。CSP 裡反過來,channel 有名字,兩端的 goroutine 是匿名的:任何拿到這個 channel 的人都能收發,送方不知道也不在乎誰在另一頭。
這個差別的實際影響:
| 共享記憶體 + 鎖 | CSP(Go channel) | Actor | |
|---|---|---|---|
| 狀態放哪 | 共享,靠鎖保護 | 共享或在 goroutine 內 | 完全私有 |
| 通訊對象 | 直接讀寫記憶體 | 具名的 channel | 具名的收件者 |
| 送出行為 | 不適用 | 預設同步阻塞 | 非同步 |
| 錯誤傳播 | 自己處理 | 自己處理(panic 會炸掉整個 process) | 監督樹接手 |
| 跨機器 | 做不到 | 做不到 | 位址可以指向遠端節點 |
CSP 的同步 channel 天然帶有背壓,寫小規模的並行協調很順手。Actor 的非同步與具名收件者,則讓它比較容易往跨機器延伸。Go 也不禁止你用 channel 模擬 actor,只是語言本身沒有給你監督樹。
什麼時候不該用
Actor 不是並行問題的通解,有幾類情況用了反而更糟。
CPU 密集的資料平行運算。 把一個大矩陣切成一萬份丟給一萬個 actor,得到的是訊息傳遞與排程的額外成本。這類工作適合 fork-join 或 SIMD。Actor 擅長的是另一種東西:大量彼此獨立、各自握有狀態、偶爾才需要通訊的實體,例如一條連線、一個會話、一台 IoT 裝置。
需要跨實體強一致性的場景。 前面說過,跨 actor 沒有原子操作。如果你的業務規則大量涉及「同時修改多個實體且必須全有全無」,關聯式資料庫的交易會比你自己刻補償邏輯省事得多。
單機的小程式。 為了三個執行緒引入完整的 actor 框架,得到的複雜度多過安全性。
還有一個更隱晦的陷阱:位址透明(location transparency)容易讓人忘記遠端呼叫是遠端的。同一個 send 在本機是幾百奈秒的記憶體操作,跨機器則要面對延遲、封包遺失與節點失聯。介面長得一樣,失敗模式完全不同。這正是 Waldo 那篇《A Note on Distributed Computing》1994 年就提出的警告,Actor 框架讓程式碼寫起來一樣,但沒有、也不可能讓網路變得可靠。
Semaphore 也能序列化,差在哪
讀到這裡你可能會想:actor 一次只處理一則訊息,那不就是值為 1 的 semaphore?
序列化的效果確實一樣。先看一段連執行緒都沒有的程式,只有 asyncio 的 150 個並行任務:
import asyncio
balance = 1000
async def withdraw(amount):
global balance
if amount > balance:
return ("error", "insufficient_funds")
await asyncio.sleep(0) # 檢查與扣款之間的任何一個 await,都是插隊的機會
balance -= amount
return ("ok", amount)
async def main():
results = await asyncio.gather(*[withdraw(10) for _ in range(150)])
ok = sum(1 for status, _ in results if status == "ok")
print(f"成功 {ok} 筆,餘額 {balance}")
asyncio.run(main())
成功 150 筆,餘額 -500
這裡沒有第二條執行緒,也沒有多核,錯誤照樣發生。只要 if 與 -= 之間存在一個 await 點,就有人插得進來。加一把 semaphore 就修好了:
sem = asyncio.Semaphore(1)
async def withdraw(amount):
global balance
async with sem: # 一次只放一個人進來
if amount > balance:
return ("error", "insufficient_funds")
await asyncio.sleep(0)
balance -= amount
return ("ok", amount)
成功 100 筆,餘額 0
結果跟 actor 版一模一樣。但這兩種寫法保護的東西不同:semaphore 管的是「同時有幾個人能進來」,balance 本身仍然是誰都摸得到的廣域變數。哪天有人寫了一個沒有 async with sem 的退款函式,這把 semaphore 一點忙也幫不上。actor 版沒有這條後門,因為 _balance 只有 _handle 拿得到,想繞過都沒有語法可以繞。
等待的形式也不一樣。semaphore 讓呼叫端停在原地,佔著一條執行緒或一個呼叫堆疊;actor 把等待具體化成 mailbox 裡的一則訊息。這件事在維運時差很多:卡在 semaphore 上的人不會出現在任何儀表板,而 mailbox 長度是個可以量、可以報警、可以拿來決定要不要開始丟訊息的數字。
至於死結,semaphore 巢狀取用就會回到文章開頭那個場景。actor 也不是免疫的:A 用同步的 ask 等 B 回覆,B 同時在 ask A,兩邊一樣互等。差別在於 ask 有逾時,死結會變成一個帶著堆疊的錯誤,而不是一條永遠不會醒來的執行緒。
| Semaphore(含 mutex) | Actor | |
|---|---|---|
| 保護的東西 | 進入臨界區的人數 | 狀態的所有權 |
| 能不能繞過 | 能,忘了 acquire 就直接改到 | 不能,外面拿不到那份狀態 |
| 等待的形式 | 呼叫端停在原地 | 訊息排在 mailbox 裡 |
| 等待好不好觀察 | 難,只看得到「有人卡住」 | mailbox 長度就是現成指標 |
| 會不會死結 | 巢狀取用就會 | 同步互等也會,但有逾時 |
| 拿手的題目 | 把並行度限制在 N、資源池 | 每個實體各自一份狀態 |
表格最後一行才是重點:兩者根本不是二選一。semaphore 真正擅長的,是 N 大於 1 的情況,例如同時最多十條資料庫連線、每秒最多五十個外部呼叫;actor 要做這件事,得自己組一個 router 加一池 worker。實務上兩個常常一起出現,actor 內部要打外部 API 時,照樣需要一把 semaphore 限流。
一句話收掉這個比較:semaphore 是你答應會遵守的約定,actor 是你想違反也違反不了的結構。
用 Python 手刻一個最小 actor
Python 沒有內建的 actor runtime,但用 asyncio 拼一個夠小的版本並不難,剛好可以把前面那三個約束變成看得到的程式碼。
import asyncio
class Account:
def __init__(self, balance):
self._balance = balance # 私有狀態:外面沒有任何人碰得到
self._inbox = asyncio.Queue() # mailbox
def tell(self, msg): # 送出就走,不等回覆
self._inbox.put_nowait((msg, None))
async def ask(self, msg, timeout=5): # 要答案就自己附一個回覆信箱
reply = asyncio.get_running_loop().create_future()
self._inbox.put_nowait((msg, reply))
return await asyncio.wait_for(reply, timeout)
async def run(self): # actor 的本體:一個永遠在跑的迴圈
while True:
msg, reply = await self._inbox.get() # 一次只取一則,序列化就發生在這行
result = self._handle(msg)
if reply is not None:
reply.set_result(result)
def _handle(self, msg):
match msg:
case ("withdraw", amount) if amount <= self._balance:
self._balance -= amount
return ("ok", amount)
case ("withdraw", _):
return ("error", "insufficient_funds")
case ("balance",):
return ("ok", self._balance)
case _:
raise ValueError(f"看不懂的訊息 {msg}")
三個約束各自對應到一行程式碼:_balance 前面的底線不是禮貌,是這個模型的地基,狀態只有 _handle 碰得到;tell 把訊息塞進佇列就返回;run 的 while 迴圈一次只 get 一則,所以 _handle 裡面愛怎麼讀寫 _balance 都行。
丟 150 個並行提款進去:
async def main():
acc = Account(1000)
asyncio.create_task(acc.run())
# 150 筆同時打進來,每筆提 10 塊,全程沒有一把鎖
results = await asyncio.gather(*[acc.ask(("withdraw", 10)) for _ in range(150)])
ok = sum(1 for status, _ in results if status == "ok")
print(f"成功 {ok} 筆,失敗 {len(results) - ok} 筆")
print(await acc.ask(("balance",)))
asyncio.run(main())
成功 100 筆,失敗 50 筆
('ok', 0)
一塊錢都沒有多發,也沒有超領。amount <= self._balance 這個檢查與後面的扣款之間不可能被插隊,因為插隊要靠並行執行,而這裡從頭到尾只有一條執行路徑在跑。
監督者也可以用同樣的方式湊出來。actor 崩潰時整個 run 迴圈會結束,外面的人接住例外,重新建一個:
registry = {}
async def supervise(name, factory):
while True:
registry[name] = actor = factory() # 重啟後是新物件,所以要有一張名字表
try:
await actor.run()
except Exception as exc:
print(f"{name} 掛了({exc}),重建一個")
async def main():
asyncio.create_task(supervise("acc", lambda: Account(1000)))
await asyncio.sleep(0) # 讓 supervisor 先把 actor 掛上名字表
print(await registry["acc"].ask(("withdraw", 300)))
registry["acc"].tell(("transfer", 100)) # 沒人看得懂的訊息
await asyncio.sleep(0.1)
print(await registry["acc"].ask(("balance",)))
asyncio.run(main())
('ok', 300)
acc 掛了(看不懂的訊息 ('transfer', 100)),重建一個
('ok', 1000)
最後一行值得盯著看一下:餘額回到 1000,那筆已經成功的提款蒸發了。重啟把狀態洗回初始值,這是 let it crash 的帳單,不是 bug。要留住的狀態就得放在 actor 外面(事件日誌、資料庫),Akka Persistence 與 event sourcing 常跟 actor 一起出現,原因就在這裡。
這個玩具版還漏掉幾件真實 runtime 會處理的事。它跑在單一 event loop 上,所以只有並行沒有平行,而 BEAM 會把 process 分給多個排程器、Akka 用 thread pool 撐多核。asyncio.Queue 預設無界,要背壓得自己寫成 Queue(maxsize=1000)。ask 的 timeout 也不是裝飾品:actor 掛掉時,還等在 future 上的人不會收到任何通知,會一路等到天荒地老,Erlang 的 gen_server:call/2 預設 5 秒逾時就是為了這件事。
真的要在 Python 上用 actor,Pykka(thread 為底)與 Thespian(可跨行程、跨主機)是現成的選擇,Ray 的 actor 則直接把這套搬到叢集上。但把上面這五十行看懂,比先學任何一個框架都划算,因為框架替你處理掉的,正是這五十行漏掉的部分。
結語
鎖的世界觀是:資料放在中間,大家都能碰,所以要輪流。Actor 的世界觀是:資料跟著行為走,誰持有狀態誰負責回答關於它的問題。
你換掉的其實是切分系統的方式。用鎖的時候你問「這個資料結構要怎麼保護」,用 actor 的時候你問「這塊狀態該屬於誰」。後面這個問題比較難,但它逼你在寫程式之前就想清楚邊界在哪,而這個邊界剛好也是崩潰時的隔離邊界、擴展時的切分邊界。
所以下次你在程式碼裡看到兩把巢狀的鎖,先別急著排上鎖順序。也許該問的是:這兩份狀態為什麼會需要同時被鎖住,它們是不是本來就該是同一個東西。