Golang 语言层面的简单优化 | 青训营

48 阅读2分钟

程序优化

优化本身是热爱代码的程序员着迷的事,但真要写好程序,首先要保证我们能按时完成我们的任务,其次再去想如何把工作做的更好,用更好的技术、工具去优化。如果一味只去要求做的尽善尽美可能会导致延期,失败,半途而废。所以,先写正确的代码,再去考虑如何去让代码更快更好的运行;先完成基本的功能,再去想如何优化它。正确是优化的基础,没有这个基础,任何的优化都是毫无意义的。此外,不要一味地追求程序的性能,越高级的性能优化手段越容易出现问题,在满足正确可靠、简洁清晰的质量要求的前提下提高程序性能就很好了。

写程序不是一蹴而就的,要经过不断地测试、修改、调优,满足正确可靠、简洁清晰等质量因素。性能优化是综合评估,往往涉及多方面,比如语言层面的数据结构的特性、算法的时间空间复杂度,部署的架构、硬件等等。

正因为优化是复杂的,不断进阶的,所以要学要用的很多。其中最基础的手段,是着眼 Golang 语言本身的特性,写更加高效的代码片段,那么接下来恩文结合 Golang 语言本身的特性给出一些性能优化建议。

性能优化建议

Benchmark

性能需要实际数据来衡量,Go语言中自带的benchmark则是一件神奇的基准性能的测试利器。有了它,开发者可以方便快捷地在测试一个函数方法在串行或并行环境下的基准表现。指定一个时间(默认是1秒),看测试对象在达到或超过时间上限时,最多能被执行多少次和在此期间测试对象内存分配情况。

以下是一个简单的递归计算斐波那契数的例子。

// fib.go
func Fib(n int) int {
	if n < 2 {
		return n
	}
	return Fib(n-1) + Fib(n-2)
}

// fib_test.go
import "testing"

func BenchmarkFib(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Fib(15)
	}
}

go test 命令可以进行基准测试:

❯ go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: performance_demo
cpu: Intel(R) Core(TM) i7-6820HQ CPU @ 2.70GHz
BenchmarkFib-8   	  358797	      3240 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	performance_demo	1.418s

显示中 BenchmarkFib 是测试函数,-8 表示 GOMAXPROCS 的值为8,一共执行了 358797 次,即 b.N = 358797,每次执行平均 3240ns,申请 0B 内存,申请了 0次。

Benchmark 是个重要工具,在很多地方都能用到。

Slice 切片

slice 翻译为切片,可以类比其他语言的动态数组,动态扩容或者收缩,在使用的时候最好能估计大致容量,在 make 时提供容量信息。

func NoPreAlloc(size int) {
	data := make([]int, 0)
	for i := 0; i < size; i++ {
		data = append(data, i)
	}
}

func PreAlloc(size int) {
	data := make([]int, 0, size)
	for i := 0; i < size; i++ {
		data = append(data, i)
	}
}

// test
func BenchmarkNoPreAlloc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		NoPreAlloc(3000)
	}
}

func BenchmarkPreAlloc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		PreAlloc(3000)
	}
}

同样的测试:

❯ go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: performance_demo
cpu: Intel(R) Core(TM) i7-6820HQ CPU @ 2.70GHz
BenchmarkNoPreAlloc-8   	   73311	     15635 ns/op	   87288 B/op	      15 allocs/op
BenchmarkPreAlloc-8     	  262519	      4231 ns/op	   24576 B/op	       1 allocs/op
PASS

切片本质是一个数组片段的描述:

  • 数组指针
  • 片段长度
  • 片段容量

切片操作不复制切片指向的元素,创建一个新的切片会复用原来的底层数组。

slice.png

在已有切片上创建切片,不会创建新的底层数组,可能导致问题:在大切片上创建有小切片,那么底层数组有引用,得不到释放。

解决:用 copy 代替 re-slice。

Map

Map 与 Slice类似,也建议预先分配内存,因为不断添加元素导致 map 扩容、rehash。

func MapNoPreAlloc(size int) {
	data := make(map[int]int)
	for i := 0; i < size; i++ {
		data[i] = i
	}
}

func MapPreAlloc(size int) {
	data := make(map[int]int, size)
	for i := 0; i < size; i++ {
		data[i] = i
	}
}

// BenchmarkMapNoPreAlloc-8   	    5791	    190321 ns/op	  177164 B/op	     118 allocs/op
// BenchmarkMapPreAlloc-8     	   12064	     98716 ns/op	   86027 B/op	      25 allocs/op

字符串拼接

+ vs Strings.Builder vs bytes.Buffer

func Plus(n int, str string) string {
	s := ""
	for i := 0; i < n; i++ {
		s += str
	}
	return s
}

func StringsBuilder(n int, str string) string {
	var builder strings.Builder
	for i := 0; i < n; i++ {
		builder.WriteString(str)
	}
	return builder.String()
}

func ByteBuffer(n int, str string) string {
	buf := new(bytes.Buffer)
	for i := 0; i < n; i++ {
		buf.WriteString(str)
	}
	return buf.String()
}

结果:“+” 最差,另外两个相近,Strings.Builder 最快。

BenchmarkPlus-8             	   79076	     22890 ns/op	   53480 B/op	      99 allocs/op
BenchmarkStringsBuilder-8   	 1000000	      1135 ns/op	    3312 B/op	       8 allocs/op
BenchmarkByteBuffer-8       	  793336	      1551 ns/op	    3008 B/op	       6 allocs/op

因为字符串在 Golang 是不可变类型,使用 + 每次重新分配内存;Strings.Builderbytes.Buffer 底层是 byte 数组,按照数组扩容的策略变化;bytes.Buffer 转字符串时重新申请了空间,Strings.Builder 将 byte 数组转换后直接返回。

空结构体

使用空结构体能节省内存,因为 struct{} 实例不占内存空间,而空结构本身具有很强的语义,可以作为任何值,在不需要值的情况下可以作为各种场景下的占位符使用。

比如用 map 实现 set,因为只需要键不需要值,所以可以用空结构体作为值,即使是 bool 都需要占1个字节。

atomic

例子

type AtomicCounter struct {
	i int32
}

func AtomicAddOne(c *AtomicCounter) {
	atomic.AddInt32(&c.i, 1)
}

type MutexCounter struct {
	i int32
	m sync.Mutex
}

func MutexAddOne(c *MutexCounter) {
	c.m.Lock()
	c.i++
	c.m.Unlock()
}

锁的实现是通过操作系统来实现,属于系统调用;atomic 操作是通过硬件实现,效率比锁高。

sync.Mutex 应该用来保护一段逻辑,不仅仅用于保护一个变量。

对于非数值操作,可以使用 atomic.Value,能承载一个 interface{}。

扩展

以上是根据 Golang 语言本身的一些性质来给出优化建议,只是优化中的冰山一角,此外还可以做的比如:

  • 使用缓存:在某些情况下,我们可以使用缓存来提高程序性能。例如,当计算结果具有重复性时,可以使用map或其他数据结构来存储已计算的结果,以避免重复计算。

  • 优化算法和数据结构:选择合适的算法和数据结构对程序性能至关重要。在实际应用中,我们应根据问题的特点优化时间、空间复杂度。

  • 使用go build -race命令检测并发问题。

  • 使用pprof分析程序性能,找到瓶颈。

  • 使用-gcflags="-m"编译选项查看编译器优化报告。

对于结构复杂的程序,复杂的调用关系,我们需要更综合的视野,抓更主要的矛盾。性能调优本身也是有时间、金钱成本的,实际工作中也是做取舍,避免常见的性能陷阱可以保证大部分程序的性能,因为硬件已经很快了,并且有网络、I/O的情况下,它们更容易成为瓶颈。

参考

高质量编程和性能调优实战

ueokande.github.io/go-slice-tr…

Effective Go