Go 高性能编程技法

Posted 技术能量站

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Go 高性能编程技法相关的知识,希望对你有一定的参考价值。

1、前言

代码的稳健、可读和高效是我们每一个 coder 的共同追求。本文将结合 Go 语言特性,为书写效率更高的代码,从常用数据结构、内存管理和并发,三个方面给出相关建议。话不多说,让我们一起学习 Go 高性能编程的技法吧。

2、常用的数据结构

2.1 反射虽好,切莫贪杯

标准库 reflect 为 Go 语言提供了运行时动态获取对象的类型和值以及动态创建对象的能力。反射可以帮助抽象和简化代码,提高开发效率。

Go 语言标准库以及很多开源软件中都使用了 Go 语言的反射能力,例如用于序列化和反序列化的 json、ORM 框架 gorm、xorm 等。

1、优先使用 strconv 而不是 fmt

基本数据类型与字符串之间的转换,优先使用 strconv 而不是 fmt,因为前者性能更佳。

// Bad
for i := 0; i < b.N; i++ 
 s := fmt.Sprint(rand.Int())


BenchmarkFmtSprint-4    143 ns/op    2 allocs/op

// Good
for i := 0; i < b.N; i++ 
 s := strconv.Itoa(rand.Int())


BenchmarkStrconv-4    64.2 ns/op    1 allocs/op

为什么性能上会有两倍多的差距,因为 fmt 实现上利用反射来达到范型的效果,在运行时进行类型的动态判断,所以带来了一定的性能损耗。

2、少量的重复不比反射差

有时,我们需要一些工具函数。比如从 uint64 切片过滤掉指定的元素。利用反射,我们可以实现一个类型泛化支持扩展的切片过滤函数。

// DeleteSliceElms 从切片中过滤指定元素。注意:不修改原切片。
func DeleteSliceElms(i interface, elms ...interface) interface 
	// 构建 map set。
	m := make(map[interface]struct, len(elms))
	for _, v := range elms 
		m[v] = struct
	
	// 创建新切片,过滤掉指定元素。
	v := reflect.ValueOf(i)                               // 取值
	t := reflect.MakeSlice(reflect.TypeOf(i), 0, v.Len()) // 构造切片
	for i := 0; i < v.Len(); i++ 
		if _, ok := m[v.Index(i).Interface()]; !ok 
			t = reflect.Append(t, v.Index(i))
		
	
	return t.Interface()

很多时候,我们可能只需要操作一个类型的切片,利用反射实现的类型泛化扩展的能力压根没用上。退一步说,如果我们真地需要对 uint64 以外类型的切片进行过滤,拷贝一次代码又何妨呢?可以肯定的是,绝大部份场景,根本不会对所有类型的切片进行过滤,那么反射带来好处我们并没有充分享受,但却要为其带来的性能成本买单。

// DeleteU64liceElms 从 []uint64 过滤指定元素。注意:不修改原切片。
func DeleteU64liceElms(i []uint64, elms ...uint64) []uint64 
	// 构建 map set。
	m := make(map[uint64]struct, len(elms))
	for _, v := range elms 
		m[v] = struct
	
	// 创建新切片,过滤掉指定元素。
	t := make([]uint64, 0, len(i))
	for _, v := range i 
		if _, ok := m[v]; !ok 
			t = append(t, v)
		
	
	return t

下面看一下二者的性能对比。

func BenchmarkDeleteSliceElms(b *testing.B) 
	slice := []uint641, 2, 3, 4, 5, 6, 7, 8, 9
	elms := []interfaceuint64(1), uint64(3), uint64(5), uint64(7), uint64(9)
	for i := 0; i < b.N; i++ 
		_ = DeleteSliceElms(slice, elms...)
	


func BenchmarkDeleteU64liceElms(b *testing.B) 
	slice := []uint641, 2, 3, 4, 5, 6, 7, 8, 9
	elms := []uint641, 3, 5, 7, 9
	for i := 0; i < b.N; i++ 
		_ = DeleteU64liceElms(slice, elms...)
	

运行上面的基准测试。

go test -bench=. -benchmem main/reflect 
goos: darwin
goarch: amd64
pkg: main/reflect
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkDeleteSliceElms-12              1226868               978.2 ns/op           296 B/op         16 allocs/op
BenchmarkDeleteU64liceElms-12            8249469               145.3 ns/op            80 B/op          1 allocs/op
PASS
ok      main/reflect    3.809s

可以看到,反射涉及了额外的类型判断和大量的内存分配,导致其对性能的影响非常明显。

随着切片元素的递增,每一次判断元素是否在 map 中,因为 map 的 key 是不确定的类型,会发生变量逃逸,触发堆内存的分配。所以,可预见的是当元素数量增加时,性能差异会越来大。

当使用反射时,请问一下自己,我真地需要它吗?

3、慎用 binary.Read 和 binary.Write

binary.Read 和 binary.Write 使用反射并且很慢。如果有需要用到这两个函数的地方,我们应该手动实现这两个函数的相关功能,而不是直接去使用它们。

encoding/binary 包实现了数字和字节序列之间的简单转换以及 varints 的编码和解码。varints 是一种使用可变字节表示整数的方法。其中数值本身越小,其所占用的字节数越少。Protocol Buffers 对整数采用的便是这种编码方式。

其中数字与字节序列的转换可以用如下三个函数:

// Read 从结构化二进制数据 r 读取到 data。data 必须是指向固定大小值的指针或固定大小值的切片。
func Read(r io.Reader, order ByteOrder, data interface) error
// Write 将 data 的二进制表示形式写入 w。data 必须是固定大小的值或固定大小值的切片,或指向此类数据的指针。
func Write(w io.Writer, order ByteOrder, data interface) error
// Size 返回 Wirte 函数将 v 写入到 w 中的字节数。
func Size(v interface) int

下面以我们熟知的 C 标准库函数 ntohl() 函数为例,看看 Go 利用 binary 包如何实现。

// Ntohl 将网络字节序的 uint32 转为主机字节序。
func Ntohl(bys []byte) uint32 
 r := bytes.NewReader(bys)
 err = binary.Read(buf, binary.BigEndian, &num)


// 如将 IP 127.0.0.1 网络字节序解析到 uint32
fmt.Println(Ntohl([]byte0x7f, 0, 0, 0x1)) // 2130706433 <nil>

如果我们针对 uint32 类型手动实现一个 ntohl() 呢?

func NtohlNotUseBinary(bys []byte) uint32 
 return uint32(bys[3]) | uint32(bys[2])<<8 | uint32(bys[1])<<16 | uint32(bys[0])<<24


// 如将 IP 127.0.0.1 网络字节序解析到 uint32
fmt.Println(NtohlNotUseBinary([]byte0x7f, 0, 0, 0x1)) // 2130706433

该函数也是参考了 encoding/binary 包针对大端字节序将字节序列转为 uint32 类型时的实现。

下面看下剥去反射前后二者的性能差异。

// Ntohl 将网络字节序的 uint32 转为主机字节序。
func Ntohl(bys []byte) uint32 
	buf := bytes.NewReader(bys)
	var num uint32
	binary.Read(buf, binary.BigEndian, &num)
	return num


func NtohlNotUseBinary(bys []byte) uint32 
	return uint32(bys[3]) | uint32(bys[2])<<8 | uint32(bys[1])<<16 | uint32(bys[0])<<24

运行上面的基准测试,结果如下:

go test -bench=BenchmarkNtohl.* -benchmem
goos: darwin
goarch: amd64
pkg: main/reflect
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkNtohl-12                       13026195                81.96 ns/op           60 B/op          4 allocs/op
BenchmarkNtohlNotUseBinary-12           1000000000               0.2511 ns/op          0 B/op          0 allocs/op
PASS
ok      main/reflect    1.841s

可见使用反射实现的 encoding/binary 包的性能相较于针对具体类型实现的版本,性能差异非常大。

2.2 避免重复的字符串到字节切片的转换

不要反复从固定字符串创建字节 slice,因为重复的切片初始化会带来性能损耗。相反,请执行一次转换并捕获结果。

// Bad
for i := 0; i < b.N; i++ 
 w.Write([]byte("Hello world"))


BenchmarkBad-4   50000000   22.2 ns/op

// Good
data := []byte("Hello world")
for i := 0; i < b.N; i++ 
 w.Write(data)


BenchmarkGood-4  500000000   3.25 ns/op

2.3 指定容器容量

尽可能指定容器容量,以便为容器预先分配内存。这将在后续添加元素时减少通过复制来调整容器大小。

1、指定 map 容量提示

在尽可能的情况下,在使用 make() 初始化的时候提供容量信息。

make(map[T1]T2, hint)

向 make() 提供容量提示会在初始化时尝试调整 map 的大小,这将减少在将元素添加到 map 时为 map 重新分配内存。

注意,与 slice 不同。map capacity 提示并不保证完全的抢占式分配,而是用于估计所需的 hashmap bucket 的数量。 因此,在将元素添加到 map 时,甚至在指定 map 容量时,仍可能发生分配。

// Bad
m := make(map[string]os.FileInfo)

files, _ := ioutil.ReadDir("./files")
for _, f := range files 
    m[f.Name()] = f

// m 是在没有大小提示的情况下创建的; 在运行时可能会有更多分配。

// Good
files, _ := ioutil.ReadDir("./files")

m := make(map[string]os.FileInfo, len(files))
for _, f := range files 
    m[f.Name()] = f

// m 是有大小提示创建的;在运行时可能会有更少的分配。

2、指定切片容量

在尽可能的情况下,在使用 make() 初始化切片时提供容量信息,特别是在追加切片时。

make([]T, length, capacity)

与 map 不同,slice capacity 不是一个提示:编译器将为提供给 make() 的 slice 的容量分配足够的内存,这意味着后续的 append() 操作将导致零分配(直到 slice 的长度与容量匹配,在此之后,任何 append 都可能调整大小以容纳其他元素)。

const size = 1000000

// Bad
for n := 0; n < b.N; n++ 
 data := make([]int, 0)
   for k := 0; k < size; k++ 
     data = append(data, k)
  


BenchmarkBad-4    219    5202179 ns/op

// Good
for n := 0; n < b.N; n++ 
 data := make([]int, 0, size)
   for k := 0; k < size; k++ 
     data = append(data, k)
  


BenchmarkGood-4   706    1528934 ns/op

执行基准测试:

go test -bench=^BenchmarkJoinStr -benchmem 
BenchmarkJoinStrWithOperator-8    66930670    17.81 ns/op    0 B/op    0 allocs/op
BenchmarkJoinStrWithSprintf-8      7032921    166.0 ns/op    64 B/op   4 allocs/op

2.4 字符串拼接方式的选择

1、行内拼接字符串推荐使用运算符+

行内拼接字符串为了书写方便快捷,最常用的两个方法是:

  • 运算符+
  • fmt.Sprintf()

行内字符串的拼接,主要追求的是代码的简洁可读。fmt.Sprintf() 能够接收不同类型的入参,通过格式化输出完成字符串的拼接,使用非常方便。但因其底层实现使用了反射,性能上会有所损耗。

运算符 + 只能简单地完成字符串之间的拼接,非字符串类型的变量需要单独做类型转换。行内拼接字符串不会产生内存分配,也不涉及类型地动态转换,所以性能上优于fmt.Sprintf()。

从性能出发,兼顾易用可读,如果待拼接的变量不涉及类型转换且数量较少(<=5),行内拼接字符串推荐使用运算符 +,反之使用 fmt.Sprintf()。

下面看下二者的性能对比。

// Good
func BenchmarkJoinStrWithOperator(b *testing.B) 
 s1, s2, s3 := "foo", "bar", "baz"
 for i := 0; i < b.N; i++ 
  _ = s1 + s2 + s3
 


// Bad
func BenchmarkJoinStrWithSprintf(b *testing.B) 
 s1, s2, s3 := "foo", "bar", "baz"
 for i := 0; i < b.N; i++ 
  _ = fmt.Sprintf("%s%s%s", s1, s2, s3)
 

执行基准测试结果如下:

go test -bench=^BenchmarkJoinStr -benchmem .
BenchmarkJoinStrWithOperator-8    70638928    17.53 ns/op     0 B/op    0 allocs/op
BenchmarkJoinStrWithSprintf-8      7520017    157.2 ns/op    64 B/op    4 allocs/op

2、非行内拼接字符串推荐使用 strings.Builder

字符串拼接还有其他的方式,比如strings.Join()、strings.Builder、bytes.Buffer和byte[],这几种不适合行内使用。当待拼接字符串数量较多时可考虑使用。

先看下其性能测试的对比。

func BenchmarkJoinStrWithStringsJoin(b *testing.B) 
 s1, s2, s3 := "foo", "bar", "baz"
 for i := 0; i < b.N; i++ 
  _ = strings.Join([]strings1, s2, s3, "")
 


func BenchmarkJoinStrWithStringsBuilder(b *testing.B) 
 s1, s2, s3 := "foo", "bar", "baz"
 for i := 0; i < b.N; i++ 
  var builder strings.Builder
  _, _ = builder.WriteString(s1)
  _, _ = builder.WriteString(s2)
  _, _ = builder.WriteString(s3)
 


func BenchmarkJoinStrWithBytesBuffer(b *testing.B) 
 s1, s2, s3 := "foo", "bar", "baz"
 for i := 0; i < b.N; i++ 
  var buffer bytes.Buffer
  _, _ = buffer.WriteString(s1)
  _, _ = buffer.WriteString(s2)
  _, _ = buffer.WriteString(s3)
 


func BenchmarkJoinStrWithByteSlice(b *testing.B) 
 s1, s2, s3 := "foo", "bar", "baz"
 for i := 0; i < b.N; i++ 
  var bys []byte
  bys= append(bys, s1...)
  bys= append(bys, s2...)
  _ = append(bys, s3...)
 


func BenchmarkJoinStrWithByteSlicePreAlloc(b *testing.B) 
 s1, s2, s3 := "foo", "bar", "baz"
 for i := 0; i < b.N; i++ 
  bys:= make([]byte, 0, 9)
  bys= append(bys, s1...)
  bys= append(bys, s2...)
  _ = append(bys, s3...)
 

基准测试结果如下:

go test -bench=^BenchmarkJoinStr .
goos: windows
goarch: amd64
pkg: main/perf
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkJoinStrWithStringsJoin-8               31543916                36.39 ns/op
BenchmarkJoinStrWithStringsBuilder-8            30079785                40.60 ns/op
BenchmarkJoinStrWithBytesBuffer-8               31663521                39.58 ns/op
BenchmarkJoinStrWithByteSlice-以上是关于Go 高性能编程技法的主要内容,如果未能解决你的问题,请参考以下文章

Go 高性能编程技法

Go 高性能编程技法

Go 高性能编程技法

Go 高性能编程技法

traits编程技法

stl源码剖析学习笔记traits编程技法简明例程