值复制成本

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

值尺寸(value size)

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

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

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

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

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

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

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

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

值复制成本

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

在实践中,我们可以将尺寸不大于4个原生字尺寸的值看作是小尺寸值。复制小尺寸值的代价是比较小的。

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

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

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

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

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

import "testing"

type S struct{a, b, c, d, e int64}
var sX = make([]S, 1000)
var sY = make([]S, 1000)
var sZ = make([]S, 1000)
var sumX, sumY, sumZ int64

func Benchmark_Loop(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sumX = 0
		for j := 0; j < len(sX); j++ {
			sumX += sX[j].a
		}
	}
}

func Benchmark_Range_OneIterVar(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sumZ = 0
		for j := range sY {
			sumZ += sY[j].a
		}
	}
}

func Benchmark_Range_TwoIterVar(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sumY = 0
		for _, v := range sY {
			sumY += v.a
		}
	}
}
运行此基准测试,我们将得到下面的结果:
Benchmark_Loop-4                500000   3228 ns/op
Benchmark_Range_OneIterVar-4    500000   3203 ns/op
Benchmark_Range_TwoIterVars-4   200000   6616 ns/op

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

Go语言101项目目前同时托管在GithubGitlab上。 欢迎各位在这两个项目中通过提交bug和PR的方式来改进完善Go语言101中的各篇文章。

本书微信公众号名称为"Go 101"。每个工作日此公众号将尽量发表一篇和Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

赞赏