- 道
- 术
- 文件 IO
- cgo
- goroutine
- time
- gc
- 字符串
- 切片
- map
- for / range
- 结构体
- 锁
- reflect
- sync.Pool
- net
- 编译器的优化
- 优化1:紧跟 range 关键字的从字符串到字节切片的转换
- 优化2:映射元素读取索引语法中被用做键值的从字节切片到字符串的转换
- 优化3:一个字符串比较表达式中被用做比较值的从字节切片到字符串的转换
- 优化4:[]rune(aString)转换的时间和空间复杂度都是O(n),但是len([]rune(aString))中的此转换不需开辟内存
- 优化5:字符串衔接表达式只需开辟一次内存,无论需要衔接多少个字符串
- 优化7:for i := range anArrayOrSlice {anArrayOrSlice[i] = zeroElement} 形式将被优化为一个内部的 memclr 操作
- 优化8:for k = range m {delete(m, k)} 形式将被优化为一个内部的 map 清空操作
- BCE(Bounds Check Elimination)优化
- 反鸡汤
自己在公司内部做的一个分享,一些内容没有做引用,就抱歉拉。
道
一般来说,优化应该从上到下进行。优化越靠近应用层效果越好。
与编译器优化一样,程序优化通常只会对总的运行时间造成很小的影响。大的改进基本都是算法和数据结构的改变,这是程序组织方式的根本性转变。
通过测量的数据来确定瓶颈,不要瞎猜。
如果性能很好了,就不要优化了。不需要优化所有内容,只需要优化代码中性能最关键的部分。
总是尽可能地编写最简单的代码,编译器会针对普通代码进行优化。更短的代码是更快的代码。
术
善用工具,pprof,perf。
要非常注意分配,尽量避免不必要的分配,尽量不要在循环里分配变量。
一般到 1.xx.15 就可以升级了,享受官方的技术红利。
文件 IO
文件 IO 没有实现 epoll 这种机制兜底,只能同步 IO,所以一定要注意文件 IO 的并发数。可以通过一些机制去限制。
var semaphore = make(chan struct{}, 10)func processRequest(work *Work) {semaphore <- struct{}{}// 磁盘 io os.read/write<-semaphore}
示例 demo

通过 http://localhost:6060/debug/pprof/ 查看,注意观察 threadcreate (系统线程)数量。
Linux io_uring 这种算比较新的官方钦定的异步 IO 。内核版本要求高,线上也普及不开来,不过听说有些公司已经抠到低版本的了,Go Team 对 io_uring 的优先级不是很高,不过社区有做,总得感觉下来 rust 社区要讨论的氛围高很多。
cgo
避免 cgo,cgo 调用类似于阻塞式 IO,它们在操作期间消耗一个线程。
ipfs 的公链底层零知识证明非常典型的计算型任务,部分计算任务是通过 gpu 去运算且耗时长,上层 go 通过 cgo 去调用底层 rust 是比较典型的使用场景。

goroutine
goroutine 频繁创建与销毁会给调度造成较大的负担。如果是百万 goroutine 这种级别的屠龙场景,可以考虑一些池化复用比如 ants,减少 runtime 调度和 gc 的压力。
goroutine 一定要知道如何以及何时退出,一定要记得释放资源 (defer close/stop)。goroutine 是最容易产生内存泄露的地方,数量一起来了,会导致 oom,被系统 kill 掉。
time
Golang 1.14 对 Timer 及其相关依赖带来大幅性能提升。
老生常谈的内存泄露的问题,time.After 虽然好用,但是坑有点多,在起 goroutine 的时候一定记得要注意。
go func() {ticker := time.NewTicker(500 * time.Millisecond)// forget ticker.Stop()}
go func() {for {select {case <-time.After: // leak..}}}
select {case <-time.After(time.Second):// do something after 1 second.case <-ctx.Done():// do something when context is finished.// resources created by the time.After() will not be garbage collected}// recommenddelay := time.NewTimer(time.Second)select {case <-delay.C:// do something after one second.case <-ctx.Done():// do something when context is finished and stop the timer.if !delay.Stop() {// if the timer has been stopped then read from the channel.<-delay.C}}
gc
堆内存分配导致垃圾回收的开销远远大于栈空间分配与释放的开销。
指针逃逸最常见场景:
函数返回指针
闭包
当切片占用内存超过一定大小,或无法确定当前切片长度时,需要运行的时候才能确定,对象占用内存将在堆上分配
往 channel 里发指针
切片包含指针结构或者指针,比如 []*string。虽然切片可能还在堆栈上,但是指针指向的元素会逃逸到堆上
对接口类型调用方法,hot path 最好记得要避免
interface{} 动态逃逸。比如字符串相加,result := “name=” + name,改动成 fmt.Sprintf(“name=%s”,name),会全部逃逸。hot path 下记得要注意
传指针缺点有
解引用的时候,编译器会生成检查。目的就是常见的 nil 的原因报 panic。值就没这个问题。
对 cpu 缓存极其不友好,都在栈上分配 cpu cache 基本百分百命中。
- Golang 里复制一个对象,其实跟复制一个指针开销差不多。cpu 一次性读在 x86 上就是 64 字节。Go 使用了一种叫做 Duff’s device 的技术,使常见的内存操作(复制)非常有效。
指针应该主要用于反映所有权语义和可变性。比如 struct method,区分是零值还是未设置值。在实践中,使用指针来避免复制的情况应该很少。不要落入过早优化的陷阱。养成按值传递数据的习惯是有好处的,只有在必要时才回到传递指针的状态。另一个额外的好处是消除 nil 的安全性增加了。
需要注意的是,逃逸分析的结果是会随着版本变化的。记得每个新的版本都可以测一下看有木有变化。
go build -gcflags="-m -m" https://github.com/filecoin-project/lotus
dgraph lab 发过文章分享过 cgo + jemalloc 的思路,这种堆外内存,Go 的 GC 就管不到了。(不推荐,太 hack 了,只是介绍下社区的一些骚操作
字符串
字符串相关操作是开发中很高频的逻辑,相关性能如下:
使用 +
fmt.Sprintf 格式化字符串
strings.Builder/bytes.Buffer/[]byte
综合易用性和性能,一般推荐使用 strings.Builder 来拼接字符串。
这是 Go 官方对 strings.Builder 的解释:
A Builder is used to efficiently build a string using Write methods. It minimizes memory copying.
Builder 用于使用 Write 方法有效地构建一个字符串。它最大限度地减少了内存的复制。
使用 + 需要开辟新的字符串的内存。而 strings.Builder,bytes.Buffer, []byte 这三个内存是以倍数申请的。strings.Builder 最快是因为 bytes.Buffer 转化为字符串时重新申请了一块空间,存放新生成的字符串变量,而 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回了回来。
// String returns the accumulated string.func (b *Builder) String() string {return *(*string)(unsafe.Pointer(&b.buf))}
// string -> []byte:// 缓冲区不要修改否则程序直接挂掉,recover 包不住func string2bytes(s string) []byte {return *(*[]byte)(unsafe.Pointer(&s))}// 上面这种转出来 cap 会很大,官方更推荐 fasthttp 的实现func s2b(s string) (b []byte) {/* #nosec G103 */bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))/* #nosec G103 */sh := (*reflect.StringHeader)(unsafe.Pointer(&s))bh.Data = sh.Databh.Len = sh.Lenbh.Cap = sh.Lenreturn b}// []byte -> stringfunc bytes2string(b []byte) string{return *(*string)(unsafe.Pointer(&b))}
比较不区分大小写字符串
// goodstrings.EqualFold(str1, str2)// badstrings.ToLower(str1) == strings.ToLower(str2)orstrings.ToUpper(str1) == strings.ToUpper(str2)
切片
众所周知,make 的时候最好能设一个合理的 cap,这样后续的 append() 操作就是零分配了,避免复制。
切片有对底层数组的引用。只要切片在内存中,就不能对数组进行垃圾回收。推荐 copy。见下:
func bar() []string{foo := []string{"1", "2", ...., "10000"}// goodresult := make([]string, len)copy(result, foo[:len])// badresult := foo[:len]return result}
切片复用
var messages []stringfor _, msg := range recv {messages = append(messages, msg)if len(messages) > maxMessageLen {marshalAndSend(messages)// 如果是很大的数组,直接清空重新复制会给 gc 造成巨大压力messages = []string}}
可以直接复用,如下
var messages []stringfor _, msg := range recv {messages = append(messages, msg)if len(messages) > maxMessageLen {marshalAndSend(messages)// 直接复用不会给 gc 造成很大的压力,但是如果成员是指针类型,记得挨个置为 nil。messages = messages[:0]}}
标准库里常见 buffer 复用, 比如 bytes.Buffer.Reset or buf = buf[:0] 都是比较常见的。
map
map 不要存指针,一多起来,gc 会扫的很慢。存纯值,gc 基本忽略扫描时间,见 9477 的讨论。如果能找到映射到切片的方法,也非常推荐转换(记得显示的置为 **nil**)。
map[int]*obj // gc slowmap[int]int // gc fast[]*obj // gc also fasr
具体见基准测试
编译器有对 m[string(bytes)] 这种情况有优化。
// goodvar m map[string]stringv, ok := m[string(bytes)]// badkey := string(bytes)val, ok := m[key]
官方 map 是 delete 的时候是没有真正 gc 的,见 20135 的讨论。其实出发点,和 mysql 的标记删除类似,防止后续会有相同的 key 插入,省去了扩缩容的操作。解决方案有 go-zero 的 safemap,核心理念就是预设一个删除阈值,如果触发会放到一个新预设好的 newmap 中。
map 最好也要指定 cap,在运行时可能会有更少的分配。
注意,与 slices 不同。map capacity 提示并不保证完全的抢占式分配,而是用于估计所需的 hashmap bucket 的数量。 因此,在将元素添加到 map 时,甚至在指定 map 容量时,仍可能发生分配。
for / range
range 有值拷贝,遍历 struct 的时候,struct 比较大,性能就不如 for 循环。对 []struct 操作一般都推荐老老实实 for 循环。
切片元素从结构体 Item 替换为指针 *Item 后,for 和 range 的性能几乎是一样的。使用指针有另一个好处,可以直接修改指针对应的结构体的值。
结构体
空结构体不占据内存空间,因此被广泛作为各种场景下的占位符使用。一是节省资源,二是空结构体本身就具备很强的语义,即这里不需要任何值,仅作为占位符。
常见比如实现 set 结构,chan 发信号。
经常访问的字段可以放在结构体第一位上,因为 hot path 在每个调用点都是内联的,将经常访问的字段放在前面可以使某些架构(amd64/x86)上的指令更加紧凑。而在其他架构上可以减少指令(计算偏移量)。
type Once struct {// done indicates whether the action has been performed.// It is first in the struct because it is used in the hot path.// The hot path is inlined at every call site.// Placing done first allows more compact instructions on some architectures (amd64/x86),// and fewer instructions (to calculate offset) on other architectures.done uint32m Mutex}
内存对齐:cpu 可以更高效访问内存中数据。
// golangci-lint 可以帮忙检测对齐golangci-lint run --disable-all --enable maligned xx.go// https://github.com/dominikh/go-tools// structlayout structlayout-optimize 这俩分别显示结构的布局(字段大小和填充),重新排序结构字段帮你自动填充
锁
读写锁的存在是为了解决读多写少时的性能问题,读场景较多时,读写锁可有效地减少锁阻塞的时间。
sync.Map 读多写少的情况下推荐使用。
锁优化的思路,拆和缩,或者替换成无锁的实现。
普罗米修斯实验室在 2019 gopher UK 上的分享,观测直方图无锁的实现思路
reflect

可读性和性能都很差,尽量尽量不要使用。
uber 的 zap 就是坚定的规避反射的。自己实现 json Encoder。 通过明确的类型调用,直接拼接字符串,最小化性能开销。

sync.Pool
fasthttp 发扬光大,复用可以复用的一切,比如常见的 struct,slice。valyala 是社区的高手,写了很多高性能的库。fasthttp 的 readme 推荐看下,介绍了挺多高性能的技巧。
在使用 sync.Pool 的时候需要注意引用问题。 切记要保证 Put 回去对象已经使用完毕。一定要记得要 Put,最好 reset 了再 Put 或者 Get 的时候 reset。
sync.Pool 有一个比较蛋疼的使用缺陷,比如当前复用的是一个 slice,cap 突然被 append 很大,又 put 回去了导致原来的数组会一直得不到释放,等于就是一段很大的内存一直跑着不会被 gc,导致性能有问题。社区的几个讨论最无脑的解决方案就是直接判断 cap 大小,小于一个阀值才能 put 回去,不然就等着 gc 慢慢回收它。参考此解决方案。
net
Go 原生标准库效率上有些拉,每一个连接至少要有一个 goroutine 来维护,有些协议实现可能有两个。因此 goroutine 总数 = 连接数 1 or 连接数 2。当连接数超过 10w 时,goroutine 栈本身带来的内存消耗就有几个 GB。
社区的高性能 event-loop 的实现, gnet,netpoll
编译器的优化
优化1:紧跟 range 关键字的从字符串到字节切片的转换
var gogogo = strings.Repeat("Go", 1024)func f() { for range []byte(gogogo) {} }func g() { bs := []byte(gogogo); for range bs {} }func main() {fmt.Println(testing.AllocsPerRun(1, f)) // 0fmt.Println(testing.AllocsPerRun(1, g)) // 1}
优化2:映射元素读取索引语法中被用做键值的从字节切片到字符串的转换
var name = bytes.Repeat([]byte{'x'}, 33)var m, s = make(map[string]string, 10), ""func f() { s = m[string(name)] } // 有效func g() { key := string(name); s = m[key] } // 无效func h() { m[string(name)] = "Golang" } // 无效func main() {fmt.Println(testing.AllocsPerRun(1, f)) // 0fmt.Println(testing.AllocsPerRun(1, g)) // 1fmt.Println(testing.AllocsPerRun(1, h)) // 1}
优化3:一个字符串比较表达式中被用做比较值的从字节切片到字符串的转换
var x = []byte{1023: 'x'}var y = []byte{1023: 'y'}var b boolfunc f() { b = string(x) != string(y) }func g() { sx, sy := string(x), string(y); b = sx == sy }func main() {fmt.Println(testing.AllocsPerRun(1, f)) // 0fmt.Println(testing.AllocsPerRun(1, g)) // 2}
优化4:[]rune(aString)转换的时间和空间复杂度都是O(n),但是len([]rune(aString))中的此转换不需开辟内存
var GoGoGo = strings.Repeat("Go", 100)func f() { _ = len([]rune(GoGoGo)) }func g() { _ = len([]byte(GoGoGo)) } // 未对len([]byte(aString))做优化func main() {fmt.Println(testing.AllocsPerRun(1, f)) // 0fmt.Println(testing.AllocsPerRun(1, g)) // 1}
优化5:字符串衔接表达式只需开辟一次内存,无论需要衔接多少个字符串
var x, y, z, w = "Hello ", "World! ", "Let's ", "Go!"var s string// 对于在编译时刻衔接的字符串的数量已知的情况下, 这种方法衔接字符串的效率最高func f() { s = x + y + z + w }func g() { s = x + y; s += z; s += w }func main() {fmt.Println(testing.AllocsPerRun(1, f)) // 1fmt.Println(testing.AllocsPerRun(1, g)) // 3}
优化7:for i := range anArrayOrSlice {anArrayOrSlice[i] = zeroElement} 形式将被优化为一个内部的 memclr 操作
const N = 1024 * 100var a [N]intfunc clearArray() { for i := range a { a[i] = 0 } }func clearSlice() { s := a[:]; for i := range s { s[i] = 0 } }func clearArrayPtr() { for i := range &a { a[i] = 0 } } // 无效 不要搞指针Benchmark_clearArray-4 77971 14698 ns/opBenchmark_clearSlice-4 76803 14771 ns/opBenchmark_clearArrayPtr-4 30687 39002 ns/op
优化8:for k = range m {delete(m, k)} 形式将被优化为一个内部的 map 清空操作
这个优化貌似对于平时编程并没有太大的意义,因为这是清空 map 条目的唯一方法。但是其实有些 Go 程序员可能会通过用 make 来新开出来一个 map 的途径来变相清空 map条目。其实两种方法各有所长。目前官方标准编译器的实现中,一个 map 的底层哈希表数组的长度是永不收缩的。所以这个优化并不释放为底层哈希表数组开辟的内存。它只是比一个一个删除操作要快得多。相比用 make 来新开出来一个 map,此优化将减少一些GC(垃圾回收)压力。所以,具体应该使用哪种方法,视具体情况而定。
BCE(Bounds Check Elimination)优化
Go 是一门内存安全的语言。检查下标越界是保证内存安全的重要举措之一。但另一方面检查下标越界也耗费一些 CPU 计算。事实上绝大部分的下标越界检查都不会发现有问题的。这就是维护内存安全的代价。
在某些情形下,编译器在代码编译阶段可以确定某些下标越界检查是不必要的从而可以避免这些检查,这样将提升程序运行效率。
编译器并不总是足够得聪明,有时需要人为干预引导编译器来消除一些下标越界检查。
反鸡汤
过早的优化是一切罪恶的根源。
可读性意味着可靠性,永远永远可读性的优先级是最高的。
一般情况下,不要用性能去取代可靠性。
