Valen 的内存安全:一种新型借用检查
Evan Ovadia 讲解 group borrowing,一种针对 Valen 的借用检查设计,它允许对同一对象不同部分的引用共存,并解释它如何将 shared-xor-mutable 放宽为 no use-after-free。
中文
复制
会记住自己指向何处的引用!
这篇文章讲的是 Valen 新的灵活借用检查器。欢迎阅读!
你们都知道我,我_热爱_探索新的内存安全方案,再用各种奇怪的方式把它们揉到一起。
这是我永恒追求的一部分:寻找内存安全的圣杯——既有 Rust 借用检查那样的威力,又像引用计数或垃圾回收那样简单灵活。12
我一直怀疑,要找到圣杯,必须先解决三个内存安全难题:
说得含糊又玄乎,我知道!后面我会解释这些到底是什么意思,以及 Valen 可能已经找到了解法。6
本文主要聚焦第一块:Valen 的更宽松的借用检查器。
所以这篇文章会讲它是什么、能做什么、怎么运作。
为了清晰起见,本文对语法糖做了一些自由发挥:自动解引用、直接索引结构体(my_vec[0])、从集合推断类型(world.entities[] 中的 entity)都会在十一月到来。除了这些语法调整之外,借用检查器对我们讨论的一切都适用。7
顺带,我还会讲一些更古怪的细节,比如:
- Valen 的借用检查器为什么长成这个样子,才能让 Valen 代码调用 Rust 代码。
- 编译器如何记住一个引用指向_哪里_。
- 为什么 Valen 只有_一种_借用引用,而其他借用检查器有两种。
另外,这篇文章_长得离谱_,背景信息也很多,所以我会在可以跳过的地方提醒你。
开始吧!
组借用:一次擦肩而过,潜力巨大
(这只是我很爱讲的背景故事,可以跳过这一节!)
Valen 的做法建立在“组借用”(Group Borrowing)提案之上,它由我的朋友 Nick Smith 设计。
它的核心概念是编译器会记住引用指向哪里(后面我会解释),并据此检查程序是否内存安全。
这套设计起步艰难,差点被时间的洪流埋没。它最初是为 Mojo 设计的,但遗憾的是,尽管我竭尽全力,也没能说服高层采用它。8 分组借用还没机会成功,就已经死了。
但我知道它有巨大的潜力,只要能设法问世。
于是我们花了几个月打磨解释、梳理它的优势,并在去年写了一篇文章9,希望其他语言能采纳它。
它们确实采纳了!它传播得很广。10
- Google 的 Carbon 语言完全转向了分组借用(甚至致谢了我们的文章,他们真是太好了!)
- Ante 开始把它纳入自己的语言,见 Shared Mutability and Stability。
- Plecra 的排除类型正在用分组借用和 GhostCell 的概念重新设计 Rust 的借用检查器。11
- Zeta 把分组借用与不相交性12结合起来,将运行时事实纳入编译期内存安全。
- Fang 正在把它纳入自己的设计,见 Cyclic References in Fang。
- 而现在,我的 Valen 把分组借用与 Vale13 的方法结合起来,还让它能与 Rust 代码互操作。
这些语言大多在围绕同一类挑战打转:如何做出更灵活的借用检查器。
例如,Valen 想做出一个足够灵活的借用检查器,能与引用计数和分代引用配合;Carbon 想做出一个足够灵活的借用检查器,能与 C++ 代码配合。
这很难,因为借用检查与其他方法之间一直存在根本性冲突。可以这样概括:14
你大概在想:“这肯定解决不了。”
看起来确实如此!
但 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);
}
注意这几处:
合起来就是: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);
}
你能看出这里的困境。
用 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)。
- 函数参数。
- 函数的“组参数”(后面会讲到)。
开头之后,会跟着这样的东西:
每份数据只有一个所有者(因为单一所有权),所以每个对象只有一条路径。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 个数组,彼此之间都有引用。
把这两件事放在一起,关键洞见就浮现了:如果我们把引用计数类型描述给借用检查器,就像它是一堆数组一样,那么我们也许就能以内存安全的方式持有指向它们的借用引用。
当然,编译器不需要把引用计数对象_真正编译_成数组。**但它_可以_用这种方式向借用检查器描述它,**用的是分组借用那套说法。
这么做的话,很多问题会一下子消失,而且它会清楚地告诉我们,分组借用还需要补上哪些积木:
就像我说的,这些现在都还讲不通。留意下一篇讲这些的文章!
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
-
或者更准确地说,“我们能不能做出一个借用检查器,支持对同一个对象有多个_读写_借用引用?” ↩
-
下面我会详细解释,这里说的“更复杂的数据”指的是完整的数据图,其中对象可以不受限制地自由引用其他对象,就像你在 C++、Java 等语言里看到的那样,而不是像 Rust 或 Fortran 那样,东西之间通常不能互相引用。 ↩
-
简而言之,这个问题由 Vale 的代际引用与区域借用混合方案回答;它的不可变区域借用让编译器把所有既有数据当作一个临时冻结的“区域”来跟踪,我们可以持有指向其中的借用引用。 ↩
-
现在我只能暗示,Valen 也许已经解决了这些问题,办法是融合 Vale 的思路、2023 年的某个思想实验、Nick Smith 的 Group Borrowing 方案,再加上一些调整让它们和谐共存。 ↩
-
具体来说,在今天的编译器里,你需要手动解引用,直接对数组下标,比如写 my_vec.data[0] 而不是对结构体,还要给所有参数标上类型,比如 world.entities[] 里的 entity &Entity。 ↩
-
在别人的语言上干活就是这样! ↩
-
你现在读的这篇文章对 group borrowing 的解释要好得多,不过旧文可以看 Group Borrowing: Zero-Cost Memory Safety with Fewer Restrictions。 ↩
-
我很快会写一篇文章,讲这些语言的做法各自如何运作、彼此如何比较,敬请期待! ↩
-
它通过一等权限把生命周期与类型检查器合并,从而拒绝更少的代码。绝对值得一看! ↩
-
Zeta 的借用检查器能证明两个引用指向同一份数据的_不同部分_(即它们“不相交”)。例如,当它知道 i != j 时,就能证明 list.get_mut(i) 和 list.get_mut(j) 不相交,即使两个索引都是运行时的值。更多内容看这里! ↩
-
Valen 和 Vale 的主要区别: ↩
-
我常把这种冲突称为“语言杀手”,因为它毁掉了许多实验性语言的许多版本,其中包括 Vale 早期的一种方案。 ↩
-
C++ 需要这个,因为我们通常就是这么用的:一个 unique_ptr 持有对象,一堆非临时的裸指针指向它。引用计数和垃圾回收也有同样的需求。两种情况下,麻烦都出在别人可能在你持有借用引用时修改对象。 ↩
-
这个目标可能有争议;我们中有些人认为,引用的所指对象不应该在你脚下被换掉,但 ID/索引的所指对象应该可以(就像 Rust 那样)。另一些人则认为这限制太强。我认为应该让用户自己选。 ↩
-
不过,是否有语言能做到 零 运行时开销的内存安全,仍有争议。参见 Chasing the Myth of Zero-Overhead Memory Safety,它同样适用于组借用。 ↩
-
通常,借用检查器不知道如何处理两个都能修改对象的引用,于是直接报编译错误。组借用的灵活性足以不禁用这种多重读写引用,因为它知道如何更精确地专门防范 use-after-free 问题。 ↩
-
普通借用检查器通常处理不了这个,因为用户想要两个读写引用(这违反了 shared-xor-mutable):一个直接使用,另一个活在 ScopeGuard 对象里。 ↩
-
这也可以作为用户自定义 defer 语句的基础! ↩
-
我手头没有现成的例子,但设想一下:你希望一堆 File 对象各自持有对同一个 FileSystem 对象的引用。借用检查通常处理不了这种情况(对同一对象的读写引用太多),但在 group borrowing 下应该没问题。 ↩
-
下面我会详细讲,不过以 group borrowing 为起点,我们可以: ↩
-
这和 Rust 的独占引用(&mut)不一样。指向同一实体的其他引用可以同时存在。 ↩
-
这个函数无法修改整个世界,只能修改其中的实体(这其实是一项巨大的超能力,意味着优化器能更好地优化这个函数_以及_它的调用方,后面我会详细解释)。 ↩
-
或者更准确地说,它_大致_知道自己指向哪里;world.entities[] 中的实体指向 entities 数组里那_一组_对象中的某个位置。 ↩
-
在 Rust 里,调用 entity_mut.advance() 需要一个 &mut Entity,然后必须把它丢掉,才能拿回外层的 &World 传给 get_collision_for_entity。之后调用 resolve 时,又得重新获取 &mut Entity。 ↩
-
具体来说,与借用检查器打交道会变得更容易(如果你的游戏恰好做 ECS 风格的遍历,还会更快),但你会失去单一所有权带来的一部分 RAII 好处。 ↩
-
是的,我知道把“C++”和“简单”放在同一个句子里有多么讽刺! ↩
-
这里有些细微之处。即使不用 GhostCell,通常也可以调整 Rust 代码的结构,让它达到组借用的速度。 ↩
-
我隐约感到“每个引用都记得它所指向对象的路径”和代际引用中“每个引用都记得它所指向对象的 ID(‘代’)”之间存在某种奇妙的呼应。_可以_说组借用是一种编译期的代际引用。不过这些留到下一篇文章再讲! ↩
-
后面我们会用引用计数来放宽这一限制。 ↩
-
你也可以说 main 的栈帧拥有 foo 的栈帧。或者反过来说也行(async 就是这么工作的)。 ↩
-
更准确地说,是负责释放它,或者负责把它交给某个能确保它最终被释放的人。 ↩
-
举个例子,如果我们有一个 union/enum Engine,其中包含 WarpEngine 或 ImpulseEngine,那么路径可能是 my_engine.WarpEngine。 ↩
-
比如关联组,或者多类型组的子类型。这些后面再细说! ↩
-
这_严格来说_并不成立;如果我们使用关联组之类的东西,对象实际上可以通过多条路径到达。不过那是另一个话题了。 ↩
-
实践中,生命周期可以当作一种非常粗粒度的位置来用,但生命周期更像是一段指令的跨度。 ↩
-
你找到彩蛋注释了! ↩
-
mut 是函数签名的一部分。这类似于有些语言把 async 写在函数上,而不是参数上。 ↩
-
我在下面会进一步讨论,不过简而言之,如果优化器知道两个函数只在读取上重叠、而不在写入上重叠,它就可以重排它们,甚至可能把计算提到循环外。 ↩
-
在 Mastodon 或 Bluesky 上问我,我很乐意解释! ↩
-
这可能需要给 LLVM 打补丁,让它能更好地利用这些信息。它倾向于用 noalias 来思考,而不是用“只读”。棘手,但可行! ↩
-
这并不一定意味着 Valen 会比 C++、Rust 等更快。我 认为 你可以在那些语言里做额外的重构或使用 unsafe 来获得同等的加速。Valen 在这方面的主要优势在于人体工程学和灵活性,而不一定是速度。 ↩
-
例如,我们可以让一个类型以公开名称公开再导出一个私有组。可以这么说,类似“关联组”的东西。 ↩
-
引用计数的限制少得多(例如引用不必是临时的,不像借用检查那样),这意味着你能解锁更多模式(观察者、侵入式链表等)。它也意味着在你访问对象时没人能释放它,这通常是好事。 ↩
-
它之所以困难,原因和 Rc 里为什么有那个 RefCell 完全一样。 ↩
-
这是一个“零成本”语言的例子,而它实际上有一些成本,形式是 ID 追逐和边界检查。 ↩
-
这对枚举也会有用! ↩