Golang 指针传递 VS 值传递

Jul 12, 2020 15:30 · 797 words · 2 minute read Golang

对于许多 Go 开发者来说,系统性地使用指针来共享 struct 而不是拷贝结构体本身看上去是性能方面的最佳选择。为了理解使用指针来代替值本身的影响,我们来回顾两个用例:

type S struct {
    a, b, c int64
    d, e, f string
    g, h, i float64
}

func byCopy() S {
    return S{
        a: 1, b: 1, c: 1,
        e: "foo", f: "foo",
        g: 1.0, h: 1.0, i: 1.0,
    }
}

func byPointer() *S {
    return &S{
        a: 1, b: 1, c: 1,
        e: "foo", f: "foo",
        g: 1.0, h: 1.0, i: 1.0,
   }
}

基于这两种函数,我们写两个性能测试,一个由值传递:

func BenchmarkMemoryStack(b *testing.B) {
    var s S

    f, err := os.Create("stack.out")
    if err != nil {
        panic(err)
    }
    defer f.Close()

    err = trace.Start(f)
    if err != nil {
        panic(err)
    }

    for i := 0; i < b.N; i++ {
        s = byCopy()
    }

    trace.Stop()
    b.StopTimer()

    _ = fmt.Sprintf("%v", s.a)
}

另一个则是指针传递:

func BenchmarkMemoryHeap(b *testing.B) {
    var s *S

    f, err := os.Create("heap.out")
    if err != nil {
        panic(err)
    }
    defer f.Close()

    err = trace.Start(f)
    if err != nil {
        panic(err)
    }

    for i := 0; i < b.N; i++ {
        s = byPointer()
    }

    trace.Stop()
    b.StopTimer()

    _ = fmt.Sprintf("%v", s.a)
}

跑一下:

$ go test ./... \
    -bench=BenchmarkMemoryStack \
    -benchmem \
    -run=^$ \
    -count=10 > stack.txt && benchstat stack.txt
name            time/op
MemoryStack-16  7.92ns ±11%

name            alloc/op
MemoryStack-16   0.00B

name            allocs/op
MemoryStack-16    0.00
$ go test ./... \
    -bench=BenchmarkMemoryHeap \
    -benchmem \
    -run=^$ \
    -count=10 > heap.txt && benchstat heap.txt
name           time/op
MemoryHeap-16  47.5ns ± 5%

name           alloc/op
MemoryHeap-16   96.0B ± 0%

name           allocs/op
MemoryHeap-16    1.00 ± 0%

**使用值拷贝比指针要快 6 ~ 7 倍!**这是为什么呢?我们来看下 trace 生成的图 go tool trace xxx.out

graph for the struct passed by copy

graph for the struct passed by pointer

第一张图(值传递)相当简洁,由于不使用“堆”,所以不回收垃圾也没有额外的 goroutine。第二张图(指针传递),指针的使用迫使 go 编译器将变量逃逸至堆中,这就给垃圾回收带来了压力。我们放大能够看到 gc 颇为活跃:

每 4ms 就会 gc。。。

再放大点我们就能看到细节:

蓝色、粉红色、红色的条都是 gc 阶段,而棕色则和堆上的内存分配有关。

sweep 是指堆内存中未被标记为使用中的内存被回收掉。当应用程序的 goroutine 试图在堆内存中分配新的值时就会发生这种活动。“扫除”造成的延迟增大了堆内存中执行分配的开销。

这个例子有点极端,但是我们能够看到在“堆”而不是“栈”分配内存的代价有点大。所以在我们的 demo 中,在栈上分配并拷贝的结构体的代码比在堆上分配并共享地址的代码要快得多。

如果我们通过 GOMAXPROCS 将 CPU 限制为 1 时那就更严重了:

$ GOMAXPROCS=1 go test ./... \
    -bench=BenchmarkMemoryHeap \
    -benchmem \
    -run=^$ \
    -count=10 > heap.txt && benchstat heap.txt
name        time/op
MemoryHeap  68.2ns ± 3%

name        alloc/op
MemoryHeap   96.0B ± 0%

name        allocs/op
MemoryHeap    1.00 ± 0%

这么看来在 Golang 中习惯性地使用指针传递而非值传递可能不是什么好事。