值复制成本

在Go编程中,值复制是很常见的操作。赋值、传参和通道发送操作均涉及到值复制。 本篇文章将谈谈各种不同种类的类型的Go值的复制成本。

值尺寸(value size)

一个值的尺寸表示此值的直接部分在内存中占用多少个字节,它的间接部分(如果存在的话)对它的尺寸没有贡献。

在Go中,如果两个值的类型为同一种类的类型,并且它们的类型的种类不为字符串、接口、数组和结构体,则这两个值的尺寸总是相等的。

事实上,对于官方标准编译器来说,任意两个字符串值的尺寸总是相等的,即使它们的字符串类型并不是同一个类型。 同样地,任意两个接口值的尺寸也都是相等的。

目前(Go 1.16),至少对于官方标准编译器来说,任何一个特定类型的所有值的尺寸都是相同的。所以我们也常说一个值的尺寸为此值的类型的尺寸。

一个数组类型的尺寸取决于它的元素类型的尺寸和它的长度。它的尺寸为它的元素类型的尺寸和它的长度的乘积。

一个结构体类型的尺寸取决于它的各个字段的类型尺寸和这些字段的排列顺序。 为了程序执行性能,编译器需要保证某些类型的值在内存中存放时必须满足特定的内存地址对齐要求。 地址对齐可能会造成相邻的两个字段之间在内存中被插入填充一些多余的字节。 所以,一个结构体类型的尺寸必定不小于(常常会大于)此结构体类型的各个字段的类型尺寸之和。

下表列出了各种种类的类型的尺寸(对标准编译器1.16来说)。 在此表中,一个word表示一个原生字。在32位系统架构中,一个word为4个字节;而在64位系统架构中,一个word为8个字节。

类型种类值尺寸Go白皮书中的要求
布尔1 byte未做特别要求
int8, uint8 (byte)1 byte1 byte
int16, uint162 bytes2 bytes
int32 (rune), uint32, float324 bytes4 bytes
int64, uint64, float64, complex648 bytes8 bytes
complex12816 bytes16 bytes
int, uint1 word架构相关,在32位系统架构中为4个字节,而在64位系统架构中为8个字节
uintptr1 word必须足够存下任一个内存地址
字符串2 words未做特别要求
指针和非类型安全指针1 word未做特别要求
切片3 words未做特别要求
映射1 word未做特别要求
通道1 word未做特别要求
函数1 word未做特别要求
接口2 words未做特别要求
结构体所有字段尺寸之和 + 所有填充的字节数一个不含任何尺寸大于零的字段的结构体类型的尺寸为零
数组元素类型的尺寸 * 长度一个元素类型的尺寸为零的数组类型的尺寸为零

值复制成本

一般说来,复制一个值的成本正比于此值的尺寸。 但是,值尺寸并非是值复制成本的唯一决定因素。 不同的CPU型号和编译器版本可能会对某些特定尺寸的值的复制做了优化。

在实践中,我们可以将尺寸不大于4个原生字并且字段数不超过4个的结构体值看作是小尺寸值。复制小尺寸值的代价是比较小的。

对于标准编译器,除了大尺寸的结构体和数组类型,其它类型均为小尺寸类型。

为了防止在函数传参和通道操作中因为值复制代价太高而造成的性能损失,我们应该避免使用大尺寸的结构体和数组类型做为参数类型和通道的元素类型,应该在这些场合下使用基类型为这样的大尺寸类型的指针类型。 另一方面,我们也要考虑到太多的指针将会增加垃圾回收的压力。所以到底应该使用大尺寸类型还是以大尺寸类型为基类型的指针类型做为参数类型或通道的元素类型取决于具体的应用场景。

一般来说,在实践中,我们很少使用基类型为切片类型、映射类型、通道类型、函数类型、字符串类型和接口类型的指针类型,因为复制这些类型的值的代价很小。

如果一个数组或者切片的元素类型是一个大尺寸类型,我们应该避免在for-range循环中使用双循环变量来遍历这样的数组或者切片类型的值中的元素。 因为,在遍历过程中,每个元素将被复制给第二个循环变量一次。

下面这个例子展示了三种遍历一个切片的方法的性能差异。

  1. package main
  2. import "testing"
  3. type S [12]int64
  4. var sX = make([]S, 1000)
  5. var sY = make([]S, 1000)
  6. var sZ = make([]S, 1000)
  7. var sumX, sumY, sumZ int64
  8. func Benchmark_Loop(b *testing.B) {
  9. for i := 0; i < b.N; i++ {
  10. sumX = 0
  11. for j := 0; j < len(sX); j++ {
  12. sumX += sX[j][0]
  13. }
  14. }
  15. }
  16. func Benchmark_Range_OneIterVar(b *testing.B) {
  17. for i := 0; i < b.N; i++ {
  18. sumY = 0
  19. for j := range sY {
  20. sumY += sY[j][0]
  21. }
  22. }
  23. }
  24. func Benchmark_Range_TwoIterVar(b *testing.B) {
  25. for i := 0; i < b.N; i++ {
  26. sumZ = 0
  27. for _, v := range sZ {
  28. sumZ += v[0]
  29. }
  30. }
  31. }

运行此基准测试,我们将得到下面的结果:

  1. Benchmark_Loop-4 424342 2708 ns/op
  2. Benchmark_Range_OneIterVar-4 407905 2808 ns/op
  3. Benchmark_Range_TwoIterVar-4 214860 5222 ns/op

可以看出,使用双循环变量的方法的效率比另外两种方法的效率低得多。 但是请注意,某些编译器版本可能会做出一些特别的优化从而消除上面几种遍历方法的效率差异。 上面的基准测试结果基于Go标准编译器1.16版本。