[译] Bad Go: pointer returns

作为一个不再年轻的C程序员,我苦恼于一点:函数返回结构体的指针是完全正常的。但我感觉这是Go的坏处,通常我认为返回结构体的值会更好。我试着证明返回结构体的值会更好。

我定义一个可以很容易改变大小 的结构体。结构体的内容是一个数组:通过改变数组的大小来简单地改变结构体的大小。

1
2
3
4
5
const bigStructSize = 10

type bigStruct struct {
  a [bigStructSize]int
}

接下来,我将创建两个程序来构建这个结构体的新版本。一个以指针将它返回,另一个返回它的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func newBigStruct() bigStruct {
   var b bigStruct
   for i := 0; i < bigStructSize; i++ {
       b.a[i] = i
   }
   return b
}

func newBigStructPtr() *bigStruct {
   var b bigStruct
   for i := 0; i < bigStructSize; i++ {
       b.a[i] = i
   }
   return &b
}

最后,我写两个基准测试来测试获取和使用这些结构体的耗时。我在结构体中的值做些简单的计算,以便让编译器不会仅仅优化整个过程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func BenchmarkStructReturnValue(b *testing.B) {
	b.ReportAllocs()

	t := 0
	for i := 0; i < b.N; i++ {
		v := newBigStruct()
		t += v.a[0]
	}
}

func BenchmarkStructReturnPointer(b *testing.B) {
	b.ReportAllocs()

	t := 0
	for i := 0; i < b.N; i++ {
		v := newBigStructPtr()
		t += v.a[0]
	}
}

bigStructSize 设置为10,按值返回将是按指针返回的两倍。在指针示例中,内存被分配到堆上,这将花费大约20ns,然后数据被设置(在两个示例中花费的时间应该是一样的),再然后指针被写入到栈中,返回结构体给调用者。在值示例中,没有内存分配,但是整个结构体必须被拷贝到栈上并返回给它的调用者。

在这种结构体大小下,在栈上复制数据的开销小于分配内存的开销。

1
2
BenchmarkStructReturnValue-8  	100000000	15.4 ns/op	 0 B/op	0 allocs/op
BenchmarkStructReturnPointer-8	50000000	36.5 ns/op	80 B/op	1 allocs/op

当修改 bigStructSize 为 100, 现在结构体包含 100个整型,以绝对值表示的差距将增加 – 尽管指针示例中增加的百分比比较少。

1
2
BenchmarkStructReturnValue-8  	20000000	105 ns/op	  0 B/op	0 allocs/op
BenchmarkStructReturnPointer-8	10000000	185 ns/op	896 B/op	1 allocs/op

如果我们将结构体改为1000个整型,返回指针是否会更快?

BenchmarkStructReturnValue-8  	2000000	 830 ns/op	   0 B/op	0 allocs/op
BenchmarkStructReturnPointer-8	1000000	1401 ns/op	8192 B/op	1 allocs/op

不,情况更糟,那增加到10000呢?

BenchmarkStructReturnValue-8  	100000	13332 ns/op	    0 B/op	0 allocs/op
BenchmarkStructReturnPointer-8	200000	11032 ns/op	81920 B/op	1 allocs/op

最后,在结构体中包含10000个整型下,结构体返回指针更快。经过进一步的调查,看来我的笔记本电脑上的临界点是2700。在这一点上,我几乎不知道为什么1000 整型会有如此大的差异。让我们看下基准的 profile !

go test -bench BenchmarkStructReturnValue -run ^$ -cpuprofile cpu2.prof
go tool pprof  post.test cpu2.prof 
(pprof) top
Showing nodes accounting for 2.25s, 100% of 2.25s total
      flat  flat%   sum%        cum   cum%
     2.09s 92.89% 92.89%      2.23s 99.11%  github.com/philpearl/blog/content/post.newBigStruct
     0.14s  6.22% 99.11%      0.14s  6.22%  runtime.newstack
     0.02s  0.89%   100%      0.02s  0.89%  runtime.nanotime
         0     0%   100%      2.23s 99.11%  github.com/philpearl/blog/content/post.BenchmarkStructReturnValue
         0     0%   100%      0.02s  0.89%  runtime.mstart
         0     0%   100%      0.02s  0.89%  runtime.mstart1
         0     0%   100%      0.02s  0.89%  runtime.sysmon
         0     0%   100%      2.23s 99.11%  testing.(*B).launch
         0     0%   100%      2.23s 99.11%  testing.(*B).runN

在值返回的示例中,几乎所有的工作发生在 newBigStruct。这一切是相关的简单,如果我们看指针示例的 profile 会发生什么?

go test -bench BenchmarkStructReturnPointer -run ^$ -cpuprofile cpu.prof
go tool pprof post.test cpu.prof 
(pprof) top
Showing nodes accounting for 2690ms, 93.08% of 2890ms total
Dropped 28 nodes (cum <= 14.45ms)
Showing top 10 nodes out of 67
      flat  flat%   sum%        cum   cum%
    1110ms 38.41% 38.41%     1110ms 38.41%  runtime.pthread_cond_signal
     790ms 27.34% 65.74%      790ms 27.34%  runtime.pthread_cond_wait
     300ms 10.38% 76.12%      300ms 10.38%  runtime.usleep
     200ms  6.92% 83.04%      200ms  6.92%  runtime.pthread_cond_timedwait_relative_np
      80ms  2.77% 85.81%       80ms  2.77%  runtime.nanotime
      60ms  2.08% 87.89%      140ms  4.84%  runtime.sweepone
      50ms  1.73% 89.62%       50ms  1.73%  runtime.pthread_mutex_lock
      40ms  1.38% 91.00%      150ms  5.19%  github.com/philpearl/blog/content/post.newBigStructPtr
      30ms  1.04% 92.04%       40ms  1.38%  runtime.gcMarkDone
      30ms  1.04% 93.08%       40ms  1.38%  runtime.scanobject

newBigStructPtr 示例中,情况要复杂的多,有很多函数使用大量的CPU。在 newBigStructPtr 的设置只花费了大约 5% 的时间。相反,在Go运行时,有很多的时间花费在处理线程,锁和垃圾回收。底层的函数返回指针很快,但是分配指针所带来的负担却是巨大的开销。

现在,这种情况非常简单,数据被创建后立即被丢弃,因此垃圾收集器将承受巨大的负担。如果返回的数据的生存期更长,则结果可能会大不相同。但这也许表明,返回具有较短生存期结构体指针是不好的。