Skip to content
Published on

当两个通过了类型检查的服务互相等着对方停住时 —— 编排式编程这条另辟蹊径的路

分享
Authors

两个服务互相等着对方停住了

你用内存安全的语言做了两个服务。两边都干净地通过了编译。没有空引用,没有数据竞争,也没有释放后使用。

可一上线,它们偶尔就一起停住。服务端在等客户端本该发出的第二条消息,而客户端认定服务端会先响应,也在那儿等着。协议对不上了。

这个 bug 的特点是:哪一侧的代码都没有错。分开读,两边都合情合理。错在两者之间,而没有任何一个编译器会去看那中间地带。

问题的根源是代码被切成了两半

想一想类型系统守护的边界到底延伸到哪里,原因就很清楚了。类型检查器看的是一个编译单元内部的值的流动。一旦走出那个单元 —— 也就是字节被放上 socket 的那一刻 —— 它什么都保证不了。对面按什么顺序期待这些字节,是检查器无从知晓的信息。

于是我们用人的纪律去填这段空白:在文档里画时序图、共享 schema、写集成测试。三样都有用,也都是惯例而不是检查。改了一侧的代码却没同步改图,编译照样通过。

在这个点上,有一条给出不同答案的路线:不要去检查中间地带,而是一开始就别把它切成两半

编排 —— 把整个系统写成一个程序

编排式编程不再按进程分别编写分布式系统,而是把整套交互写成一份全局描述。客户端发出这个、服务端算出那个再回传、客户端收下它,这条流程在同一个程序里按顺序读下来。

这个名字出自 Fabrizio Montesi 2013 年的博士论文,此后有不少语言和库实现了这个想法。概念的核心是视角的转换。我们现在写的代码是从单个节点的视角写的,而编排是从系统之外俯视的视角写的。把它想成「时序图变得可执行了」,大体不差。

端点投影做的是什么

全局描述没法直接执行,因为真正跑起来的是各节点上的进程。所以编译阶段多出一道工序:端点投影。

投影会从一份编排里机械地抽取出各节点的代码。客户端的二进制里只留下客户端该做的发送、接收与计算,服务端的二进制同理。这两个产物彼此并不认识,但因为出自同一份原本,顺序不可能对不上。

这里要紧的是:我们一直用手在做的事,变成了编译器的事。此前把客户端代码和服务端代码对齐,靠的是人看着图往两边各誊一遍。两次誊写,被一次自动变换取代了。

死锁为什么在设计层面就消失了

这里有个容易误解的地方要点明:死锁之所以消失,不是因为编译器聪明,而是因为那样的程序根本写不出来

在全局描述里,通信由单一的语法结构表达。「谁把什么发给谁」这一行同时生出一次发送和一次接收。只有发送没有接收,或者双方都从接收开始 —— 这样的情形在源码里无从写起。因此投影的结果里也不可能出现这样的配对。

自 Carbone 与 Montesi 2013 年的工作以来,这个性质就以「设计层面无死锁」的说法为人所知。不过范围要弄清楚:这条保证针对的是由协议错配引发的死锁。节点挂掉、网络断开、陷入无限循环、外部资源上被加了锁,这些是另外的问题。

Wyzer 在这上面加了什么

最近公开的 Wyzer 是把这个想法往系统编程这一侧搬的一次尝试。README 把这门语言介绍为静态类型、可编译的资源导向语言,并说明它把编排与 Perceus 内存模型结合了起来。

语法上最扎眼的一点是:节点会附在类型上。

role @Client;
role @Server;

fn fetch_data(query: str@Client) -> str@Client {
    // 客户端把 query 交给服务端
    let server_query: str@Server = query;

    // 服务端进行处理
    let server_result: str@Server = db_lookup(server_query);

    // 服务端把结果送回客户端
    let client_result: str@Client = server_result;

    return client_result;
}

在这里,网络传输不是另一次函数调用,而是向另一个节点类型的赋值。而且这门语言把这次赋值当作线性移动来处理 —— 也就是说,交出去的值会在原处消亡。README 里贴出的错误信息就是这个结果:移动之后再用它,就会得到一条「使用了已移动的变量」的编译错误。

这个设计瞄准的地方很明确。分布式系统里常见的一类 bug,就是把已经发出去的数据在本地继续当作最新的来用;一旦把传输做成移动,这个失误就变成了类型错误。

不要借用检查器也能拿到原地更新的那条路

内存这一侧的选择也值得一并看。Wyzer 说明它既不放垃圾回收器也不放借用检查器,而是选了 Perceus。

Perceus 是 2021 年在 PLDI 上发表的技法:由编译器精确地插入引用计数的增减指令,使得在没有环的程序里不留垃圾。再叠上复用分析之后,当某个值只有一个引用时,就可以不重新分配,直接改写既有内存。让函数式风格写出来的代码被编译成原地更新的这种方式叫 FBIP,它在 Koka 语言里有实现。

这个组合为什么自然,也值得点一下。在编排里,值在节点之间移动意味着所有权发生了转移,而数所有权正是引用计数本来就在做的事。README 里「对内存和网络使用同一套规则」的设计原则就出自这里。不过这是语言自己标举的设计意图,那套规则在这两个领域是不是真的都好用,得等实现出来之后再判断。

把这门语言的现状照实写下来

为了不夸大成熟度,我只写确认到的内容。仓库采用 Apache-2.0 许可证,主要语言是 OCaml。在我查看的时点,一个 release 都还没有发布。而且作者本人在公开文章里说明,它经过了五个月的研究和几周的开发,很快会发布 0.1.0。也就是说,第一个版本还没出来。

所以现在还不是拿这门语言去规划做点什么的阶段。我仍然觉得它值得一读,是因为这个项目把「为什么要做它」写成了一个问题。作者说明自己是从对 Rust 的不满出发的,理由是:类型检查守得住内存,却守不住分布式死锁与服务之间的协议错配。这个诊断与语言的完成度无关,它是对的。而针对这个诊断,存在一条积累了十多年的学术脉络 —— 这才是从这个项目上能拿到的最实用的信息。

小结与出处

如果我们只能靠文档和测试去对齐服务间的协议,那不是因为没有工具,而是因为我们用的语言只看得见一个节点。编排是一条改变这个视野的路;视野一变,有些 bug 就不再是需要修的对象,而是变成了根本无法表达的东西。

  • Choreographic Programming —— Fabrizio Montesi 本人给出的定义。这个术语出自 2013 年博士论文、端点投影的作用,以及「设计层面无死锁」出自 Carbone 与 Montesi 2013 年的工作,都是从这份文档确认的。
  • Wyzer-Lang/wyzer 仓库 —— 语法示例、设计原则、错误信息的形态。正文中关于 Wyzer 的叙述和代码都取自这个仓库的 README。
  • 从仓库元数据确认到的事项:许可证 Apache-2.0、语言 OCaml、没有 release。「0.1.0 还没发布」这条说法出自作者公开的介绍文章。
  • Perceus: Garbage Free Reference Counting with Reuse —— Reinking, Xie, de Moura, Leijen, PLDI 2021。FBIP 与复用分析的出处。
  • 我没有构建或运行过 Wyzer 编译器。正文中的代码是 README 里附的示例,并不是我亲自确认过的行为。