4. TLAplus建模实战:用TLA+建模一个简单的计数器系统

好,咱们今天来点真格的。

前面聊了那么多理论,什么TLA+和RTLA的区别,什么形式化验证的价值。但说实话,光说不练假把式。这一章,我就带你亲手用TLA+建模一个最简单的系统——计数器。

你别小看计数器。我当年刚接触TLA+时,就是从这个例子入门的。它麻雀虽小,五脏俱全。你把它吃透了,后面那些分布式共识算法、缓存一致性协议,理解起来就顺了。

4.1 我们要建模什么?

一个计数器,说白了就是一个整数变量,支持两个操作:自增读取

听起来简单吧?但你要用形式化语言把它描述清楚,就没那么简单了。你得定义:

  • 计数器的初始值是什么?
  • 自增操作怎么改变状态?
  • 读取操作返回什么?
  • 系统允许哪些行为序列?

嗯,这里要注意:TLA+建模的不是“程序怎么运行”,而是“系统允许哪些行为”。这是思维上的一个坎,迈过去就好了。

核心思想:TLA+描述的是系统所有可能的行为轨迹,而不是某一条具体的执行路径。

4.2 第一个TLA+规范:从零开始

好,我们直接上代码。这是计数器最基础的TLA+规范:

---- MODULE Counter ----
EXTENDS Naturals

VARIABLE counter

Init == counter = 0

Increment == counter' = counter + 1

Read == TRUE

Next == Increment \/ Read

Spec == Init /\ [][Next]_counter
====

我来一行行拆解给你看。

EXTENDS Naturals:引入自然数模块。TLA+本身没有内置整数,你得显式引入。我刚开始写的时候老忘这行,结果报错半天找不到原因。

VARIABLE counter:声明一个变量叫counter。注意,TLA+里变量是全局的,没有局部变量这个概念。这是和编程语言很大的不同。

Init == counter = 0:定义初始状态。counter等于0。这里的==是“定义为”的意思,不是赋值。

Increment == counter' = counter + 1:自增操作。注意那个counter',带撇号表示“下一个状态的值”。整句话的意思是:下一个状态中,counter的值等于当前状态的值加1。

Read == TRUE:读取操作。它不改变任何变量,所以条件就是TRUE——永远可以执行。

Next == Increment \/ Read:下一步要么自增,要么读取。这是系统的所有可能动作。

Spec == Init /\ [][Next]_counter:完整的规范。初始状态满足Init,并且每一步要么执行Next,要么保持counter不变(这是[]和下标_counter的含义)。

个人经验:我见过很多新手把Next写成Increment /\ Read,用AND代替OR。结果系统永远只能同时做两件事,这显然不对。记住:\/是“或”,/\是“与”。

4.3 运行模型检查:让TLC帮你找bug

规范写完了,怎么验证它是对的?用TLC模型检查器。

你需要在规范里加一行配置:

====
\* 下面是模型检查的配置
INVARIANT counter >= 0
====

这个不变量是说:counter永远不能小于0。你想想看,如果系统里有人执行了counter' = counter - 1,那这个检查就会报错。

我当年第一次跑TLC时,看到那个绿色的“No error found”,心里那个爽啊。但别高兴太早——TLC只能检查有限状态。如果你的计数器没有上限,理论上状态是无限的。你需要设置一个边界,比如:

CONSTANT MaxCounter = 100

然后修改Increment:

Increment == counter < MaxCounter /\ counter' = counter + 1

这样TLC就能在有限空间里检查了。

避坑指南:我曾经犯过一个错误——忘了加边界条件,结果TLC跑了两个小时还没跑完。后来才发现状态空间爆炸了。记住:模型检查不是万能的,你得给它一个合理的边界。

4.4 增加复杂度:多线程计数器

单线程的计数器太简单了。咱们来点挑战——两个线程同时操作同一个计数器。

在TLA+里,多线程就是多个动作的交替执行:

---- MODULE ConcurrentCounter ----
EXTENDS Naturals

VARIABLE counter, thread1_done, thread2_done

Init == counter = 0 /\ thread1_done = FALSE /\ thread2_done = FALSE

Thread1Increment == ~thread1_done /\ counter' = counter + 1 /\ thread1_done' = TRUE /\ UNCHANGED thread2_done

Thread2Increment == ~thread2_done /\ counter' = counter + 1 /\ thread2_done' = TRUE /\ UNCHANGED thread1_done

Next == Thread1Increment \/ Thread2Increment

Spec == Init /\ [][Next]_<>
====

这里我引入了两个布尔变量,标记每个线程是否已经执行过。每个线程只能自增一次。

你猜最终counter的值是多少?

如果两个线程都执行了,counter应该是2。但如果TLC检查时发现counter可能等于1,那就说明有线程没执行——规范有问题。

关键点:并发系统的核心问题是“交错”。TLA+天然支持所有可能的交错序列,这正是它的威力所在。

4.5 用SVG画一张流程图

说了这么多,咱们用一张图把计数器的建模流程串起来:

计数器TLA+建模流程 步骤1:定义变量 VARIABLE counter 步骤2:初始状态 Init == counter = 0 步骤3:定义动作 Increment, Read 步骤4:组合Next Next = Inc \/ Read 步骤5:完整规范 Spec = Init /\ [][Next]_c 步骤6:模型检查 TLC验证不变量 从变量定义到模型检查,每一步都对应TLA+规范中的一行代码

4.6 验证结果怎么看?

TLC跑完之后,会给你一个报告。我总结一下常见的几种结果:

结果 含义 怎么办?
No error found 在检查的边界内,所有不变量都成立 恭喜!但别忘了扩大边界再测一次
Invariant violation 某个不变量被违反了 查看TLC给出的反例轨迹,找到哪一步出了问题
Deadlock detected 系统卡住了,没有下一步可执行 检查Next公式,是不是漏了某个动作?
State space exhausted 状态太多,TLC跑不完 缩小边界,或者用对称性减少状态

我的习惯:每次修改规范后,先跑一个小边界(比如MaxCounter=5),快速验证逻辑对不对。确认无误后再放大边界。这样能节省大量时间。

4.7 小结:你学到了什么?

这一章,我们亲手建了一个计数器模型。你学会了:

  • 怎么声明变量、定义初始状态
  • 怎么用counter'描述状态变化
  • 怎么用Next组合多个动作
  • 怎么用TLC做模型检查
  • 怎么处理多线程并发

说实话,计数器虽然简单,但它包含了TLA+建模的所有核心要素。你把这个吃透了,后面那些复杂的系统——分布式锁、Raft共识算法、两阶段提交——本质上都是在这个基础上做加法。

嗯,下一章咱们会聊一个更实际的例子。但在此之前,我建议你亲手把这段代码敲一遍,跑一跑TLC。纸上得来终觉浅,绝知此事要躬行。


公众号:蓝海资料掘金营,微信deep3321