Go 编程性能优化 | 青训营笔记

144 阅读4分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第4篇笔记。

性能调优

性能优化的前提是满足正确可靠、简洁清晰等质量因素。

性能优化是综合评估,有时候时间效率和空间效率可能对立。

Benchmark

Benchmark 是 Go 提供的基准性能测试工具。

使用命令参考 c.biancheng.net/view/124.ht…

# -bench=. 表示运行 xxx_test.go 文件里的所有基准测试
# -benchmen 显示内存分配情况
go test -bench=. -benchmem
// from fib.go
func Fib(n int) int {
	if n < 2 {
		return n
	}
	return Fib(n-1) + Fib(n-2)
}

// from fib_test.go
func BenchmarkFib10(b *testing.B) {
	//run the Fib function b.N times
	for n := 0; n < b.N; n++ {
		Fib(10)
	}
}
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkFib10-8         4262046               283.1 ns/op             0 B/op          0 allocs/op
PASS
ok      bytedancecourse/src     1.525s
  • BenchmarkFib10-8:BenchmarkFib10 是测试函数名,-8 表示 GOMAXPROCS 的值为 8

    GOMAXPROCS 在 1.5 版本后,默认为 CPU 核数 参考 pkg.go.dev/runtime#GOM…

  • 4262046:表示一共执行 4262046 次,即 b.N 的值

  • 283.1 ns/op:每次执行花费 283.1 ns

  • 0 B/op:每次执行申请多大内存

  • 0 allocs/op:每次执行申请几次内存

Slice

使用 Slice 时,尽可能在使用 make() 初始化切片时提供容量信息,进行内存的预分配

看一个例子:

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

func PreAlloc(size int) {
	data := make([]int, 0, size)
	for k := 0; k < size; k++ {
		data = append(data, k)
	}
}
# size 800000000
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkNoPreAlloc-8                  1        70511907000 ns/op       34197152912 B/op              82 allocs/op
BenchmarkPreAlloc-8                    1        21504366500 ns/op       6400000024 B/op        		  4 allocs/op
PASS
ok      bytedancecourse/src     93.967s

可以看出,预先分配内存时,每次执行花费的时间,每次申请的内存大小和次数都显著降低了。

为什么差异这么大?这与切片的底层实现有关

切片本质是一个数组片段的描述,结构如下:

type slice struct {
    array unsafe.Pointer	//数组指针
    len int					//片段的长度
    cap int					//片段的容量(当插入遇到不够时会进行扩容,重新分配内存)
}

扩容过程:

image-20220512111713553

扩容过程时非常消耗时间的,所以在初始化切片时,进行与分配内存。

注意:

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

这会导致一个新的问题:大内存未释放

由于在已有切片的基础上创建切片,不会创建新的底层数组。当原切片比较大,代码在原切片的基础新建小切片时,原切片的底层数组在内存中还存在引用,无法被 GC 回收,得不到释放。

解决这个问题,可以使用 copy 替代 re-slice。

看一个例子:

// from tmp.go
func GetLastBySlice(origin []int) []int {
	return origin[len(origin)-2:]
}

func GetLastByCopy(origin []int) []int {
	result := make([]int, 2)
	copy(result, origin[len(origin)-2:])
	return result
}

func generateWithCap(n int) []int {
	rand.Seed(time.Now().UnixNano())
	nums := make([]int, 0, n)
	for i := 0; i < n; i++ {
		nums = append(nums, rand.Int())
	}
	return nums
}

func printMem(t *testing.T) {
	t.Helper()
	var rtm runtime.MemStats
	runtime.ReadMemStats(&rtm)
	t.Logf("%.2f MB", float64(rtm.Alloc)/1024./1024.)
}

// from tmp_test.go
func testGetLast(t *testing.T, f func([]int) []int) {
	result := make([][]int, 0)
	for k := 0; k < 100; k++ {
		origin := generateWithCap(128 * 1024)
		result = append(result, f(origin))
	}
	printMem(t)
	_ = result
}

func TestLastCharsBySlice(t *testing.T) { testGetLast(t, GetLastBySlice) }
func TestLastCharsByCopy(t *testing.T)  { testGetLast(t, GetLastByCopy) }
$> go test -run=. -v
=== RUN   TestLastCharsBySlice
    tmp_test.go:11: 100.17 MB
--- PASS: TestLastCharsBySlice (0.27s)
=== RUN   TestLastCharsByCopy
    tmp_test.go:11: 1.18 MB
--- PASS: TestLastCharsByCopy (0.29s)
PASS
ok      bytedancecourse/src     0.780s

可以看到,使用 copy 只需要 1.18 MB 的内存,而使用 re-slice 的使用了 100.17 MB 内存。

Map

Map 也有预分配的性能优化点。

// from tmp.go
func NoPreAlloc(size int) {
	data := make(map[int]int)
	for k := 0; k < size; k++ {
		data[k] = 1
	}
}

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

// from tmp_test.go
func BenchmarkNoPreAlloc(b *testing.B) {
	NoPreAlloc(200000000)
}

func BenchmarkPreAlloc(b *testing.B) {
	PreAlloc(200000000)
}

$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkNoPreAlloc-8                  1        45518223300 ns/op       11916524872 B/op         7805704 allocs/op
BenchmarkPreAlloc-8                    1        28873591000 ns/op        5752119240 B/op         2897363 allocs/op
PASS
ok      bytedancecourse/src     75.698s

从结果可以看出,预分配内存的各项指标都优于没有预分配内存。

原因分析:

  • 不断向 map 中添加元素的操作会触发 map 的扩容
  • 提前分配好空间可以减少内存拷贝和 Rehash 的消耗

所以尽可能提前预估好需要的空间,进行预分配。

字符串处理

在平时使用中,字符串的拼接不建议用 +

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

func StrBuilder(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()
}

// from tmp_test.go
func BenchmarkPlus(b *testing.B) {
	Plus(1000000, "hello")
}

func BenchmarkStrBuilder(b *testing.B) {
	StrBuilder(1000000, "hello")
}

func BenchmarkByteBuffer(b *testing.B) {
	ByteBuffer(1000000, "hello")
}
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkPlus-8                        1        54521057300 ns/op       401620455680 B/op         416090 allocs/op
BenchmarkStrBuilder-8           1000000000               0.002991 ns/op        0 B/op          0 allocs/op
BenchmarkByteBuffer-8           1000000000               0.002990 ns/op        0 B/op          0 allocs/op
PASS
ok      bytedancecourse/src     54.767s

可以看出,使用 + 拼接性能最差,strings.Builder,bytes.Buffer 相近,但 strings.Buffer 更快。

原因分析:

  • 字符串在 Go 是不可变类型,占用内存大小是固定的,使用 + 每次都会重新分配内存
  • strings.Builder 和 bytes.Buffer 底层都是使用 []bytes 数组,在内部维护了内存扩容策略,不需要每次拼接重新分配内存

在实际应用中,更推荐使用 strings.Buffer 。因为 bytes.Buffer 转化为字符串时重新申请了一块空间,而 strings.Buffer 直接将底层 []bytes 数组转换成了字符串类型返回。

// bytes.Buffer
// To build strings more efficiently, see the strings.Buffer type
func (b *Buffer) String() string {
    if b == nil {
        // Special case, useful in debugging.
        return "<nil>"
    }
    return string(b.buf[b.off:])
}
// String returns the accumulated string.
func (b *Builder) String() string {
    return *(*string)(unsafe.Pointer(&b.buf))
}

如果可以预估字符串拼接后的长度,可以预分配内存进一步提高效率。

// from tmp.go
func PreStrBuilder(n int, str string) string {
	var builder strings.Builder
	builder.Grow(n * len(str))
	for i := 0; i < n; i++ {
		builder.WriteString(str)
	}
	return builder.String()
}

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

// from tmp_test.go
const str = "hello"
const n = 400000

func BenchmarkPreStrBuilder(b *testing.B) {
	PreStrBuilder(n, str)
}

func BenchmarkPreByteBuffer(b *testing.B) {
	PreByteBuffer(400000, "hello")
}
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkPlus-8                        1        50508202100 ns/op       401620667232 B/op         418312 allocs/op
BenchmarkStrBuilder-8           1000000000               0.001988 ns/op        0 B/op          0 allocs/op
BenchmarkByteBuffer-8           1000000000               0.003001 ns/op        0 B/op          0 allocs/op
BenchmarkPreStrBuilder-8        1000000000               0.001717 ns/op        0 B/op          0 allocs/op
BenchmarkPreByteBuffer-8        1000000000               0.002678 ns/op        0 B/op          0 allocs/op
PASS
ok      bytedancecourse/src     50.752s

可以看到,无论是 strings.builder 还是 bytes.buffer ,预分配内存都由于没有预分配内存的

空结构体

空结构体 struct{} 实例不占据任何内存空间,可作为各种场景下的占位符使用。

这样可以节省资源,且空结构体本身具有很强的语义,即这里不需要任何值,仅作为占位符。

// from tmp.go
func EmptyStructMap(n int) {
	m := make(map[int]struct{})
	
	for i := 0; i < n; i++ {
		m[i] = struct{}{}
	}
}

func BoolMap(n int) {
	m := make(map[int]bool)
	
	for i := 0; i < n; i++ {
		m[i] = false
	}
}

// from tmp_test.go
func BenchmarkEmptyStructMap(b *testing.B) {
	EmptyStructMap(10000000)
}

func BenchmarkBoolMap(b *testing.B) {
	BoolMap(10000000)
}
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkEmptyStructMap-8              1        1367137100 ns/op        402392336 B/op    306036 allocs/op
BenchmarkBoolMap-8                     1        1347185100 ns/op        442902624 B/op    306176 allocs/op
PASS
ok      bytedancecourse/src     2.974s

空结构体可以用于实现 Set,只需要把 map 的值设为空结构体即可。

Set 的开源实现:github.com/deckarep/go…

atomic 包

在一些多线程编程的场景时,使用 atomic 维护一个变量,使用 mutex 维护一段逻辑。

// from tmp.go
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()
}

// from tmp_test.go
func BenchmarkAtomicAddOne(b *testing.B) {
	c := AtomicCounter{i: 0}
	for i := 0; i < 1000000; i++ {
		go func() {
			AtomicAddOne(&c)
		}()
	}
}

func BenchmarkMutexAddOne(b *testing.B) {
	c := MutexCounter{
		i: 0,
		m: sync.Mutex{},
	}
	for i := 0; i < 1000000; i++ {
		go func() {
			MutexAddOne(&c)
		}()
	}
}
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkAtomicAddOne-8         1000000000               0.2537 ns/op          0 B/op          0 allocs/op
BenchmarkMutexAddOne-8          1000000000               0.2628 ns/op          0 B/op          0 allocs/op
PASS
ok      bytedancecourse/src     7.894s

可以看出,使用 atomic 要比 mutex 好。

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

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

小结

  • 避免常见的性能陷阱可以保证大部分程序的性能
  • 普通应用代码,不要一味地追求性能
  • 越高级的性能优化手段越容易出现问题
  • 在满足正确可靠、简洁清晰的质量要求前提下提高程序性能