转载 | 优化Golang中的堆分配:案例研究

Apr 27, 2025

我参与开发 Dolt,这是第一个具有类似 git 版本控制功能的 SQL 数据库,完全用 Go 语言编写。通常来说,数据库需要非常快速。因此,我们在持续集成(CI)工作流中做了大量测试,以便在代码合并到主分支之前监控性能回退。

上个月,一次本应无操作(no-op)的重构提交,导致 sysbench 的 types_scan 基准测试性能回退了 30%。

这次更改涉及一个名为 ImmutableValue 的类型,它表示内容哈希,一个包含该哈希数据的可选字节缓冲区,以及一个 ValueStore 接口,该接口可以将内容哈希解析为二进制大对象(binary blobs)。ImmutableValue 有一个 GetBytes 方法,会检查二进制大对象是否已经加载,如果还没有加载,则使用 ValueStore 将其加载到缓冲区中,然后返回该缓冲区。重构的部分目标是将有关 ValueStore 如何解析哈希的具体实现细节隐藏起来,通过将这些细节移动到接口方法中实现。

下面是经过清理的原始 GetBytes 实现版本:

func (t *ImmutableValue) GetBytes(ctx context.Context) ([]byte, error) {
  if t.Buf == nil {
      err := t.load(ctx)
      if err != nil {
          return nil, err
      }
  }
  return t.Buf[:], nil
}

func (t *ImmutableValue) load(ctx context.Context) {
  if t.Addr.IsEmpty() {
      t.Buf = []byte{}
      return
  }
  WalkNodes(ctx, &t.valueStore, h, func(ctx context.Context, n Node) {
        if n.IsLeaf() {
            t.Buf = append(t.buf, n.GetValue(0)...)
        }
  })
}

以下是新的实现的一个简化版本:

func (t *ImmutableValue) GetBytes(ctx context.Context) ([]byte, error) {
    if t.Buf == nil {
        if t.Addr.IsEmpty() {
            t.Buf = []byte{}
            return t.Buf, nil
        }
        buf, err := t.valueStore.ReadBytes(ctx, t.Addr)
        if err != nil {
            return nil, err
        }
        t.Buf = buf
    }
    return t.Buf, nil
}

// Assert that nodeStore implements ValueStore
var _ ValueStore = &nodeStore{}

type nodeStore struct {
  chunkStore interface {
    WalkNodes(ctx context.Context, h hash.Hash, cb CallbackFunc)
  }
  // Other fields removed for simplicity
}

func (vs nodeStore) ReadBytes(ctx context.Context, h hash.Hash) (result []byte) {
    vs.chunkStore.WalkNodes(ctx, h, func(ctx context.Context, n Node) {
        if n.IsLeaf() {
            result = append(result, n.GetValue(0)...)
        }
    })
    return result
}

性能分析显示,新的 ReadBytes 方法有三分之一的运行时间花在调用 runtime.newobject 上,这是 Go 用来在堆上分配内存的内置函数。而这种分配仅在新的实现中发生。

根据以上情况,这个练习有两个问题需要思考:

  • 额外的内存分配发生在哪里?
  • 为什么这次分配发生在堆上?为什么不在栈上发生?

解决方法如下:

- func (vs nodeStore) ReadBytes(ctx context.Context, h hash.Hash) (result []byte, err error) {
+ func (vs *nodeStore) ReadBytes(ctx context.Context, h hash.Hash) (result []byte, err error) {

一个字符的改变导致了性能差异达 30%。

Value Receivers vs Pointer Receivers

Go 不是一门面向对象的语言。它没有继承或抽象类,实际上它根本不存在类。它具有动态分发功能,但仅限于接口。

Go 确实有方法(methods),它们指定一个接收者类型,并且可以在该类型的值上调用。方法不仅用于实现接口,也充当函数的命名空间。因此,忽略接口的情况下,func (receiver ReceiverType) Foo(param ParamType) 等价于 func ReceiverType.Foo(receiver ReceiverType, b ParamType),并且具有以下额外特性:

ReceiverType 必须是值类型(即不是指针也不是接口的类型),或者是指向值类型的指针。该值类型称为方法的基础类型。所以,如果基础类型是 T,那么接收者可以是 T*T

对于基础类型 T(意味着接收者类型是 T*T),表达式 a.Foo 是有效的,前提是 aT*T

这意味着以下所有方式都是调用方法的有效写法:

type ReceiverType struct {
  s string
}

func (receiver ReceiverType) ValueReceiver() {
  fmt.Printf("called ValueReceiver(%s)", receiver.s)
}
func (receiver *ReceiverType) PointerReceiver() {
    fmt.Printf("called PointerReceiver(%s)", receiver.s)
}

value := ReceiverType{"value"}
pointer := &ReceiverType{"pointer"}

func main() {
  value.ValueReceiver()    // 1) This is equivalent to ReceiverType.ValueReceiver(value)
  value.PointerReceiver()  // 2) This is equivalent to ReceiverType.PointerReceiver(&value)

  pointer.PointerReceive() // 3) This is equivalent to ReceiverType.PointerReceive(pointer)
  pointer.ValueReceiver()  // 4) This is equivalent to ReceiverType.ValueReceiver(*pointer)
}

注意,选项4涉及对接收者进行解引用,并将该值作为函数参数传递。这意味着接收者的值会被复制。

这就是我们对第一个问题的回答:因为 ReadBytes 方法使用的是值接收者,每次调用时都会创建一个新的 nodeStore

但问题不仅仅是调用方法时会复制参数:在像 Go 这样按值传递的语言中,函数参数总是会被复制。但通常这些副本存在于栈上,在栈上创建值的开销相对较小。问题在于这些副本是创建在堆上,且即使是很小的堆分配,当数量庞大时,也会造成较大的开销。

Stack Allocation vs Heap Allocation

在许多语言中,是否在栈上还是堆上创建某个对象通常是显而易见的。例如在 C++ 中,函数参数和局部变量总是在栈上,唯一进行堆分配的方式是显式使用像 new 这样的关键字。这种程度的控制可以使程序性能更可预测,但也容易导致出现指向已释放内存的指针。下面是一个 C++ 的例子:

#include <iostream>
#include <string>

int* getPointer(int x) {
    return &x;
}

int main()
{
  int* ptr1 = getPointer(1);
  int* ptr2 = getPointer(2);
  std::cout << *ptr1;
}

运行上述代码会打印“2”,而不是你可能预期的“1”。这是因为函数参数 x 存在于栈上,并且在 getPointer 函数返回后立即不复存在。该内存随后被重新用于后续调用,导致被指向的内存被新的栈帧覆盖。

对于从 C++ 转到 Go 的人来说,最令人惊讶的部分之一是,等效的 Go 代码是完全安全且正确的。

func GetPointer(x int) *int {
    return &x
}

func main() {
    ptr1 := GetPointer(1)
    ptr2 := GetPointer(2)
    fmt.Println(*ptr1)
}

第一次调用中创建的指针依然有效,尽管它指向的是一个已经不存在的栈帧中的函数参数。这看起来像魔法,但其实不是:这里对 x 的引用是有效的,因为 x 被分配到了堆上。Go 编译器允许将局部变量分配到堆上,这也包括函数参数。在这种情况下,编译器推断出指向 x 的指针会比函数调用存在的时间更长,因此它保证该值被分配在堆上,以确保指针保持有效。

在我们的实际代码中,vs 接收者值就是在堆上分配的。但为什么会这样呢?你如何影响 Go 是将变量分配在堆上还是栈上?

不幸的是,你无法做到这一点。如果语言能够让编译器知道某个变量必须分配在栈上,并且在无法满足时强制编译时错误,那将会很不错。但 Go 并没有提供这样的机制。栈分配和堆分配的概念在语言里甚至不存在。用户预计不需要关心它……当然,直到你在进行性能优化时,才必须关注它。

所以,让我们带着这个问题来看一看我们的代码。新复制的接收者值被存储在变量 vs 中。这个内存是否可能存在时间比 GetBytes 调用还长的指针?

下面是函数以及 nodeStore 类型定义:

type nodeStore struct {
  chunkStore interface {
    WalkNodes(ctx context.Context, h hash.Hash, cb CallbackFunc)
  }
  // Other fields removed for simplicity
}

func (vs nodeStore) ReadBytes(ctx context.Context, h hash.Hash) (result []byte) {
    vs.chunkStore.WalkNodes(ctx, h, func(ctx context.Context, n Node) {
        if n.IsLeaf() {
            result = append(result, n.GetValue(0)...)
        }
    })
    return result
}

我们看到在方法调用 vs.chunkstore.WalkNodes 时,vs 被解引用。理论上,像这样的调用点可能会导致引用泄漏:如果 chunkstore 是值类型,而 WalkNodes 拥有指针接收者,那么调用时会隐式获取 chunkstore 的地址,而这个地址指向我们新复制的结构体中的内容。但仔细观察,这里并非如此,因为 chunkstore 是一个接口。在 Go 语言中,接口值本质上是一个智能指针。因此,传递给 WalkNodes 函数的值,要么是实现该接口的值的指针,要么是该值的副本。在这两种情况下,接收者都不会指向 vsvs 不会逃逸(escape)。它不会存活超过对 GetBytes 的调用,因此也不需要被存储在堆上。

但是编译器为什么还是将它存储在堆上呢?

之前我说过当编译器“推断出指向 x 的指针将存在时间超过函数调用”时,会将局部变量分配到堆上。但更准确的说法是,编译器在“无法推断出指向 x 的指针不会超出函数调用时间”时,才选择堆分配。这是因为如果编译器无法确定是否发生逃逸,它必须谨慎处理,假设会发生逃逸。堆分配虽然较慢,但总是安全的。

编译器试图证明一个变量不会超过当前栈活跃时间的过程称为逃逸分析。

【官方文档中详细讲解了编译器的逃逸分析,这里给出链接】(https://go.dev/doc/faq#stack_or_heap) 引用如下:

在当前的编译器中,如果一个变量被取址,该变量就有可能被分配到堆上。但是,基本的逃逸分析可以识别出某些情况下,这些变量不会在函数返回后继续存活,因此可以将它们分配在栈上。

重点是我的:这是一个基本的逃逸分析。因此,尽管我们知道方法执行完毕后,对被复制的接收者不会有任何引用,但逃逸分析可能不够复杂,无法检测出这一点。

语言确实给了我们一点小提示:虽然我们无法控制一个值会被分配到哪里,但可以通过运行 go build -gcflags "-m" 命令,让编译器告诉我们该值被分配到了哪里以及原因。

当我带着这个额外的参数编译 Dolt 时,我在输出中发现了以下内容:

store/prolly/tree/node_store.go:93:7: parameter ns leaks to {heap} with derefs=1:
store/prolly/tree/node_store.go:93:7:   flow: {heap} = *ns:
store/prolly/tree/node_store.go:93:7:     from ns.chunkStore (dot of pointer) at store/prolly/tree/node_store.go:99:14
store/prolly/tree/node_store.go:93:7:     from ns.chunkStore.WalkNodes(ctx, ref) (call parameter) at store/prolly/tree/node_store.go:99:24
store/prolly/tree/node_store.go:93:7: leaking param content: ns

编译器看到 ns.chunkStore 被作为参数传递给某个函数,认为该值会发生逃逸。因此,编译器无法确定接收者是否可以安全地存储在栈上。

这可能是编译器的一个 bug。也有可能存在一些边缘情况,通过该方法调用引用确实可以逃逸,而编译器没有足够的上下文来排除这种可能性。编译器很复杂,我可以理解它可能做出任何一种判断。

The Takeaway

解决方法很简单:使用指针接收者而不是值接收者,避免不必要的复制。但是,探究这次回归的原因却揭示了令人惊讶的复杂行为。

如果这次调查让我们有所收获,那就是:

  • 理解为什么编译器会在内存分配上做出决策对于编写高性能代码非常重要。像 -gcflags "-m" 这样的工具能够提供对编译器决策的深入见解,对于理解和优化性能非常有帮助。
  • 在有垃圾回收的语言中,将值存储在堆上始终是安全的,但这会带来性能开销:不仅在最初分配时有成本,垃圾回收时也会产生开销。由于堆分配总是安全的,除非能够证明栈分配也是安全的,否则 Golang 会优先选择堆分配。
  • 推荐使用指针接收者,以避免不必要的复制,因为这些复制很容易导致额外的堆分配。

https://inasa.dev/posts/rss.xml