死锁本应该是运行时问题。这就是它们如此烦人的原因。你的代码编译干净,测试通过,然后它在生产环境中卡住,因为进程A在等待进程B,而进程B在等待进程A。

会话类型扭转了这一点。它们将进程之间的通信协议编码到类型系统本身中。某些类别的死锁不再是运行时的意外,而开始成为编译器错误。你字面意义上无法编写会导致死锁的代码。

会话类型实际上是什么

会话类型是通信通道的类型规范。通道不再是一个你从中读取和写入的无类型管道,而是一个携带类型的会话类型化通道,该类型描述了允许在其上执行的确切操作序列。

发送一个整数,然后接收一个字符串,然后关闭。编译器在每一步跟踪这个序列。偏离它,你会得到一个类型错误,而不是运行时死锁。

这个想法来自进程演算,并在Haskell、Scala、OCaml和Rust中有实现。Rust的所有权系统和仿射类型使其成为一个特别自然的契合,但这个概念是语言无关的。

消息传递死锁实际上如何发生

考虑两个需要交换数据的进程。一个常见的错误看起来像这样:

// Process A
tx1.send(data_a)?;
let result_a = rx2.recv()?;

// Process B
tx2.send(data_b)?;
let result_b = rx1.recv()?;

两个进程都试图先发送。如果通道缓冲区已满,两者都会在发送时阻塞。两者都永远达不到接收。这是一个经典的通信不匹配死锁。

你可能会想”别这么干就行了”。但在一个拥有数十个通道、条件逻辑和六个月后会被重构的代码的真实系统中,这种模式不断潜入。类型检查器对你的发送和接收序列是否连贯没有意见。

会话类型如何让死锁无法表示

核心技巧是会话类型在每次操作后都会改变。通道没有静态类型。它有一个类型,在你使用它之后变成其他东西。

以下是在Rust中演示这个想法的最小实现:

use std::marker::PhantomData;

struct Send<T, Next>(PhantomData<(T, Next)>);
struct Recv<T, Next>(PhantomData<(T, Next)>);
struct Close;

struct Chan<P>(PhantomData<P>);

impl<P> Chan<P> {
    fn new() -> Self {
        Chan(PhantomData)
    }
}

impl Chan<Close> {
    fn close(self) {}
}

impl<T, Next> Chan<Send<T, Next>> {
    fn send(self, value: T) -> Chan<Next> {
        drop(value);
        Chan(PhantomData)
    }
}

impl<T: Default, Next> Chan<Recv<T, Next>> {
    fn recv(self) -> (T, Chan<Next>) {
        (T::default(), Chan(PhantomData))
    }
}

类型为Chan<Send<i32, Recv<String, Close>>>的通道意味着:你必须发送一个i32,然后你将拥有一个可以接收String的通道,然后你将拥有一个只能关闭的通道。

send方法消耗旧通道并返回一个更新类型的新通道。因为Rust的所有权系统确保self被消耗,你不能再次使用旧通道。编译器不会让你发送两次,或乱序接收,或忘记关闭。

对于我们的死锁场景,你用互补类型定义两个端点:

type Client = Send<i32, Recv<String, Close>>;
type Server = Recv<i32, Send<String, Close>>;

fn client(ch: Chan<Client>) {
    let ch = ch.send(42);
    let (msg, ch) = ch.recv();
    println!("{}", msg);
    ch.close();
}

fn server(ch: Chan<Server>) {
    let (req, ch) = ch.recv();
    println!("{}", req);
    let ch = ch.send("hello".to_string());
    ch.close();
}

进程A先发送再接收。进程B先接收再发送。协议类型强制执行这个顺序。

如果有人将进程B重构为先发送,编译器会立即拒绝:

// This will NOT compile:
fn bad_server(ch: Chan<Server>) {
    // error: no method named `send` found for struct `Chan<Recv<...>>`
    let ch = ch.send("hello".to_string());
}

类型系统说这个通道处于接收状态。你不能发送。死锁变得无法表达。

理论变得复杂的地方:分支和递归

真正的协议不是线性序列。它们有选择。服务器可能提供认证或注册。会话类型用内部和外部选择类型来处理这个。

struct Credentials;
struct Token;
struct UserInfo;
struct Account;

enum AuthProtocol {
    Login(Send<Credentials, Recv<Token, Close>>),
    Register(Send<UserInfo, Recv<Account, Close>>),
}

客户端提供一个选择,服务器选择一个。两个端点必须在选择上达成一致,否则类型不匹配。

递归协议,比如一个循环回到菜单的持久连接,需要递归类型定义。这是大多数语言挣扎的地方。Rust支持这个,但会变得冗长。

还有多方问题。上面的会话类型是二元的:两个端点。如果三个或更多进程协调,你需要多方会话类型,这明显更复杂且成熟的实现更少。

你应该知道的权衡

会话类型消除了一类错误,但它们不能消除所有死锁。每个进程都在等待外部资源的全局死锁,或者进程在没有进展的情况下空转的活锁,仍然可能发生。会话类型专门针对通信不匹配。

它们还引入了编译时开销。来自深层类型栈的错误消息可能令人费解。一个简单的通道类型错误可能会在编译器输出中扩展为50行嵌套的泛型。Rust生态系统在这方面有所改进,但调试会话类型不匹配仍然是一项需要习得的技能。

动态拓扑是另一个痛点。会话类型在通信图是静态且编译时已知的情况下效果最好。如果你基于运行时数据生成通道,比如一个参与者数量可变的聊天室,会话类型会变得难以应用。

今天如何尝试这个

如果你想在Rust中试验会话类型,session_types crate提供了基于原始理论的成熟实现。对于更符合人体工程学的API,sesh提供了一种替代方法。

在Haskell中,session-types通过Haskell的类型级编程提供类似的保证。对于更接近工业用途的东西,看看带有生成客户端存根的Protocol Buffers。虽然严格意义上不是会话类型,但生成的代码强制执行相同的原则:协议是外部定义的,编译器检查你的使用是否符合它。

如果你正在构建一个具有固定通信进程集的分布式系统,从绘制通信图开始。为每条消息画箭头。如果图复杂到让你担心不匹配,那就是会话类型发挥作用的时候。

FAQ

会话类型能防止所有死锁吗?

不能。它们防止由通信不匹配引起的死锁,比如两个进程都在等待发送。它们不能防止资源死锁、活锁或涉及外部系统的死锁。

会话类型和状态机有什么区别?

会话类型是编码在类型系统中的状态机。状态转换由编译器在每次通道操作上强制执行,而不是在运行时检查。

会话类型在生产中使用吗?

二元会话类型在研究系统和专门领域中使用。多方会话类型仍然主要是学术性的。这些概念影响了现代API设计,包括生成的gRPC客户端和Rust的类型状态模式。

我可以不用Rust使用会话类型吗?

可以。Haskell、Scala和OCaml都有会话类型库。即使在没有仿射类型的语言中,你也可以用线性类型检查器或运行时断言来近似这个模式。

死锁现在是一个类型问题

死锁在成为类型问题之前是运行时问题。会话类型不是银弹,但对于具有良好定义协议的传消息系统,它们将整个类别的缺陷从”在生产中捕获”转移到”在编译时捕获”。下次你勾勒新服务架构时,这是一个值得考虑的权衡。