16.3 停机问题
停机问题(halting problem)询问任意程序在给定输入上最终是否能停止。其数学形式化定义为:
HALT = { ⟨M,w⟩ | machine M eventually halts on input w }语言 HALT 是可识别的(recognizable):通用图灵机可以通过单步模拟程序 M(w) 的方式,若模拟运行(simulation)最终停机,则图灵机进入接受(accept)状态;但如果 M(w) 永远死循环,该识别器(recognizer)也只能永远无限运行下去。令人遗憾的是,HALT 在数学上是不可判定的(undecidable):即根本不存在任何算法,能够无差别地检查每一对编码后的程序与输入(encoded program/input),在保证停机的前提下总是正确地回答停机问题(halting question)。
这并不意味着我们在工程中永远无法证明任何特定代码的终止性(termination)。编译器(Compiler)经常能通过数据流分析证明特定循环(loop)是有限次数的、结构化递归是严格单调缩小的,或者有限状态机必定会在有界步骤内遭遇相同的重复状态。其终极结论是:在图灵完备的运行环境(Turing-complete setting)下,绝对没有一个通用的程序(procedure)能够无差别地正确判定每一个任意程序的停机行为。
对角线法(Diagonalization)击败完美的停机预言机(Halting Oracle)
我们使用反证法(proof by contradiction)。假设在理论上存在一个总是能够停机并给出正确答案的预言函数 HALTS(program, input),接着我们利用自指特性,构造一个特殊的挑战程序 D:
function D(program):
if HALTS(program, program):
loop forever
else:
halt immediately现在请求预言机对求值表达式 D(D) 进行判定。如果预言机返回“会停机”,那么按照程序 D 的控制流,它将进入第一个 if 分支并无限地进行死循环;反之,如果预言机说它“会死循环”,那么按照控制流,D 将进入第二个 else 分支并立即停机。在这两种分支情况下预言机全都会给出错误的判断,从而产生致命的逻辑矛盾。这严格证明了假设的“全停机且绝对正确的预言机”在数学上是不可能存在的。
这个对角线证明(diagonalization proof)的核心在于三个决定性要素:程序能够进行数学上的有限编码(finite encoding),从而使一个程序完全可以在语义上把自己作为输入数据读入;通用机(universal machine)能够解释执行这些编码;以及挑战程序 D 在读取自身的编码时,故意制造与预言相反的控制流。无论是将源代码故意隐藏、修改高级语法(syntax)、注入复杂的优化(optimization)抑或是用各种高级机器学习算法来实现预言,都完全无法规避上述逻辑矛盾,只要所声称的底层契约(contract)仍是“对所有的程序/输入组合都必定能在有限时间内给出正确的答案”。
运行更久不能判定非终止性
模拟运行(Simulation)可以有效找到停机凭证(halting witness)。如果程序会在第 400 步停止,那么在虚拟机中运行 400 步就能确凿地证明这件事。然而,400 步时触发的超时(timeout)同时兼容至少两种迥然不同的可能:当前程序确实陷入了无限死循环;或者,它会在第 401 步、2^n 步,甚至某个根据忙碌海狸(busy-beaver)原理计算出的极大延迟后彻底停机。
在数学上,并不存在一个可计算的通用超时阈值(universal timeout bound),能单凭程序及输入的大小就覆盖该尺度下的所有停机计算。若其存在,我们只需模拟运行到该阈值,之后立刻断言“该程序绝不会停机”,便可判定停机语言 HALT,这与它的不可判定性相矛盾。因此,在实践中一味地提高静态求值器的燃料(fuel)限制可以扩大求值覆盖,却永远无法将超时(timeout)转化为在充满不确定性的通用代码上的死循环/发散证明(divergence proof)。
推理终止性的实用工程方式
真实的工业级工具通常使用“对某个合理子集分析很强、对棘手情况诚实以对”的安全折中方案。秩函数(Ranking function)把复杂的程序状态映射到良基关系(well-founded order)的数学偏序上,用以严格证明每次循环迭代(loop iteration)后状态都在单调下降;尺寸变化终止性分析(size-change termination)负责严密跟踪递归参数(recursive arguments)在调用链上如何逐步缩小;抽象解释(abstract interpretation)在编译时寻找递归和循环的归一不变量(inductive invariant);SMT 求解器自动化处理各种算术底层的证明义务(proof obligation);安全类型系统则把递归限制在结构上必定更小的值(structurally smaller value)的解构上;此外,用户显式提供的代码标注(annotation)(如不变量 invariant 或递减子句 decreases clause)也可以交由编译器设施进行自动校验。
这些实用的工程技术在数学上通常是安全但不完备(sound but incomplete)的:即一旦成功给出了终止性(termination)证明,结论必定百分之百有效,但会有部分在实际中确实会停机的程序因为分析精度丢失而无法被其成功证明。其他的缺陷检索技术(bug-finding techniques)可能会重点搜索死循环(cycle)或分析超长的执行轨迹(long trace),但它们并不声称自己完成了普适的数学定理证明。有界模型检测(Bounded model checking)则能在指定的有界范围内给出绝对精确和没有误报的终止性答案。
停机问题的不可避免性还完美解释了为什么部分编译期设施(compile-time facilities)必须在设计时采取某些强硬的硬上限。模板实例化深度(Template instantiation depth)、宏递归(macro recursion)、常量求值步数(const-evaluation steps)、优化器迭代次数(optimizer iteration count)以及证明搜索燃料(proof-search fuel),全都是为了在这个不可判定的深渊前强行保持编译器构建过程的积极响应。当程序触及设定的阈值上限时,编译器的诊断信息(diagnostic)应当诚实且明确地区分资源耗尽(resource exhaustion)与语义上不合规拒绝(semantic rejection)的区别:“在资源预算内未能成功建立终止性证明”在科学上截然不同于“已严格证明该代码会发生死循环”。
最后,不可判定性(undecidability)不应该作为我们设计低劣编译器工具的开脱借口。在工业界,绝大多数用户编写的实用程序都具备静态分析器(analyzer)可以充分发掘和利用的清晰结构、良好约定、有限数据与常见范式。数学定理(Theorem)用于规定全局性保证(universal guarantee)的可行上限;而精巧、高超的工程设计,则负责决定我们的分析工具在不得不向用户安全返回不确定(unknown)之前,能够清晰、高效且绝对正确地处理多少真正有行业价值的高难度实践案例。