乐趣区

关于go:Golang-编程珠玑

作者:崔国科—— MO 研发工程师

导读

2017 年左右开始接触 golang,那时国内对于 golang 的材料还很少。

当初随着云原生生态的蓬勃发展,在 kubernetes、docker 等等泛滥明星我的项目的带动下,国内有越来越多的守业公司以及大厂开始拥抱 golang,各种介绍 golang 的书籍、博客和公众号文章也变得越来越多,其中不乏品质极高的材料。

相干的材料曾经足够丰盛,因而这篇文章不会详述 golang 的某一个方面,而是次要从工程实际的角度登程,去介绍一些货色。因为在工作过程中,我留神到一些令人丧气的代码,其中有些甚至来自于高级程序员。

上面是本文目录概览,咱们将从内存无关的话题开始:

  1. 内存相干
  2. Golang Profiling
  3. 如何写性能测试

Part 1 内存相干

1.1 编译器内存逃逸剖析

先看这样一段代码:

package main

//go:noinline
func makeBuffer() []byte {return make([]byte, 1024)
}

func main() {buf := makeBuffer()
    for i := range buf {buf[i] = buf[i] + 1
    }
}

示例代码中函数 makeBuffer 返回的内存位于函数栈上,在 C 语言中,这是一段谬误的代码,会导致未定义的行为。

在 Go 语言中,这样的写法是容许的,Go 编译器会执行 escape analysis:当它发现一段内存不能搁置在函数栈上时,会将这段内存搁置在堆内存上。例如,makeBuffer 向上返回栈内存,编译器主动将这段内存放在堆内存上。

通过 -m 选项能够查看编译器剖析后果:

$ go build -gcflags="-m" escape.go
# command-line-arguments
./escape.go:8:6: can inline main
./escape.go:5:13: make([]byte, 1024) escapes to heap

除此之外,也存在其余一些状况会触发内存的“逃逸”:

  • 全局变量,因为它们可能被多个 goroutine 并发拜访;
  • 通过 channel 传送指针

    type Hello struct {name string}
    ch := make(chan *Hello, 1)
    ch <- &Hello{name: "world"}
  • 通过 channel 传送的构造体中持有指针

    type Hello struct {name *string}
    ch := make(chan *Hello, 1)
    name := "world"
    ch <- Hello{name: &name}
  • 局部变量过大,无奈放在函数栈上
  • 本地变量的大小在编译时未知,例如 s := make([]int, 1024) 兴许不会被放在堆内存上,然而 s := make([]int, n) 会被放在堆内存上,因为其大小 n 是个变量
  • 对 slice 的 append 操作触发了其底层数组重新分配

留神:下面列出的状况不是详尽的,并且可能随 Go 的演进发生变化。

在开发过程中,如果程序员不留神 golang 编译器的内存逃逸剖析,写出的代码可能会导致“额定”的动态内存调配,而“额定”的动态内存调配通常会和性能问题分割在一起(具体会在前面 golang gc 的章节中介绍)。

示例代码给咱们的启发是:留神函数签名设计,尽量避免因函数签名设计不合理而导致的不必要内存调配。向上返回一个 slice 可能会触发内存逃逸,向下传入一个 slice 则不会,这方面 Cockroach Encoding Function 给出了一个很好的例子。
接下来,咱们看下接口相干的事件。

1.2 interface{} / any

any 是 golang 1.18 版本引入的,跟 interface{} 等价。

type any = interface{}
在 golang 中,接口实现 为一个“胖”指针:一个指向理论的数据,一个指向函数指针表(相似于 C ++ 中的虚函数表)。

咱们来看上面的代码:

package interfaces

import ("testing")

var global interface{}

func BenchmarkInterface(b *testing.B) {var local interface{}
  for i := 0; i < b.N; i++ {local = calculate(i) // assign value to interface{}}
  global = local
}

// values is bigger than single machine word.
type values struct {
  value  int
  double int
  triple int
}

func calculate(i int) values {
  return values{
    value:  i,
    double: i * 2,
    triple: i * 3,
  }
}

在性能测试 BenchmarkInterface 中,咱们将函数 calculate 返回的后果赋值给 interface{} 类型的变量。

接下来,对 BenchmarkInterface 执行 memory profile:

$ go test -run none -bench Interface -benchmem -memprofile mem.out

goos: darwin
goarch: arm64
pkg: github.com/cnutshell/go-pearls/memory/interfaces
BenchmarkInterface-8    101292834               11.80 ns/op           24 B/op          1 allocs/op
PASS
ok      github.com/cnutshell/go-pearls/memory/interfaces        2.759s

$ go tool pprof -alloc_space -flat mem.out
(pprof) top 
(pprof) list iface.BenchmarkInterface
Total: 2.31GB
    2.31GB     2.31GB (flat, cum) 99.89% of Total
         .          .      7:var global interface{}
         .          .      8:
         .          .      9:func BenchmarkInterface(b *testing.B) {.          .     10:   var local interface{}
         .          .     11:   for i := 0; i < b.N; i++ {2.31GB     2.31GB     12:           local = calculate(i) // assign value to interface{}
         .          .     13:   }
         .          .     14:   global = local
         .          .     15:}
         .          .     16:
         .          .     17:// values is bigger than single machine word.
(pprof)

从内存分析后果看到:向接口类型的变量 local 赋值,会触发内存“逃逸”,导致额定的动态内存调配。

go 1.18 引入范型之前,咱们都是基于接口实现多态,基于接口实现多态,次要存在上面这些问题:

  1. 失落了类型信息,程序行为从编译阶段转移到运行阶段;
  2. 程序运行阶段不可避免地须要执行类型转换,类型断言或者反射等操作;
  3. 为接口类型的变量赋值可能会导致“额定的”内存调配;
  4. 基于接口的函数调用,理论的调用开销为:指针解援用(确定办法地址)+ 函数执行开销。编译器无奈对其执行内联优化,也无奈基于内联优化执行进一步的优化。

对于接口的应用,这里有一些提醒:

  • 代码中防止应用 interface{} 或者 any,至多防止在被频繁应用的数据结构或者函数中应用
  • go 1.18 引入了范型,将接口类型改为范型类型,是防止额定内存调配,优化程序性能的一个伎俩

1.3 Golang gc

后面咱们理解到,golang 编译器执行 escape analysis 后,依据须要数据可能被“搬”到堆内存上。

这里简略地介绍下 golang 的 gc,从而理解写 golang 代码时为什么应该尽量避免“额定的”内存调配。

1.3.1 Introduction

gc 是 go 语言十分重要的一部分,它大大简化了程序员写并发程序的复杂度。

人们发现写工作良好的并发程序仿佛也不再是那少部分程序员的独有技能。

glang gc 应用一棵树来保护堆内存对象的援用,属于追踪式的 gc,它基于 “标记 - 革除“算法工作 ,次要分为两个阶段:

  1. 标记阶段 - 遍历所有堆内存对象,判断这些对象是否在用;
  2. 革除阶段 - 遍历树,革除没有被援用的堆内存对象。

执行 gc 时,golang 首先会执行一系列操作并进行应用程序的执行,即 stopping the world,之后复原应用程序的执行,同时 gc 其余相干的操作还会并行地执行。所以 golang 的 gc 也被称为 concurrent mark-and-sweep,这样做的目标是尽可能减少 STW 对程序运行的影响。

严格地说,STW 会产生两次,别离在标记开始和标记完结时。

golang gc 包含一个 scavenger,定期将不再应用的内存返还给操作系统。

也能够在程序中调用 debug.FreeOSMemory(),手动将内存返还给操作系统。

1.3.2 gc 触发机制

相比于 java,golang 提供的 gc 管制形式比较简单:通过环境变量 GOGC 来管制。

runtime/debug.SetGCPercent allows changing this percentage at run time.

GOGC 定义了触发下次 gc 时堆内存的增长率,默认值为 100,即上次 gc 后,堆内存增长一倍时,触发另一次 gc。

例如,gc 触发时以后堆内存的大小时 128MB,如果 GOGC=100,那么当堆内存增长为 256MB 时,执行下一次 gc。

另外,如果 golang 两分钟内没有执行过 gc,会强制触发一次。

咱们也能够在程序中调用 runtime.GC() 被动触发 gc。

# 通过设置环境变量 GODEBUG 能够显示 gc trace 信息

$ GODEBUG=gctrace=1 go test -bench=. -v

# 当 gc 运行时,相干信息会写到规范谬误中 

留神:为了缩小 gc 触发次数而减少 GOGC 值并不一定能带来线性的收益,因为即使 gc 触发次数变少了,然而 gc 的执行可能会因为更大的堆内存而有所缩短。在大多数状况下,GOGC 维持在默认值 100 即可。

1.3.3 gc hints

如果咱们的代码中存在大量“额定”的堆内存调配,尤其是在代码要害门路上,对于性能的负面影响是十分大的:

  • 首先,堆内存的调配自身就是绝对耗时的操作
  • 其次,大量“额定”的堆内存调配意味着额定的 gc 过程,STW 会进一步影响程序执行效率。

极其状况下,短时间内大量的堆内存调配,可能会间接触发 OOM,gc 甚至都没有执行的机会。

所以,不要“天真”的认为 gc 会帮你搞定所有的事件:你留给 gc 解决的工作越少,你的性能才会越“体面”。

从性能优化的角度,打消那些“额定的”内存调配收益非常显著,通常也会是第一或者第二优先的选项。

然而,堆内存的应用并不能完全避免,当须要应用时,能够思考采纳某些技术,例如通过 sync.Pool 复用内存来缩小 gc 压力。

1.3.4 有了 gc 为什么还会有内存透露?

即使 golang 是 gc 语言,它并不是肯定没有内存透露,上面两种状况会导致内存透露的状况:

  1. 援用堆内存对象的对象长期存在;
  2. goroutine 须要耗费肯定的内存来保留用户代码的上下文信息,goroutine 透露会导致内存透露。

1.3.5 代码演示

代码见于文件 gc.go

  • 函数 allocator 通过 channel 传送 buf 类型的构造体,buf 类型的构造体持有堆内存的援用;
  • 函数 mempool 通过 channel 接管来自 allocator 的 buf,循环记录在 slice 中;
  • 同时,mempool 还会定期打印利用以后内存状态,具体含意参考 runtime.MemStats。

运行代码 gc.go:

$ go run gc.go
HeapSys(bytes),PoolSize(MiB),HeapAlloc(MiB),HeapInuse(MiB),HeapIdle(bytes),HeapReleased(bytes)
 12222464,     5.00,     7.11,     7.45,  4415488,  4300800
 16384000,    10.00,    12.11,    12.45,  3334144,  3153920
 24772608,    18.00,    20.11,    20.45,  3334144,  3121152
 28966912,    22.00,    24.11,    24.45,  3334144,  3121152
 33161216,    25.00,    27.11,    27.45,  4382720,  4169728
 37355520,    32.00,    34.11,    34.45,  1236992,   991232
 41549824,    36.00,    38.11,    38.45,  1236992,   991232
 54132736,    48.00,    50.11,    50.45,  1236992,   991232
 58327040,    51.00,    53.11,    53.45,  2285568,  2039808

通过程序输入后果,咱们能够理解到:如果程序中存在变量持有对堆内存的援用,那么这块堆内存不会被 gc 回收。

因而应用援用了堆内存的变量赋值时,例如将其赋值给新的变量,须要留神避免出现内存透露。通常倡议将赋值无关的操作封装在办法中,以通过正当的 API 设计避免出现“意想不到”内存泄露。并且封装还带来的益处是进步了代码的可测性。

1.3.6 参考资料

  • Blog: Go Data Structures: Interfaces
  • GOGC on golang’s document
  • GC 的意识

Part 2 Golang Profiling

profiler 运行用户程序,同时配置操作系统定期送出 SIGPROF 信号:

  • 收到 SIGPRFO 信号后,暂停用户程序执行;
  • profiler 收集用户程序运行状态;
  • 收集结束复原用户程序执行。

如此循环。

profiler 是基于采样的,对程序性能存在肯定水平的影响。

“Before you profile, you must have a stable environment to get repeatable results.”

2.1 Supported Profiling

By default, all the profiles are listed in runtime/pprof.Profile

a. CPU Profiling

CPU profiling 使能后,golang runtime 默认每 10ms 中断应用程序,并记录 goroutine 的堆栈信息。

b. Memory Profiling

Memory profiling 和 CPU profiling 一样,也是基于采样的,它会在堆内存调配时记录堆栈信息。

默认每 1000 次堆内存调配会采样一次,这个频率能够配置。

留神:Memory profiling 仅记录堆内存调配信息,疏忽栈内存的应用。

c. Block Profiling

Block profiling 相似于 CPU profiling,不过它记录 goroutine 在共享资源上期待的工夫。

它对于查看利用的并发瓶颈很有帮忙,Blocking 统计对象次要包含:

  • 读 / 写 unbuffered channel
  • 写 full buffer channel,读 empty buffer channel
  • 加锁操作

如果基于 net/http/pprof,应用程序中须要调用 runtime.SetBlockProfileRate 配置采样频率。

d. Mutex Profiling

Go 1.8 引入了 mutex profile,容许你捕捉一部分竞争锁的 goroutines 的堆栈。

如果基于 net/http/pprof,应用程序中须要调用 runtime.SetMutexProfileFraction 配置采样频率。

留神:通过 net/http/pprof 对线上服务执行 profiling 时,不倡议批改 golang profiler 默认值,因为某些 profiler 参数的批改,例如减少 memory profile sample rate,可能会导致程序性能呈现显著的降级,除非你明确的晓得可能造成的影响。

2.2 Profiling Commands

咱们能够从 go test 命令,或者从应用 net/http/pprof 的利用中获取到 profile 文件:

## 1. From unit tests
$ go test [-blockprofile | -cpuprofile | -memprofile | -mutexprofile] xxx.out

## 2. From long-running program with `net/http/pprof` enabled
## 2.1 heap profile
$ curl -o mem.out http://localhost:6060/debug/pprof/heap

## 2.2 cpu profile
$ curl -o cpu.out http://localhost:6060/debug/pprof/profile?seconds=30

获取到 profile 文件之后,通过 go tool pprof 进行剖析:

# 1. View local profile
$ go tool pprof xxx.out

# 2. View profile via http endpoint
$ go tool pprof http://localhost:6060/debug/pprof/block
$ go tool pprof http://localhost:6060/debug/pprof/mutex

2.3 Golang Trace

咱们能够从 go test 命令,或者从应用 net/http/pprof 的利用中获取到 trace 文件:

# 1. From unit test
$ go test -trace trace.out

# 2. From long-running program with `net/http/pprof` enabled
curl -o trace.out http://localhost:6060/debug/pprof/trace?seconds=5

获取到 trace 文件之后,通过 go tool trace 进行剖析,会主动关上浏览器:

$ go tool trace trace.out

2.4 Profiling Hints

如果大量工夫耗费在函数 runtime.mallocgc,意味着程序产生了大量堆内存调配,通过 Memory Profiling 能够确定调配堆内存的代码在哪里;

如果大量的工夫耗费在同步原语(例如 channel,锁等等)上,程序可能存在并发问题,通常意味着程序工作流程须要从新设计;

如果大量的工夫耗费在 syscall.Read/Write,那么程序有可能执行大量小 IO;

如果 GC 组件耗费了大量的工夫,程序可能调配了大量的小内存,或者调配的堆内存比拟大。

2.5 代码演示

代码见于文件 contention_test.go:

  • Block Profiling with Unit Test

    $ go test -run ^TestContention$ -blockprofile block.out
    $ go tool pprof block.out
    (pprof) top
    (pprof) web
  • Mutex Profiling with Unit Test

    $ go test -run ^TestContention$ -mutexprofile mutex.out
    $ go tool pprof mutex.out
    (pprof) top
    (pprof) web
  • Trace with Unit Test

    $ go test -run ^TestContention$ -trace trace.out
    $ go tool trace trace.out

2.6 参考资料

  • net/http/pprof examples

Part 3 如何写性能测试

性能问题不是猜想进去的,即使咱们“强烈的认为”某处代码是性能瓶颈,也必须通过验证。

“Those who can make you believe absurdities can make you commit atrocities” – Voltaire

对于性能测试来说,很容易写出不精确的 Benchmark,从而造成谬误的印象。

3.1 Reset or Pause timer

func BenchmarkFoo(b *testing.B) {heavySetup()  // 在 for 循环之前执行设置工作,如果设置工作比拟耗时,那么会影响测试后果的准确性
  for i := 0; i < b.N; i++ {foo()
  }
}

优化形式

func BenchmarkFoo(b *testing.B) {heavySetup()
  b.ResetTimer()  // 重置 timer,保障测试后果的准确性
  for i := 0; i < b.N; i++ {foo()
  }
}

如何进行 timer

func BenchmarkFoo(b *testing.B) {
  for i := 0; i < b.N; i++ {b.StopTimer() // 进行 timer
    heavySetup()
    b.StartTimer() // 启动 timer
    foo()}
}

3.2 进步测试后果可信度

对于 Benchmark,有很多因素会影响后果的准确性:

  • 机器负载状况
  • 电源治理设置
  • 热扩大 (thermal scaling)
  • ……

雷同的性能测试代码,在不同的架构,操作系统下运行可能会产生截然不同的后果;

雷同的 Benchmark 即使在同一台机器运行,前后也可能产生不统一的数据。

简略的形式是减少 Benchmark 运行次数或者屡次运行测试来获取绝对精确的后果:

  • 通过 -benchtime 设置性能测试工夫(默认 1 秒)
  • 通过 -count 屡次运行 Benchmark
package benchmark

import (
        "sync/atomic"
        "testing"
)

func BenchmarkAtomicStoreInt32(b *testing.B) {
        var v int32
        for i := 0; i < b.N; i++ {atomic.StoreInt32(&v, 1)
        }
}

func BenchmarkAtomicStoreInt64(b *testing.B) {
        var v int64
        for i := 0; i < b.N; i++ {atomic.StoreInt64(&v, 1)
        }
}

屡次运行测试,得出置信度较高的后果:

$ go test -bench Atomic -count 10 | tee stats.txt

$ benchstat stats.txt
goos: darwin
goarch: arm64
pkg: github.com/cnutshell/go-pearls/benchmark
                   │   stats.txt   │
                   │    sec/op     │
AtomicStoreInt32-8   0.3131n ± ∞ ¹
AtomicStoreInt64-8   0.3129n ± ∞ ¹
geomean              0.3130n
¹ need >= 6 samples for confidence interval at level 0.95

如果提醒 benchstat 未找到,通过 go install 命令装置:go install http://golang.org/x/perf/cmd/…

3.3 留神编译器优化

package benchmark

import "testing"

const (
        m1 = 0x5555555555555555
        m2 = 0x3333333333333333
        m4 = 0x0f0f0f0f0f0f0f0f
)

func calculate(x uint64) uint64 {x -= (x >> 1) & m1
        x = (x & m2) + ((x >> 2) & m2)
        return (x + (x >> 4)) & m4
}

func BenchmarkCalculate(b *testing.B) {
        for i := 0; i < b.N; i++ {calculate(uint64(i))
        }
}

func BenchmarkCalculateEmpty(b *testing.B) {
        for i := 0; i < b.N; i++ {// empty body}
}

运行示例代码中的测试,两个测试的后果雷同:

$ go test -bench Calculate
goos: darwin
goarch: arm64
pkg: github.com/cnutshell/go-pearls/benchmark
BenchmarkCalculate-8            1000000000               0.3196 ns/op
BenchmarkCalculateEmpty-8       1000000000               0.3154 ns/op
PASS
ok      github.com/cnutshell/go-pearls/benchmark        0.814s

那么如何防止这种状况呢,后面介绍 golang 接口的时候,给出了一个例子:

var global interface{}

func BenchmarkInterface(b *testing.B) {var local interface{}
  for i := 0; i < b.N; i++ {local = calculate(uint64(i)) // assign value to interface{}}
  global = local
}

将被 calculate 的返回值赋给本地变量 local,循环完结后将本地变量 local 赋值给一个全局变量 global,这样能够防止函数 calculate 被编译器优化掉。

3.4 总结

谬误的性能测试后果会导致咱们做出谬误的决定,正所谓“差之毫厘,谬以千里”,写性能测试代码并不是外表上看起来的那么简略。

退出移动版