在生产环境里等 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、数据库查询和日志。聚焦状态机:有哪些 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 替你干测试的活儿。