在生產環境裡等 race condition 冒出來,那不叫測試。那叫披著勤奮外衣的僥倖。
你可以把應用跑上幾週,盯著 metrics dashboard,最後照樣發布一個 concurrency bug——它只會在兩個 request 剛好打進同一個 cache eviction window 時才觸發。問題不在於你運氣差。問題在於你的測試策略指望 scheduler 恰好在你盯著的時候,按你的利益作惡。
有更好的辦法。model checking 能在數秒內窮舉並發邏輯的所有 interleaving,而不是乾等幾天。它會找到你壓根沒想到要寫測試的那類 race condition。
model checking 到底在幹什麼
model checking 不是 fuzzer。它不會往程式碼裡扔隨機 input 然後祈禱。它構建系統所有可能狀態的數學模型,然後窮盡遍歷。
把你的並行程式想像成一張圖。每個 node 是一個狀態:變數的值、queue 的內容、哪個 thread 握著哪個 lock。每條 edge 是一步:某個 thread 讀了一個值、acquire 了一個 mutex、或者發了一個訊息。scheduler 決定下一個跑哪個 thread,這個選擇讓圖產生分支。
model checker 會走過這張圖上的每一條 path。如果有任何 path 導致兩個 thread 在沒有 synchronization 的情況下往同一塊記憶體寫資料,它就會報告一個 counterexample:觸發 bug 的精確 step 序列。
核心洞察是:model checker 掌控 scheduler。在生產環境,你被作業系統拿捏。在 model checker 裡,你就是作業系統。你可以在一条關鍵程式碼執行前 pause 某個 thread,讓另一個 thread 跑完整個方法,再 resume 第一個 thread。你可以探索那種在現實世界裡需要 cosmic ray 加 network hiccup 才能復現的 interleaving。
model checker 毫秒就能抓住的 race condition
來看一個經典 bug:兩個 goroutine 對一個 shared counter 做 increment。
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// Read, increment, write: three steps
val := counter
val++
counter = val
}()
}
wg.Wait()
fmt.Println(counter) // Expected 2, often prints 1
}
跑一千次,它可能每次都輸出 2。scheduler 不會隨傳隨到地當你的敵人。但 model checker 一看到 read-increment-write 這三步序列就知道:如果 goroutine A 讀 0,然後 goroutine B 也讀 0,然後各自 increment 再寫回 1,counter 最後就是 1 而不是 2。
它不需要跑幾天。它只需要探索兩個 branch。
怎麼給自己的程式碼做 model checking
你不需要對整個 codebase 做 formal verification 就能獲得 model checking 的價值。大多數團隊把並發真正關鍵的 20% 程式碼建模出來,就能拿到 80% 的收益。
workflow 如下:
-
隔離並發元件。 砍掉 HTTP handler、資料庫查詢和日誌。聚焦 state machine:有哪些 thread 或 goroutine、它們碰了哪些 shared state、用了哪些 synchronization primitive?
-
寫抽象模型。 把真實資料結構替換成只保留你關心的行為的簡化版。如果你要檢查一個 job queue,你不需要真實的 job payload。你只需要一個 queue、若干 worker、以及一個標記 job 是否在跑的 flag。
-
定義你想保持的 properties。 這些就是你的 invariant。“兩個 worker 不會處理同一個 job。” “job 不會遺失。” “cache 和資料庫永遠不會不一致。”
-
讓 model checker 跑起來。 結果要不是”所有 invariant 成立”,就是甩給你一個 counterexample trace。
以下是用一個簡單的 explicit-state checker 在 Python 裡寫的最小模型:
from collections import namedtuple
# Abstract model of a counter with two threads
State = namedtuple('State', ['counter', 'pc1', 'pc2'])
def successors(state):
"""Return all states reachable in one step."""
results = []
c, pc1, pc2 = state
# Thread 1: three-step increment
if pc1 == 0:
results.append(State(c, 1, pc2)) # read
elif pc1 == 1:
results.append(State(c, 2, pc2)) # increment
elif pc1 == 2:
results.append(State(c + 1, 3, pc2)) # write
# Thread 2: three-step increment
if pc2 == 0:
results.append(State(c, pc1, 1))
elif pc2 == 1:
results.append(State(c, pc1, 2))
elif pc2 == 2:
results.append(State(c + 1, pc1, 3))
return results
def check():
initial = State(0, 0, 0)
visited = set()
stack = [initial]
while stack:
state = stack.pop()
if state in visited:
continue
visited.add(state)
# Invariant: if both threads finished, counter must be 2
if state.pc1 == 3 and state.pc2 == 3 and state.counter != 2:
print(f"BUG: counter={state.counter}")
return
stack.extend(successors(state))
print("No race condition found in model.")
check()
這不是 production-grade 的東西。它是二十行 Python,用來演示概念。真正的 model checker 比如 TLA+、Spin 或 mCRL2 會處理 state deduplication、temporal logic property 和 partial-order reduction,讓你能檢查擁有數百萬 states 的系統。但 mental model 是一樣的:encode 你的系統,聲明 invariant,窮舉。
坑:state space explosion
model checking 不是免費的。如果你有四個 thread,每個都有十個可能的下一步,state graph 就會指數級膨脹。加上一個 64-bit integer,state space 就會變成字面意義上無法檢查。
解法是 abstraction。你不是在模型化真實的 cache。你是在模型化”cache 裡有這個 key”或者”cache 裡沒有這個 key”。你不是在模型化真實的資料庫。你是在模型化”committed”或者”uncommitted”。
這就是很多人栽跟頭的地方。他們試圖 model check 真實程式碼,然後 checker 跑到天荒地老。於是他們徹底放棄 model checking——這就好像因為曾經試圖在一個 test case 裡測整個應用,所以連 unit test 也全部放棄一樣。
鐵律是:模型化 concurrency,不要模型化 business logic。如果你的 race condition 取決於某個 user ID 的精確值,那你已經輸了。race condition 是因為 interleaving 而產生的,跟資料值沒關係。
真正好用的工具
如果你想開始給真實程式碼做 model checking,你有選擇。
Go: gosim 讓你用 deterministic scheduler 跑 Go 程式,而這個 scheduler 可以由 model checker 控制。它會找到打破你 sync.Mutex 假設的那個 interleaving。
Rust: shuttle 是 AWS Labs 的 deterministic concurrency testing framework。它用不同的 scheduler 選擇把你的 async 程式碼跑上幾千遍,會找到 loom(另一個優秀工具)在 memory model 層面檢查的那些 race。
分散式系統: TLA+ 是工業級選擇。Amazon 用它找出 DynamoDB 和 S3 裡的 bug,趕在發布前修掉。學習曲線真實存在,但對關鍵系統來說 return on investment 是可測量的。
溫和起步: 寫一段上面那樣的 Python 腳本。花一小時,找 bug,建立那種讓 TLA+ 不再神秘的直覺。
這替代不了什麼
model checking 找的是並行程式碼裡的 logic error。它找不到 performance regression、memory leak,或者只在生產負載下才冒出來的 bug。它也不會告訴你 mutex contention 在每秒一萬個 request 時會不會爆炸。
把它和 stress testing 一起用,而不是代替 stress testing。stress testing 告訴你系統能不能扛住負載。model checking 告訴你系統能不能扛住負載製造出來的那些 interleaving。
從哪兒開始
挑一個曾經咬過你的並發元件。job queue、connection pool、rate limiter 都行。用 plain English 寫下 invariant,然後花一個下午搭一個微型模型。
你很可能會發現一個你根本不知道存在的 bug。或者你會確信自己擔心的那個 bug 不可能發生。無論哪種結果,都比再盯一週 production graph 和乾等要強。
如果你想要一個具體的下一步,Learn TLA+ 教學會帶你從零開始,一個週末就能檢查分散式 consensus protocol。Go 和 Rust 的話,把 shuttle 或 loom 加進 test suite,在 CI 裡跑。第一次它在 code review 之前就抓住一個 race 的時候,你會納悶自己當初為什麼會信任 scheduler 替你幹測試的活兒。