likes
comments
collection
share

Rust的Pin类型是用来做什么的,以及为什么它很难使用

作者站长头像
站长
· 阅读数 20

本文翻译自 without.boats/blog/pin/

Rust相关问题可以咨询xingzixi2

Pin 类型(以及一般的固定概念)是Rust Pandoc生态系统的其余部分所依赖的基础构建块。不幸的是,它也是最难访问和最容易被误解的Rust元素之一。这篇文章旨在解释 Pin 实现了什么,它是如何实现的,以及 Pin 当前的问题是什么。

几个月前,Modular公司的博客上有一篇有趣的文章,该公司正在开发一种名为Mojo的新语言。在Rust中讨论 Pin 的简短部分中,我发现它非常简洁地捕捉到了该主题公开讨论的时代精神:

在Rust中,没有价值认同的概念。对于指向自己成员的自引用结构,如果对象移动,则该数据可能会变得无效,因为它将指向内存中的旧位置。这造成了复杂性的高峰,特别是在future需要自我引用和存储状态的部分,所以你必须用Pin包装Self以保证它不会移动。在Mojo中,对象有一个标识,因此引用self.foo将始终返回内存中的正确位置,而不会对程序员造成任何额外的复杂性。

这些评论的某些方面让我感到困惑。术语“值标识”在本文中没有任何定义,在Mojo的文档中也找不到,所以我不清楚Modular如何声称Mojo解决了 Pin 要解决的问题。尽管如此,我确实认为对 Pin 可用性的批评是很好的:当用户被迫与它交互时,确实会出现“复杂性峰值”。我会使用的短语实际上是“复杂性悬崖”,就像用户突然发现自己从悬崖上摔下,掉进了一片他们不理解的复杂、不规范的API的海洋中。这是一个问题,如果问题得到解决,对Rust用户来说将是非常有价值的。

碰巧的是,Rust的这个小角落是我的烂摊子;向Rust添加 Pin 以支持自引用类型是我的主意。我对如何解决这种复杂性峰值有一些想法,我将在随后的帖子中详细阐述。在我到达那里之前,我需要首先尝试解释,尽可能有效地解释 Pin 实现了什么,它是如何存在的,以及为什么它目前很难使用。

Requirements 要求

为了解释 Pin 存在的原因,我们需要回到最初的开发阶段。我们试图解决的问题是,为了支持BRAC函数中的引用,我们需要能够将这些引用存储在 Future 中。问题是这些引用可能是自引用,这意味着它们指向同一对象的其他字段。

Consider this toy example:考虑这个玩具的例子:

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
}

这两个函数都计算为匿名的future类型;一个future类型的函数计算为每个可能的步骤,它可以暂停的状态:当它开始时,每个等待点,当它完成。

出于我们示例的目的,我们将调用 foo 评估为 Foo<'a> 的匿名未来( 'a 是 z 参数的生命周期)和 bar 评估为 Bar 的匿名未来。让我们问问自己, Bar 的内部状态是什么?就像这样:

enum Bar {
    // When it starts, it contains only its arguments
    Start { x: i32, y: i32 },

    // At the first await, it must contain `z` and the `Foo` future
    // that references `z`
    FirstAwait { z: i32, foo: Foo<'?> }

    // When its finished it needs no data
    Complete,
}

注意 Foo<'_> 未来的生命周期中的 '? :那可能是什么生命周期?它不是比 Bar 更长寿的一生, Bar 没有一生。 Foo 对象借用了 Bar 的 z 字段,该字段被存储在同一个结构体中的沿着。这就是为什么这些未来类型被称为“自引用“:它们包含引用自身其他字段的字段。

在这里我们必须明确区分: Pin 的目标不是允许用户在安全的Rust中定义自己的自引用类型。今天,如果你试图手工定义 Bar ,真的没有安全的方法来构造它的 FirstAwait 变体。实现这一目标将是一个有价值的目标,但它与 Pin 的目标是正交的。 Pin 的目标是使操作自引用类型变得安全,这些自引用类型由编译器从编译器中生成,或者在运行时(如时雄)中使用不安全的代码实现。

然而,自引用类型已经定义,一旦它存在,它就会出现问题。假设 Bar 已被置于 FirstAwait 状态,因此它包含对自己的 z 字段的引用。如果您要移动 Bar ,那么这些引用现在将悬挂并指向死内存,这些内存可能会重新用于不同的值。因此,重要的是,一旦可以将Bar置于 FirstAwait 状态,它就不会再次移动。在开发 Pin 之前,Rust中的任何对象都可以被移动,如果你拥有它的所有权,或者即使你有一个可变的引用。所以这是我们需要解决的问题:我们需要表达一个要求,即从某个点开始,对象不能被移动。

非解决方案:移动构造函数和偏移指针

在我们继续之前,我想花点时间讨论一下这个问题的两种解决方案,它们经常被提出,但不起作用(至少在Rust中)。这两种方法都与 Pin 采取的方法截然不同:它们不是说值不能再次移动,而是试图使自引用值最终可以移动。

第一个是move构造函数。其思想是,每当移动值时,您将运行一些代码,类似于在删除值时运行的析构函数。然后,这段代码可以“修复”任何自引用指针,使它们指向新的位置。我在过去的文章中讨论过这个问题,但这不是一个可行的解决方案,因为在Rust中,这些指针可以存在于任何地方,而不仅仅是“内部”被移动的值。例如,你可以有一个指向你自己状态的指针向量,所以move构造函数需要能够跟踪到这个向量。它最终需要与垃圾收集相同的运行时内存管理,这对Rust来说是不可行的。

移动构造函数不起作用的另一个原因是Rust很早就确认它永远不会有移动构造函数,并且存在许多不安全的代码,这些代码假设可以通过复制内存来移动值。添加move构造函数将是Rust的一个突破性变化。

另一个有时被提出的非解决方案是偏移指针。这种情况下的想法是,不是将自引用编译为普通引用,而是将它们编译为相对于包含它们的自引用对象的地址的偏移量。这不起作用,因为在编译时不可能确定引用是否是自引用:同一个值可能在不同的分支中。例如,下面是之前 bar 的修改版本:

async fn bar(x: i32, y: i32mut z: &mut i32) {
    let mut z2 = x + y;
    if random() {
        z = &mut z2;
    }
    foo(z).await;
}

当你调用 foo 时, z 可能是指向同一个对象的指针,也可能是指向其他地方的指针。在编译时无法确定。您需要编译对偏移量和引用的某种枚举的引用;当我们在使用codec/await时,这被认为是不切实际的。

The “pinned typestate” “固定类型状态”

在排除了使这些物体可移动的任何选择之后,我们因此要求该物体不可移动。但是我们需要明确的是需求是什么,因为人们经常对需求做出错误的假设。

最重要的是,这些对象并不意味着总是不可移动的。相反,它们应该在生命周期的某段时间内自由移动,并且在某个点上,它们应该停止移动。这样,你可以移动一个自我参照的未来,当你将它与其他未来组合在一起时,直到最终你把它放在它将存在的地方,只要你轮询它。所以我们需要一种方法来表达一个对象不再允许移动;换句话说,它是“固定在适当的位置”。

当我们尝试使用API来表达这个需求时,Ralf Jung非常友好地将这个想法形式化。在Ralf的模型中,即使在使用Warehouse/await之前,对象也可以处于两种“类型状态”之一:它们是“拥有的”,在这种状态下它们可以自由移动,或者它们是“共享的”,在这种状态下它们在某个生命周期内不能移动(因为它们有指向它们的引用)。为了支持自引用的未来类型,Ralf的模型获得了第三种类型状态,称为“pinned”。

一旦对象进入固定类型状态,它就永远无法再次移动。更具体地说,如果不先执行其析构函数,就不能使其内存无效。这个定义还包括一些其他的边缘情况,比如释放内存而不运行析构函数,但是在不运行析构函数的情况下使对象的内存无效的主要方法是将对象移动到一个新的位置。理解固定类型状态的最简单方法是将其视为要求对象永远不会再次移动。

关于固定类型状态的另一个事实是,对于大多数类型来说,它是完全不相关的。如果type的值永远不能包含任何自引用,则固定它是无用的。因此对于大多数类型的对象,人们希望类型能够选择退出进入固定的类型状态,以便您可以在需要时再次移动它们。

在Ralf的博客上有关于Rust的正式模型中固定类型状态的更详细的描述,供感兴趣的人参考。但是,在理解了固定的要求(首先是非正式的,然后是Ralf正式的)之后,我们遇到了一个问题,那就是找到一种最好的方式来表示一个在Rust的表面语言中进入固定类型状态的对象。Ralf的模型描述了语言的语义,但没有指定面向用户的API或语法。我们最终得到的解决方案是 Pin 类型,但这不是我们尝试的第一个解决方案。

?Move

在尝试 Pin 之前,我们尝试了一个基于新特性的解决方案,我们称之为 Move 。这个想法是大多数类型将实现 Move ,并且它们不会改变,但是任何可以包含自引用的类型都不会实现 Move 。对于这些没有实现 Move 的类型,每当你引用该类型的值时,该值就进入固定的类型状态,并且不再可以移动。

这个定义同时也有些复杂--人们经常假设 Move 控件在移动,这不是最初的提议--而且在其他方面也有些直观--你不可能在不引用该值的情况下将自引用存储在值中,所以将转换到固定的类型状态与引用的引用联系起来提供了一个简单的安全保证。对此的检查可以在编译器中自动实现:对于不实现 Move 的类型,禁止在引用这些类型后移动它们的值,就像禁止在移动非 Copy 类型后移动它们的值一样。这种行为甚至在分支中实现。

这种设计有一个基本的限制,那就是有时候你想引用一个值,而这个值以后将是自引用的,而不需要将它固定在适当的位置。例如,您可能想将其暂时存储在 Option 中,然后使用 Option::take 将其带走。这可能是最初的 Move trait最重要的问题,但我们甚至没有真正发现这个问题,因为我们很早就发现添加 Move 不会是一个向后兼容的更改。

我以前写过这方面的文章,但让我重申一下。Rust中有两种自动实现的“标记特征”:

  1. 1. 自动特征:如果类型的所有字段都实现了这些特征,则这些特征会自动为类型实现。主要的例子包括 Send 和 Sync 。
  2. 2. 特性:如果类型的所有字段都实现了它们,则这些字段将自动为类型实现,并且假定泛型参数也实现了它们,除非它们显式地选择退出。唯一的例子是 ?Sized 。

我们沿着都知道我们不能让 Move 成为一个自动trait,因为有一些稳定的API依赖于这样一个事实,即你总是可以移出一个可变的引用。这方面的经典示例是mem::swap,它交换同一类型的两个值的位置。你不能允许交换没有实现 Move 的类型,但是在那个API上没有 Move 绑定,向它添加一个新的绑定将是一个突破性的变化。

因此,我们的假设是,我们需要添加 Move 作为a?属性: ?Move 。默认情况下,所有泛型都将被假定为 Move ,但如果API不需要移动参数的能力,则可以将 T: ?Move 绑定添加到API。这已经不是很吸引人了:很多API不需要值是可移动的,并且可能会获得 ?Move 绑定,使得Rust文档更难全面理解。但是整个计划都失败了,因为增加了5#作为一个?Trait也不是向后兼容的。

问题在于关联类型:添加的位置?关联类型的Trait边界位于trait的定义位置。如果trait的关联类型没有?Trait绑定,所有使用该trait的代码都可以假设关联类型实现了该trait。此外,放宽现有trait的限制将是一个突破性的变化,因为依赖于该限制的代码是允许存在的

下面是一个使用 IntoFuture 的例子,它假设关联的未来类型具有实现 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();
    // This would become an error if you add
    // `type IntoFuture: ?Move` to the trait:
    mem::swap(&mut f1, &mut f2);
}

这个问题很普遍,因为许多基本运算符都涉及关联类型。例如,你甚至不能有一个可变的引用到一个 ?Move 类型实现 DerefMut ,因为指针的目标是一个关联类型:

fn swap_derefs<T: DerefMut<Target: Sized>>(mut r1: T, mut r2: T) {
    // This would become an error if you add
    // `type Target: ?Move` to the trait:
    mem::swap(&mut *r1, &mut *r2);
}

函数的返回类型、迭代器的项、索引运算符返回的值、算术运算符返回的值等等也是如此。Trait和一个版本不能很容易地用来解决这个问题,因为trait的接口必须保持相同,这样不同版本的两个crate才能组合在一起。

Pin

鉴于这种局限性,我们开始从一个完全不同的方向解决问题。我们设计了一个新的引用类别,当引用被创建时,它将对象置于固定的类型状态,而不是使固定的类型状态成为对象类型的属性,每当引用被引用时,它就进入对象类型。这用 Pin 类型表示。

Pin 是一个包装器类型,可以包装任何类型的指针(包括内置的引用类型和库定义的“智能指针”,如 Box )。这意味着指针将其目标置于固定的类型状态,因此它永远不会再被移动。为了尽可能减少必要的更改,我们将其实现为库API,而不是由编译器强制执行不可移动性。这意味着当代码实际上需要改变被固定的对象时,它必须使用不安全的API来访问它,并保证对象不会通过普通的可变引用移动。

由于大多数类型在固定类型状态和正常类型之间没有有意义的差异,因此添加了 Unpin auto trait。这允许在类型不能自引用的情况下从固定指针获取可变引用,而无需不安全代码。如果对象实现了 Unpin ,那么将对象移出 Pin 是完全安全的。这很像 Move ,但通过将此行为仅绑定到固定指针,我们避免了向后兼容性问题以及原始问题,即如果不将其固定到位,则无法引用 ?Move 对象。因为固定只适用于固定的指针,普通的非固定引用仍然可以很好地处理不是 Unpin 的类型。

在Pin类型和pin模块的文档中有更多的细节,这些年来已经发展成为一个全面而清晰的解释,因为它存在于Rust中。

当然, Pin 接口的最大优点是它可以向后兼容添加。因为所有可以移动引用数据的API(如 swap )都需要一个可变引用,一旦你用 Pin 固定了一个对象,你就不能再在这个对象上调用它们了。但是因为新的pinned typestate只适用于特殊的pinned引用,所以它不需要对Rust语言的其余部分进行破坏性的更改。这就是为什么我们继续使用这种设计:可以在不破坏任何现有代码和违反Rust向后兼容性保证的情况下添加。

The problems with Pin``Pin 的问题

尽管以向后兼容的方式满足了我们的要求,但 Pin 在可用性方面存在一些问题。当用户必须处理 Pin 时,这确实是一个“复杂性峰值”。但这种复杂性的原因是什么呢?

一种理论认为,问题在于,尽管 Move trait是由编译器强制执行的,但 Pin trait需要不安全的代码来在对象被固定时改变对象。对于 Move trait,这是通过标记不移动对象 ?Move 的可变API自动启用的。在某种程度上这是正确的,但我们应该小心不要夸大它。例如,你已经可以使用Pin::set赋值给一个固定的对象,这是完全安全的。实际上需要改变固定对象的代码很少:一般来说,这是编译器在将您的pixed c函数降低到未来时生成的代码,而不是您自己编写的代码。

另一个理论(由Yosh Wuyts在这里提出)是 Pin 难以使用的原因是它的“条件”。我也不觉得这是个问题。Rust和编程中的很多东西都是“有条件的”,但被称赞为使程序员的生活更轻松。例如,非词汇生命周期都是关于使生命周期在条件的不同分支中的不同点结束,每个人都认为这使Rust更容易理解。也许有一些命名的问题,使它很难理解之间的关系 Pin (类型)和 Unpin (性状),但我不认为这是问题的核心。

在我看来, Pin 的问题在于,它是作为一个纯库类型实现的,而普通的引用类型有大量的语法糖和支持作为语言的一部分的内置类型。当你处理固定引用时,引用的很多好的特性都消失了。这使得体验变得更糟,更重要的是打破了许多用户的心理模型,因为他们已经建立了一个基于编译器接受的引用行为的理解,一旦你处理固定引用,类似的代码就不再被接受。

一个非常突出的例子是“reborrowing”的概念,��通的可变引用具有这种概念,而固定引用则没有。考虑一下: &mut T 并没有实现 Copy ,但它完全允许在一行中多次将其作为参数传递,就像这样:

fn incr(x: &mut i32) {
    *x += 1;
}

fn incr_twice(x: &mut i32) {
    incr(x);
    incr(x);
}

大多数用户从来没有想过为什么这是允许的,但事实上它违反了Rust的一个基本规则:不实现 Copy 的类型不能被移动一次以上。原因是编译器中有一个隐式强制,称为“reborrowing”,当使用可变引用时,它会在功能上插入一个“reborrow”(就好像你写了 &mut *x 而不是 x ),再次借用引用,而不是移动它。

Pin.Pin 没有这个功能,因为它是一个普通的库类型,不实现 Copy 。这意味着当你不止一次使用 Pin<&mut T> 时,你会得到一个关于在移动后使用该值的错误,有时甚至是一个关于生命周期的更难以理解的错误。相反,您必须使用Pin::as_mut函数显式地重新借用 Pin 。这种差异是当用户尝试使用 Pin 时产生大量混淆的原因。

考虑上面提到的 set 的例子:它可以安全地分配给 Pin ,但是你需要使用 set 方法。您可以使用解引用和赋值操作符来赋值给可变引用。但 Pin 不是这样的,你需要学习特殊的API。存在许多这样的特殊情况,这是因为 Pin 是一种在语言语法中没有支持的库类型。

毫无疑问,这类问题中最严重的是“固定预测”问题。“projection”是一个花哨的编程语言术语,用于字段访问:从一个对象“projecting”到该对象的字段(我猜在同一意义上,一个遮阳篷可能会从墙上伸出)。固定投影的问题在于,获取对对象的固定引用并获得对该对象的字段的固定引用是具有挑战性的。有像pin-project-lite这样的第三方crate来解决这个问题,但它们需要学习一个复杂的新API,包括宏,强调固定引用比普通引用更难使用,因为它们是一个库类型。

最糟糕的部分是固定投射和 Drop 特质之间非常不幸的交互。问题的出现是因为 Drop::drop 采用了一个普通的可变引用。考虑这种可能性:你有一个类型,它有一个自引用字段。你将project固定到那个字段并轮询它,然后,在析构函数中,你移出那个字段(因为你现在有一个未固定的可变引用),将那个future固定到堆栈,并在那里轮询它。你刚刚违反了固定担保。

像pin-project-lite这样的crates使用的解决方案是限制您定义析构函数的能力。这在实践中是可行的,但这一事实是额外的复杂性,在解释确切的固定保证是什么时必须记录下来。不幸的是, Drop 在 Pin 之前是稳定的,所以我们不得不围绕它工作。

In my next post… 在我的下一篇文章中…

尽管存在这些可用性问题, Pin 是我在Rust工作中最自豪的成就。我们允许用户将包含任意引用的javascript函数安全地编译为自引用对象;如果没有这个javascript/await,它就不会像现在这样可用,因为引用是用户编写Rust的基本部分。我们这样做的方式是完全向后兼容已经存在的语言。 Pin 现在是高性能网络服务和其他异步编程用例的繁荣生态系统的基本组成部分。

Pin 代表了一个复杂性悬崖,使用固定引用比使用普通引用要困难得多。这就是为什么几天后我将转向如何改进 Pin 的主题。关键的概念是固定位置的概念。

转载自:https://juejin.cn/post/7394789388143067145
评论
请登录