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

目录

通过Cgo创建内存

jemalloc

在字节片上放置Go结构

用分配器摊销Calloc的成本

明智地参考

处理分配的GB

排序可变长度数据

捕捉内存泄漏

结论

推荐阅读


 

曼尼斯·赖·贾 因(Manish Rai Jain)

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

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

当您手动管理内存时,内存使用率较低,可预测,并且允许突发的内存分配不会引起内存使用率的疯狂飙升。对于使用Go内存的Dgraph,所有这些都是问题1。实际上,Dgraph内存不足是我们从用户那里听到的非常普遍的抱怨。

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

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

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

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

 

通过Cgo创建内存


灵感来自Cgo Wiki的有关将C数组转换为Go切片的部分。我们可以使用mallocC分配内存,然后将 unsafe其传递给Go,而不会受到Go GC的干扰。

import "C"
import "unsafe"
...
        var theCArray *C.YourType = C.getTheArray()
        length := C.getTheArrayLength()
        slice := (*[1 << 28]C.YourType)(unsafe.Pointer(theCArray))[:length:length]

但是,如golang.org/cmd/cgo中所述,以上内容带有警告。

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

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

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

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

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

 

jemalloc


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

我们切换了API,以将jemalloc 3用于callocfree调用。它执行得很漂亮: jemalloc本地支持几乎没有内存碎片的线程。来自我们的内存使用情况监视测试的分配-解除分配周期在预期的限制之间循环,而忽略了运行测试所需的少量开销。

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

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

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


要安装jemalloc,请从此处下载它,然后运行以下命令:

./configure --with-jemalloc-prefix='je_' --with-malloc-conf='background_thread:true,metadata_thp:auto'
make
sudo make install

整个Calloc代码如下所示:

	ptr := C.je_calloc(C.size_t(n), 1)
	if ptr == nil {
		// NB: throw is like panic, except it guarantees the process will be
		// terminated. The call below is exactly what the Go runtime invokes when
		// it cannot allocate memory.
		throw("out of memory")
	}
	uptr := unsafe.Pointer(ptr)

	atomic.AddInt64(&numBytes, int64(n))
	// Interpret the C pointer as a pointer to a Go array, then slice.
	return (*[MaxArrayLen]byte)(uptr)[:n:n]

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

 

在字节片上放置Go结构


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

type 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)
}

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

使用默认的Go内存,我们看到为链结列表分配了31 MiB的内存,包含2M个对象,但没有通过jemalloc分配。

$ go run .
Allocated memory: 0 Objects: 2000001
node: 0
...
node: 2000000
After freeing. Allocated memory: 0
HeapAlloc: 31 MiB

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

$ go run -tags=jemalloc .
Allocated memory: 30 MiB Objects: 2000001
node: 0
...
node: 2000000
After freeing. Allocated memory: 0
HeapAlloc: 399 KiB

 

用分配器摊销Calloc的成本


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

$ time go run .
go run .  1.15s user 0.25s system 162% cpu 0.861 total

$ time go run -tags=jemalloc .
go run -tags=jemalloc .  5.29s user 0.36s system 108% cpu 5.200 total

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

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

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

Allocator做得很好的是便宜地布局数百万个结构,并在完成后释放它们,而不会涉及Go堆。当使用新的allocator构建标记运行时,上面显示的相同程序的运行速度甚至比Go内存版本还要快。

$ time go run -tags="jemalloc,allocator" .
go run -tags="jemalloc,allocator" .  1.09s user 0.29s system 143% cpu 0.956 total

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

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

 

明智地参考


不要从手动分配的内存中引用Go分配的内存。

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

type node struct {
  val int
  next *node
  buf []byte
}

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

$ go run -race -tags="jemalloc" .
Allocated memory: 16 B Objects: 2000001
unexpected fault address 0x1cccb0
fatal error: fault
[signal SIGSEGV: segmentation violation code=0x1 addr=0x1cccb0 pc=0x55a48b]

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

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

b := allocator.AllocateAligned(nodeSz)
n := (*node)(unsafe.Pointer(&b[0]))
n.val = -1
n.buf = allocator.Allocate(16) // Allocate 16 bytes
rand.Read(n.buf)

 

处理分配的GB


分配器非常适合手动分配数百万个结构。但是,在一些用例中,我们需要创建数十亿个小对象并对它们进行排序。即使使用Allocator,人们在Go中执行此操作的方式也类似:

var nodes []*node
for i := 0; i < 1e9; i++ {
  b := allocator.AllocateAligned(nodeSz)
  n := (*node)(unsafe.Pointer(&b[0]))
  n.val = rand.Int63()
  nodes = append(nodes, n)
}
sort.Slice(nodes, func(i, j int) bool {
  return nodes[i].val < nodes[j].val
})
// nodes are now sorted in increasing order of val.

所有这些1B节点都是在上手动分配的Allocator,这很昂贵。我们还需要在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内存。

我们遇到的一个关键限制是切片的大小不同。此外,我们只能按顺序访问这些片段,而不能按相反或随机的顺序访问这些片段,而无需事先计算和存储偏移量。大多数就地排序算法都假定值的大小相同,为5,可以随机访问并且可以轻松交换。Go的sort.Slice工作方式相同,因此不适合使用z.Buffer

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

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

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

buffer := z.NewBuffer(256<<20) // Start with 256MB via Calloc.
buffer.AutoMmapAfter(1<<30)    // Automatically mmap it after it becomes 1GB.

for i := 0; i < 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 < nr.val
})

// Iterate over nodes in increasing order of val.
buffer.SliceIterate(func(b []byte) error {
  n := (*node)(unsafe.Pointer(&b[0]))
  _ = n.val
  return nil
})

 

捕捉内存泄漏


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

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

当我们确实发现泄漏时,我们最初尝试使用jemalloc内存探查器。但是,我们很快意识到这没有帮助。由于Cgo边界,它看不到整个调用堆栈。所有的探查认为是分配和去分配来自同一个未来z.Callocz.Free电话。

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

// If leak detection is enabled.
pc, _, l, ok := runtime.Caller(1)
if ok {
  dallocsMu.Lock()
  dallocs[uptr] = &dalloc{
    pc: pc,
    no: l,
    sz: n,
  }
  dallocsMu.Unlock()
}

// Induced leak to demonstrate leak capture. The first number shows
// the size of allocation, followed by the function and the line
// number where the allocation was made.
$ go test -v -tags="jemalloc leak" -run=TestCalloc
...
LEAK: 128 at func: 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.11T'Challa)版本将是第一个包括所有的这些内存管理功能。我们的目标是确保Dgraph绝不需要超过32 GB的物理RAM来运行任何类型的工作负载。并使用z.Calloc, z.Freez.Allocatorz.Buffer帮助我们实现了与围棋这一目标。


  1. 多年来,我们尝试了Go中所有的交易技巧。使用sync.Pool,维护我们自己的空闲列表,尽可能避免在堆上分配内存,使用缓冲区舞台等。↩︎
  2. 当您获得使用手动内存管理语言编写的经验时,您就会着眼于分配和释放。此外,性能分析工具还可以帮助您确定内存泄漏以从代码库中消除它们。这与在Go中编写代码时关注并发模式没有什么不同。并发和手动内存管理对于外部人员而言尤其困难,但对于定期使用这些语言的开发人员而言,这只是游戏的一部分。↩︎
  3. 我们没有将jemalloc与tcmalloc或其他库进行比较。↩︎
  4. 实际上,由于需要互斥锁,一些在分配器中管理自由列表的实验比仅使用Calloc和Free慢。↩︎
  5. 从切片var buf []string的感知来看,可变长度字符串的切片仍为固定大小。buf[i]buf[j]占用完全相同的内存量,因为它们都是指向字符串的指针,并且可以在内轻松交换buf。这里不是这种情况,因为字节片被放置在更大的字节缓冲区上。↩︎

 

推荐阅读


ptmalloc、tcmalloc与jemalloc内存分配器对比分析

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

 

已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 酷酷鲨 设计师:CSDN官方博客 返回首页