☰
CPN ML入门指南:着色Petri网建模的类型、多重集与实战技巧
2026/9/29 16:35:59 网站建设 项目流程

做系统建模的人,很多都听说过Petri网;做Petri网建模,在工程领域绕不开 CPN Tools;而只要打开 CPN Tools,你就避不开 CPN ML。CPN ML 是着色 Petri 网(Colored Petri Nets)的声明语言,负责表达颜色集、变量、函数、守卫和弧表达式,可以说是模型真正“跑起来”的灵魂。但很多新手一上来会把它当作 Python 或者普通 Standard ML 去写,结果在类型和多重集上连续踩坑。这篇指南就从头讲清楚 CPN ML 是什么、语法怎么组织、在 CPN Tools 里怎么实操,以及我实际建模中踩过的坑。

如果你正准备用着色 Petri 网做流程建模、协议验证、系统性能分析,或者正在上形式化方法相关的课程,这篇文章应该能帮你省下不少“试错税”。我会尽量用做工程的口吻来写,不绕弯子,该给代码的地方直接给代码,该提醒的坑提前说。CPN ML 本身并不神秘,它本质上是一门“限制过的”函数式语言,只要把类型和绑定这两个核心概念拿捏住,剩下的都是熟练度问题。

1. 先搞明白 CPN ML 是怎么来的

1.1 从普通 Petri 网到着色 Petri 网

传统 Petri 网里,令牌就是一个个黑点,库所里有没有令牌、有多少令牌,是唯一的信息。这种模型的优势是简洁、抽象,特别适合描述并发、冲突、同步这类结构问题。但一旦要区分“这个令牌是订单 A 还是订单 B”“这个资源属于哪个进程”,黑点就不够用了。解决办法就是给令牌加颜色,也就是让令牌携带类型和值,这就是着色 Petri 网的核心思想。

颜色在这里不是红黄蓝绿,而是数据类型。一个库所的类型由一个颜色集(colset)定义,库所里的每个令牌必须是该颜色集的一个值;一条弧上流动的也不是单个值,而是一个多重集(multiset),也就是“每个值各有多少个副本”。这样一来,同一张网可以表达远比传统 Petri 网丰富的业务语义,比如队列里的任务、消息通道里的报文、缓冲区里的数据包,而不需要把网结构放大十倍。

CPN ML 就是用来描述这些颜色集、变量、函数、守卫和弧表达式的语言。你可以把它理解为 CPN Tools 的“模型行为脚本”,但它不是事后附加的插件,而是从建模到仿真、再到状态空间分析都绕不开的核心层。模型的静态结构看得见,动态行为规则则全部由 CPN ML 表达,所以我一直觉得,真正决定一个 CPN 模型质量高低的,往往不是网画得多漂亮,而是声明区里的类型和函数设计得好不好。

1.2 CPN ML 与 Standard ML 的血缘关系

CPN ML 不是从零发明的语言,它建立在 Standard ML(SML)之上。Standard ML 是一门有几十年历史的函数式语言,强调强类型、不可变数据、模式匹配和数学化的表达方式。CPN ML 保留了 SML 的很多关键语法:用val做值绑定,用fun定义函数,用case做分支,用列表、元组、记录组织数据。如果你之前写过一点 Haskell、OCaml 或者 Scala,看到 CPN ML 会觉得挺亲切。

不过 CPN ML 也做了明显的改造,最大的变化就是它把多重集作为一等公民,并引入了一组适合 Petri 网的运算符。例如++表示多重集合并,1\x表示值x的一个副本,库所的初始标识和弧表达式都默认按多重集语义来解释。这种设计让 CPN ML 更贴合并发系统建模场景,但也带来一个容易让新手混淆的点:普通 SML 里常见的列表拼接符号@和 CPN ML 里的多重集合并符号++`,功能完全不同,写混了工具直接报错。

如果你完全没接触过函数式编程,也不要慌。你需要先理解几个概念:不可变数据(变量绑定之后不可改)、模式匹配(用结构去解构数据)、表达式求值(一切都是值)。花一两个小时过一遍 SML 基础语法,再回到 CPN ML,会发现学习曲线陡降。相反,如果一上来就硬写,很容易卡在“为什么变量不能赋值”“为什么类型不对”这种基础问题上。

1.3 为什么建模语言要做“减法”

有朋友问过我,既然有 Python、Java 这么成熟的通用语言,为什么不在 CPN Tools 里直接用它们,非要再学一门 DSL?这个问题问到点子上了。通用语言确实强大,但正因为它强大,所以不适合直接做形式化模型的运行时描述。CPN Tools 的关键能力是生成并分析状态空间,也就是把所有可能发生的状态转移穷举出来,然后检查可达性、死锁、有界性等性质。

要做到这一点,工具必须在编译期对表达式进行类型检查,并且保证每个表达式在给定绑定下都能确定地求值。通用语言的动态特性、外部 IO、隐式类型转换、可变全局状态,都会让这种穷举分析变得不可控甚至不可能。CPN ML 有意做“减法”,去掉了一部分通用语言能力,换来了类型安全、可判定性和静态检查能力。它不是万能的通用编程语言,而是为着色 Petri 网建模而生的专业工具语言。

所以学 CPN ML 的心态要摆正:不要想着在里面写出多“炫”的代码,而要想着怎么用最小的类型集合和函数集合,精确表达你要建模的并发系统。代码写得再漂亮,如果状态空间爆炸、模型跑不动,那对形式化分析来说依然不是好模型。

2. 类型体系与基本语法:入门第一关

2.1 颜色集:把类型翻译成 Petri 网词汇

在 CPN ML 里,一切都是从颜色集开始的。颜色集(colset)决定令牌的类型,库所选择哪个颜色集,库所里能放什么值,就完全确定了。声明颜色集的语法很直观:

colset U = unit; colset I = int; colset S = string; colset B = bool;

除了这些基础类型,建模时更常用的是复合类型。比如枚举类型用来表达状态机里的状态:

colset Status = with Ready | Busy | Done;

再比如用product组合多个字段,表示订单这样的数据:

colset Order = product I * S;

一旦字段变多,用record更可读:

colset Customer = record {id:I; name:S; level:I};

如果要表达缓冲区、队列、栈,可以用列表:

colset Buffer = list I;

下面这张速查表方便你快速对齐:

颜色集写法含义典型用途
colset I = int;整数计数、编号
colset S = string;字符串名称、消息文本
colset B = bool;布尔条件标记
colset St = with A | B;枚举状态机状态
colset P = product I * S;元组固定字段组合
colset R = record {x:I; y:S};记录复杂对象
colset L = list I;列表队列、缓冲
colset Sub = int with 1..10;受限整数缩小状态空间

定义颜色集时有个要注意的点:名称在声明区内必须唯一,而且不要和 CPN ML 内置类型冲突。复合颜色集里引用的其他颜色集,必须先定义后使用。CPN Tools 在检查模型时会按依赖顺序处理声明区,如果顺序明显不对,会直接给出错误提示。

2.2 变量、let 表达式与守卫

CPN ML 里的变量不是“可以反复赋值的容器”,而是模式匹配占位符。声明一个变量:

var n : I; var st : Status;

这个变量的作用范围是整个模型。当一个变迁发生时,输入弧上的变量会从当前库所的令牌中取值完成绑定;输出弧和守卫里的变量必须已经在输入弧上绑定。这是并发建模里最关键的一点:变量是绑定出来的,不是赋值出来的。

如果需要临时计算中间结果,可以用let ... in ... end。例如:

let val m = n + 1 val doubled = m * 2 in doubled end

这种结构在弧表达式里很常用,尤其当输出逻辑稍微复杂时,用let把中间过程写清楚,比堆一大串嵌套表达式可读性好很多。

守卫写在变迁旁边,用方括号括起来,例如[n > 0]。守卫必须返回布尔值。只有守卫为真的绑定组合,变迁才能发生。这个机制可以用来表达“只有满足条件才允许转移”的业务约束,非常实用。

2.3 函数定义:把重复逻辑拆出去

如果同一个计算在多个弧上都要用,就应该写成函数。CPN ML 函数定义的风格和 SML 一致:

fun double (x : I) = 2 * x; fun classify (n : I) = if n > 0 then "positive" else "non-positive";

函数也支持模式匹配,可以像下面这样定义递归函数:

fun lengthOfList nil = 0 | lengthOfList (x :: xs) = 1 + lengthOfList xs;

在 CPN ML 中写类型标注不是强制要求,但我的建议是尽量给函数参数写类型标注。原因很简单:CPN Tools 的报错信息有时候比较“含蓄”,显式类型能让你和工具都更快定位问题,也让模型更容易被别人看懂。

有了函数之后,弧表达式就可以写得很简洁。比如:

fun nextCounter (n : I) = if n < 10 then n + 1 else n;

弧上只需要写nextCounter(n),而不是重新写一遍判断逻辑。模型网图里的表达式越短,越容易检查出来结构性问题。

2.4 多重集:弧表达式的核心

在 CPN 里,弧上流动的是多重集,不是单个值。多重集说明的是“每个值各有多少个副本”。CPN ML 给出了简洁的写法:

1`"a" (* 字符串 "a" 的一个副本 *) 2`(1, "x") (* 元组 (1,"x") 的两个副本 *)

如果有多种值的副本,用++合并:

1`"a" ++ 2`"b"

这个表达式表示多重集中包含"a"的一个副本和"b"的两个副本。库所的初始标识写法也遵循同一套规则,比如一个计数器库所初始有一个令牌0,就写:

1`0

如果库所初始为空,写empty。

这里我特别提醒一句:单个值写在弧上时,CPN Tools 会自动把它包装成“一个副本”的多重集,所以你经常看到弧表达式就是一个裸变量n,这和写1\n是等价的。但当你要输出多个不同值的副本时,就必须显式使用++。不要忘记给每个子表达式加括号,例如(1`x) ++ (1`y)`,避免优先级引起的奇怪解析。

列表在多重集语义下经常让人困惑。如果库所颜色集是list I,那么一个令牌就是“一个列表”,而不是把列表里的每个元素当成独立令牌。这是完全不同的建模语义,后文实操部分我会再展开。

3. 在 CPN Tools 实操:从声明到能跑的模型

3.1 安装与界面

CPN Tools 是着色 Petri 网的主流建模工具,支持 Windows、Linux 和 macOS 环境。拿到安装包后一路默认安装即可。首次打开时会有一个图形化主窗口,模型编辑区居中,工具栏和辅助窗口分布在四周。界面看起来有点老派,但功能非常集中:左边或者侧边栏可以看到当前模型的层次结构,双击模型中的元素就能编辑属性。

实际建模时,你大部分时间会在两个地方切换:一个是图形编辑区,负责画库所、变迁和弧;另一个是声明区,负责维护颜色集、变量和函数。CPN Tools 也支持分层着色 Petri 网,可以用替换变迁把大模型拆成多个页面,但新手阶段建议先在一个页面里把基本流程跑通,再考虑模块化。

3.2 声明区:先写好颜色集、变量和函数

打开模型文件后,在左侧“Declarations”区域双击,会打开声明编辑器。我的习惯是先写声明,再画网,因为库所的颜色集和弧表达式都依赖声明区里的名字。

以一个非常简单的“计数器”模型为例。先定义整数颜色集和变量,再定义一个递增函数:

colset INT = int; var n : INT; fun inc (x : INT) = x + 1;

如果你要建模一个带状态机的简单任务流,可以再加枚举类型和记录类型:

colset Task = record {id:INT; status:Status}; colset Status = with Idle | Running | Done; var t : Task;

注意这里的定义顺序:Status要先于Task定义,因为Task引用了Status。CPN Tools 对声明顺序有依赖检查,但养成“被依赖的写在前面”的习惯,能少踩很多无谓的坑。

3.3 画一个计数器模型

打开一个新建模型页面,依次做这几步:

  1. 从工具面板拖一个库所(Place)到画布,命名为Counter,颜色集选INT。
  2. 在库所的初始标识属性里输入1\0`。
  3. 拖一个变迁(Transition),命名为Inc。
  4. 画一条从Counter到Inc的输入弧,弧表达式写n。
  5. 再画一条从Inc回到Counter的输出弧,弧表达式写inc(n)。
  6. 给Inc加一个守卫[n < 10]。

完成以后,这个模型表达的含义是:Counter库所里有一个整数令牌;当令牌值小于 10 时,变迁Inc可以发生;发生一次就把令牌值加 1,再送回同一个库所。仿真时你会看到令牌的值从 0 一路变化到 1、2、3……直到 10,然后因为守卫不满足而停止。

整个过程看起来很简单,但它把 CPN ML 的几个核心概念全部串起来了:颜色集决定令牌类型,变量n通过输入弧绑定,守卫过滤绑定,输出弧用函数产生新令牌。很多复杂模型本质上就是这种简单结构的叠加和交错。

弧表达式里还可以直接写更复杂的逻辑。比如输出表达式:

(if n < 5 then 1`n else 1`(n * 2))

这里特别要注意,表达式的最终类型必须和库所颜色集完全匹配。如果你写1\n,而n已经声明为INT,那没问题;但如果库所颜色集是INT,你却在初始标识里写1`"0"`,类型检查立刻报错。

3.4 仿真与状态空间分析

模型画完后,先做一次语法和类型检查。CPN Tools 的“检查模型”功能会捕获大部分拼写和类型错误。检查通过后,就可以进入仿真模式。仿真分为单步和自动两种,单步仿真方便你观察每个变迁发生时令牌的变化,自动仿真适合快速跑通整条流程。

真正体现 CPN ML 和着色 Petri 网价值的,是状态空间工具。点击状态空间工具里的“计算状态空间”按钮,CPN Tools 会根据当前模型生成一个状态空间报告,里面包含状态数量、弧数量、死锁标识、活锁情况、有界性等信息。这个能力对验证系统性质极其重要,比如“是否存在某个不可达状态”“有没有可能走到死锁”。而这一切能成立,正因为 CPN ML 表达式具有静态类型和可判定语义。

不过要提醒一句:状态空间生成是把双刃剑。模型稍微复杂一点,状态数就会爆炸,计算时间可能从几秒变成几小时。所以从一开始设计模型时,就要刻意控制类型域的大小,用受限整数、枚举而不是无限类型,能少很多后患。

4. 常见错误与排查技巧实录

4.1 类型不匹配是最常见的“入门劝退”

CPN ML 是强类型语言,类型不匹配在检查阶段就会被揪出来,但报错信息对新手不友好。最常见的场景是:弧表达式写的变量和库所颜色集不一致。比如库所颜色集是Task记录,但弧上写了一个INT变量;或者输出表达式返回了字符串,目标库所却要求整数。这类问题排查起来有固定套路:先看报错信息里提到的库所和过渡元素,再逐个确认颜色集、变量声明、表达式返回值三者是否一致。

另外,复合颜色集在比较时也很容易出问题。product I * S和record {id:I; name:S}在语义上是不同颜色集,即使字段一模一样也不能混用。枚举颜色集更是如此,每个枚举元素都是独立的标签,不能用一个字符串去匹配一个枚举标签。

4.2 多重集写法的几个坑

我见过最多的问题是把多重集符号和列表操作搞混。++是多集合并,作用于多重集;列表拼接在 CPN ML 里用@。如果你在列表颜色集的弧表达式里写q ++ [x],工具会提示类型错误,因为[x]是列表,不是多集。反过来,在普通整数库所的弧上写1\x ++ 1`y之前,要确认你确实想让库所里同时出现值x和值y` 的副本。

另一个容易踩的坑是副本数量的含义。2\x表示“两个完全相同的 x 令牌”,不是“一个令牌,值等于 2*x”。想表达后者,应该直接写(2 * x),外面再包上1``。语义不同,模型行为也完全不同,排查时别忘了回头看看是否把值计算和副本数量搞混。

4.3 变量绑定与未绑定变量

CPN 模型中,变迁守卫里出现的变量必须在输入弧上完成绑定。如果你在守卫里写了一个声明过的全局变量,但它没有出现在任何输入弧上,工具会给出未绑定变量的提示。这里的解决思路是:要么把它加入到某个输入弧表达式中,作为绑定来源;要么用let局部定义临时值。

还要注意,同一个变迁的多个输入弧可以绑定不同的变量,通过输入弧的“模式”解构复杂令牌。例如输入弧表达式写(id, status),如果库所颜色集是product I * Status,那么变迁内部就可以直接使用变量id和status。这种解构式写法非常强大,但也要保证模式与库所颜色集完全一致,否则类型和绑定错误会一起冒出来。

4.4 状态空间爆炸与性能问题

状态空间爆炸是着色 Petri 网建模绕不开的坎。一个整数变量如果取值范围是int,理论上就有几十亿个可能取值,状态空间指数级增长。新手最容易忽略的就是“类型域的大小”。

应对方法有几条。第一,尽量把颜色集限制在业务需要的范围内,比如用int with 1..100而不是int。第二,能用枚举的状态就少用整数,枚举的取值为有限集合,状态空间更容易控制。第三,模型结构上尽量做分层,用替换变迁把大模型拆成相互独立的小模块,避免一张网上同时出现大量并发交叉。第四,在跑状态空间分析之前,先用小参数验证模型逻辑,再逐步放大。

仿真性能问题和状态空间爆炸还不完全一样。即使不做穷举分析,仅做随机仿真,如果模型里频繁复制大列表或做重量级函数计算,仿真也会明显变慢。这时候可以优化弧表达式,减少不必要的数据复制,或者用记录裁剪字段。

4.5 工具操作上的小坑

声明区代码里混入中文标点,比如全角括号、全角分号,是 CPN Tools 报“语法错误”的常见原因。写代码时务必把输入法切到英文状态。

给库所、变迁取名时,尽量用字母、数字和下划线,避免空格和特殊符号。虽然工具允许一些字符,但后续在状态空间查询里写表达式时,特殊符号会给自己找麻烦。

还有一个容易被忽略的点:修改声明区后,一定要重新执行模型的类型检查。CPN Tools 有时不会在你切回图形界面时立刻重新编译声明,如果你发现弧表达式报错,先去声明区看有没有漏掉的分号或未定义名称。

5. 进阶:让 CPN ML 代码更像工程代码

5.1 用 record 替代过长的 product

当多个字段组合在一起,product的颜色集写起来简单,但可读性很差。比如:

colset Order = product I * S * I * B;

你根本分不清哪个字段是订单号、哪个是客户名、哪个是优先级。换成 record 之后,声明清晰,访问也清晰:

colset Order = record {id:I; customer:S; amount:I; urgent:B}; var o : Order;

访问字段用#操作,比如#amount o。这样模型里的弧表达式一眼就能看懂是在传输哪个业务字段,状态空间分析时也更方便写性质断言。

5.2 把业务逻辑封装成函数

当你发现同样一段计算出现在不同页面、不同弧上的时候,就应该及时把它抽成函数。比如判断订单是否紧急:

fun isUrgent (o : Order) = #urgent o orelse #amount o > 100;

然后在守卫里直接写:

[isUrgent(o)]

这样一来,后续业务规则变化只需要改函数定义,不需要在每条弧上找着改。CPN ML 的函数在声明区统一维护,天然适合做这种复用。我自己的经验是,模型越复杂,越要坚持“网图只负责结构、声明区负责逻辑”的分离原则。

5.3 用列表和函数建模队列

缓冲区、任务队列在 Petri 网里是很常见的建模对象。用列表颜色集可以很自然地把一个队列表示成一个令牌:

colset Queue = list I;

入队操作可以封装成函数:

fun enqueue (q : Queue, x : I) = q @ [x];

出队操作通常结合模式匹配:

fun dequeue (q : Queue) = case q of [] => empty | x :: xs => 1`(x, xs);

这里要注意,dequeue返回的多集元素类型必须和目标库所的颜色集匹配。如果你想把x和剩余队列xs分别放到不同库所,那弧表达式也要相应拆开写。列表和多重集的区别在这里再次体现:x :: xs只是把列表解开成“头部元素 + 剩余列表”,并不代表两个令牌,要和弧上的多重集语义区分开。

高阶函数也可以用来处理批量数据。比如计算队列中所有任务的处理时间总和:

fun totalTime (q : Queue) = foldr (op +) 0 q;

这类函数在建模统计指标、性能评价时非常有用,而且因为它们不依赖全局可变状态,所以在并发模型的语义下更安全,也更容易验证。

5.4 后续还能往哪里走

CPN ML 的能力并不局限于画图和仿真。CPN Tools 的状态空间查询接口允许你用 ML 风格表达式编写自定义查询,检查像“是否存在某个状态满足特定谓词”“所有可达状态是否都满足某个不变量”这类性质。这属于形式化验证的范畴,学习曲线比基础语法陡不少,但一旦掌握,你的建模能力会明显上一个台阶。

如果你之后接触到更大规模系统建模,可以进一步研究分层着色 Petri 网(Hierarchical CPN),把复杂系统拆成若干子页,用替换变迁组织层次关系。CPN ML 的设计完全支持这种扩展,颜色集、变量和函数在全局声明区统一管理,子页之间通过端口库所和替换变迁交互。这个方向对初学者可能有点远,但至少先了解有这个路线,免得以后走弯路。

我个人在实际操作中的体会是:CPN ML 的语法并不难,真正难的是用“类型 + 多重集 + 绑定”的思维去描述并发系统。很多模型之所以跑不通,不是因为工具不好用,而是因为建模的人在潜意识里还在用命令式编程的思路,总想着“把变量改成一个新值”,而不是“用绑定和约束来描述一次变迁发生的条件与结果”。把观念转过来之后,你会发现 CPN ML 其实是一套非常优雅、非常适合验证的建模语言。希望这篇指南能帮你少踩几个坑,也祝你早日画出能跑、能查、能验证的模型。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询