最近这个 Pin 的事情在 Rust 圈里可能是风头正劲,很多人都发文章来讨论这个事。当时决定要用这个 Pin 的人就是 boats,他把 Pin 的来龙去脉做了一个非常系统性的梳理。这篇文章非常热乎,就是这几天写的。原文链接在这里。我们来看它的内容。
我把这篇文章的标题理解为”Pin 大起底”。当然这篇文章他后面还要写一篇文章,讲到了 Pin 类型以及更一般化讲到这个 Pin 的概念。
Pin 是 Rust 的异步编程生态系统的基础构建模块之一。遗憾的是它也是异步 Rust 中最难理解、最容易让人产生误解的部分。其实如果我们看的这个 Pin 够多,用的足够多的话,现在也不认为他是难理解,也不认为它容易产生误解。我认为是它难用,理解是理解的,如果了解的足够多的话,应该不会产生误解,难用才是它比较讨厌的地方。
这篇文章旨在解释 Pin 的作用、它的由来以及目前 Pin 的问题所在。
几个月前我读到了一篇关于 Mojo 公司开发的新语言 Mojo 的博客文章,其中有一段简短的讨论,涉及到了 Rust 中的 Pin。这段话非常简洁地概括了人们普遍对 Pin 的理解:
“Rust 中没有值标识的概念。对于一个指向自身成员变量的自引用结构体,如果对象被移动,那么该数据可能会变得无效,因为它会指向内存中的错误位置。这会带来额外的复杂性,尤其是在异步 Rust 中,当 future 需要自引用并存储状态的时候,你必须用 Pin 来包装这个 self 对象,来保证它不会被移动。在 Mojo 中,对象拥有标识,因此引用像 self.foo 总是会返回内存中的正确位置,而程序员就无需任何额外的工作了。”
这篇文章提到的”标识”让我有些疑惑,因为在文章中或者 Mojo 文档中,没有找到对这个术语的定义。因此我并不清楚 Mojo 公司是如何声称 Mojo 解决了 Pin 要解决的问题。尽管如此,我确实认为这篇博文对 Pin 易用性的批评是中肯的。当用户被迫与 Pin 交互的时候,的确会遇到复杂性暴增的问题。我更倾向于用”复杂性峭壁”来形容这种情况,因为用户会突然发现自己跌落到一个复杂、非惯用 API 的海洋中,而他们对此一无所知。这是一个亟待解决的问题,解决它对 Rust 的用户非常有益。
好,他讲到的这篇文章,因为这篇文章本身有很多人讨论。那讨论讲到他这个关于读到的这篇文章,这篇博客文章简单来说,很多人认为是有很多错误的。而且那篇文章讲的 Mojo 不是一个非常正式或者成熟的语言。
巧合的是,Rust 中这个让人生畏的 Pin 类型,正是我一手造成的。我之所以引入 Pin,是为了让 Rust 支持自引用类型。我有一些想法可以解决 Pin 的复杂性问题,我将在以后的文章中详细阐述。也就是说这篇文章只是他的想法的一个开头,就是未来几天之后,他可能要写另一篇文章去讲怎么改进 Pin,怎么去解决 Pin 的复杂性。不过在继续讨论解决方案之前,我需要尽力高效地解释 Pin 的作用、它诞生的原因,以及它目前为什么难用。其实难用才是 Pin 的最大问题,理解谁不能理解它呢?能理解它。
Pin 类型的必要性
为了解释 Pin 类型的存在,我们需要回到异步 await 功能的最初开发阶段。当时我们要解决的问题是:为了支持异步函数中的引用,我们需要能够将这些引用存储在 future 的内部。问题在于这些引用可能是自引用的,这就意味着它们指向同一个对象的其他字段。
来看下这个例子:
1 | async fn foo(x: &str) -> &str { |
这两种函数 foo 和 bar 都会计算出一个匿名的 future 类型。需要注意的是 async 函数返回的 future 类型会包含一系列的状态,分别对应函数暂停的每一个可能的点:函数开始的时候、每一个 await 表达式的地方,以及函数完成的时候。就有点像状态机一样的里面的东西。
将 foo 函数返回的匿名 future 类型称为 FooTick<’a>,这里的 Tick<’a> 表示参数 x 的生命周期。同样 bar 函数返回的匿名 future 类型标记为 Bar。
那我们问问 Bar 内部会有什么样状态呢?也就是说这个 future 打包成 future 这个东西,它里面是什么样子?
1 | enum Bar { |
这里面需要注意的就是 FooTick 里面它的生命周期中的这个问号。这个问号代表什么生命周期呢?它并不是比 Bar 更长的生命周期,因为 Bar 本身没有生命周期。相反 foo 对象会借用 Bar 中的 s 字段,它们存储在同一个枚举里面。这就是为什么这些 future 类型被称为自引用的,它们包含引用自身其他字段的字段。
需要澄清一点:Pin 的目标并不是允许用户在安全的 Rust 中定义他们自己的自引用类型。现在如果你尝试手动定义 Bar,实际上没有办法安全地构造它的 AfterString 变体。实现这一点是一个有价值的目标,但它跟 Pin 的目标并不相关。Pin 的目标是使编译器从异步函数里面生成的自引用类型,或者像 tokio 这样的 runtime 环境中使用不完全内存连续的自引用类型,能够被安全地操作。
说白了就是要搞这个自引用类型,但是不管自引用类型是怎么定义的,一旦它存在,就会带来问题。假设 Bar 进入到 AfterString 的状态,那么它就包含了对自己 s 字段的引用。如果你移动了这个 Bar,如果你有自引用的 future 你移动了,那么这些引用就会变成悬垂引用,就指向了无效的内存了。而这部分内存可能会被重新利用,另一个值可能占用了这块内存。你这个指针指的内存,这个地方他可能被其他的值占用了,那你跟之前指的就不是一个了,那指错了。
因此一旦 Bar 可能进入 AfterString 状态,就必须保证它不会再被移动了。就是你把你的执行权交回去之后,你这个地方不能动了,因为你还要拿回来,返回的时候要在这个地方。你的指针不能变,你这个东西不能动,一动你的指针就指错了。这是它的问题。
所以这就是我们要解决的问题:我们需要表达这样的需求,也就从某个特定的点开始,对一个对象就不能再被移动了。就我把执行权扔出去之后,我这就不能动了。await 之后就不能动了。但是在 await 之前你随便动,没关系。await 之前随便动,这是你的执行权,扔出去之后,它不能动。
非解决方案:移动构造函数和偏移指针
在继续讨论 Pin 之前,我想花点时间讨论两个经常被提出的解决方案,但最终都无法奏效,至少在 Rust 中不行。这两种方法都跟 Pin 的方法截然不同,他们不是说值能不能再次被移动,而是试图让这个自引用的值仍然可以被移动。这就想替代 Pin 的原因,就它仍能动。解决这个问题。
移动构造函数:
这个想法是每当一个值被移动时,就会执行一些代码。类似于这个值被释放的时候运行的析构函数,就是自动析构函数一样。这种方式,然后这段代码可以修复任何自引用的指针。就是这个自引用指针的,不是说你指的不对吗?他把你矫正搞对了,修复它。就是就这么去干。把它指向新的位置了。你要正确的位置,你走过去就行了嘛。你以前指到错位置,你现在移动的就指到新位置上面去。是这么想的。
我过去曾在关于 Rust 的异步历史的文章中讨论过这一点,但是这并不是一个可行的解决方案。因为在 Rust 中,这些指针可以存在于任何地方,而不仅仅是被移动的值里面。比如说你可以有一个指向你自己状态的指针向量,因此移动构造函数需要能够跟踪到那个向量。它最终需要跟垃圾回收一样的 runtime 内存管理。这对于 Rust 来说是不可行的。Rust 是静态语言,没有 GC。就是为了高效,不要 GC。
移动构造函数不可行的另一个原因是,Rust 在很早的时候就断言它永远不会有移动构造函数。并且存在大量的不安全代码,它们假设可以通过复制内存来移动值。添加移动构造函数会破坏 Rust 的兼容性。
偏移指针:
这种方法的想法是不将自引用编译成普通的引用,而是将它们编译成相对于包含他们自引用对象的地址的偏移量。这行不通,因为在编译的时候没有办法确定引用是否是自引用。你是自引用吗,还不是自引用?不知道。同一个值在不同的分支中都有可能存在。
比如说这里就下面这个例子,对之前 bar 函数的修改版本:
1 | async fn bar(b: bool) -> String { |
当你调用这个 foo 的时候,r 可能是指向同一个对象的指针,也可能指向其他地方的指针。在编译的时候就没法确认这一点。其实就 runtime 的时候你才知道对不对。你需要将引用编译成某种偏移量和引用的枚举,这在我们研究异步 await 时候被认为是不现实的。是不现实的。
好,那我们再看 Pin 的内部结构。
那既然排除了让这些对象可移动的任何选项,那么我们就需要这些对象是不可移动的。但是我们需要澄清确切的要求,因为人们经常对所需的内容做出错误假设。
最重要的是什么?这些对象并不是一直不可移动。不要搞错了。Pin 并不是一直不可以移动。相反就是说它们应该可以在生命周期的某个时间内自由地移动,然后在某个时刻不允许移动。这样的话你可以再将自引用 future 跟其他 future 组合成一个整体的时候移动它。你就可以跟其他自引用的 future 和其他的 future 组合一下,直到最终将它放置到你轮询它存在的位置。你要轮询的时候,它的位置是在哪个地方不能动。
因此我们需要一种方法来表达对象不再允许移动,换句话说就是原地固定。它是原地固定的。但我们就是根据你的要求固定的。说白了就固定到位的。
当我们在这个试验用语表达这一要求的 RFC 时候,Rust 团队就非常好心地去形式化了这个想法。所谓形式化叫什么?在系统性地去做了这个事情。就是让这个不用非正式表达式以后,用哪种方式去实现了这种自动,自动去实现了这种方式。根据模型去做了。
你看在 Rust 的模型中,即使在进行异步 await 工作之前,对象也可以处于两种类型
好的,我会继续整理剩余的内容:
你看在 Rust 的模型中,即使在进行异步 await 工作之前,对象也可以处于两种类型状态之一:它们要么是 owned 的,在这种情况下它可以自由地移动;要么是 shared 的,在这种状态下它们在某个生命周期内不可移动。
为了支持自引用 future 类型,Rust 引入了第三种类型状态,称为 pinned。一旦一个对象进入 pinned 状态,它就永远不能再移动了。更具体地说,它的内存不能在不执行它的析构函数的情况下失效。这个定义还包括一些其他特殊情况,比如在不运行析构函数的情况下不释放内存,但是通过将对象移动到新的位置来使对象的内存失效的主要方式是移动对象。理解 pinned 状态最简单的方法是把它视为要求对象永远不会再次移动。
关于 pinned 状态的另一个事实是,对于大多数类型来说,它完全无关紧要。如果某个类型的值永远不包含任何自引用,那么 pinning 它就毫无作用。因此对于大多数类型对象,我们希望类型能够选择不要 pinned 状态,以便在需要的时候能够再次移动它。我们希望类型能够选择,不要对于那些感兴趣的人,可以在 Rust 的博客上找到关于 Rust 的正式模型中这个 pinned 状态的更详细的描述。
但是在理解了 Pin 的要求之后,我们面临的问题是怎样在 Rust 的表层语言中表示对象进入 pinned 状态。Rust 的模型描述了语言的语义,但没有指定面向用户的 API 或者语法。我们最终得到的是 Pin 类型,但这并不是我们尝试的第一个解决方案。
所以他在决定用 Pin 之前,他尝试了 Move 特征。在尝试 Pin 之前,我们尝试了一种基于名为 Move 的新特征的解决方案。这个想法是大多数类型都会实现 Move,并且它们本身不会改变,但是任何可以包含自引用的类型都不会实现 Move。对于这些没有实现 Move 的类型,只要你引用这个类型的某个值,这个值就会进入 pinned 状态,并且不再可以移动。
这个定义既有点复杂,人们通常认为 Move 完全控制移动,但这并不是最初的提案。也有一些直观之处,你不可能在值中存储一个自引用而不引用这个值进行存储。因此将转换为 pinned 状态与获取引用联系起来,提供了一个简单的安全保障,并且编译器可以自动实现此检查。对于没有实现 Move 的类型,在引用它们之后不允许移动这些类型的值,就像在移动非 Copy 类型值之后不允许移动它们一样。这种行为甚至在一个分支中都实现了。
这个设计有一个根本性的限制,那就是有时候你想引用一个稍后会成为自引用的值,不需要把它固定到那个位置或者叫原地固定。如果你可能希望把它短暂地存储在 Option 中,然后使用 Option::take 把它取出。这可能是原始 Move 特征最重大的问题,但我们甚至还没有真正看出这个问题,因为我们很早就发现添加 Move 是不相互兼容的更改,就是它不能相互兼容。
我以前写过这个,但让我重申一下,Rust 中有两种自动实现的标记特征。一个自动特征是什么呢?如果类型的所有字段都实现了这个特征,那就会自动为类型实现这些特征。主要例子包括 Send 和 Sync。那 ?特征是什么呢?如果类型的所有字段都实现了这些特征,并且泛型参数也假定实现了这些特征,就会自动为这个类型实现这些特征。唯一的实例是什么?是 ?Sized。
我们一直都知道,我们不能将 Move 作为一个自动特征,因为有一些稳定的 API 依赖于你可以始终移出可变引用这一事实。经典例子是什么?是 mem::swap,它交换相同类型两个值的位置。你不能允许交换不实现 Move 的类型,但是这个 API 上没有 Move 绑定,并且向它添加新绑定会破坏兼容性。
因此我们假设我们需要将 Move 添加为一个 ?特征,就是 ?Move。默认情况下所有泛型都将它假设为 Move,但是如果 API 不需要移动参数的功能,可以向 API 添加一个 T: ?Move 绑定。这并不很吸引人,许多 API 不需要它可移动的,并且可能获得一个 ?Move 绑定。这使得整个 Rust 的文档更难理解。
但是整个计划都因为将 Move 添加为 ?特征也不是相互兼容而失败了。就这两种方式都不是相互兼容的。问题在于什么?关联类型添加 ?特征绑定会是在特征的定义的地方。如果某个特征的关联类型没有 ?Move 约束,那么所有使用这个特征的代码都可以假设关联类型实现了这个特征。更重要的是什么,放松现有特征上的约束会是一个破坏性的改动,因为允许存在依赖于该约束的代码。
这里通过使用 IntoFuture 来举例,它假设关联的 Future 类型具有实现 Move 这个特征的类型的行为。IntoFuture: ?Move 的话,这个问题很普遍,因为许多基本运算符都涉及关联类型。比如你甚至不能让一个可变引用指向一个 ?Move 类型来实现 DerefMut,因为指针的目标是关联类型。函数的返回类型、迭代器的元素、所有运算符返回的值、算术运算符返回的值等等都适用。
同样道理,添加另一个 ?特征根本不是相互兼容的,并且版本也不能轻易地用来解决这个问题,因为必须保持特征的接口一样,以便在不同版本的 crate 可以结合在一起。可以组合在一块才行。
Pin 的类型
鉴于这个限制,我们着手从完全不同的方向解决这个问题。与其将 pinned 的类型状态作为对象类型的属性,使它在被引用的时候进入这个状态,我们设计了一种新的引用类别,在创建引用的时候将对象置于固定的类型状态。只用 Pin 类型来表示。
Pin 是一个包装器类型,可以包装任何类型的指针,既可以是内置的引用类型,也可以是像 Box 这样的库定义的智能指针。这就意味着该指针会把它的目标放入固定的类型状态,因此它永远不能再被移动。
为了尽可能减少必要的更改,我们把它作为库的 API 实现。其实你看把它作为库的 API 实现之前,有一个就是写这个 C++ 接口的这个人对吧,就对他进行吐槽了。因为他没有放在这个标准库里面,而是作为一个库 API 的事情。而不是让编译器强制执行不可移动性。他只是为了后面的这种灵活性,但是对于这个写这个 FFI 的,就很难受。
这就意味着当代码实际上需要修改被这个 pinned 的对象的时候,它必须使用一个不安全的 API 来访问它,并保证对象不会通过普通的可变引用被移动。由于大多数类型在固定的类型状态和普通类型状态之间没有实质性的区别,因此添加了 Unpin 这种特征。这允许类型在不是自引用时,从 pinned 的指针获取可变引用,而不需要使用不安全代码。
如果对象实现了 Unpin,那么将它移出 Pin 是完全安全的。这类似于 Move,但是通过将这种行为只绑定到 pinned 的指针上,我们避免了相互兼容性问题,以及最初无法引入一个 ?Move 对象而不是将它固定到位的问题。因为 Pin 只适用于固定的指针,所以普通的非固定引用仍然可以很好地跟不是 Unpin 的类型工作。
Pin 类型和 pin 模块的文档中还有更多细节。这些文档经过多年发展,已经成为当今关于 Pin 存在全面清晰的解释。
当然 Pin 接口最大优势在于它是相互兼容的。因为所有可以移动有根数据(比如说 swap)的 API 都需要一个可变引用,所以一旦你用 Pin 固定那个对象,你就不能再对这个对象调用这个 API 了。但是因为新的固定类型状态只适用于特殊的固定引用,所以它不需要破坏 Rust 语言其他部分。这就是我们选择这种设计的原因。它可以在不破坏任何现有代码的情况下添加,并且不会违背 Rust 的向后兼容性保证。所以说这是他原因。
Pin 的问题
尽管 Pin 以相互兼容的方式满足了我们的需求,但事实证明它在可用性方面存在几个问题。当用户需要处理 Pin 时,它确实会带来复杂性激增。但是这种复杂性的原因是什么呢?
其实 Pin 解决了向后兼容,但是它没有解决向前的复杂性。我这跟之前是兼容的,但是你考虑未来了吗?未来他就比较难受,用起来难受。这是它的矛盾点。
一种理论认为,Pin 的问题在于 Move 特征可以由编译器自动强制执行,而 Pin 特征却需要使用不安全代码来修改固定对象。对于 Move 特征,通过将不移动对象的变异 API 标记为 ?Move,可以自动启用这项功能。在某种程度上这是正确的,但我们不要夸大其词。比如你可以用 Clone 对一个固定的对象进行复制,这是完全安全的。实际上需要修改固定对象的情况很少见,通常这是编译器在把你的异步函数转换为 future 的时候生成的代码,而不是你自己编写的代码。其实他已经给我们这个开发者做了这个很多的一个工作了,它自动生成的不是你编的,不是你手动去搞的。
另一种理论,就是我们之前讲的这个 Rust RFC 那个之前写的那个博客什么的,那个链接里面提出的,认为 Pin 难以使用的原因是它是条件性的。这也不是我认为的问题所在。Rust 和编程中的许多东西都是条件性的,都被设计为简化了程序员的生活。比如非词法生命周期,都是为了让生命周期在条件不同的分支中以不同的点结束。每个人都认为这就使得 Rust 更易于理解了。也许存在一些命名问题,使人们难以理解 Pin 和 Unpin 之间的关系,但我认为这不是问题的核心。
在我看来,Pin 的问题在于什么?它作为一个纯的库类型实现,而普通的引用类型则拥有大量的语法糖和作为语言内置类型一部分的支持。你看这就是其实之前那个文章吐槽的是对的,就是你没有给他,你就是永远都是一个临时的跟你打招呼的这么个事儿。你没有得到大量的语法糖和内置类型的这种支持,这才是难受的地方。
当你处理 Pin 的引用的时候,我们讲到了之前那篇文章,其实有三种引用。这个 Pin 的引用的时候,许多引用具有的优点就会消失。这就使得体验变得更糟。更重要的是打破了许多用户的思维模型,因为他们已经基于编译器接收的内容建立了对引用行为的理解。就是处理 Pin 的引用,类似的代码都会被拒绝。就说你对引用的这种理解,比如说我指到的就会怎样,如果是 owned 的就会怎样,这是我们都已经理解了,但是你这 Pin 的上面不适用。
那看一个非常突出的例子是什么?再次借用(reborrow)的概念。普通的可变引用具有这种概念,而固定的引用就没有。你比如这个例子:
1 | fn foo(x: &mut T) { |
就是 &mut T 引用 &mut T 没有实现 Copy,但是连续多次把
原文链接: https://dashen.tech/2018/07/25/Rust-Pin大起底/
版权声明: 转载请注明出处.