Golang:使用Arenas的手动内存管理方案

2022-02-26 04:48:23

我们建议为围棋实现记忆竞技场。竞技场是一种从连续的内存区域分配一组内存对象的方法,其优点是从竞技场分配对象通常比一般内存分配更有效,而且更重要的是,竞技场中的所有对象都可以一次释放,只需最小的内存管理或垃圾收集开销。竞技场通常不是为垃圾收集语言实现的,因为它们显式释放竞技场内存的操作不安全,因此不符合垃圾收集语义。然而,我们提议的实现使用动态检查来确保无竞技场的操作是安全的。该实现保证,如果无竞技场操作不安全,程序将在任何错误行为发生之前终止。我们已经在Google上实现了arenas,并且在许多大型应用程序的CPU和内存使用方面节省了高达15%,这主要是由于垃圾收集CPU时间和堆内存使用的减少。

围棋是一种垃圾收集语言。应用程序代码从未显式释放分配的对象。Go运行时会自动运行一个垃圾收集算法,在应用程序代码无法访问分配的对象一段时间后释放它们。自动内存管理简化了Go应用程序的编写,确保了内存安全。

然而,大型Go应用程序会花费大量CPU时间进行垃圾收集。此外,平均堆大小通常比需要的大得多,以减少垃圾收集器需要运行的频率。

非垃圾收集语言也有很大的内存分配和去分配开销。为了处理对象生命周期变化很大的复杂应用程序,非垃圾收集语言必须有一个通用堆分配器。由于所分配对象的大小和生命周期不同,这样的分配器必须有相当复杂的代码来为新对象查找内存和处理内存碎片。

减少非垃圾收集语言的分配开销的一种方法是基于区域的内存管理,也称为arenas。其思想是,应用程序有时会遵循这样一种模式:代码段分配大量对象,操作这些对象一段时间,然后完全使用这些对象,从而大致同时释放所有(或几乎所有)对象。代码段可以分配所有对象来计算结果或提供服务,但在计算完成时不需要任何对象(可能只有少数结果对象除外)。

在这种情况下,使用arena进行基于区域的内存分配是有用的。其思想是在代码段的开头分配一个称为竞技场的大内存区域。竞技场通常是一个连续的区域,但可以扩展成大的区块大小。然后所有的物品都可以在竞技场上高效地分配。通常情况下,竞技场中的对象只是连续分配的。然后在代码段的末尾,只需释放竞技场,就可以以非常低的开销释放所有分配的对象。任何预期寿命更长且持续时间超过代码段末尾的结果对象都不应从竞技场中分配,或者应该在竞技场释放之前完全复制。

竞技场对于许多常见的编程模式都很有用,如果适用,它可以减少非垃圾收集语言中的内存管理开销。例如,对于提供内存密集型请求的服务器,每个请求都可能是独立的,因此在满足请求时,可以释放在提供特定请求时分配的大部分或所有对象。因此,在请求期间分配的所有对象都可以在竞技场中分配,然后在请求完成时立即释放。

在相关方面,Arena对于协议缓冲区处理非常有用,尤其是在将wire格式解组到内存中的协议消息对象时。解密一条消息';s wire format to memory可以创建许多大型对象、字符串、数组等,因为消息的复杂性以及子消息在其他消息中的频繁嵌套。一个程序通常可以解组一条或多条消息,在一段时间内使用内存中的对象,然后使用这些对象完成。在这种情况下,在解密消息时创建的所有对象都可以从竞技场中分配并立即释放。C++协议缓冲文档提供了使用ARIENS的一个例子。竞技场可能同样适用于其他类型的协议处理,例如解码JSON。

我们希望在围棋语言中获得竞技场的一些好处。在下一节中,我们将提出一个竞技场的设计,该竞技场适合Go语言,并允许显著的性能优势,同时确保内存安全。

请注意,有许多应用程序arenas将不再有用,包括没有';不能分配大量数据,应用程序分配的对象的生命周期变化很大,不能';不符合竞技场分配模式。竞技场旨在针对对象寿命非常明确的情况进行有针对性的优化。

我们建议在Go标准库中添加一个新的arena包。竞技场套餐将允许分配任意数量的竞技场。可以从竞技场的内存中分配任意类型的对象,竞技场会根据需要自动增大大小。当竞技场中的所有对象不再使用时,竞技场可以显式释放以高效回收其内存,而无需进行常规垃圾收集。我们要求实现提供安全检查,这样,如果无竞技场操作不安全,程序将在任何错误行为发生之前终止。

为了获得最大的灵活性,我们希望API能够分配任何类型的对象和切片,包括可以在运行时通过反射生成的类型。

包竞技场类型竞技场结构{//包含筛选或未报告的字段}//New分配一个新竞技场。func New()*Arena//Free释放竞技场(以及从竞技场分配的所有对象),以便//支持竞技场的内存可以快速重用,而无需垃圾//收集开销。释放后,应用程序不得调用此//arena上的任何方法。func(a*Arena)Free()//New从Arena a分配一个对象。如果objPtr的具体类型是//指向类型T(**T)的指针,New将分配类型为//T的对象,并将指向该对象的指针存储在*objPtr中。竞技场a释放后,不得//访问该对象。func(a*Arena)New(objPtr接口{})//NewSlice从Arena a分配一个切片。如果slicePtr//的具体类型是*[]T,则NewSlice创建一个具有指定//容量的元素类型T的切片,其备份存储来自Arena,并将其存储在//*slicePtr中。切片的长度设置为容量。竞技场a被释放后,不能//访问该片。func(a*Arena)新闻片(slicePtr接口{},cap int)

应用程序可以使用arena创建任意数量的竞技场。新的,每个人都有不同的一生。可以使用.New在特定竞技场中分配具有指定类型的对象,其中a是竞技场。类似地,可以使用a.NewSlice从竞技场分配具有指定元素类型和容量的片段。因为对象和切片指针是通过空接口传递的,所以可以分配任何类型。这包括在运行时通过reflect库生成的类型,因为reflect。值可以很容易地转换为空接口。

应用程序使用.Free显式释放竞技场和从竞技场分配的所有对象。在此调用之后,应用程序不应再次访问竞技场,也不应取消引用指向从此竞技场分配的任何对象的指针。如果应用程序访问内存已被释放的任何对象,则需要实现来引起运行时erro并终止Go程序。关联的错误消息应表明终止是由于访问已释放竞技场中的对象。此外,如果在调用.Free之后调用了.New或a.NewSlice,则实现必须引起恐慌或终止Go程序。a、 如果调用New和a.NewSlice时使用的参数形式不正确,也会引起恐慌(**T代表a.New,而*[]T代表a.NewSlice)。

导入(“arena”…)类型T struct{val int}func main(){a:=arena.New()var ptrT*ta.New(&;ptrT)ptrT.val=1 var sliceT[]T a.NewSlice(&;sliceT,100)sliceT[99]。val=4 a.Free()

可能存在实现定义的限制,例如,如果调用.New或a.NewSlice请求的对象或切片太大,则无法从arena分配该对象。在这种情况下,对象或片是从堆中分配的。如果有这样一个实现定义的限制,我们可能希望有一种方法来公开该限制。我们已经将其列为“开放问题”一节中提到的可能指标之一。另一种API是,如果对象或切片太大,则不分配它,而是保持指针参数不变。这种替代API似乎更有可能导致编程错误,指针参数在被访问或复制到其他地方之前没有被正确检查。

出于优化目的,允许实现延迟实际释放竞技场或其内容。如果使用这种优化,只要对象的内存仍然可用且正确(即不存在错误行为的可能性),如果在释放包含对象的竞技场后访问该对象,则允许该应用程序正常运行。在这种情况下,竞技场的不当使用。将不会检测到Free,但应用程序将正确运行,并且在其他运行期间可能会检测到不正确的使用。

以上四个函数是基本的API,在大多数情况下可能就足够了。还有两个与字符串相关的API调用非常有用。Go中的字符串是特殊的,因为它们类似于切片,但是只读的,必须在创建时用其内容初始化。因此,NewSlice调用不能用于创建字符串。下面的NewString在arena中分配一个字符串,用字节片的内容初始化它,并返回字符串头。

//NewString在竞技场a中分配一个新字符串,它是b的副本,然后//返回新字符串。func(a*Arena)新闻字符串(b[]字节)字符串

此外,在围棋中使用竞技场的一个常见错误是使用从某个全局数据结构(如缓存)中的竞技场分配的字符串,这可能导致在释放竞技场后访问该字符串时出现运行时异常。这个错误是可以理解的,因为字符串是不可变的,所以经常被认为与内存分配是分开的。为了处理分配方法未知的字符串的情况,只有当传入的字符串(更准确地说,它的字节备份数组)是从竞技场分配的时,HeapString才使用堆内存创建字符串的副本。如果已经从堆中分配了字符串,那么它将原封不动地返回。因此,返回的字符串始终可用于可能超过当前领域的数据结构。

//HeapString返回输入字符串的副本,返回的副本//是从堆中分配的,而不是从任何竞技场分配的。如果已经从堆中分配了//,那么实现可能会精确返回s。在应用程序代码不确定s//是否从竞技场分配的某些情况下,此函数//非常有用。func堆字符串(s字符串)字符串

当然,在全局数据结构中错误使用竞技场中的对象的问题可能会发生在字符串以外的其他类型中,但是字符串是跨数据结构共享的常见情况。

我们在";实施";部分请注意,上面的arena API可能在没有实际实现arena的情况下实现,而只是使用标准的Go内存分配原语。我们可以通过这种方式实现API,以便在某些架构上实现兼容性,对于这些架构,真正的arena实现(包括安全检查)无法有效地实现。

上述API有许多可能的替代方案。我们讨论了几个备选方案,部分是为了证明我们选择API的合理性。

对上述API的一个简单调整是取消无竞技场操作。在这种情况下,一旦不再有指向竞技场本身或竞技场中包含的任何对象的指针,竞技场将仅由垃圾收集器自动释放。没有空闲操作的一个大问题是竞技场的大部分性能都得益于更快地重用内存。虽然竞技场中的对象分配速度会稍快一些,但内存使用率可能会大幅增加,因为这些大型竞技场对象在不再使用后,要等到下一次垃圾收集时才能被收集。这将是一个特别有问题的问题,因为竞技场是大块的内存,通常只有部分内存已满,因此会增加碎片。我们在没有显式释放竞技场的情况下设计了这种方法的原型,并且无法为实际应用程序获得明显的性能优势。一个显式的空闲操作几乎可以立即重用竞技场的内存。此外,如果一个应用程序能够使用arenas进行几乎所有的分配,那么垃圾收集可能基本上是不需要的,因此可能会延迟相当长的时间。

一个功能相似但感觉不同的替代API将取代(*Arena)。新建和(*竞技场)。带有以下内容的新闻片:

//New从arena分配给定类型的对象,并返回指向该对象的//指针。func(a*Arena)New(typ reflect.Type)接口{}//NewSlice从//Arena分配给定元素类型和容量的片段,并将该片段作为接口返回。切片的长度//设置为容量。func(a*Arena)NewSlice(typ reflect.Type,cap int)接口{

这个API可能看起来更简单,因为它直接返回分配的对象或切片,而不是要求传入一个指针来指示结果应该存储在哪里。这允许方便地使用Go惯用的短变量声明,但确实需要类型断言来将返回值转换为正确的类型。此备用API指定要使用reflect分配的类型。类型,而不是通过传递包含指向所需分配类型的指针的接口值。对于已经在许多不同类型上工作并使用反射的应用程序和库,请使用反射指定类型。打字可能很方便。然而,对于许多应用程序来说,只传入指向所需类型的指针似乎更方便。

有两种选择的NewSlice调用中存在效率差异。在";提案";节中,切片头对象已在调用方中分配,只需要分配切片的支持元素数组。在许多情况下,这可能就是所需要的,因此效率更高。在本节中的新API中,Slice调用也必须分配Slice对象,以便在接口中返回它,这在通常不需要时会导致额外的堆或竞技场分配。

a.New的另一种选择是传入一个指向类型T的指针,并返回指向类型T的指针(两者都作为空接口):

//新的,假设objPtr的具体类型是指向类型T的指针,//从arena分配类型T的对象,并返回指向//对象的指针。func(a*Arena)新(objPtr接口{})接口{}

这个API调用的一个示例用法是:intPtr:=a.New((*int)(nil))。(*int)。虽然这也允许使用短变量声明,并且不需要使用反射,但其余的用法相当笨拙。

我们可以在API中添加一个可选选项,使用类型参数化以简洁直接的方式表示要分配的类型。例如,我们可以使用通用的NewOf和NewSliceOf函数:

//NewOf返回一个指向T类型对象的指针,该对象是从//arena a.func arena分配的。NewOf[T any](a*Arena)*T

//NewSliceOf返回元素类型为T的片段,容量上限//从arena a func arena分配。[T any]的新闻许可证(a*Arena,cap int)[T]

出于两个原因,我们认为这些API的通用变体不能完全取代上述建议的方法。首先,NewOf函数只能分配在编译时指定类型的对象。因此,它无法满足我们的目标,即支持在运行时(通常通过反射库)计算类型的对象的分配。其次,Go中的泛型刚刚进入Go 1.18,所以我们不想强迫用户在准备好之前使用泛型。

为了适应围棋语言,我们要求围棋中竞技场的语义是完全安全的。然而,我们提出的API有一个明确的无竞技场操作,可能会被错误地使用。应用程序可能会在指向从分配的对象的指针仍然可用时释放竞技场,然后稍后尝试访问从分配的对象。

因此,我们要求Arena的任何实现都必须防止不正确的访问,而不会导致任何不正确的行为或数据损坏。如果访问了一个由于无竞技场操作而已被释放的对象,我们当前的API实现会导致内存故障(并终止Go程序)。

我们当前的实现性能良好,在Linux amd64位体系结构上为许多大型应用程序节省了内存分配和GC开销。目前尚不清楚类似的方法是否适用于地址空间更为有限的32位体系结构。

竞技场的物理页面可以被操作系统用于其他竞技场。

如果指向竞技场a中某个对象的指针仍然存在并且被取消引用,它将出现内存访问故障,这将导致Go程序终止。因为实现知道竞技场的地址范围,所以它可以在终止过程中给出竞技场特定的错误消息。

因此,我们总是通过为每个竞技场使用新的地址范围来确保安全,以便我们总是能够检测到对在现在已释放的竞技场中分配的对象的不当访问。

实际实现与上述想法略有不同,因为如果需要,竞技场会动态增长。在我们的实现中,每个竞技场从一个大的#34;块";,并根据需要通过添加另一个相同大小的块来增量增长。对于64位体系结构上的当前Go运行时,所有数据块的大小都被专门选择为64 MB(兆字节),以便能够在没有内存泄漏的情况下高效地回收堆元数据,并避免碎片。

这些区块的地址范围不需要是连续的。因此,当我们在上面提到每个竞技场A使用不同的地址范围时,我们的意思是每个区块使用不同的地址范围。

每个区块及其包含的所有对象都完全参与GC标记/扫描,直到区块被释放。部分地

......