使用jemalloc在Go中手动进行内存管理

2020-11-22 06:10:32

自2015年成立以来,Dgraph Labs一直是Go语言的用户。五年后,Go代码达到20万行,我们很高兴地报告,我们仍然坚信Go过去并且仍然是正确的选择。我们对Go的兴奋已经超出了构建系统的范围,甚至使我们甚至可以使用Go编写脚本,而这些脚本通常是用Bash或Python编写的。我们发现使用Go可以帮助我们构建干净,可读,可维护并且最重要的是高效并发的代码库。

但是,自早期以来,我们一直关注的一个领域是:内存管理。我们没有反对Go垃圾收集器的方法,但是尽管它为开发人员提供了便利,但是它具有其他内存垃圾收集器所面临的相同问题:它根本无法与手动内存管理的效率竞争。

当您手动管理内存时,内存使用率较低,可预测并且允许内存分配的突发事件不会导致内存使用率的疯狂飙升。对于使用Go内存的Dgraph,所有这些都是一个问题1.实际上,Dgraphr用尽内存是我们收到了用户的非常普遍的投诉。

诸如Rust之类的语言之所以得到发展,部分原因在于它允许安全手动进行内存管理。我们可以完全理解。

根据我们的经验,与尝试用垃圾回收语言优化内存使用情况相比,进行手动内存分配和追踪潜在的内存泄漏所花费的精力更少。手动内存管理在构建具有几乎无限的可伸缩性的数据库系统时值得付出麻烦。

我们对Go的热爱和避免使用Go GC的需求使我们找到了Go中进行手动内存管理的新颖方法。当然,大多数Go用户将永远不需要手动内存管理。除非您需要,否则我们建议您不要这样做。当您确实需要它时,您就会知道。

在这篇文章中,我将分享我们在Dgraph Labs中从手动内存管理的探索中学到的知识,并说明我们如何在Go中手动管理内存。

灵感来自Cgo Wiki的有关将C数组转换为Goslices的部分。我们可以使用malloc在C中分配内存,并使用不安全的内存将其传递给Go,而不会受到Go GC的干扰。

导入“ C”导入“不安全” ... var theCArray * C.YourType = C.getTheArray()长度:= C.getTheArrayLength()slice:=(* [1 << 28] C.YourType)(unsafe.Pointer (theCArray))[:length:length]

注意:当前的实现存在一个错误。尽管允许Go代码向C存储器写入nil或C指针(而不是Go指针),但是如果C存储器的内容似乎是Go指针,则当前实现有时可能会导致运行时错误。因此,如果Go代码要在其中存储指针值,请避免将未初始化的C内存传递给Go代码。将C中的内存清零,然后再传递给Go。

因此,我们不使用malloc,而是使用其价格稍高的同级Calloc。 calloc的工作方式与malloc相同,除了在将内存返回给调用方之前将内存清零。

我们首先实现基本的Calloc和Free函数,它们通过Cgo为Go分配和取消分配字节片。为了测试这些功能,我们开发并运行了连续的内存使用测试。这个测试无休止地重复了一个分配/取消分配循环,在该循环中,它首先分配了各种随机大小的内存块,直到分配了16GB的内存,然后释放了这些块,直到仅分配了1GB的内存。

此程序的C等效行为符合预期。我们会看到htop中的RSSmemory增加到16GB,然后下降到1GB,再增加回16GB,依此类推。但是,使用Calloc和Free的Go程序在每个周期后会逐渐使用更多的内存(请参见下表)。

由于默认的C.calloc调用中缺乏线程意识,我们将此行为归因于内存碎片。在Go#dark-arts Slack频道提供了一些帮助(特别感谢Kale Blankenship)之后,我们决定尝试jemalloc。

jemalloc是通用的malloc(3)实现,它强调避免碎片整理和可扩展的并发支持。 jemalloc于2005年首次成为FreeBSD libc分配器,此后它便进入了许多依赖其可预测行为的应用程序。 — http://jemalloc.net

我们将API切换为使用jemalloc 3进行calloc和免费调用。而且它表现出色:jemalloc原生支持线程而几乎没有内存碎片。来自我们的内存使用情况监视测试的分配-解除分配周期在预期的限制之间循环,而忽略了运行测试所需的少量开销。

为了确保我们正在使用jemalloc并避免名称冲突,我们在安装过程中添加了je_前缀,因此我们的API现在正在调用je_calloc和je_free,而不是calloc和free。

在上图中,通过C.calloc分配Go内存导致主要的内存碎片,导致该程序在第11个周期内占用了20GB的内存。与jemalloc等效的代码没有明显的碎片,每个周期下降到接近1GB。

在程序结束时(最右边的小凹处),释放了所有分配的内存之后,C.calloc程序仍然占用了不到20GB的内存,而jemalloc则显示了400MB的内存使用量。

ptr:= C.je_calloc(C.size_t(n),1)如果ptr == nil {//注意:throw就像是恐慌,除了它保证进程将//终止。下面的调用正是Go运行时无法//分配内存时调用的。 throw(“内存不足”)} uptr:= unsafe.Pointer(ptr)atomic.AddInt64(&numBytes,int64(n))//将C指针解释为指向Go数组的指针,然后进行切片。返回(* [MaxArrayLen] byte)(uptr)[:n:n]

我们将此代码作为Ristretto的z软件包的一部分,因此Dgraph和Badgercan都可以使用它。为了使我们的代码切换到使用jemalloc分配字节片,我们添加了一个构建标签jemalloc。为了进一步简化我们的部署,我们通过设置正确的LDFLAGS,使jemalloc库在任何生成的Go二进制文件中静态链接。

现在我们有了分配和释放字节片的方法,下一步是使用它来布局Go结构。我们可以从一个基本的(完整的代码)开始。

类型node struct {val int next * node} var nodeSz = int(unsafe.Sizeof(node {}))func newNode(val int)* node {b:= z.Calloc(nodeSz)n:=(* node)( unsafe.Pointer(&b [0]))n.val = val return n} func freeNode(n * node){buf:=(* [z.MaxArrayLen] byte)(unsafe.Pointer(n))[:nodeSz: nodeSz] z.Free(buf)}

在上面的代码中,我们使用newNode在C分配的内存上布置了Go结构,并创建了一个对应的freeNode函数,一旦完成了该结构,就可以释放内存。 Go结构体具有基本数据类型int和指向下一个节点结构体的指针,所有这些结构体均在程序中设置和访问。我们分配了2M个节点对象,并从其中创建了一个链接列表,以演示jemalloc的正常运行。

使用默认的Go内存,我们看到有2M个对象为链表分配了31 MiB的堆,但没有通过jemalloc分配任何东西。

$ go run。分配的内存:0对象:2000001node:0 ... node:2000000释放后。分配的内存:0 HeapAlloc:31 MiB

使用jemalloc build标签,我们看到通过jemalloc分配了30 MiB的内存,在释放链接列表后,该内存下降为零。 Go堆分配仅为399 KiB,这可能来自运行程序的开销。

$ go run -tags = jemalloc。分配的内存:30 MiB对象:2000001node:0 ... node:2000000释放后。分配的内存:0 HeapAlloc:399 KiB

上面的代码可以很好地避免通过Go分配内存。但是,这样做是有代价的:降低性能。随着时间运行两个实例,我们看到没有jemalloc,该程序在1.15s内运行。使用jemalloc时,它在5.29s时的速度慢了约5倍。

$时间去运行。去运行。 1.15s用户0.25s系统162%cpu 0.861 total $ time go run -tags = jemalloc .go run -tags = jemalloc。 5.29s用户0.36s系统108%cpu 5.200总计

我们将性能降低归因于这样的事实,即每次分配内存时都会进行Cgo调用,并且每个Cgo调用都会带来一些开销。为此,我们在ristretto / z包中编写了一个Allocator库。该库在一个调用中分配了更大的内存块,然后可用于分配许多小对象,从而避免了昂贵的Cgo调用。

分配器从缓冲区开始,用尽后创建一个两倍大小的新缓冲区。它维护着所有已分配缓冲区的内部列表。最后,当用户处理完数据后,他们可以调用Release一次释放所有这些缓冲区。请注意,分配器不执行任何内存移动操作,这有助于确保我们拥有的所有结构指针保持有效。

尽管这看起来有点像tcmalloc / jemalloc使用的平板式内存管理,但这要简单得多。一旦分配,您将无法仅释放一个结构。您只能释放分配器4使用的所有内存。

分配器做得很好的是在完成时以便宜和免费的方式布局数百万个结构,而无需涉及Go堆。上面显示的同一程序在使用新的分配器构建标记运行时,其运行速度甚至比Go内存版本还要快。

$ time go运行-tags =“ jemalloc,allocator” .go运行-tags =“ jemalloc,allocator”。 1.09s用户0.29s系统143%cpu 0.956

从Go 1.14开始,-race标志打开内存对齐检查forstructs。分配器具有一个AllocateAligned方法,该方法返回以正确的指针对齐开始的内存,以通过这些检查。根据结构的大小,这可能会导致一些内存浪费,但由于正确的字边界,会使CPU指令更高效。

我们面临另一个内存管理问题:有时内存分配发生在与释放不同的地方。这两个地方之间唯一的通信可能是分配的结构,无法传递实际的分配器对象。为了解决这个问题,我们为每个分配器对象分配一个唯一的ID,这些对象存储在uint64引用中。每个新的分配器对象都会根据其引用存储在全局地图上。然后可以使用此引用来调用分配对象,并在不再需要数据时将其释放。

如上所示,在手动分配结构时,重要的是要确保结构内没有对Go分配内存的引用。考虑对上述结构进行轻微修改:

让我们使用上面定义的root:= newNode(val)函数来手动分配节点。但是,如果我们然后设置root.next =&node {val:val},它通过Go内存分配链表中的所有其他节点,则必然会遇到以下分段错误:

$ go run -race -tags =“ jemalloc”。分配的内存:16 B对象:2000001意外的故障地址0x1cccb0严重错误:fault [信号SIGSEGV:分段违规代码= 0x1 addr = 0x1cccb0 pc = 0x55a48b]

Go分配的内存会被垃圾回收,因为没有有效的Go结构指向它。只有C分配的内存在引用它,而Go堆对此没有任何引用,从而导致上述错误。因此,如果您创建一个结构并手动为其分配内存,那么确保所有递归可访问字段也都被手动分配非常重要。

例如,如果上面的结构使用一个字节片,我们也将使用分配器分配该字节片,以避免将Go内存与C内存混合。

分配器非常适合手动分配数百万个结构。但是,在某些情况下,我们需要创建数十亿个小对象并对它们进行排序。即使使用Allocator,也可以在Go中做到这一点,就像这样:

var个节点[] * nodefor i:= 0;我<1e9; i ++ {b:= allocator.AllocateAligned(nodeSz)n:=(* node)(unsafe.Pointer(&b [0]))n.val = rand.Int63()nodes = append(nodes,n)} sort.Slice (节点,func(i,j int)bool {return节点[i] .val <节点[j] .val})//节点现在按val的升序排序。

所有这些1B节点都是在分配器上手动分配的,这很昂贵。我们还需要在Go中支付分片的成本,而分片的成本为8GB(每个节点指针8个字节,1B条目)本身就非常昂贵。

为了处理这些用例,我们构建了z.Buffer,可以将其映射到文件上,以允许Linux根据系统的需要对内存进行分页。它实现了io.Writer并取代了对bytes.Buffer的依赖。

更重要的是,z.Buffer提供了一种分配较小数据片段的新方法。通过调用SliceAllocate(n),z.Buffer将写入要分配的切片的长度(n),然后分配切片。这使z.Buffer能够理解切片边界,并使用SliceIterate正确地对其进行迭代。

对于排序,我们最初尝试从z.Buffer获取切片偏移量,访问切片进行比较,但仅对偏移量进行排序。给定一个偏移量,z.Buffer可以读取该偏移量,找到切片的长度并返回该切片。因此,该系统允许我们以排序的顺序访问切片,而不会引起任何内存移动。虽然这很新颖,但是这种机制给内存带来了很大压力,因为我们仍然要付出8GB的内存罚款,只是为了将这些补偿带入Go内存。

我们遇到的一个关键限制是切片的大小不同。此外,我们只能按顺序访问这些片段,而不能按逆序或随机顺序访问这些片段,而无需事先计算和存储偏移量。交换。 Go的排序方式.Slice的工作原理相同,因此不适用于z.Buffer。

由于这些限制,我们发现合并排序算法最适合此工作。通过归并排序,我们可以按顺序操作缓冲区,只占用缓冲区大小的一半以上的内存,这不仅比将偏移量引入内存更便宜,而且在可预测性方面要好得多的内存使用开销(大约缓冲区大小的一半)。更好的是,运行合并排序所需的开销本身就是内存映射的。

合并排序也有一个非常积极的作用。使用基于偏移量的排序,我们必须在遍历和处理缓冲区的同时将偏移量保留在内存中,这给内存带来了更大的压力。使用归并排序,在迭代开始时会释放所需的额外内存,这意味着更多的内存可用于缓冲区处理。

z.Buffer还支持通过Calloc分配内存,并在内存超过用户指定的限制时自动对其进行内存映射。这使得它在所有大小的数据上都能很好地工作。

buffer:= z.NewBuffer(256 << 20)//通过Calloc.buffer.AutoMmapAfter(1 << 30)从256MB开始//变为1GB后自动映射它。for i:= 0;我<1e9; i ++ {b:= buffer.SliceAllocate(nodeSz)n:=(* node)(unsafe.Pointer(&b [0]))n.val = rand.Int63()} buffer.SortSlice(func(left,right [] byte)bool {nl:=(* node)(unsafe.Pointer(&left [0]))nr:=(* node)(unsafe.Pointer(&right [0]))return nl.val

如果不涉及内存泄漏,所有讨论将是不完整的。现在,我们正在使用手动内存分配,因此肯定会有内存泄漏,而我们忘记了释放内存。我们如何抓住那些?

我们早期做的一件简单的事情是让原子计数器跟踪通过这些调用分配的字节数,因此我们可以快速知道通过z.NumAllocBytes()在程序中手动分配了多少内存。如果在我们的内存测试结束时仍然还有剩余的内存,则表明存在泄漏。

当确实发现泄漏时,我们最初尝试使用jemalloc memoryprofiler。但是,我们很快意识到这没有帮助。由于Cgo边界,它没有看到整个调用堆栈。探查器看到的只是来自相同z.Calloc和z.Free调用的分配和取消分配。

借助Go运行时,我们能够快速构建一个简单的系统来将调用者捕获到z.Calloc中,并将其与z.Free调用进行匹配。该系统需要互斥锁,因此我们默认不选择启用它。取而代之的是,我们使用一个泄漏构建标记来为我们的开发版本打开泄漏调试消息。这会自动检测泄漏,并打印出发生泄漏的地方。

//如果启用了泄漏检测。pc,_,l,ok:= runtime.Caller(1)if ok {dallocsMu.Lock()dallocs [uptr] =&dalloc {pc:pc,no:l,sz:n, } dallocsMu.Unlock()} //诱导泄漏以演示泄漏捕获。第一个数字显示//分配的大小,然后显示函数和//进行分配的行号。$ go test -v -tags =“ jemalloc泄漏” -run = TestCalloc ...泄漏:128函数:github.com/dgraph-io/ristretto/z.TestCalloc 91

使用这些技术,我们可以兼得两全:在关键的,受内存限制的代码路径中,我们可以进行手动内存分配。同时,我们可以在非关键代码路径中获得自动垃圾回收的好处。即使您不习惯使用Cgo或jemalloc,也可以将这些技术应用到更大的Go内存块中,从而产生类似的影响。

上面提到的所有库都可以在Ristretto / z软件包的Apache 2.0许可下获得。 memtest和演示代码位于contrib文件夹中。

使用这些库已经使Badger和Dgraph(尤其是Badger)获得了巨大的收益。现在,我们可以在有限的内存使用情况下处理数TB的数据,这与您从C ++程序所期望的一致。我们正在进一步确定需要对Go内存施加压力的区域,并通过切换到手动内存管理来缓解压力。

Dgraph v20.11(T'Challa)版本将是第一个包含所有这些内存管理功能的版本。我们的目标是确保Dgraph绝不需要超过32 GB的物理RAM来运行任何类型的工作负载。使用z.Calloc,z.Free,z.Allocator和z.Buffer可以帮助我们通过Go实现这一目标。

多年来,我们尝试了Go中所有的交易技巧。使用sync.Pool,维护我们自己的空闲列表,尽可能避免在堆上分配内存,使用缓冲区舞台等。 ↩︎

当您获得使用手动内存管理语言编写的经验时,您会着眼于分配和释放。此外,性能分析工具还可以帮助您确定内存泄漏,以从代码库中消除它们。这与在Go中编写代码时获得令人敬畏的并发模式没有什么不同。并发和手动内存管理对于外部人员而言尤其困难,但是对于定期使用这些语言的开发人员而言,这只是游戏的一部分。 ↩︎

实际上,由于需要互斥锁,一些在分配器中管理自由列表的实验被证明比仅使用Calloc和Free慢。 ↩︎

从切片的感知来看,可变长度字符串的片段var buf [] string的大小仍然是固定的。 buf [i]和buf [j]占用的内存量完全相同,因为它们都是指向字符串的指针,并且可以很容易地在buf中交换。这里不是这种情况,因为字节片被放置在更大的字节缓冲区上。 ↩︎