跳到主要內容
程式語言

Actor 模式:把「同時」變成「排隊」

共享記憶體加鎖為什麼難寫對?Actor 模式用私有狀態與非同步訊息換掉鎖。本文從死結談起,比較 Swift 的編譯器隔離與 Erlang 的行程隔離,並拆解 actor reentrancy 這個容易踩的坑。

寫過多執行緒的人都碰過這種程式碼:

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}}.

intensityperiod 那兩行才是這套機制不會失控的關鍵。重啟不是無限供應的:短時間內連續掛太多次,監督者就判定這不是暫時性故障,自己也停掉、往上一層報。錯誤於是沿著監督樹往上冒,直到某一層有能力處理,或者整個系統認賠重來。

這帶出 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 / ElixirSwift
隔離靠什麼執行期:獨立 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_serverhandle_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 把訊息塞進佇列就返回;runwhile 迴圈一次只 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 的時候你問「這塊狀態該屬於誰」。後面這個問題比較難,但它逼你在寫程式之前就想清楚邊界在哪,而這個邊界剛好也是崩潰時的隔離邊界、擴展時的切分邊界。

所以下次你在程式碼裡看到兩把巢狀的鎖,先別急著排上鎖順序。也許該問的是:這兩份狀態為什麼會需要同時被鎖住,它們是不是本來就該是同一個東西。