Havenlon 执行控制工程 II 01|为什么执行控制语言最好不是图灵完备的?

📅 2026/8/22 12:07:07
Havenlon 执行控制工程 II 01|为什么执行控制语言最好不是图灵完备的?
程序语言长期追求的是表达能力循环、递归、函数、动态调用、网络访问、文件操作、状态修改、运行时扩展。语言越强能描述的行为越复杂。对通用软件工程来说这几乎天然是优势。但如果一门语言的职责不是完成业务而是决定某个高风险动作能不能进入执行评价标准就要换一套。执行控制语言真正需要的可能不是什么都能表达而恰恰是有些事情永远不能表达。因为一旦裁决运行时本身拥有无限循环、任意函数调用、网络访问、动态加载和外部副作用甚至能直接改变现实状态那么负责判断能不能执行的系统本身就越来越像另一个执行器。这会带来一个基础的架构矛盾如果裁决层拥有无限执行能力我们又凭什么确定它仍然只是裁决层所以在真正动手构建执行边界时第一条相当反直觉的原则是执行控制语言的能力应该被主动削弱。一、规则写了什么和规则理论上能做什么假设直接用一门通用脚本语言编写执行策略。一条规则可能写得很朴素金额低于上限且审批有效则允许。这条规则本身没有问题但语言允许的远不止这些——读文件、访问数据库、请求远程接口、调用系统命令、动态加载模块、修改全局状态、创建线程、进入无限循环甚至直接调用执行接口。于是安全性不再只取决于策略写了什么还取决于策略理论上可以做什么。这两者的差距很大。一套高风险控制系统不能仅依赖开发规范去告诉所有人请不要在策略里写副作用——这类约定会在人员流动、代码复用和一次普通重构中失效。更可靠的做法是让运行时从能力上就不允许这些行为存在。安全约束进入语言模型本身而不是停留在评审清单里。二、终止性与可预测的资源上界图灵完备意味着在资源充足时一门语言可以表达任意可计算过程。对通用计算这是强大能力同时也带来一个经典代价有些程序的行为在执行之前无法简单判定——它会不会结束某些输入下是否陷入死循环需要多长时间会访问多少状态。普通业务程序可以接受这种复杂性因为它的目标就是完成复杂计算。而裁决运行时处在现实执行发生之前它更需要另外一组性质给定明确输入一定会结束结束时间有明确上界不产生额外副作用输出空间有限且不会因为规则本身的执行而改变待判断的世界。这里的目标不是写出任意程序而是在有限时间里对有限事实作出有限判断。设想一个执行器正准备发起高风险动作它向裁决层提问当前是否允许执行而运行时开始执行规则之后没有返回——可能是死循环可能是递归失控可能在等待网络或另一个服务也可能卡在某把锁上。此时系统怎么办无限等待会让执行路径卡死超时后默认放行等于绕过边界超时后默认拒绝则可能被构造输入用来制造拒绝服务。这些问题不是加一个超时就能彻底解决的。更理想的状态是从语言设计层面尽量保证裁决过程必然终止有限的表达结构、有限的遍历、明确的操作符、受限的数据模型甚至不提供传统意义上的任意循环。这不是语言太弱而是把终止性从开发者的责任变成系统的性质。同样的道理适用于资源。如果一条策略有时一毫秒返回有时几十秒返回某些输入还会造成极端消耗那么运行时自身就成了新的不确定来源会连带影响超时策略、并发能力、故障恢复和执行窗口也给资源耗尽留下空间。所以控制语言最好在设计上就约束复杂度——可访问的数据规模、表达式深度、集合操作范围、规则数量、可执行步数。重点不是追求极致性能而是让系统在执行之前就能大致确定这次裁决一定会在有限的资源范围内完成。对靠近设备、运行在受限环境中的边界来说这一点尤其现实——那里没有可以随时扩容的余地。三、判断世界不应该顺手改变世界这是这类语言最重要的设计原则之一。如果一条策略在判断过程中可以写数据库、改配置、发消息、调接口、触发付款、改变设备状态那么它就不再只是一个裁决函数而已经具备执行能力。原本清晰的结构——事实进入策略、策略产出裁决、裁决与执行条件共同交给执行器——会变成策略在判断途中顺手改变了世界而它的结论甚至可能建立在自己刚刚修改过的状态之上。裁决应该观察世界而不是在裁决过程中改变世界。因此运行时更适合被设计成接收输入、计算、输出有限结果不拥有外部副作用。这样同一份输入在同一套规则下会得到稳定且可审计的行为。与之相关的是另一个常见设计让策略在运行时自行去查数据库、问风控、拉取设备状态。表面上很方便——需要什么自己取。但这让裁决过程同时承担了事实获取与事实判断两件事。规则执行到一半调用外部服务超时了该怎么办换备用源用缓存还是继续重试规则的语义突然被网络状态左右。更麻烦的是事后想重放这次裁决时当时的外部状态可能已经不存在了。更清晰的结构是由外层系统先把执行所需的事实收集并固定成输入运行时只对这些明确输入作判断。它不需要知道某个事实来自数据库还是设备只需要知道当前输入里有没有这个事实、是否有效、是否过期、是否与其他事实冲突。这与第一季讨论过的未知、缺失、过期、冲突自然衔接。四、声明式表达以及不变量这个正确的目标通用程序大多是命令式的先做这个再做那个满足条件就循环。执行控制规则更适合声明式表达——不是先查额度再请求审批系统然后看时间最后决定而是只有当以下事实同时成立这类执行才允许继续amount approved_limit AND approval.valid AND target intent.target AND state.fresh它描述的是成立条件而不是完成任务的步骤。这非常契合裁决的本质——运行时不需要知道如何让条件成立它只负责判断条件现在到底成不成立。这也顺带避免了策略自己去补数据、调服务、尝试恢复甚至寻找替代路径。顺着这个方向可以更明确地定义这类语言应该擅长什么不是业务流程而是不变量——那些一旦被违反就不应允许执行继续的条件。最终目标必须等于意图中的目标金额不得超出被授权的范围必要的审批必须存在且有效关键证据必须连续状态不能过期某类冲突不允许存在。这些条件的共同特征是不负责告诉系统怎样完成任务只负责告诉系统什么情况下这件事绝对不能继续。表达边界正是一门有限语言最擅长的事。五、把隐含环境变成显式输入确定性是另一条核心要求。如果一条策略今天返回允许明天在完全相同的输入下却因为随机数、网络状况或某个全局状态返回拒绝它几乎无法审计。在策略版本、输入和运行时版本都明确的前提下同一次裁决应当尽可能得到相同结论。这带来几项直接的工程价值可以重放可以审计可以离线验证可以让证据说明当时为什么得到这个结论也可以让多个独立节点对同一输入作出一致判断——这对将来构建多个彼此独立的裁决边界尤其重要。一个很典型的反例是让规则直接读取当前时间。看上去再正常不过但几年后重新执行这份策略以复核某次裁决时这个值已经完全不同。随机数、环境变量、当前系统状态、网络返回结果都有同样的问题。如果它们会影响裁决就更适合被明确固定成事实输入——不是让运行时自己去取时间而是由执行链明确提供本次裁决使用的可信时间事实是什么。把隐含环境转化为显式输入是构建可验证运行时的核心思想之一。它也顺带回答了另一个问题当某项输入缺失时运行时该不该聪明一点自己去查审批服务、退回缓存、再不行就按业务状态推断在普通自动化系统里这显得很贴心在执行控制里则不应如此。缺失就应当保持缺失运行时的职责不是想办法让规则通过而是准确判断当前输入是否足够不够就输出对应的拒绝。事实的获取与恢复由外层负责重新拿到有效事实后可以再次提交裁决。运行时不制造事实它只判断事实。六、类型以及为什么二值逻辑不够如果所有输入最终都退化成字符串或任意结构很多错误只能在运行时暴露。金额和账户标识都是字符串时间戳和计数值都是数字语义混淆几乎必然发生。更成熟的做法是让数据带有明确的语义类型主体、目标、金额、时间、计数、证据引用、裁决结果各自不同。这不需要复杂的对象体系重要的只是一条原则——不同安全语义的对象不应该因为底层表示相似就可以自由互换。这与此前讨论过的密码学域分离其实是同一种思想域分离是密码学层的类型隔离规则层的类型系统是语义层的隔离二者都在防止语义漂移。二值逻辑同样不够用。一条规则判断设备处于安全状态如果这个事实根本不存在应该返回假还是未知假意味着系统明确知道设备不安全未知意味着系统不知道它现在是否安全。两者最终可能都导致拒绝但审计路径和恢复方式完全不同。所以这类语言必须认真处理不确定状态如何进入语义至少不能让缺失、未知、过期、冲突被语言的默认行为悄悄压缩成假或者更危险地变成某个默认值。安全语言不仅要能表达事实成立也要能表达事实尚未成立。七、输出、失败与版本输出同样需要受限。如果运行时可以产出任意对象——任意请求、任意命令、任意交易数据——它实际上已经握有一部分执行器能力。更清晰的做法是限制输出类型只产生裁决结论、理由和受约束的参数。重点不在具体枚举而在于策略只应该产生判断不应该自由产生动作。动作属于执行器两者在类型层和能力层都应尽量分开。这样即使规则写错它能影响的是裁决而不是直接获得任意的现实执行能力。同理语言里不该存在隐藏动作。普通业务代码中常见的那类辅助函数——名字看着像检查实际顺手修正状态名字看着像确认实际不存在就创建——在控制层非常危险因为它模糊了读与写的界限。成熟的运行时应当保证每一个原语的语义都清晰哪些只读取输入哪些是纯计算哪些输出裁决最好根本不存在顺便帮你处理一下的能力。安全边界最怕的不是复杂规则而是无法从规则表面看出来的副作用。失败也必须是受控语义。解析失败、类型错误、引用了不存在的字段、版本不兼容——普通程序可以抛出异常交由上层处理而控制运行时需要明确的安全结果。核心原则只有一条任何无法得到完整有效裁决的运行状态都不能自动落入放行。否则运行时自身的错误就成了安全旁路。只要策略可以被更新就存在策略供应链——谁编写谁评审谁发布谁加载哪个版本正在运行。如果策略本身是通用代码那么发布一条策略实质上等同于发布一段新程序作者也就间接获得了很大的运行时能力。而在受限语言下即使一条错误或恶意的规则进入系统它最大的破坏也被运行时框住它可以错误地放行或拒绝——这已经很严重——但它无法顺手读取文件、外传密钥、执行命令、访问未知网络或改写数据。爆炸半径被显著压缩。版本问题同样绕不开。语言会演化新增操作符调整语义扩展输入类型改变默认行为。必须能够回答同一份策略在不同运行时版本下含义是否一致如果不能保证策略就需要与明确版本绑定。协议和策略语言最危险的兼容不是旧规则运行失败而是旧规则悄悄以新含义继续运行。八、可证明的弱多数语言希望证明自己更快、更灵活、更强、支持更多语法。执行控制语言可能恰恰应该强调它做不到什么不能访问网络不能写文件不能执行任意函数不能无限循环不能动态加载代码不能悄悄改变状态不能直接驱动执行器。听上去很弱。但这些不能本身就是边界。运行时越有限越容易回答一个关键问题这套规则最终可能产生哪些行为限制表达能力换来的是可验证性——面对几百行通用代码安全评审很难说清它究竟允许什么因为真实行为要结合整个执行过程才能理解而当语言只允许布尔组合、比较、集合关系、有限引用和明确的内建操作时评审者更容易回答规则依赖哪些输入、什么条件触发放行、什么情况必定拒绝、有没有隐含副作用。这不仅方便人读也让静态分析、策略比对、版本差异、测试生成和冲突检测成为可能。有人会问语言这么弱复杂业务规则怎么办答案不是把业务塞进来。业务系统仍然可以很复杂风险模型可以很复杂模型判断和组织流程都可以参与——它们最终把处理好的事实作为输入送进控制层。运行时不重做整个业务系统它只负责少量但关键的事检查那些在现实改变之前绝不能被违反的不变量。上层负责丰富的计算与解释下层负责有限的裁决与稳定的拒绝。第一季反复讨论的是为什么为什么权限不够为什么审批不够为什么策略不该成为最终权威为什么执行需要独立边界。这一季的问题变成了怎么造。而落到工程上的第一步既不是选芯片也不是设计接口而是先回答负责裁决的那个运行时究竟应该拥有什么能力。如果它可以无限计算、无限访问、无限修改它自己就会重新变成一个权力中心。所以构建执行边界的第一件事反而是主动拿掉一些能力让它可终止、可预测、可审计、确定性、无副作用只消费显式事实只输出有限裁决不直接触碰现实。对一门执行控制语言来说不能做什么往往比能做什么更重要。通用语言的伟大之处在于我们几乎可以让它做任何事。而执行控制语言成熟的标志可能恰好相反我们能够相当明确地证明有些事情它永远做不了。