开始:我应该使用指针而不是结构的副本吗?

图片
由Rene French创建的原始地鼠为“旅途之旅”创建的插图。

在性能方面,系统地使用指针而不是复制结构本身以将结构共享给许多Go开发人员似乎是最好的选择。为了理解使用指针而不是结构副本的效果,我们将考虑两个用例。

密集数据分配


当您想共享一个结构以访问其值时,让我们看一个简单的示例:

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,
  }
}

基于这两种方法,我们可以编写2个基准。第一个是通过副本传递结构的位置:

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=BenchmarkMemoryHeap -benchmem -run=^$ -count=10 > head.txt && benchstat head.txt
go test ./... -bench=BenchmarkMemoryStack -benchmem -run=^$ -count=10 > stack.txt && benchstat stack.txt

我们得到以下统计数据:

name          time/op
MemoryHeap-4  75.0ns ± 5%

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

name          allocs/op
MemoryHeap-4    1.00 ± 0%

------------------

name           time/op
MemoryStack-4  8.93ns ± 4%

name           alloc/op
MemoryStack-4   0.00B

name           allocs/op
MemoryStack-4    0.00

使用该结构的副本比使用该结构的指针快8倍!

为了理解为什么,让我们看一下由跟踪生成的图形:

图片
复制所

图片
传递的结构图形复制指针

传递的结构图形第一个图形非常简单。由于不使用堆,因此没有垃圾收集器和多余的gorutin。

在第二种情况下,使用指针会使Go编译器将变量移至堆并充当垃圾收集器。如果增加图的比例,我们将看到垃圾收集器占据了过程的重要部分:

图片

此图显示垃圾收集器每4毫秒启动一次。

如果再次放大,我们将获得有关到底发生了什么的详细信息:

图片

蓝色,粉红色和红色条纹是垃圾收集器的阶段,而棕色的条纹与堆中的分配相关联(在图中标记为“ runtime.bgsweep”):

清除是从堆中与数据相关的未标记为未使用部分的释放。当goroutine尝试在堆内存中隔离新值时,将发生此操作。清除延迟会增加在堆内存中执行分配的成本,并且不适用于与垃圾回收相关的任何延迟。

www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html

即使这个例子有点极端,我们仍然看到在堆上而不是在栈上分配变量的代价是多么昂贵。在我们的示例中,结构在堆栈上分配和复制的速度比在堆上创建的结构快得多,并且其地址被共享。

如果您不熟悉堆栈/堆,并且想进一步了解它们的内部细节,则可以在Internet上找到很多信息,例如Paul Gribble的这篇文章

如果使用GOMAXPROCS = 1将处理器限制为1,则情况可能更糟。

name        time/op
MemoryHeap  114ns ± 4%

name        alloc/op
MemoryHeap  96.0B ± 0%

name        allocs/op
MemoryHeap   1.00 ± 0%

------------------

name         time/op
MemoryStack  8.77ns ± 5%

name         alloc/op
MemoryStack   0.00B

name         allocs/op
MemoryStack    0.00

如果放置在堆栈上的基准没有改变,那么堆上的指示器将从75ns / op降低到114ns / op。

密集函数调用


我们将在结构中添加两个空方法,并对基准进行一些调整:

func (s S) stack(s1 S) {}

func (s *S) heap(s1 *S) {}

在堆栈上放置基准的基准将创建结构并将其传递给副本:

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

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

堆的基准将通过指针传递结构:

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

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

不出所料,现在的结果是完全不同的:

name          time/op
MemoryHeap-4  301µs ± 4%

name          alloc/op
MemoryHeap-4  0.00B

name          allocs/op
MemoryHeap-4   0.00

------------------

name           time/op
MemoryStack-4  595µs ± 2%

name           alloc/op
MemoryStack-4  0.00B

name           allocs/op
MemoryStack-4   0.00

结论


在go中使用指针代替结构的副本并不总是一件好事。为了为您的数据选择一个好的语义,我强烈建议阅读Bill Kennedy撰写的有关值/指针语义的文章这将使您更好地了解可用于结构和内置类型的策略。此外,对内存使用情况进行性能分析绝对可以帮助您了解分配和堆的情况。

All Articles