Valen 的内存安全:一种新型借用检查

Evan Ovadia 讲解 group borrowing,一种针对 Valen 的借用检查设计,它允许对同一对象不同部分的引用共存,并解释它如何将 shared-xor-mutable 放宽为 no use-after-free。

中文
复制

会记住自己指向何处的引用!

这篇文章讲的是 Valen 新的灵活借用检查器。欢迎阅读!

你们都知道我,我_热爱_探索新的内存安全方案,再用各种奇怪的方式把它们揉到一起。

这是我永恒追求的一部分:寻找内存安全的圣杯——既有 Rust 借用检查那样的威力,又像引用计数或垃圾回收那样简单灵活。12

我一直怀疑,要找到圣杯,必须先解决三个内存安全难题:

  • 能不能做出一个_更宽松_的借用检查器?3
  • 能不能让借用检查器读懂_更复杂的数据?_45
  • 能不能让这两套系统同时协作?

说得含糊又玄乎,我知道!后面我会解释这些到底是什么意思,以及 Valen 可能已经找到了解法。6

本文主要聚焦第一块:Valen 的更宽松的借用检查器。

所以这篇文章会讲它是什么、能做什么、怎么运作。

为了清晰起见,本文对语法糖做了一些自由发挥:自动解引用、直接索引结构体(my_vec[0])、从集合推断类型(world.entities[] 中的 entity)都会在十一月到来。除了这些语法调整之外,借用检查器对我们讨论的一切都适用。7

顺带,我还会讲一些更古怪的细节,比如:

  • Valen 的借用检查器为什么长成这个样子,才能让 Valen 代码调用 Rust 代码。
  • 编译器如何记住一个引用指向_哪里_。
  • 为什么 Valen 只有_一种_借用引用,而其他借用检查器有两种。

另外,这篇文章_长得离谱_,背景信息也很多,所以我会在可以跳过的地方提醒你。

开始吧!

组借用:一次擦肩而过,潜力巨大

(这只是我很爱讲的背景故事,可以跳过这一节!)

Valen 的做法建立在“组借用”(Group Borrowing)提案之上,它由我的朋友 Nick Smith 设计。

它的核心概念是编译器会记住引用指向哪里(后面我会解释),并据此检查程序是否内存安全。

这套设计起步艰难,差点被时间的洪流埋没。它最初是为 Mojo 设计的,但遗憾的是,尽管我竭尽全力,也没能说服高层采用它。8 分组借用还没机会成功,就已经死了。

但我知道它有巨大的潜力,只要能设法问世。

于是我们花了几个月打磨解释、梳理它的优势,并在去年写了一篇文章9,希望其他语言能采纳它。

它们确实采纳了!它传播得很广。10

这些语言大多在围绕同一类挑战打转:如何做出更灵活的借用检查器。

例如,Valen 想做出一个足够灵活的借用检查器,能与引用计数和分代引用配合;Carbon 想做出一个足够灵活的借用检查器,能与 C++ 代码配合。

这很难,因为借用检查与其他方法之间一直存在根本性冲突。可以这样概括:14

  1. 借用检查有“共享异或可变”限制:如果你持有某个对象的引用,其他人就不能修改这个对象。
  2. 其他方法则希望允许持有某个对象的引用,同时让其他人修改它。1516

你大概在想:“这肯定解决不了。”

看起来确实如此!

但 group borrowing 实际上解决了这个冲突,它把“shared-xor-mutable”放宽为“no use-after-free”。

这话很晦涩,现在看毫无意义,但等我解释清楚 group borrowing 的工作原理,你就明白了。

所以先看看 group borrowing 能做什么,然后我再解释它是怎么工作的。

group borrowing 能做什么

Group borrowing 让程序在零运行时开销的前提下获得内存安全。17

它在编译期捕获 use-after-free 问题,而且限制比之前的方案更少。

下面是一个编译期捕获 use-after-free 错误的例子:

valen

struct Entity {
  hp int;
}
struct World {
  entities Vec<Entity>;
}
func main() int {
  let world = World(Vec<Entity>.new());

  world.entities.push(Entity(42));
  let first_ref = &world.entities[0];

  world.entities = Vec<Entity>.new();

  // Compile error: Used a borrow after invalidated
  return first_ref.hp;
}

这种方案灵活得不同寻常。通常编译器处理不了下面这段程序,但这个方案能看出它是安全的:18

valen

func main() int {
  let list = Vec<int>.new();

  // Make two refs to the list
  let ref_a = &list;
  let ref_b = &list;

  // Mutate the list through both refs
  ref_a.push(42);
  ref_b.push(73);

  return 42;
}

Group borrowing 接受很多对 borrow checker 来说通常很难处理的模式,比如:

  • 多个局部变量指向同一个对象,并通过其中任意一个写入(如上例)。
  • 一个参数指向某个对象,另一个参数指向该对象_内部_的某处,只通过后者写入(下一节会看到)。
  • 多个函数参数指向同一个对象,并通过其中任意一个写入。19
  • 做一个“回滚”结构体,在离开作用域时修改某个对象,类似 ScopeGuard 模式。2021
  • 多个结构体持有对同一个子系统的可变引用。22
  • 还有更多,我会在下面进一步说明。23

这些都是我们在 C++ 里喜欢的模式,但还没有哪门语言能在编译期以内存安全的方式全部实现。

旁注

(有趣的题外想法)

一个更有意思的例子

(或者,跳到工作原理)

例如,下面这个 step 函数接收一个 World 引用,以及指向其中某个 entity 的引用。

关键部分在于 world 的 entities[] 中那个带 mut 的实体,下面我会解释。

valen

struct World {
  entities Vec<Entity>;
}
func step(world &World, entity in world.entities[] mut) {
  entity.advance();
  let collision = world.get_collision_for_entity(entity);
  entity.resolve(collision);
}

注意这几处:

  • in 表示“对其中某一个的引用”。
  • world.entities[] 表示“world 的 entities 的元素”。
  • 那里的 mut 表示“我们可以修改它”。2425

合起来就是:entity 是对 world 的 entities 中某一个元素的引用,而且我们可以修改它。

这就是这套方法的核心概念:编译器知道引用指向哪里,26 因为用户通过 in 指定了它指向哪里。

接下来看看它在其他语言里是什么样子,就能明白这有多好。(或者直接跳到它是如何工作的!)

先看 C++ 的等价写法:

c++

struct World {
  vector<Entity> entities;
};
void step(const World& world, Entity& entity) {
  entity.advance();
  auto collision = world.getCollisionForEntity(entity);
  entity.resolve(collision);
}

当然,上面的代码需要小心维护内存安全,因为 C++ 不会在编译期检查内存安全。

再看看 Rust 的等价写法,这里我们_确实_能获得自动的内存安全:27

rs

struct World {
  entities: Vec<Entity>,
}
fn step(world: &mut World, entity_id: usize) {
  let entity_mut = &mut world.entities[entity_id]; // bounds check, possible panic
  entity_mut.advance();
  let entity_read = &world.entities[entity_id]; // bounds check, possible panic
  let collision = world.get_collision_for_entity(entity_read);
  let entity_mut = &mut world.entities[entity_id]; // bounds check, possible panic
  entity_mut.resolve(collision);
}

Rust 用起来通常很舒服,但在某些情况下(比如这个例子),它不如 C++ 那样快或简单,因为每当同一层级中别的地方需要 &mut 时,我们都得重新获取这个引用。

另一种选择是把程序重构得更“扁平”(姑且这么说),但这也有它自己的取舍。28

如果你想玩得更花哨,可以用 GhostCell,给数据加上 brand,再用 GhostToken 访问:

rs

struct World<'brand> {
  entities: Vec<GhostCell<'brand, Entity>>,
}
fn step<'brand>(
  world: &World<'brand>,
  entity: &GhostCell<'brand, Entity>,
  token: &mut GhostToken<'brand>,
) {
  entity.borrow_mut(token).advance();
  let collision = world.get_collision_for_entity(entity, token);
  entity.borrow_mut(token).resolve(collision);
}

你能看出这里的困境。

  • C++ 的例子很简单,29 但不内存安全。
  • 原生 Rust 内存安全,但稍慢一点,也稍复杂一点。30
  • 加上 GhostCell 后速度与 C++ 相同,但仍然有点复杂。

用 Valen 的方式,我们得到的是又快、又安全、又简单的东西:

valen

struct World {
  entities Vec<Entity>;
}
func step(world &World, entity in world.entities[] mut) {
  entity.advance();
  let collision = world.get_collision_for_entity(entity);
  entity.resolve(collision);
}

相当漂亮的简化!

现在,终于可以解释它_是如何工作的_了。

23

这不像 Rust 的唯一引用(&mut)。可以存在指向同一个实体的其他引用。

24

这个函数不能修改整个 world,只能修改其中的实体(这其实是一项巨大的超能力,意味着优化器能更好地优化这个函数_以及_它的调用方,不过这一点我之后会详细解释)。

25

更准确地说,它只知道自己的指针大致指向哪里;world.entities[] 里的实体指向 entities 数组中某个对象所在的组中的某处。

26

在 Rust 里,调用 entity_mut.advance() 需要一个 &mut Entity,然后必须把它丢掉,才能拿回包含它的 &World,传给 get_collision_for_entity。再往后,调用 resolve 时又得重新取一次 &mut Entity。

27

具体来说,这样更容易应付借用检查器(如果你的游戏恰好做 ECS 风格的遍历,还会更快),但会失去单一所有权带来的一部分 RAII 好处。

比如,把一个异构树摊平成多个数组之后,节点就不会随父节点自动删除了。

另外,如果拆成太多并行集合,还可能因为“追 ID”而遭遇缓存未命中的性能下降。

说到底,我对任何会把我们推向某种特定架构的内存安全方案都持谨慎态度,因为它未必是最好的。

即便是组借用,某种程度上也有这个问题,所以我很期待把引用计数或分代引用融合进来。

28

是的,我知道把“C++”和“简单”放进同一个句子里有多讽刺!

29

这里有些细微之处。即使不用 GhostCell,通常也可以调整 Rust 代码的结构,让它达到组借用的速度。

Valen 的借用检查器如何工作

太长不看: 这套方案由五个机制组成:

  • 每个对象都按照单一所有权,由一个路径来描述。
  • 每个引用都会记住自己所指向对象的路径。31
  • 销毁一个对象时,所有指向它路径的东西都不再可用。
  • 函数签名描述它修改哪些路径。
  • 调用函数时,所有指向其修改路径内部的东西都不再可用。

上面说得太抽象了,下面我把这些逐一展开。

单一所有权

太长不看: Valen 的借用检查器建立在单一所有权之上,和 C++、Rust 一样。每个值都归某个包含它的对象、数组、栈帧或全局变量“所有”。32

如果你已经了解 C++ 或 Rust 的工作原理,可以直接跳过!

如果你不了解(或者只是想读一读!),我来解释一下什么是单一所有权。

在单一所有权中,每一份数据都有一个“所有者”。比如……

如果我们有这样一个 C++ 程序:

c++

#include <vector>
struct Engine { int fuel; };
struct Ship { unique_ptr<Engine> engine; };
void foo(vector<Ship>* ships) { ... }
void main() {
    vector<Ship> ships;
    ...
    foo(&ships);
}

……或者这个 Valen 程序:

valen

struct Engine { fuel int; };
struct Ship { engine Box<Engine>; }
func foo(ships &Vec<Ship>) { ... }
func main() {
    ships = Vec<Ship>.new();
    ...
    foo(&ships);
}

……我们可以这样说:33

  • main 的栈帧“拥有” vector ships;
  • vector ships 拥有每个 Ship。
  • 每个 Ship 拥有它的 Engine(通过 unique_ptr)。
  • 每个 Engine 拥有它的 int fuel;
  • foo 不拥有这个 vector,它只有一个裸指针。
  • main 是唯一没有所有者的事物。

如果你写过 C++ 或 Rust,这种思维方式你应该很熟悉。

如果你写过 C,你可能也会这样思考,尽管 C 并不显式追踪单一所有权。如果你追踪一个对象从 malloc() 调用到 free() 调用的完整旅程,指针经过的所有变量/字段处理的都是“拥有指针”,可以说就是这样。这几乎就像侦探追踪证据的“保管链”一样。换句话说,就是在任何给定时刻,谁对它“负责”。34

甚至 Java 和 C# 程序员有时也会用单一所有权的思路来思考。如果你需要在某个时刻调用一个对象的 “dispose”/“cleanup”/“destroy”/“unregister”/等 方法,你可以追踪这个对象从 new 到那个(概念上具有破坏性的)方法调用的完整旅程,而那些变量/字段处理的就是它的“拥有引用”,可以说就是这样。

到目前为止所解释的单一所有权,是很多语言的基础:

  • 如果加上普通(不受限制)的指针和引用,你就得到 C++。
  • 如果加上分代引用和区域借用,你就得到 Vale。
  • 如果加上排他性检查引用,你就得到 Rust。
  • 如果加上组借用和其他一些机制,你就得到 Valen。

有了单一所有权,每个对象都可以用一条“路径”来描述,我接下来会解释。

30

我感觉到“每个引用都记得它所指向对象的路径”和分代引用中“每个引用都记得它所指向对象的 ID(‘代’)”之间有一种奇妙的共鸣。可以说,组借用是一种编译期的分代引用。不过更多内容留到下一篇文章再讲!

31

后面我们会用引用计数来放宽这个限制。

32

你也可以说 main 的栈帧拥有 foo 的栈帧。或者反过来讲也行(async 就是这么工作的)。

33

或者更准确地说,负责释放它,或者负责把它交给某个最终会确保它被释放的人。

路径

每个对象都可以用一条路径来描述。

以这个例子来说:

valen

struct Entity { hp int; }
struct World {
  diameter int;
  entities Vec<Entity>;
}
func main() int {
  let x = 42;
  let world = World(73, Vec<Entity>.new());

  ...
}

下面是这个程序里的所有路径:

  • x
  • world
  • world.diameter
  • world.entities
  • world.entities[]
  • world.entities[].hp

路径总是以下面之一开头:

  • 局部变量(比如 world)。
  • 函数参数。
  • 函数的“组参数”(后面会讲到)。

开头之后,会跟着这样的东西:

  • .diameter,用来指定某个成员(如上)。
  • [],用来指定集合的_元素_(如上)。
  • .SomeType,用来指定 union 的某个变体。35
  • 以及另外几种形式,我后面会讲。36

每份数据只有一个所有者(因为单一所有权),所以每个对象只有一条路径。37

那路径有什么用?

引用!

编译器知道每个引用指向哪条路径,这有助于维护内存安全。

34

举个例子,如果我们有一个 union/enum Engine,里面要么是 WarpEngine,要么是 ImpulseEngine,那么路径可能是 my_engine.WarpEngine。

35

比如关联组,或者多类型组的子类型。这些后面再细说!

36

严格来说这并不总是成立;如果我们用上关联组之类的东西,对象其实可以通过多条路径到达。不过那是另一个话题了。

每个引用都知道自己指向哪条路径

比如在这个程序里:

valen

struct Entity { hp int; }
struct World {
  entities Vec<Entity>;
}
func main() int {
  let world = World(Vec<Entity>.new());

  world.entities.push(Entity(42));

  let entity_ref = &world.entities[0];

  ...
}

entity_ref 的类型是 &Entity in world.entities[]。

也就是说,它是对 world 的 entities 的某个元素的引用。

而在我们之前看到的例子里:

valen

struct World {
  entities Vec<Entity>;
}
func step(world &World, entity in world.entities[] mut) {
  entity.advance();
  let collision = world.get_collision_for_entity(entity);
  entity.resolve(collision);
}

你可以看到 world.entities[] 里的那个 entity,它指定了一个 entity 参数,指向 world 的 entities 的某个元素。

如果你懂 Rust:路径其实不太像生命周期。把它们当成两回事来理解会更容易。38

接下来看看我们为什么要做这些。看看如何用路径来保证内存安全!

37

实践中,生命周期可以当作一种非常粗粒度的位置,但它更像一段指令的跨度。

用路径保证内存安全39

编译器用路径来检测释放后使用(use-after-free); 它会检测出我们正在解引用一个指向已被销毁数据的引用。

它靠这两条规则做到这一点:

  • 当我们修改某个对象时,我们把任何指向该对象_内部_的引用标记为“已失效”。
  • 当我们试图使用一个“已失效”的引用时,编译器会报错。

看看这个 main 函数。

valen

struct Entity { hp int; }
struct World {
  entities Vec<Entity>;
}
func main() int {
  let world = World(Vec<Entity>.new());

  world.entities.push(Entity(42));
  let first_ref = &world.entities[0];

  world.entities = Vec<Entity>.new();

  // Compile error: Used a borrow after invalidated
  return first_ref.hp;
}

在这段代码里,发生了两件事:

  • 在 world.entities = ... 处,编译器让所有指向其内部的引用(world.entities[])失效;它让 first_ref 失效了。
  • 在 return first_ref.hp 处,编译器看到我们在使用 first_ref,于是报错。

注意我是这么说的:

当我们修改某个对象时,我们把任何指向该对象_内部_的引用标记为“已失效”。

我们不会让指向某个对象_本身_的引用失效。我们让指向该对象_内部_的引用失效。

看看这个 main 函数。

valen

struct Ship { fuel int; }
func main() int {
  let vec = Vec<Ship>.new();
  vec.push(Ship(42));

  let vec_ref = &vec;
  let elem_ref = &vec[0];

  // Mutate `vec`.
  // Don't invalidate `vec_ref`.
  // Invalidate `elem_ref`.
  vec.push(Ship(73));

  // Can still use `vec_ref`.
  vec_ref.push(Ship(81));

  // Can't use `elem_ref`,
  // Compile error: Used a borrow after invalidated
  return elem_ref.fuel;
}

当我们调用 push 时,这会修改那个 vec。

这会让所有指向 vec _内部_的引用失效。

也就是说 elem_ref 失效了(不过 vec_ref 仍然有效)。

所以我们仍然可以使用 vec_ref,但不能使用 elem_ref。

注意,这一切都没有描述某个引用是否可变。

而 Valen 只有一种借用引用。

如果你来自 C++ 或 Rust,可能会觉得困惑,因为这两种语言都有多种引用(C++ 有 const 和非 const,Rust 有 & 和 &mut)。

在 Valen 中,某个东西的可变性不属于它的类型, 而是由外围函数决定的。

Valen 确实会在函数参数上标注 mut,但那只是语法糖。mut 不属于参数的类型。40

38

你找到了彩蛋注释!

Reckless 中士 是一匹马,在美国海军陆战队里一路升到了上士军衔。

Reckless 上士什么都吃:炒蛋、啤酒、可口可乐,据说还有扑克筹码。

她获得了很高的荣誉,被授予“战争与和平中的动物”勇敢奖章。

如果你看到了这个彩蛋,就在 HN、lobste.rs 或 reddit 的评论里悄悄写上“morning sun”(她的韩文名字),就能拿到属于你自己的奖章!

(恭喜 q2dominic、timpi4ever 和 joonazan 最先找到!)

39

mut 是函数签名的一部分。这有点像某些语言把 async 放在函数上,而不是放在参数上。

函数签名会传达失效信息

那么编译器是怎么知道 push 修改了 vec 的?

push 必须在签名里说明这一点,用的就是 mut 关键字。

这是 push 的签名:

valen

func push<T>(self &Vec<T> mut) { ... }

……它是下面这段的语法糖:

valen

func push<T>(self &Vec<T>) mut(self) { ... }

注意,mut(self) 并不属于 self 的类型,它更像是属于_函数_。

所以当 main 调用 vec.push(...) 时,main 是把 vec 作为 self 传进去的。

编译器看到 mut(self),知道 self 实际上就是 vec,于是就知道 vec 被修改了。

它就是这样知道要让任何指向 vec _内部_的引用失效的。

再举一个例子:

valen

func step(world &World, entity in world.entities[] mut) {
  entity.advance();
  let collision = world.get_collision_for_entity(entity);
  entity.resolve(collision);
}

我们修改的不是整个世界,只是 world.entities[] 里的某个东西。

这很好,因为这样不会让调用方对 entities 的引用失效。

比如,下面这个 step_and_read 调用的就是上面那个_不会_让 entity 失效的 step 函数。

valen

func step_and_read(world &World, entity in world.entities[] mut) {
  // Only modifies an entity's contents.
  step(world, entity);

  // ...so can still use `entity`!
  entity.read_book();
}

如果我们把 Valen 编译器做对(再给 LLVM 打上补丁),这_或许_能让我们以一种前所未有的方式利用优化器。41

40

我在下面还会多谈一些,不过长话短说:如果优化器知道两个函数只在读上有重叠、在写上没有,它就可以重排它们,甚至可能把计算提到循环外面。

群体借用的一处变奏

最初的群体借用设计已经包含了很多上面这些东西(还有更多!)。下面是 Valen 在此基础上要加的东西,好让它_大放异彩_。

可变中的不可变

回想一下这个程序:我们接收一个不可变的世界,唯独它的实体元素除外:

valen

struct World {
  entities Vec<Entity>;
}
func step(world &World, entity in world.entities[] mut) {
  entity.advance();
  let collision = world.get_collision_for_entity(entity);
  entity.resolve(collision);
}

我们收到两个参数引用,可以通过更具体的那个进行修改,而它之外的东西一概不能改。

Valen 加入这个特性,是为了让我们不必再用经典的借用检查套路——把整个 world 以可变方式传进来。

这会带来几个好处。

第一,它有助于程序的架构设计,因为它帮我们写出更强的 API。

我们可以强制保证,整个函数里任何东西都无法改动我们预期之外的世界部分。

我猜这在团队里带那种爱折腾的新人(或者爱往你脸上甩 vibe code 的产品经理)时会特别好用。

第二,我怀疑这个特性能让 Valen 代码优化得更好。

这里我不深入展开,42 但大致上说,当你给优化器提供更细粒度的信息,告诉它什么可能变、什么不会变,它就能更好地找出可以重排代码来提速的地方。4344

临时唯一性

Valen 的目标之一,是能够无缝调用 Rust 代码,就像 Kotlin 调 Java、C++ 调 C 那样轻松。

当然,这很棘手,因为 group borrowing 和 Rust 的引用是两种不同的东西:

  • Group borrowing 允许别人修改你的引用所指向的数据。
  • 在 Rust 里,每个引用要么是共享的(&),要么是唯一的(&mut)。你持有引用期间,没人能改动你的引用所指向的东西。

于是问题就来了:能不能把一个 group borrowing 引用临时变成 Rust 引用?

答案是:可以。

机制很简单。当 Valen 调用 Rust 时:

  • 如果某个 Valen 引用是那次调用期间唯一能触及该数据的引用,我们就可以把它交给 Rust 的 &mut。
  • 如果有多个 Valen 引用可能指向同一个对象,那它们只能交给 Rust 的 & 引用。

我在 Memory Safety Across the Valen/Rust Boundary 里稍微聊过这个话题,去看看!

这个临时转换 有可能 有一天演变成一个更通用的特性。我们希望 Valen 支持类似 Rust 的 Vec 那样的东西,其中每个元素都是唯一引用,而分组借用做不到这一点。

我猜 Valen 可以有类似“唯一组”的东西,比如在函数上标注 unique(world.ships[]),这样就能阻止创建指向该层级那部分的新引用。

不过这个方向还需要更多思考。Valen 最大的优势之一就是它只有一种借用引用……所以引入第二种“唯一”引用需要仔细斟酌。

41

在 Mastodon 或 Bluesky 上问我,我很乐意解释!

42

这可能需要给 LLVM 打补丁,让它能更好地利用这些信息。它习惯从 noalias 的角度思考,而不是从“只读”的角度。棘手,但可行!

43

这并不一定意味着 Valen 会比 C++、Rust 等更快。我 认为 你可以在那些语言里做额外的重构,或者用 unsafe,来获得同等的加速。Valen 在这里的主要优势在于人体工程学和灵活性,而不一定是速度。

通配符后代路径

这是另一个帮助 Valen 调用 Rust 代码的特性。

在尝试让 Golden Spike 跑起来的时候,我遇到了一个难题。

当 Valen 调用一个返回引用的 Rust 函数时(比如下面的 -> &Gem),它怎么知道那个引用指向 哪里?

例如:

rs

impl Chest {
  pub fn gem(&self) -> &Gem { &self.gem }
}

Valen 对此感到困惑,因为 Valen 喜欢知道每个引用的路径。

在 Valen 的理想世界里,它会这样看待上面的代码:

valen

func gem(self &Chest) -> &Gem in self.gem { &self.gem }

但可惜!Valen 无法从 Rust 代码中推断出 self.gem 里的 in。

但如果你把视野拉远一点,Rust 代码想表达的是它返回的东西指向 self “内部的某处”。

那么,让我们把这个概念加到 Valen 里!

Valen 现在会把上面的 Rust 函数解释成相当于这样:

valen

func gem(self &Chest) -> &Gem in self.gem... { &self.gem }

in self.gem... 的意思是“指向 self.gem 内部的某处”。

这叫做通配符后代路径,确实需要一个更好的名字。

我在 Memory Safety Across the Valen/Rust Boundary 里对它做了更多描述。

我怀疑它的用处不止于 Rust 互操作。它可以从签名中抹去内部细节,让库 API 更灵活、更具前向兼容性。不过这里也要小心,因为理论上其他特性也能达到同样的目的。45

44

比如,我们可以让一个类型以公开名字重新导出一个私有组。可以称之为“关联组”之类的东西。

这一切如何契合

上文我提到了“内存安全圣杯”,以及为了找到它需要解决的三个挑战:

  • 我们能否做出一个_更宽松_的借用检查器?
  • 我们能否让借用检查器读取_更复杂的数据?_
  • 我们能否让这两个系统同时协同工作?

Valen 的借用检查器是第 1 点的关键:它允许我们对任何对象持有多个引用,并允许通过其中任何一个进行读写。

第 2 点和第 3 点会放在另一篇文章里(因为这篇已经太长了!),但我忍不住想快速预览一下它的核心内容。

下面是我为了在半页纸内解释它而匆忙写下的草稿。没人应该试图理解它。快,跳到前面去!

回想一下上面的例子:

valen

struct World {
  entities Vec<Entity>;
}
func step(world &World, entity in world.entities[] mut) {
  ...
}

我希望用户在不指定路径时,可以选择使用引用计数类。像这样:

valen

class World {
  entities Vec<Entity>;
}
func step(world World, entity Entity) mut {
  ...
}

能让用户自由地这样简化,那就太好了。46

当然,把引用计数和借用混合在一起是出了名的困难。47

答案来自我在 2023 年做的一个思想实验,我叫它 Arrrlang。名字是个玩笑,但前提很有意思:把堆表示为 N 个全局数组,其中 N 是程序中类型的数量。48

我想到:组借用_理解_数组。而引用计数可以看作 N 个数组,彼此之间都有引用。

把这两件事放在一起,关键洞见就浮现了:如果我们把引用计数类型描述给借用检查器,就像它是一堆数组一样,那么我们也许就能以内存安全的方式持有指向它们的借用引用。

当然,编译器不需要把引用计数对象_真正编译_成数组。**但它_可以_用这种方式向借用检查器描述它,**用的是分组借用那套说法。

这么做的话,很多问题会一下子消失,而且它会清楚地告诉我们,分组借用还需要补上哪些积木:

  • 一种“多类型分组”,类似 Vale 的 region,而普通分组借用要求每个分组只能有一个类型。4950
  • 一种“运行时”分组。指向运行时分组的引用不会被失效。

就像我说的,这些现在都还讲不通。留意下一篇讲这些的文章!

45

引用计数的限制少得多(比如引用不必是临时的,借用检查则不然),这意味着你能解锁多得多的模式(观察者、侵入式链表等等)。它还意味着,在你访问某个对象时,没人能把它释放掉,这通常是件好事。

但这取决于你的视角。引用计数偶尔会在循环上出问题,而且它和线性类型不太合得来。大多数情况下,我喜欢引用计数。对于更复杂的部分,或者需要速度的部分,我喜欢借用。

我认为一门语言最好两者都支持。

46

它困难的原因,和 Rc 里为什么会有那个 RefCell 完全一样。

47

它曾是一个“零开销”语言的例子,而它实际上是有开销的,表现为 ID 追逐和边界检查。

48

这对 enum 也会有用!

49

有趣的是,Iris 在底层就是这样表示堆的,而 Iris 曾被用来形式化验证 Rust 的某些部分是安全的。

这次就到这里

感谢阅读!

接下来几篇文章里,我会进一步解释我们将如何把引用计数和分代引用融合进来,以及 Valen 的分组借用检查器是如何实现的。

这只是冰山一角。想第一时间看到后续内容,可以订阅我的 RSS feed、关注 r/valen,或者加入 Valen discord。也欢迎在 Bluesky 和 Mastodon 上关注我!

Cheers,

  • Evan Ovadia

附录:类型稳定性例外

这部分还没在 Valen 中实现。敬请期待!

分组借用还可以更聪明一点:如果你写了 my_vec.push(42),那么即使 push 声明的是 mut(self),它也不会让指向 my_vec.size 的指针失效。

道理很简单:无论谁怎么修改 my_vec(它是一个 Vec),my_vec.size 处的那个东西始终还在。谁也没法把 Vec 的 size 改成整数以外的东西。换句话说,它是“类型稳定”的。

稍微推广一下:分组借用永远不会让任何类型稳定的数据失效。

它不会让指向任何类型稳定的内含物(基本类型、struct 或定长数组)的指针失效,也不会让指向这些内含物内部类型稳定之物的指针失效。它只会让指向类型不稳定之物(enum 的内容、指针的指向对象)内部的指针失效。

比如下面这个程序里,无论谁修改 Level 的内容,都不会让你对它的 Terrain 的引用失效。

valen

struct Level {
  terrain Terrain;
  entities HashMap<EntityId, Entity>;
  location_to_entity HashMap<Location, EntityId>;
}
func add_entity(level &Level mut, entity Entity) {
  level.location_to_entity.insert(entity.loc, entity.id);
  level.entities.insert(entity.id, entity);
}
func main() {
  let level = ...;
  let terrain_ref = &level.terrain;

  // Doesn't invalidate terrain_ref!
  level.add_entity(Entity(1, Loc(4, 5), 42));

  print(terrain_ref.tiles.len());
}

我的立场是:如果你想让别人花时间读点什么,那就自己花时间亲手写。

感谢阅读 =)

这篇文章会完整解释这一点,继续往下读吧!

所以我说“读取更复杂的数据”,意思是:我们怎么才能让不可变借用引用指向那种任意形状的数据图?

(注意,Rust 的结构体可以持有指向其他 Rust 结构体的引用,但只能是临时的。这就是为什么 ECS 在 Rust 游戏里这么受欢迎。也是为什么我仍然把 Rust 归在 Fortran 那一边。)

  • Valen 会有 group borrowing,Vale 有的是 region borrowing。
  • Valen 会有普通的代际引用,Vale 有的是概率性代际引用。
  • Valen 会有(而 Vale 没有)Rust 互操作、Rc、comptime。
  • Valen 不会有(而 Vale 有)完美重放、无惧 FFI。

两者都采用线性类型。

语法大体上仍然相同,但 Valen 可能会在语法上更偏向“简化版 Rust”。

在约束引用和 Vale 的当前混合方案之间,曾有一种我称之为“混合分代内存”的方案:在一个作用域内“拴住”一个分代引用,就能让该引用保持存活。我前后做了 三十二 个版本才放弃。

  • 让程序在运行时更快,因为可以给优化器提供更多信息。
  • 不用 Cell、RefCell 或 Mutex 就能实现引用计数。
  • 出于同样的理由,可以使用 generational references。

比如,把一棵异构树摊平成多个数组后,父节点被删除时,子节点不会自动删除。

另外,如果拆分成太多并行集合,可能会遭遇“ID 追逐”式的缓存未命中,拖慢速度。

说到底,我对任何会把我们推向某种特定架构的内存安全方案都持谨慎态度,因为它未必是最好的。

即使是组借用,在某种程度上也会产生这种效果,这也是我热衷于把引用计数或代际引用融合进来的原因。

Sergeant Reckless 是一匹马,在美国海军陆战队中晋升到了上士军衔。

Sergeant Reckless 什么都爱吃,包括炒鸡蛋、啤酒、可口可乐,显然还有扑克筹码。

她获得了很高的荣誉,并被授予“战争与和平中的动物”勇敢奖章。

如果你看到了这个彩蛋,就在 HN、lobste.rs 或 reddit 的评论里悄悄写上“morning sun”(她的韩文名字),就能获得属于你自己的奖励!

(恭喜 q2dominic、timpi4ever 和 joonazan 最先发现!)

但这取决于你的视角。引用计数偶尔会在循环上出问题,而且它与线性类型不太兼容。对大多数东西,我喜欢引用计数。对更复杂的部分,或者需要速度的部分,我喜欢借用。

我认为一门语言同时支持两者是好事。

Footnotes

  1. 这个探索带来了很多有趣的实验,涉及约束引用、线性类型、引用计数,还有更多东西。 ↩

  2. 这篇文章 0% 是 AI 写的。更多想法在这里。 ↩

  3. 或者更准确地说,“我们能不能做出一个借用检查器,支持对同一个对象有多个_读写_借用引用?” ↩

  4. 下面我会详细解释,这里说的“更复杂的数据”指的是完整的数据图,其中对象可以不受限制地自由引用其他对象,就像你在 C++、Java 等语言里看到的那样,而不是像 Rust 或 Fortran 那样,东西之间通常不能互相引用。 ↩

  5. 简而言之,这个问题由 Vale 的代际引用与区域借用混合方案回答;它的不可变区域借用让编译器把所有既有数据当作一个临时冻结的“区域”来跟踪,我们可以持有指向其中的借用引用。 ↩

  6. 现在我只能暗示,Valen 也许已经解决了这些问题,办法是融合 Vale 的思路、2023 年的某个思想实验、Nick Smith 的 Group Borrowing 方案,再加上一些调整让它们和谐共存。 ↩

  7. 具体来说,在今天的编译器里,你需要手动解引用,直接对数组下标,比如写 my_vec.data[0] 而不是对结构体,还要给所有参数标上类型,比如 world.entities[] 里的 entity &Entity。 ↩

  8. 在别人的语言上干活就是这样! ↩

  9. 你现在读的这篇文章对 group borrowing 的解释要好得多,不过旧文可以看 Group Borrowing: Zero-Cost Memory Safety with Fewer Restrictions。 ↩

  10. 我很快会写一篇文章,讲这些语言的做法各自如何运作、彼此如何比较,敬请期待! ↩

  11. 它通过一等权限把生命周期与类型检查器合并,从而拒绝更少的代码。绝对值得一看! ↩

  12. Zeta 的借用检查器能证明两个引用指向同一份数据的_不同部分_(即它们“不相交”)。例如,当它知道 i != j 时,就能证明 list.get_mut(i) 和 list.get_mut(j) 不相交,即使两个索引都是运行时的值。更多内容看这里! ↩

  13. Valen 和 Vale 的主要区别: ↩

  14. 我常把这种冲突称为“语言杀手”,因为它毁掉了许多实验性语言的许多版本,其中包括 Vale 早期的一种方案。 ↩

  15. C++ 需要这个,因为我们通常就是这么用的:一个 unique_ptr 持有对象,一堆非临时的裸指针指向它。引用计数和垃圾回收也有同样的需求。两种情况下,麻烦都出在别人可能在你持有借用引用时修改对象。 ↩

  16. 这个目标可能有争议;我们中有些人认为,引用的所指对象不应该在你脚下被换掉,但 ID/索引的所指对象应该可以(就像 Rust 那样)。另一些人则认为这限制太强。我认为应该让用户自己选。 ↩

  17. 不过,是否有语言能做到 零 运行时开销的内存安全,仍有争议。参见 Chasing the Myth of Zero-Overhead Memory Safety,它同样适用于组借用。 ↩

  18. 通常,借用检查器不知道如何处理两个都能修改对象的引用,于是直接报编译错误。组借用的灵活性足以不禁用这种多重读写引用,因为它知道如何更精确地专门防范 use-after-free 问题。 ↩

  19. 例子见这一页里的“fn attack”示例。这对借用检查器来说通常很难处理。不过,GhostCell有时能帮上忙! ↩

  20. 普通借用检查器通常处理不了这个,因为用户想要两个读写引用(这违反了 shared-xor-mutable):一个直接使用,另一个活在 ScopeGuard 对象里。 ↩

  21. 这也可以作为用户自定义 defer 语句的基础! ↩

  22. 我手头没有现成的例子,但设想一下:你希望一堆 File 对象各自持有对同一个 FileSystem 对象的引用。借用检查通常处理不了这种情况(对同一对象的读写引用太多),但在 group borrowing 下应该没问题。 ↩

  23. 下面我会详细讲,不过以 group borrowing 为起点,我们可以: ↩

  24. 这和 Rust 的独占引用(&mut)不一样。指向同一实体的其他引用可以同时存在。 ↩

  25. 这个函数无法修改整个世界,只能修改其中的实体(这其实是一项巨大的超能力,意味着优化器能更好地优化这个函数_以及_它的调用方,后面我会详细解释)。 ↩

  26. 或者更准确地说,它_大致_知道自己指向哪里;world.entities[] 中的实体指向 entities 数组里那_一组_对象中的某个位置。 ↩

  27. 在 Rust 里,调用 entity_mut.advance() 需要一个 &mut Entity,然后必须把它丢掉,才能拿回外层的 &World 传给 get_collision_for_entity。之后调用 resolve 时,又得重新获取 &mut Entity。 ↩

  28. 具体来说,与借用检查器打交道会变得更容易(如果你的游戏恰好做 ECS 风格的遍历,还会更快),但你会失去单一所有权带来的一部分 RAII 好处。 ↩

  29. 是的,我知道把“C++”和“简单”放在同一个句子里有多么讽刺! ↩

  30. 这里有些细微之处。即使不用 GhostCell,通常也可以调整 Rust 代码的结构,让它达到组借用的速度。 ↩

  31. 我隐约感到“每个引用都记得它所指向对象的路径”和代际引用中“每个引用都记得它所指向对象的 ID(‘代’)”之间存在某种奇妙的呼应。_可以_说组借用是一种编译期的代际引用。不过这些留到下一篇文章再讲! ↩

  32. 后面我们会用引用计数来放宽这一限制。 ↩

  33. 你也可以说 main 的栈帧拥有 foo 的栈帧。或者反过来说也行(async 就是这么工作的)。 ↩

  34. 更准确地说,是负责释放它,或者负责把它交给某个能确保它最终被释放的人。 ↩

  35. 举个例子,如果我们有一个 union/enum Engine,其中包含 WarpEngine 或 ImpulseEngine,那么路径可能是 my_engine.WarpEngine。 ↩

  36. 比如关联组,或者多类型组的子类型。这些后面再细说! ↩

  37. 这_严格来说_并不成立;如果我们使用关联组之类的东西,对象实际上可以通过多条路径到达。不过那是另一个话题了。 ↩

  38. 实践中,生命周期可以当作一种非常粗粒度的位置来用,但生命周期更像是一段指令的跨度。 ↩

  39. 你找到彩蛋注释了! ↩

  40. mut 是函数签名的一部分。这类似于有些语言把 async 写在函数上,而不是参数上。 ↩

  41. 我在下面会进一步讨论,不过简而言之,如果优化器知道两个函数只在读取上重叠、而不在写入上重叠,它就可以重排它们,甚至可能把计算提到循环外。 ↩

  42. 在 Mastodon 或 Bluesky 上问我,我很乐意解释! ↩

  43. 这可能需要给 LLVM 打补丁,让它能更好地利用这些信息。它倾向于用 noalias 来思考,而不是用“只读”。棘手,但可行! ↩

  44. 这并不一定意味着 Valen 会比 C++、Rust 等更快。我 认为 你可以在那些语言里做额外的重构或使用 unsafe 来获得同等的加速。Valen 在这方面的主要优势在于人体工程学和灵活性,而不一定是速度。 ↩

  45. 例如,我们可以让一个类型以公开名称公开再导出一个私有组。可以这么说,类似“关联组”的东西。 ↩

  46. 引用计数的限制少得多(例如引用不必是临时的,不像借用检查那样),这意味着你能解锁更多模式(观察者、侵入式链表等)。它也意味着在你访问对象时没人能释放它,这通常是好事。 ↩

  47. 它之所以困难,原因和 Rc 里为什么有那个 RefCell 完全一样。 ↩

  48. 这是一个“零成本”语言的例子,而它实际上有一些成本,形式是 ID 追逐和边界检查。 ↩

  49. 这对枚举也会有用! ↩

  50. 有趣的是,Iris 在底层就是这样表示堆的,它曾被用来形式化验证 Rust 的部分内容是安全的。 ↩

来源: verdagon.dev← 返回首页