龙虾中的内存管理

2021-07-31 01:28:26

这是对 Lobster 中内存管理如何工作的更深入的解释,通常不需要完全理解即可使用该语言。对于那些想用另一种语言实现类似方案的人来说,这可能会很有趣。内存管理是语言的一个方面,它对语言的结果有最大的影响之一:它影响类型系统和你可以拥有的类型的种类,它影响时间和空间的效率,它影响认知模型程序员可以表示哪些数据结构,它会影响延迟、互操作性等等。然而,它经常被忽视,同时几乎不可见。许多人认为这是一个“已解决”的问题,大约 95% 的编程语言都使用某种形式的垃圾收集:分配,然后担心以后回收无法访问的对象。我认为还远远没有解决。如果我必须在“计算机科学中最不优雅的算法”中名列前 10 名,那么第 1 名将是一个简单的选择。仅仅因为“谁在指向这个对象”是一个难题,我们甚至不会尝试跟踪它,而是反复扫描堆的大部分以在以后恢复该信息,这种想法真的让我感到畏缩。问题可能是,在很长一段时间内,替代方案都不是很好。低级语言将使用手动内存管理(高效但容易出错),有些语言使用引用计数。然后是基于线性类型或区域的语言,但那些从未进入主流。我个人一直认为引用计数是“最不坏”的解决方案,这就是它最初用于 Lobster 的原因。许多人完全不理会它,因为它不能收集循环,但我发现,在实际使用中,在程序退出时获得“循环报告”,帮助程序员知道在哪里打破这些循环,在很大程度上就足够了。

我提到了线性(每个对象只能有一个指向它的指针)和区域(每个对象都分配了一个比它们都长的内存池),并且我过去已经实现了实现这两种策略的语言。虽然可行,但我当时觉得他们需要太多的程序员帮助才能成为主流。有一个内存管理设计空间,我将其统称为“所有权”,它是线性模型的概括:一个指针始终拥有一个对象(并负责其释放),而其他指针仅“借用”它。这现在提出了新的问题:我们如何确定谁是所有者?什么时候可以借钱?我们如何检测比所有者寿命更长的借款人?当有借款人时,所有者被修改怎么办?我们是否在编译时、运行时或混合时检查其中任何一项?过去有很多所有权模型,但直到最近它们才变得更加主流:Rust 是典型的例子,它试图在编译时解决上述所有问题,但需要大量程序员的帮助。带有 std::unique_ptr 的现代 C++ 本质上是一种所有权模型,除了用“我们不知道”来回答“我们如何检测借款人比所有者寿命更长”等问题的模型。但是即使有这些警告,它在很大程度上也能很好地工作,并且比 C 的手动内存管理甚至早期 C++ 的手动 RAII 有了巨大的改进。 Swift 是少数使用引用计数(继承自目标 C)的语言之一,它有意(可选)转移到所有权。

Nim 打算从 GC 转移到所有权(参见帖子 1 2),其模型部分由程序员辅助,部分运行时。许多更多研究或鲜为人知的语言,例如 Gel、Dyon、CoSy、ParaSail Cone Scopes Pure RC GC Mitten 我个人认为这个大方向是内存管理的未来。对于大多数想要加入的程序员来说,它只需要比 Rust 更友好。龙虾提供了一个可能的方向。最好的内存管理是......根本不必管理它!你可以制作一个非常快的内存管理器,但它永远不会打败简单地管理更少的对象。这就是 Lobster 按值结构背后的想法,它提供与 C/C++、Rust 甚至 C# 中可用的对象类似的“零抽象成本”对象,但令人惊讶的是,在大多数其他语言中却没有。坦率地说,这是实现更高效内存使用的最有效和最便宜的方法,坦率地说,每种语言都应该拥有。本质上,您使用 struct 关键字而不是通常的类来声明一个对象,这会导致 Lobster 始终在父级中内联分配此对象,其中“父级”可以是另一个结构或类、向量或简单的堆栈(作为一个局部变量)。在实现级别上,就好像您将结构的所有字段作为字段/变量添加到父级,因此编写 sx(其中 s 是结构)与编写 x 一样便宜。结构消失。这当然有局限性,因为您完全依赖于父级的生命周期,并且您通过复制而不是指针来分配这些类型的结构。这意味着它不适用于大对象,但通常最多 4 个字段左右,这仍然比堆分配更有效。目前,这样的结构是不可变的,因为这对小的、复制的对象很有意义。这在未来可能会放宽以允许突变。

在某些情况下,可以使用短期引用代替复制实现,这在 C++ 中很常见。然而,在许多情况下这实际上并没有更快,更重要的是,参考将受到下面的所有权分析的影响。通过使它们复制,我们基本上“减轻了对该算法的压力”,这意味着它更有可能产生最佳的所有权分配和更少的错误。拥有这些按值结构对于像 Lobster 这样的语言尤其重要,该语言迄今为止专注于游戏和计算机图形领域,这些领域大量使用 2D/3D 向量。让这些类型尽可能便宜很重要。 Lobster 将其原始(运行时)引用计数与所有权分析算法相结合,以获得“编译时引用计数”。这是一种全自动算法,大部分不需要程序员干预。在我的个人测试中,使用几十个 Lobster 程序(其中许多是中型游戏原型),当从运行时转换到编译时引用计数时,我只需要在 2 个位置进行微小的更改,其余的“都可以”。或者更确切地说,我能够让算法跳过箍,这样就不需要更改代码了。本质上,该算法为每个新的堆分配选择一个所有者,通常是分配给它的第一个变量、字段或向量元素,然后尽可能地尝试从那里“借用”所有用途。初始所有权和所有借用不需要任何运行时引用计数。如果代码中的其他地方想要拥有相同的值,它将仅针对此特定用途插入引用计数增加(在 Rust 中,这会导致错误)。使用此分析能够删除大约 95% 的运行时引用计数操作。这是默认行为,因为它对于大多数代码来说“足够好”,并且对程序员来说非常方便。然而,对于那些程序员想要最大限度控制的情况,他们可以在每个变量的基础上选择更像 Rust 的模型,这将保证永远只有一个所有者,如果分析无法保证这一点。这也将能够从对象中完全删除引用计数字段,进一步优化。然而,在撰写本文时,这尚未实施。这些错误可能是所有权分析的结果,尽管所有这些错误都被证明是非常罕见的。

在这里,y 借用了 x,然后 f 在 x 仍然被借用时尝试覆盖 x,这将导致它在 y 仍然指向它时解除分配“hello”。使用借用值的代码也访问原始值的情况非常罕见。正如我所提到的,这在大量测试代码中只发生了两次,并且很容易避免。这通常永远不会触发,因为分配给的参数通常被强制为所有者,但目前存在一些与动态调度相关的例外情况,这种情况仍然可能发生。将来可能会完全消除。这两个我什至从未见过发生过,它们之所以存在,主要是因为我不够聪明,无法写出它们不可能发生的正式证明,然后确保我的代码与证明相匹配 :) “基本事实”算法可以在typecheck.h中找到,本节只是对其的描述。如果你希望得到像典型的 PL 论文那样的方程式页面,我将不得不失望。等等,什么,类型检查器?是的,尽管我更愿意让所有权分析成为一个独立的算法,但它与类型检查交织在一起。这样做的原因是 Lobster 的很多功能都集中在它的“Flow Sensitive Type Specialization”,这意味着它按调用图顺序对函数进行类型检查,并根据类型对它们进行专门化。事实证明,为了最优化地删除引用计数操作,我们也希望专注于所有权。让它成为一个单独的算法意味着重复很多这样的逻辑,所以我决定交错它们。所有权分析与大多数其他语言的工作方式不同,它不仅适用于变量和其他存储位置,而且适用于语言中的所有值。更准确地说,每个 AST 节点都有一个它期望其子节点的所有权“种类”,以及它传递给其父节点的所有权种类。因此,该算法的核心是发生在孩子和父母之间的匹配:

当两人同意时,什么都不会发生。这是大部分时间。例如在 let a = [ 1, 2, 3 ] 中,向量想要拥有,而变量想要拥有,每个人都很高兴。没有触及引用计数。当父母想要拥有,但孩子想要借用时,要么必须插入引用计数增加(默认情况下),要么是错误(可选)。例如,在 let a = b 中,b 想借(借)自己,而 a 想拥有,所以这要调和。当父母想要借用,而孩子想要拥有时,我们在当前作用域中插入一个匿名变量,稍后将删除该值,就像 C++ 中的 R 值发生的情况一样。例如在 print [ 1, 2, 3 ] 中,像大多数内置函数一样,print 默认只想要借用值,并且想要拥有向量。因此,它会在作用域末尾保留并删除。拥有的价值观很简单……这些价值观是任何时候都需要有人负责的。借来的值有点复杂。每当表达式返回变量、字段或向量元素的值时,我们希望默认借用这些值,因为 L 值已经拥有它。我们有一堆当前处于活动状态的 L 值,表达式返回的所有权类型是指这些堆栈元素。这些堆栈值也被引用计数,因为多个借用可能一次处于活动状态。这个引用计数随着借来的值被“消耗”而减少。使用引用计数 > 0 改变借用的 L 值会产生上述错误。最后引用计数不为 0 是所有权分析中的一个错误 :) 请注意,变量当前始终拥有。这样做是为了简单起见,因为理论上可以根据其初始 RHS 的需要使变量拥有或借用,但在定义和后续分配不一致的情况下,这很尴尬,并且通常使所有权分析更加困难。一旦更好地理解算法的结果,或者当存在更准确的数据流信息时,这可能会在未来重新审视。变量生命周期的结束通常是作用域的结束,尽管正在处理使其“最后一次使用”,请参见下面的返回。

函数所有权特化可能是 Lobster 独有的,并且解决了一个重要问题:如果函数的不同调用者具有不同的所有权类型,这对于它们的参数来说是理想的,编译器(或者更糟的是,程序员)将不得不选择一个,并且让其他人是次优的。这不仅会产生不必要的引用计数,还会减少在严格所有权的上下文中可以无错误运行的代码量。例如:如果 x 由于第一次调用而被固定为自己的,那么第二次调用的效率就会降低,甚至会出错。现在,由于专业化,他们都获得了最大的效率。现在在这个简单的例子中,很容易看出 x 应该真正借用,但它并不总是那么简单,并且需要更复杂的所有权分析,使用“逻辑变量”,就像当前的类型推断(假设你想要在没有程序员帮助的情况下执行此操作)。这些是当前不同 AST 类型如何与其子项(想要)和父项(结果)进行终生交互的方式。请注意,大多数代码还使用所有权类型“any”,这意味着所有权无关紧要(例如标量),或者接收者对任何类型的所有权都无所谓。如果导致两个分支的所有权类型的联合。如果两者相等或任何一方都不关心,则联合很简单。如果它们不同,则默认为拥有。它有专门的代码来检测一个分支是否永远不会返回(其中有一个 return 语句来转义 if),其中所有权类型是另一个分支的。条件想借。如果有一个分支当然不会产生任何结果。 switch 类似于 if,但目前总是默认拥有案例返回的值,因为在分支不返回的上下文中在许多案例之间进行正确的所有权类型联合更难。这应该改进。 and 和 or 再次类似于 if,但有一些特殊情况,因为有时保证忽略一个值(a 和 b 或 c 永远不会导致 a)或者值在它们不兼容时被强制为 bool(“a”或“b”导致想要拥有的字符串,但 [ 1 ] 或 "hello" 导致立即删除值的布尔值)。

因为借用了它迭代的东西并且不关心它的身体。它产生一个想要拥有的元素变量。注意:这可能会在未来更改以允许借用。字符串常量在概念上会产生一个拥有的值(它们是一个堆对象),但目前它们实际上会产生一个借用。这是因为字符串最常传递给更愿意借用的上下文(如 + 或许多内置函数),从而产生大量引用计数流失。为避免这种情况,VM 在第一次使用时分配字符串,但绝不让引用计数降至 0,这意味着它们可以安全地借用,并减少总引用计数。或者更简单地说:就好像每个实际使用的字符串常量都存在一个匿名全局变量,您可以从中借用。 + 在字符串上想要借用,并返回拥有。对于所有二元运算符,原则上都是相同的,但是由于“内联结构”,这对大多数运算符不再重要。默认情况下,内置函数想要借用它们的所有参数,并返回一个拥有的值。这条规则也有例外,可以在声明内置函数时进行标记,主要针对直接对向量或其他数据结构进行操作的函数。例如 push 想要拥有它的第二个参数,而 top 返回一个借用的值。如上所述, return 通常希望拥有它返回的内容,但目前对于返回单个变量的常见情况是一个例外,它假装借用它。这是因为否则它通常会导致引用计数增加,然后随着变量超出范围而减少。扩展发生这种情况的情况,并且通常还允许变量的最后一次使用归其最后一次使用而不是等待范围结束,计划作为未来的改进。