async 会把一段代码转化为一个实现了 Future 特征的状态机。在普通的同步方法中调用阻塞函数会阻塞整个线程,而阻塞的 Future 却是放弃对线程的控制,转而允许其他 Future 来执行。

PinPin 类型(以及一般的固定/pinning 概念)是构建整个 Rust 异步生态系统的基础模块。然而不幸的是,它也是异步 Rust 中最难掌握且最常被误解的元素之一。本文旨在解释 Pin 到底实现了什么,它是如何诞生的,以及 Pin 目前面临的问题是什么。
几个月前,正在开发新语言 Mojo 的 Modular 公司的博客上有一篇有趣的文章。在简短讨论 Rust 中 Pin 的部分,我发现它非常精辟地概括了公众对此话题讨论的时代精神(主流看法):
在 Rust 中,没有“值标识(value identity)”的概念。对于指向自身成员的自引用结构体,如果对象发生移动,该数据可能会失效,因为它会指向内存中的旧位置。这造成了复杂度的激增,特别是在异步 Rust 的某些场景中,Future 需要进行自引用并存储状态,因此你必须用
Pin包装Self以确保它不会被移动。在 Mojo 中,对象具有标识(identity),因此引用self.foo将始终返回正确的内存位置,程序员无需处理任何额外的复杂性。
这些言论的某些方面让我感到困惑。“值标识”这个词在文章的任何地方都没有定义,我也无法在 Mojo 的文档中找到它,因此我不清楚 Modular 声称 Mojo 是如何解决 Pin 旨在解决的问题的。尽管如此,我认为该文章对 Pin 易用性的批评是很到位的:当用户被迫与它打交道时,确实存在“复杂度的激增(complexity spike)”。我更倾向于用“复杂度悬崖(complexity cliff)”这个词来形容——用户突然发现自己被推下悬崖,掉进了一片由他们不理解的、不符合直觉的复杂 API 组成的汪洋大海。这是一个严峻的问题,如果能解决这个问题,对 Rust 用户来说将具有巨大价值。
碰巧的是,Rust 的这个小角落是我弄出来的烂摊子;向 Rust 中添加 Pin 以支持自引用类型是我的主意。对于如何解决这种复杂度的激增,我有自己的想法,我将在后续文章中详细说明。但在那之前,我需要尽可能高效地解释清楚 Pin 实现了什么功能,它是如何被创造出来的,以及为什么它目前很难使用。
为了解释 Pin 为什么存在,我们需要回顾一下 async/await 最初的开发过程。我们试图解决的问题是:为了在异步函数中支持引用,我们需要能够将这些引用存储在 Future 内部。问题在于,这些引用可能是“自引用”的,这意味着它们指向了同一个对象的其他字段。
考虑下面这个玩具级的例子:
async fn foo<'a>(z: &'a mut i32) { ... }
async fn bar(x: i32, y: i32) -> i32 {
let mut z = x + y;
foo(&mut z).await;
z
}
这两个函数都会求值(evaluate)为一个匿名的 future 类型;异步函数求值得到的 future 类型包含它可能暂停的每一个步骤的状态:启动时、每个 await 点以及完成时。
为了说明我们的例子,我们将 foo 得到的匿名 future 称为 Foo<'a>('a 是参数 z 的生命周期),将 bar 得到的匿名 future 称为 Bar。让我们问问自己,Bar 的内部状态会是什么样子的?大致如下:
enum Bar {
// 当它启动时,仅包含其参数
Start { x: i32, y: i32 },
// 在第一个 await 时,它必须包含 `z` 以及引用了 `z` 的 `Foo` future
FirstAwait { z: i32, foo: Foo<'?> },
// 当它完成时,不需要任何数据
Complete,
}
请注意 Foo<'_> future 生命周期中的 '?:这会是什么生命周期呢?它并不是一个比 Bar 活得更久的生命周期,Bar 本身并没有生命周期。实际上,Foo 对象借用了 Bar 的 z 字段,而该字段与它存储在同一个结构体中。这就是为什么这些 future 类型被称为“自引用(self-referential)”的原因:它们包含引用了自身其他字段的字段。
在这里我们必须做个澄清:Pin 的目标并不是允许用户在安全的 Rust (Safe Rust) 中定义他们自己的自引用类型。 时至今日,如果你试图手动定义 Bar,确实没有安全的方法来构造其 FirstAwait 变体。让这成为可能将是一个有价值的目标,但这与 Pin 的目标是正交(不相关)的。Pin 的目标是使得操作自引用类型变得安全,这些自引用类型通常是由编译器从异步函数生成的,或者是像 tokio 这样的运行时中使用 unsafe 代码底层实现的。
无论自引用类型是如何定义的,一旦它存在,就会带来一个问题。想象一下,Bar 已经进入了 FirstAwait 状态,因此它包含对自身 z 字段的引用。如果你此时移动了 Bar,这些引用就会变成悬垂指针,指向已经失效的内存,而这块内存可能会被重新用于其他值。因此,至关重要的是,一旦 Bar 可以被置于 FirstAwait 状态,它就不能再被移动了。在 Pin 被开发出来之前,在 Rust 中,如果你拥有一个对象的所有权,甚至只是拥有它的可变引用,任何对象都是可以被随意移动的。所以这就是我们需要解决的问题:我们需要表达一种约束,即从某个特定时刻起,对象将不能被移动。
在继续之前,我想花点时间讨论一下经常被提议但行不通的两个解决方案(至少在 Rust 中行不通)。这两者的处理方式与 Pin 截然不同:它们不是声明值不能再被移动,而是试图让自引用的值即使移动了也能正常工作。
第一个是移动构造函数(move constructor)。其想法是,每当一个值被移动时,就会运行一些代码,这类似于当值被丢弃时运行的析构函数(destructor)。这段代码可以“修复(fix-up)”任何自引用指针,使它们指向新的位置。我过去曾在关于异步 Rust 历史的文章中讨论过这个问题,但这并不是一个可行的解决方案。因为在 Rust 中,这些指针可能存在于任何地方,而不仅仅是正在被移动的值的“内部”。例如,你可能有一个包含指向自身状态指针的 Vector,因此移动构造函数需要能够追踪到这个 Vector 的内部。这最终需要与垃圾回收(GC)相同类型的运行时内存管理,这对 Rust 来说是不可行的。
移动构造函数行不通的另一个原因是,Rust 早在早期就明确表示它永远不会有移动构造函数,而且已经存在大量不安全(unsafe)代码,这些代码假定只要通过按字节拷贝内存就可以安全地移动值。添加移动构造函数对 Rust 来说将是一个破坏性变更(breaking change)。
有时被提议的另一个非解决方案是偏移指针(offset pointer)。这种情况下的想法是,不把自引用编译成普通的引用,而是将它们编译成“相对于包含它们的自引用对象地址的偏移量”。这行不通,因为在编译时无法确定一个引用是否会成为自引用:同一个值在不同的分支中可能兼具这两种情况。例如,这是前面 bar 的修改版本:
async fn bar(x: i32, y: i32, mut z: &mut i32) {
let mut z2 = x + y;
if random() {
z = &mut z2;
}
foo(z).await;
}
当调用 foo 时,z 可能是指向同一个对象的指针(自引用),也可能是指向其他地方的指针。这在编译时是无法确定的。你必须将引用编译成某种由“偏移量”和“普通引用”组成的枚举(enum);在开发 async/await 时,这被认为是不切实际的。
在排除了让这些对象可移动的任何选项后,我们因此得到一个约束条件:该对象必须是不可移动的。但我们需要确切地明确这个约束是什么,因为人们常常对此做出错误的假设。
最重要的是,这些对象并非 始终 不可移动。相反,它们在其生命周期的初期是可以自由移动的,直到某个特定的时刻起,它们才应该停止移动。这样一来,在将自引用的 future 与其他 future 组合时,你可以四处移动它,直到最终将其放置到在轮询(poll)期间一直存在的位置。所以我们需要一种方法来表达“一个对象不再允许被移动”;换句话说,它被“固定在原地(pinned in place)”。
当我们在尝试用各种 API 表达这一约束时,Ralf Jung 很好心地将这个想法进行了形式化(formalize)。在 Ralf 的模型中,甚至在开发 async/await 之前,对象就可以处于两种“类型状态(typestates)”之一:它们要么是“被拥有的(owned)”(在此状态下可以自由移动),要么是“被共享的(shared)”(在此状态下它们在某段生命周期内不能被移动,因为有引用正指向它们)。为了支持自引用的 future 类型,Ralf 的模型增加了第三种类型状态,被称为固定的(pinned)。
一旦对象进入 pinned 类型状态,它就永远不能再被移动了。更具体地说,在未首先执行其析构函数的情况下,其内存不能失效(invalidated)。这个定义还包含了一些其他的边缘情况,例如在不运行析构函数的情况下释放内存;但不运行析构函数就使对象内存失效的主要方式,就是将对象移动到新的位置。理解 pinned 类型状态的最简单方法,就是把它看作是要求该对象永远不再被移动。
关于 pinned 类型状态的另一个事实是,对于大多数类型来说,它完全无关紧要。如果该类型的值永远不可能包含任何自引用,那么固定它就是多此一举。所以,对于大多数对象类型,我们会希望这些类型能够“选择退出(opt out)”进入 pinned 类型状态,以便在你需要时能够再次随意移动它们。
对于感兴趣的读者,在 Ralf 的博客上有关于 Rust 形式化模型中 pinned 类型状态的更详细描述。但在理解了固定的需求(首先是非正式的,然后由 Ralf 形式化)之后,我们面临的问题变成了:如何在 Rust 的表面语言(surface language)中寻找表示对象进入 pinned 类型状态的最佳方式。Ralf 的模型描述了语言的语义,但没有指定面向用户的 API 或语法。我们最终得出的解决方案是 Pin 类型,但这并不是我们尝试的第一个方案。
?Move 方案在尝试 Pin 之前,我们尝试过一个基于新 trait 的解决方案,我们将其称为 Move。当时的构想是,大多数类型都会实现 Move,它们的行为不会发生任何变化;但是任何可能包含自引用的类型都不会实现 Move。对于这些未实现 Move 的类型,每当你获取该类型值的引用时,该值就会进入 pinned 类型状态并且不再能被移动。
这个定义在某种程度上有些复杂——人们经常误认为 Move 控制着移动操作本身,但这并非最初的提议——但它在另一方面又很直观:如果你不获取一个值的引用并将其存储起来,你就不可能在这个值中存储自引用。因此,将“进入 pinned 状态的转换”与“获取引用”这两个操作绑定在一起,提供了一种非常直接的安全保证。而且这种检查可以在编译器中自动实现:对于未实现 Move 的类型,一旦它们被引用过,就不允许再移动它们的值,就像你不能在非 Copy 类型的值被移动后再次移动它们一样。这种行为甚至在一个开发分支中被实现了出来。
然而该设计有一个根本的局限性:那就是有时你确实想获取一个稍后会变成自引用的值的引用,但不希望此刻就把它固定在原地。例如,你可能想将其短暂地存储在 Option 中,然后稍后使用 Option::take 将其取走。这可能是最初 Move trait 面临的最显著问题,但当时我们甚至还没进展到真正发现那个问题的地步,因为我们很早就发现,添加 Move 根本不会是一个向后兼容(backward compatible)的变更。
我以前曾写过这方面的内容,但请允许我重申一下。在 Rust 中有两种自动实现的“标记特征(marker traits)”:
Send 和 Sync。? 声明退出(opt out)。目前唯一的例子是 ?Sized。我们一直都很清楚我们不能将 Move 设计成 Auto trait,因为有很多稳定的 API 依赖于“你总是可以从可变引用中移出(move out)值”这一事实。最经典的例子是 mem::swap,它交换两个相同类型值的位置。你绝不能允许去交换未实现 Move 的类型,但现有的 mem::swap 的 API 上并没有 Move 约束,而现在为它强行添加一个新约束将是一个破坏现有代码的变更(breaking change)。
因此,我们当时的设想是,我们需要将 Move 作为一个 ?Trait 引入:?Move。默认情况下,所有的泛型参数都会被隐式假设具有 Move 特征,但如果某个 API 不需要移动参数的能力,它可以向该 API 显式添加一个 T: ?Move 约束。这本身已经不太具有吸引力了:有大量的 API 并不需要移动值,理论上它们都需要加上一个 ?Move 约束,这将导致 Rust 的文档整体上变得更加难以阅读。但真正让整个计划泡汤的,是把 Move 作为 ?Trait 添加上去同样是不向后兼容的。
问题出在**关联类型(associated types)**上:向关联类型添加 ?Trait 约束的地方是在 trait 的定义处。如果一个 trait 的关联类型没有显式声明 ?Trait 约束,那么所有使用该 trait 的代码都允许合法地假定该关联类型实现了这个 trait(即默认实现了 Move)。此外,在现有的 trait 上放宽(relax)约束将是一个破坏性变更,因为完全可能已经有代码依赖了该约束(依赖其必须是 Move)。
下面是一个使用 IntoFuture 的例子,它假设关联的 future 类型具有 Move 的能力:
fn swap_into_future<T: IntoFuture>(into_f1: T, into_f2: T) {
let mut f1 = into_f1.into_future();
let mut f2 = into_f2.into_future();
// 如果你向 trait 中添加 `type IntoFuture: ?Move`,
// 这里的代码就会报错:
mem::swap(&mut f1, &mut f2);
}
这个问题非常普遍,因为许多核心基础操作符都涉及关联类型。例如,你甚至不能让一个指向 ?Move 类型的可变引用去实现 DerefMut,因为指针的 Target 是一个关联类型:
fn swap_derefs<T: DerefMut<Target: Sized>>(mut r1: T, mut r2: T) {
// 如果你向 trait 中添加 `type Target: ?Move`,
// 这里的代码就会报错:
mem::swap(&mut *r1, &mut *r2);
}
函数的返回类型、迭代器的 Item、索引操作符返回的值、算术操作符返回的值等等,全部同理。添加另一个新的 ?Trait 根本无法保持向后兼容,并且我们也无法轻易利用 edition(Rust 的版本机制)来解决这个问题,因为 trait 的接口必须保持一致,只有这样,分别处于不同 edition 下的两个 crate 才能组合在一起工作。
Pin 方案的诞生鉴于上述限制,我们开始沿着完全不同的方向来解决这个问题。我们不再将 pinned 类型状态作为对象类型的一种“被引用即触发”的属性,而是设计了一种新类别的引用,当这种特殊引用被创建时,会将对象强制置于 pinned 类型状态。这就是 Pin 类型的由来。
Pin 是一个包装类型(wrapper type),它可以包装任何种类的指针(包括内置的引用类型和由库定义的像 Box 这样的“智能指针”)。它的含义是:该指针会将其目标对象放入 pinned 类型状态,因此它绝不能再被移动。为了使所需的语言更改尽可能小,我们将它实现为了一个库 API,而不是由编译器强行保证其不可移动性。这意味着,当代码实际上需要修改被固定的对象时,它必须使用 unsafe API 来获取底层的可变引用,并向编译器人工保证对象不会通过那个普通的可变引用被意外移动。
因为对于大多数类型而言,pinned 状态和普通状态之间根本没有本质区别,所以我们添加了 Unpin 这个 auto trait。如果某类型不可能是自引用的(实现了 Unpin),这允许你在不需要 unsafe 代码的情况下,直接从固定的指针中获取普通的可变引用。如果对象实现了 Unpin,将其从 Pin 中移出是绝对安全的。这非常像当初的 Move 思路,但通过将此行为仅仅绑定到“固定的指针(pinned pointers)”上,我们彻底规避了向后兼容性的问题,同时也解决了最初那个“在不固定对象的前提下无法引用 ?Move 对象”的痛点。因为 pinning 仅仅作用于特殊的 pinned 指针,普通的非固定引用在面对未实现 Unpin 的类型时,依然能完美地正常工作。
在 Pin 类型和 pin 模块的官方文档中有更多的细节,多年来,它们已经发展成为对当今 Rust 中 pinning 机制全面而清晰的解释。
当然,Pin 接口最大的优势在于它的加入完全做到了向后兼容。因为所有能够移动被引用数据(如 swap)的 API 都要求传入一个普通的 &mut T 可变引用,一旦你使用 Pin 固定了一个对象,你就再也无法向这些 API 传入该对象了。但由于新的 pinned 类型状态仅适用于这种特殊的 pinned 引用,它不需要对 Rust 语言的其他部分进行任何破坏性的变更。这就是为什么我们最终推进了这个设计:我们可以在不破坏任何现有代码、不违反 Rust 向后兼容性保证的情况下将其加入语言中。
Pin 存在的问题尽管 Pin 以向后兼容的方式满足了我们的需求,但在可用性方面,事实证明它存在几个显著的问题。当用户不得不应对 Pin 时,确实体会到了“复杂度的激增”。但这种复杂度的根本原因是什么呢?
一种理论认为,当初的 Move trait 本应由编译器来强制执行约束,而 Pin 类型则强制要求在固定状态下修改对象时必须使用 unsafe 代码。若使用 Move trait,只需将不会移动对象的修改型 API 标记为 ?Move 即可自动实现安全。在某种程度上这是事实,但我们应该小心不要夸大它。例如,你现在已经可以使用 Pin::set 为被固定的对象安全地赋值。更重要的是,真正需要亲自去修改被固定对象的代码其实非常罕见:通常来说,那都是编译器将你的异步函数降级(lower)为 future 时自动生成的代码,而不是你自己手写的业务逻辑。
另一种理论(由 Yosh Wuyts 提出)认为,Pin 之所以难以使用,是因为它是“有条件的(conditional)”。这在我看来也不是核心问题所在。在 Rust 和编程中,有很多东西都是“有条件的”,但它们都被赞誉为让程序员的生活变得更轻松了。例如,非词法作用域生命周期(NLL)的核心就是让生命周期在条件语句的不同分支中的不同点结束,所有人都认为这让 Rust 变得更容易理解了。也许存在一些命名上的缺陷,使得 Pin(一个类型)和 Unpin(一个 trait)之间的关系让人摸不着头脑,但我认为这依然不是问题的症结。
在我看来,Pin 的核心痛点在于:它被实现为了一个纯粹的库类型,而普通的引用作为语言的一部分内置类型,拥有着大量的语法糖和语言层面的特权支持。
当你处理 pinned 引用时,普通引用所拥有的许多优秀特性都不翼而飞了。这让开发体验变得非常糟糕,更重要的是,它打破了许多用户的心智模型。因为用户是基于“编译器会接受什么代码”来建立对引用行为的直觉和理解的,而一旦换成处理 pinned 引用,原本看着完全一样的逻辑却不再被编译器接受了。
一个非常突出的例子是**再借用(reborrowing)**的概念,正常的可变引用具有该概念,而 pinned 引用却没有。考虑下面这个例子:&mut T 并没有实现 Copy,然而像下面这样连续多次将同一个引用作为参数传递是完全被允许的:
fn incr(x: &mut i32) {
*x += 1;
}
fn incr_twice(x: &mut i32) {
incr(x);
incr(x);
}
绝大多数用户从来没有想过去问“为什么这是合法的?”,但事实上它违反了 Rust 的一个最基本规则:没有实现 Copy 的类型不能被移动超过一次。之所以合法,是因为编译器中存在一种隐式转换机制,叫做“再借用”。在这个过程中,当传递可变引用时,编译器在功能上会自动插入一个“再借用”操作(就好像你写了 &mut *x 而不是单纯的 x 一样),从而重新借用一次该引用,而不是把它 move 进去。
Pin 并没有享受到这种特权遍历,因为它只是一个普通的库类型,并且没有实现 Copy。这意味着当你多次使用 Pin<&mut T> 时,你会遇到一个“移动后使用(use after move)”的报错,有时甚至会引发更加令人费解的生命周期错误。作为替代方案,你必须使用 Pin::as_mut 函数显式地去“再借用” Pin。当用户尝试使用 Pin 时,这种行为差异是造成大量困惑的罪魁祸首。
这样的例子不胜枚举。想想上面提到的 set:向 Pin 赋值是安全的,但是你需要专门调用 set 方法。而对于普通的可变引用,你可以通过解引用(*)和赋值运算符(=)直接赋值。但对 Pin 却不能这样,你必须去学习其专门的 API。存在许多诸如此类的特殊情况,根本原因都在于 Pin 是一个没有获得语言语法层面支持的库类型。
毫无疑问,这个类别中最严重的问题是**固定投影(pinned projections)**问题。
“投影(projection)”是编程语言中对于字段访问的行话:即从一个对象“投影”到该对象的一个内部字段(我猜其字面画面感类似于雨篷从墙壁上“凸出/投影”出来)。结构体固定投影的问题在于:当你持有一个对象的 pinned 引用时,想要获取该对象其中一个内部字段的 pinned 引用,这在安全 Rust 中极具挑战性。
存在一些第三方 crate(比如 pin-project-lite)来解决这个问题,但代价是它们需要用户学习一套包含宏的复杂新 API。这进一步放大了 pinned 引用比普通引用难用得多的事实——仅仅因为它们只是一个库类型。
这里面最糟糕的部分,是固定投影和 Drop trait 之间发生了一种非常不幸的相互冲突。问题的产生是因为 Drop::drop 的签名强制接收的是一个普通的可变引用(&mut self)。试想这样一种场景:你有一个类型,它的某个字段是自引用字段。你对那个字段进行了固定投影并对它进行了轮询(poll)。然后,在析构函数(Drop)执行时,由于你拿到的是 &mut self(一个非固定的可变引用),你可以合法地将该字段 move 出去,将那个 future 固定在栈上,并在那里轮询它。——就这样,你在完全不使用 unsafe 代码的情况下,打破了所有的固定保证(pinning guarantees)。
像 pin-project-lite 这样的 crate 采用的解决方案是,利用宏生成的代码来限制你自己实现 Drop 析构函数的能力。这在实践中勉强可行,但这个事实本身构成了极其庞大的额外复杂性;当你向别人确切地解释固定保证到底是什么时,你不得不把这部分复杂的妥协写入文档中。不幸的是,Drop 的签名在 Pin 出现之前就已经被稳定(stable)下来了,所以我们当时别无选择,只能通过这种痛苦的方式进行变通。