一些Go性能优化手段
go性能优化
[TOC]
字符串拼接问题
字符串(string)作为一种不可变类型,在与字节数组(slice, [ ]byte)转换时需付出 “沉重” 代价,根本原因是对底层字节数组的复制。这种代价会在以万为单位的高并发压力下迅速放大,所以对它的优化常变成 “必须” 行为。
- “+”连接,性能最差,会导致频繁的内存分配和数据复制
- strings.Join 会预先计划好字符串连接需要的空间,一次性申请足够的空间,避免频繁的内存分配和数据拷贝
- 通过bytes.Buffer,先准备足够内存,防止中途内存扩张
- 通过unsafe进行指针操作
//+字符串连接,会导致数据拷贝
func StrPlus() {
s := "hello"
for i := 0; i < 100; i++ {
s += "world"
}
_ = s
}
//strings.Join 会预先计划好字符串连接需要的空间,一次性申请足够的空间,避免频繁的内存分配和数据拷贝
func StrJoin() {
b := make([]string, 0, 101)
b = append(b, "hello")
for i := 1; i <= 100; i++ {
b = append(b, "world")
}
_ = strings.Join(b, "")
}
//通过bytes.Buffer
//也是单次申请足够内存,避免多次内存扩张
func StrBuffer() {
var b bytes.Buffer
b.Grow(510)//先准备足够内存,防止中途内存扩张
b.WriteString("hello")
for i := 1; i <= 100; i++ {
b.WriteString("world")
}
}
//通过unsafe.Pointer指针操作
func str2bytes(s string) []byte {
x := (*[2]uintptr)(unsafe.Pointer(&s))
h := [3]uintptr{x[0], x[1], x[1]}
return *(*[]byte)(unsafe.Pointer(&h))
}
func bytes2str(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}
func Unsafe() {
s := strings.Repeat("world", 100)
b := str2bytes(s)
s2 := bytes2str(b)
_ = s2
}
基准测试,性能对比
//go test -v -bench . -benchmem
BenchmarkStrPlus-4 200000 7786 ns/op 26896 B/op 100 allocs/op
BenchmarkStrJoin-4 1000000 1140 ns/op 512 B/op 1 allocs/op
BenchmarkStrBuffer-4 2000000 931 ns/op 512 B/op 1 allocs/op
BenchmarkUnsafe-4 10000000 151 ns/op 512 B/op 1 allocs/op
通过对比,+号连接,每次都进行内存分配,性能最差。
小结:
- 直接“+”连接,每次运算都会产生一个新的字符串,重复内存分配,给 gc 带来额外的负担,所以性能比较差。
- 使用
fmt.Sprintf()
,内部使用 []byte 实现,不像直接运算符这种会产生很多临时的字符串,但是内部的逻辑比较复杂,有很多额外的判断,还用到了 interface,所以性能也不是很好。 strings.Join()
,join会先根据字符串数组的内容,计算出一个拼接之后的长度,然后申请对应大小的内存,这种方式效率相对较高。在已有字符串数组的场合,使用 strings.Join() 能有比较好的性能。buffer.WriteString()
,相对理想一些,可以当成可变字符使用,对内存的增长也有优化。在高性能场合推荐使用这种。- unsafe 非必要场景不推荐使用
array还是slice
Go 里面的 array 以 pass-by-value 方式传递,即传值。而slice按地址传递,避免了数据的复制。那么哪个性能更好呢?
很多地方推荐就用 slice 代替 array,企图避免数据拷贝,企图提升性能。但这也要分场景。否则反而会降低性能。
//array与slice对比
const capacity = 1024
func Array() [capacity]int {
var a [capacity]int
for i := 0; i < capacity; i++ {
a[i] = i
}
return a
}
func ArrayStr() [capacity]string {
var a [capacity]string
for i := 0; i < capacity; i++ {
a[i] = "x"
}
return a
}
func Slice() []int {
a := make([]int, capacity)
for i := 0; i < capacity; i++ {
a[i] = i
}
return a
}
go test -v -bench . -benchmem
BenchmarkArray-4 2000000 731 ns/op 0 B/op 0 allocs/op
BenchmarkSlice-4 1000000 1302 ns/op 8192 B/op 1 allocs/op
BenchmarkArrayStr-4 1000000 312 ns/op 0 B/op 0 allocs/op
这里数组的性能比切片好,string的操作,明显比int操作开销大.
逃逸分析
go build -gcflags=-m array_slice.go
./array_slice.go:3:6: can inline main
...go:6:6: can inline init.0
./array_slice.go:26:11: make([]int, capacity) escapes to heap
array 有更好的性能,还避免了堆内存分配,也就是说减轻了 GC 压力。若熟悉汇编的,容易看出来。函数 array 返回值的复制只需用 “CX + REP” 指令就可完成。
生成汇编代码 go tool compile -N -l -S array_slice.go
或者 go build -gcflags -S array_slice.go
....
0x007d 00125 (array_slice.go:11) JMP 169
0x007f 00127 (array_slice.go:12) MOVQ "".i(SP), AX
0x0083 00131 (array_slice.go:12) MOVQ "".i(SP), DX
0x0087 00135 (array_slice.go:12) CMPQ AX, $1024
0x008d 00141 (array_slice.go:12) JCS 145
0x008f 00143 (array_slice.go:12) JMP 206
0x0091 00145 (array_slice.go:12) SHLQ $3, AX
0x0095 00149 (array_slice.go:12) MOVQ DX, "".a+8(SP)(AX*1)
0x009a 00154 (array_slice.go:12) JMP 156
0x009c 00156 (array_slice.go:11) MOVQ "".i(SP), AX
0x00a0 00160 (array_slice.go:11) INCQ AX
0x00a3 00163 (array_slice.go:11) MOVQ AX, "".i(SP)
0x00a7 00167 (array_slice.go:11) JMP 115
0x00a9 00169 (array_slice.go:14) PCDATA $2, $1
0x00a9 00169 (array_slice.go:14) LEAQ "".~r0+8216(SP), DI
0x00b1 00177 (array_slice.go:14) PCDATA $2, $2
0x00b1 00177 (array_slice.go:14) LEAQ "".a+8(SP), SI
0x00b6 00182 (array_slice.go:14) MOVL $1024, CX
0x00bb 00187 (array_slice.go:14) PCDATA $2, $0
0x00bb 00187 (array_slice.go:14) REP
0x00bc 00188 (array_slice.go:14) MOVSQ
0x00be 00190 (array_slice.go:14) MOVQ 8200(SP), BP
0x00c6 00198 (array_slice.go:14) ADDQ $8208, SP
....
整个 array 函数完全在栈上完成,而 slice 函数则需执行 makeslice,继而在堆上分配内存,这就是问题所在。
对于小对象,数据复制成本远小于在堆上内存分配和GC回收操作
。
另外需要注意的是,slice 缩容时,被缩掉对象如果不置 nil,是不会释放的对应的内存的。
map扩容开销
内置 map 类型是必须的。使用频率很高;可借助 runtime 实现深层次优化(比如说字符串转换,以及 GC 扫描等)。有很多需特别注意的地方。
map 会按需扩张,但须付出数据拷贝和重新哈希成本
。哈希类的数据结构都难免有哈希冲突,重新哈希计算的问题,如有可能,应尽可能预设足够容量空间,避免此类行为发生。
func SetMap(m map[int]int) {
for i := 0; i < 10000; i++ {
m[i] = i
}
}
func BenchmarkMap(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
m := make(map[int]int)
b.StartTimer()
SetMap(m)
}
}
func BenchmarkCapMap(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
m := make(map[int]int, 10000)
b.StartTimer()
SetMap(m)
}
}
//BenchmarkMap-4 2000 730063 ns/op 687136 B/op 275 allocs/op
//BenchmarkCapMap-4 5000 335578 ns/op 2709 B/op 9 allocs/op
从测试结果看,预设容量的 map 显然性能更好,更极大减少了堆内存分配次数。
不恰当的defer延迟调用
延迟调用(defer)确实是一种 “优雅” 机制。可简化代码,并确保即便发生 panic 依然会被执行。如将 panic/recover 比作 try/except,那么 defer 似乎可看做 finally。
通过延迟释放锁和立刻释放做性能对比:
var m sync.Mutex
func Lock() {
m.Lock()
m.Unlock()
}
func DeferLock() {
m.Lock()
defer m.Unlock()
}
//go test -v -bench . -benchmem
//BenchmarkLock-4 100000000 15.2 ns/op 0 B/op 0 allocs/op
//BenchmarkDeferLock-4 30000000 51.2 ns/op 0 B/op 0 allocs/op
通过测试,性能存在数倍差距。
defer 实现机制
编译器通过 runtime.deferproc “注册” 延迟调用,除目标函数地址外,还会复制相关参数(包括 receiver)。在函数返回前,执行 runtime.deferreturn 提取相关信息执行延迟调用。这其中的代价自然不是普通函数调用一条 CALL 指令所能比拟的。defer只有在函数return之前,或者报错时才会触发执行。
"".main STEXT size=119 args=0x0 locals=0x28
0x0000 00000 (defer.go:4) TEXT "".main(SB), ABIInternal, $40-0
0x0000 00000 (defer.go:4) MOVQ (TLS), CX
0x0009 00009 (defer.go:4) CMPQ SP, 16(CX)
0x000d 00013 (defer.go:4) JLS 112
0x000f 00015 (defer.go:4) SUBQ $40, SP
0x0013 00019 (defer.go:4) MOVQ BP, 32(SP)
0x0018 00024 (defer.go:4) LEAQ 32(SP), BP
0x001d 00029 (defer.go:4) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (defer.go:4) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (defer.go:4) FUNCDATA $3, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
0x001d 00029 (defer.go:5) PCDATA $2, $0
0x001d 00029 (defer.go:5) PCDATA $0, $0
0x001d 00029 (defer.go:5) MOVL $16, (SP)
0x0024 00036 (defer.go:5) PCDATA $2, $1
0x0024 00036 (defer.go:5) LEAQ "".wrap·1·f(SB), AX
0x002b 00043 (defer.go:5) PCDATA $2, $0
0x002b 00043 (defer.go:5) MOVQ AX, 8(SP)
0x0030 00048 (defer.go:5) PCDATA $2, $1
0x0030 00048 (defer.go:5) LEAQ go.string."hxx"(SB), AX
0x0037 00055 (defer.go:5) PCDATA $2, $0
0x0037 00055 (defer.go:5) MOVQ AX, 16(SP)
0x003c 00060 (defer.go:5) MOVQ $3, 24(SP)
0x0045 00069 (defer.go:5) CALL runtime.deferproc(SB)
0x004a 00074 (defer.go:5) TESTL AX, AX
0x004c 00076 (defer.go:5) JNE 96
0x004e 00078 (defer.go:5) JMP 80
0x0050 00080 (defer.go:6) XCHGL AX, AX
0x0051 00081 (defer.go:6) CALL runtime.deferreturn(SB)
0x0056 00086 (defer.go:6) MOVQ 32(SP), BP
0x005b 00091 (defer.go:6) ADDQ $40, SP
0x005f 00095 (defer.go:6) RET
0x0060 00096 (defer.go:5) XCHGL AX, AX
0x0061 00097 (defer.go:5) CALL runtime.deferreturn(SB)
0x0066 00102 (defer.go:5) MOVQ 32(SP), BP
0x006b 00107 (defer.go:5) ADDQ $40, SP
0x006f 00111 (defer.go:5) RET
0x0070 00112 (defer.go:5) NOP
0x0070 00112 (defer.go:4) PCDATA $0, $-1
0x0070 00112 (defer.go:4) PCDATA $2, $-1
0x0070 00112 (defer.go:4) CALL runtime.morestack_noctxt(SB)
0x0075 00117 (defer.go:4) JMP 0
defer反面使用事例
//当多个 goroutine 执行该函数时,再算上 httpGet 所需时间。
//原本的并发设计,因为错误的 defer 调用变成 “串行”。
func d() {
urlList := []string{"www.xx.com", "www.ccc.com", "test.cn"}
for _, v := range urlList {
go func(url string) {
m.Lock()
defer m.Unlock()
http.Get(url)
}(v)
}
}
//如果 files 是个 “超大” 列表,在a函数结束前,会有不小的隐式 “资源泄露”
//这些不能及时回收的对象,会导致 GC 在内的相关性能问题。
//解决:去掉defer,或者循环内存独立为函数,及时return
func a() {
files, _ := ioutil.ReadDir("./log")
for _, v := range files {
f, err := os.Open(v.Name())
if err != nil {
continue
}
defer f.Close() //不推荐在for循环中, 可能导致内存泄漏
//其他操作
}
}
如果单个函数里过多的 defer 调用可尝试合并。最起码,在并发竞争激烈时,mutex.Unlock 不应该使用 defer,而应尽快执行,仅保护最短的代码片段。
闭包
闭包(closure)也是很常见的编码模式,因它隐式携带上下文环境变量,因此可让算法代码变得更加简洁。但任何 “便利” 和 “优雅” 的背后,往往都是更复杂的实现机制,无非是语法糖或编译器隐藏了相关细节。最终,这些都会变成额外成本在运行期由 CPU、runtime 负担。甚至因不合理使用,造成性能问题
。
闭包与普通函数调用性能对比:
func sum() func() int { //go build -gcflags=-m closure.go
s := 0 //moved to heap: s
return func() int { //func literal escapes to heap
s += 1 //&s escapes to heap
return s
}
}
func ClosureCount() {
f := sum()
for i := 0; i < 10000; i++ {
f()
}
}
func Count() {
s := 0
for i := 0; i < 10000; i++ {
s += 1
}
}
//go test -v -bench . -benchmem
//BenchmarkClosureCount-4 100000 17183 ns/op 24 B/op 2 allocs/op
//BenchmarkCount-4 500000 2891 ns/op 0 B/op 0 allocs/op
任何 “便利” 和 “优雅” 的背后,往往都是更复杂的实现机制,无非是语法糖或编译器隐藏了相关细节。最终,这些都会变成额外成本在运行期由 CPU、runtime 负担。甚至因不合理使用,造成性能问题。
//逃逸分析
//go build -gcflags=-m closure.go
func T1() {
x := 100
go func(i int) { //func literal escapes to heap
i++
println(i)
}(x)
x++
}
//闭包会引用函数外部的变量(原环境变量),导致y逃逸到堆上这样增加了 GC 扫描和回收对象的数量。
func closureT1() {
y := 200 // moved to heap: y
go func() { //func literal escapes to heap
y++ // &y escapes to heap
println(y)
}()
y++
}
样是因为闭包引用原对象,造成数据竞争(data race).
闭包未必总能将事情 “简单化”。在学习 Go 底层实现过程中,你会了解到,所有 “简单” 都是由编译器或运行时用一堆复杂过程堆出来的。
并发安全计数器对比(channel,mutex,atomic)
//用 channel 实现并发安全的计数器
func ChanCounter() chan int {
ch := make(chan int)
go func() {
for i := 1; ; i++ {
ch <- i
}
}()
return ch
}
//锁,保护局部数据安全
//闭包,引用原环境变量,保持数据状态
func MutexCounter() func() int {
var m sync.Mutex
var x int
return func() (n int) {
m.Lock()
x++
n = x
m.Unlock()
return
}
}
//不使用闭包,加锁的计数器
var mu sync.Mutex
var count int
func MCounter()(n int) {
mu.Lock()
count++
n = count
mu.Unlock()
return
}
//通过原子操作
func AtomicCounter() func() int64 {
var x int64
return func() int64 {
return atomic.AddInt64(&x, 1)
}
}
//核对计数器在并发下的正确性
func check() {
N := 10000
var wg sync.WaitGroup
ch := ChanCounter()
for x := 0; x < 8; x++ {
wg.Add(1)
go func() {
for i := 0; i < N; i++ {
_ = <-ch
}
wg.Done()
}()
}
wg.Wait()
println(<-ch) //80001
}
性能测试对比:
//go test -v -bench . -benchmem -memprofile=mem.out -cpuprofile=cpu.out
BenchmarkChanCounter-4 2000000000 0.29 ns/op 0 B/op 0 allocs/op
BenchmarkMutexCounter-4 100000000 16.1 ns/op 0 B/op 0 allocs/op
BenchmarkMCounter-4 100000000 15.6 ns/op 0 B/op 0 allocs/op
BenchmarkAtomicCounter-4 30000000 43.6 ns/op 24 B/op 2 allocs/op
//cpu 使用top10
flat flat% sum% cum cum%
1130ms 23.84% 23.84% 1190ms 25.11% sync.(*Mutex).Unlock
1080ms 22.78% 46.62% 1080ms 22.78% sync.(*Mutex).Lock
1070ms 22.57% 69.20% 1070ms 22.57% runtime.pthread_cond_signal
530ms 11.18% 80.38% 530ms 11.18% prof_improve.BenchmarkChanCounter
180ms 3.80% 84.18% 1340ms 28.27% prof_improve.MutexCounter.func1
170ms 3.59% 87.76% 1340ms 28.27% prof_improve.MCounter
120ms 2.53% 90.30% 120ms 2.53% runtime.newstack
110ms 2.32% 92.62% 110ms 2.32% runtime.nanotime
60ms 1.27% 93.88% 200ms 4.22% runtime.mallocgc
50ms 1.05% 94.94% 1390ms 29.32% prof_improve.BenchmarkMCounter
//内存使用top10
flat flat% sum% cum cum%
708.01MB 99.56% 99.56% 708.01MB 99.56% prof_improve.AtomicCounter
0 0% 99.56% 708.01MB 99.56% prof_improve.BenchmarkAtomicCounter
0 0% 99.56% 708.01MB 99.56% testing.(*B).launch
0 0% 99.56% 708.01MB 99.56% testing.(*B).runN
通过对比,通道的性能最好,加锁的开销还是很大,原子操作atomic.AddInt64竟然消耗了大量的内存,性能也最差。
interface{}
在go中接口用途很广,很重要。但其性能开销也很大。首先,相比静态绑定,动态绑定性能要差很多;其次,运行期需额外开销,比如接口会复制对象,哪怕仅是个指针,也会在堆上增加一个需 GC 处理的目标。
package main
func main() {}
type Tester interface {
Test(int)
}
//Data实现了接口Tester
type Data struct {
x int
}
func (d Data) Test(x int) {
d.x = x
}
func call(d *Data) {
d.Test(100)
}
//参数为Tester接口,传入实现接口的实现作为参数
func ifaceCall(t Tester) {
t.Test(100)
}
基准测试:
func BenchmarkCall(b *testing.B) {
for i := 0; i < b.N; i++ {
call(&Data{x:100})
}
}
func BenchmarkIface(b *testing.B) {
for i := 0; i < b.N; i++ {
ifaceCall(&Data{x:100})
}
}
//go test -v -bench . -benchmem
//BenchmarkCall-4 2000000000 0.29 ns/op 0 B/op 0 allocs/op
//BenchmarkIface-4 100000000 16.1 ns/op 8 B/op 1 allocs/op
通过对比,使用接口来动态绑定参数的代价有点大,对于有性能要求的组件需要慎重使用。
go build -gcflags=-m interface.go
./interface.go:3:6: can inline main
./interface.go:14:6: can inline Data.Test
./interface.go:18:6: can inline call
./interface.go:19:8: inlining call to Data.Test
./interface.go:23:6: can inline ifaceCall
/var/folders/dp/ytjhzc4172s7phkzl_pxrkvw0000gn/T/go-build597406699/b001/_gomod_.go:6:6: can inline init.0
./interface.go:18:11: call d does not escape
./interface.go:23:16: leaking param: t
<autogenerated>:1: inlining call to Data.Test
<autogenerated>:1: (*Data).Test .this does not escape
<autogenerated>:1: leaking param: .this
普通调用被内联,但接口调用就没有.
通过对比其汇编指令,就算在 ifaceCall 内部,依然需要通过接口相关机制完成调用。
反射优化
反射(reflect)弥补静态语言在动态行为上的不足,被频繁使用。但是反射的性能却差很多。
如果是 reflect.Type,可将其缓存,避免重复操作耗时。但 Value 显然不行,因为它和具体对象绑定,内部存储实例指针。换个思路,字段相对于结构,除名称(name)外,还有偏移量(offset)这个唯一属性。利用偏移量,将 FieldByName 变为普通指针操作,就可以实现性能提升。
package main
import (
"reflect"
)
func Incr(d interface{}) int64 {
v := reflect.ValueOf(d).Elem()
f := v.FieldByName("X")
x := f.Int()
x++
f.SetInt(x)
return x
}
var offset uintptr = 0xFFFF //避开offset = 0 的字段
func UnsafeIncr(d interface{}) int64 {
if offset == 0xFFFF {//这里是性能的主要开销
t := reflect.TypeOf(d).Elem()
x, _ := t.FieldByName("X")
offset = x.Offset
}
p := (*[2]uintptr)(unsafe.Pointer(&d))
px := (*int64)(unsafe.Pointer(p[1] + offset))
*px++
return *px
}
func main() {
d := struct {
X int
}{100}
println(Incr(&d))
println(UnsafeIncr(&d))
}
性能测试:
func BenchmarkIncr(b *testing.B) {
d := struct {
X int
}{100}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = Incr(&d)
}
}
func BenchmarkUnsafeIncr(b *testing.B) {
d := struct {
X int
}{100}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = UnsafeIncr(&d)
}
}
go test -v -bench . -benchmem -memprofile=reflect_mem.out - cpuprofile=reflect_cpu.out
BenchmarkIncr-4 20000000 73.9 ns/op 8 B/op 1 allocs/op
BenchmarkUnsafeIncr-4 1000000000 2.55 ns/op 0 B/op 0 allocs/op
通过对比,性能差距很大。
内存使用分析:
flat flat% sum% cum cum%
157MB 98.96% 98.96% 157MB 98.96% reflect.(*structType).Field
1.16MB 0.73% 99.68% 1.16MB 0.73% runtime/pprof.StartCPUProfile
0 0% 99.68% 157MB 98.96% prof_improve.BenchmarkIncr
0 0% 99.68% 157MB 98.96% prof_improve.Incr
0 0% 99.68% 1.16MB 0.73% main.main
0 0% 99.68% 157MB 98.96% reflect.(*rtype).FieldByName
0 0% 99.68% 157MB 98.96% reflect.(*structType).FieldByName
0 0% 99.68% 157MB 98.96% reflect.Value.FieldByName
0 0% 99.68% 1.16MB 0.73% runtime.main
0 0% 99.68% 157MB 98.96% testing.(*B).launch
reflect.(*structType).Field 占据了绝大部分内存开销。
上述优化,通过unsafe.Pointer指针和偏移量来代替reflect.(*structType).Field 相关操作。每次执行 reflect.TypeOf,这于性能优化不利。
引入map来缓存reflect操作:
//缓存内存地址偏移量,避免频繁的高成本reflect.TypeOf,FieldByName
var cache = map[*uintptr]map[string]uintptr{}
func UnsafeIncr1(d interface{}) int64 {
itab := *(**uintptr)(unsafe.Pointer(&d))
m, ok := cache[itab]
if !ok {
m = make(map[string]uintptr)
cache[itab] = m
}
offset, ok := m["X"]
if !ok {
t := reflect.TypeOf(d).Elem()
x, _ := t.FieldByName("X")
offset = x.Offset
//缓存内存地址偏移量,避免频繁的高成本reflect.TypeOf,FieldByName
m["X"] = offset
}
p := (*[2]uintptr)(unsafe.Pointer(&d))
px := (*int64)(unsafe.Pointer(p[1] + offset))
*px++
return *px
}
BenchmarkIncr-4 20000000 80.2 ns/op 8 B/op 1 allocs/op
BenchmarkUnsafeIncr-4 1000000000 2.51 ns/op 0 B/op 0 allocs/op
BenchmarkUnsafeIncr1-4 100000000 11.2 ns/op 0 B/op 0 allocs/op
因为增加了map,性能所有下降。
ps:利用指针类型转换实现性能优化,本就是 “非常手段”,是一种为了性能而放弃 “其他” 的做法。与其担心代码是否适应未来的变化,不如写个单元测试,确保在升级时做出必要的安全检查。
当然,很多基础库也都用到了反射,很多时候,使用发射能否带来一些便利或是省一些代码,平常的业务代码,瓶颈多数也不在这,把架构,缓存,消息中间件用好,就可以解决至少一半的性能问题,如果是对很高频的,对性能敏感的应用,再好好考虑反射的问题。
channel批处理
作为内置类型,通道(channel)从运行时得到很多支持,其自身设计也算得上精巧。但不管怎么说,它本质上依旧是一种队列,当多个 goroutine 并发操作时,免不了要使用锁。某些时候,这种竞争机制,会导致性能问题。
const (
max = 500000
bufSize = 100
)
//单个数据逐个发送
func ChanCount(data chan int, done chan struct{}) int {
count := 0
//接收
go func() {
for x := range data {
count += x
}
close(done)
}()
//发送
for i := 0; i < max; i++ {
data <- i
}
close(data)
<- done
return count
}
go runtime 源码实现过程中,会看到大量利用 “批操作” 来提升性能的样例。在此,我们可借鉴一下,看看效果对比。
//批量处理,批量发送,接收
//把数据打包放在数组中,批量发送接收
const block = 500
func ChanBlockCount(data chan [block]int, done chan struct{}) int {
count := 0
//接收统计
go func() {
for a := range data {
for _, v := range a {
count += v
}
}
close(done)
}()
//发送
for i := 0; i < max; i += block {
var b [block]int
for n := 0; n < block; n++ {
b[n] = i + n
if n + i == max - 1 {
break
}
}
data <- b
}
close(data)
<- done
return count
}
测试代码:
func BenchmarkChanBlockCount(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
data := make(chan int, bufSize)
done := make(chan struct{})
b.StartTimer()
_ = ChanCount(data, done)
}
}
func BenchmarkChanCount(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
data := make(chan [block]int, bufSize)
done := make(chan struct{})
b.StartTimer()
_ = ChanBlockCount(data, done)
}
}
//go test -v -bench . -benchmem -memprofile=chan_mem.out -cpuprofile=chan_cpu.out
BenchmarkChanBlockCount-4 30 34647397 ns/op 610 B/op 1 allocs/op
BenchmarkChanCount-4 1000 1814089 ns/op 44 B/op 1 allocs/op
经测试,性能差距挺大。
与数组相比,slice可以减少数据的复制
,把用数组打包改为切片试试:
//用slice传递数据
//slice 能减少数据的复制
func ChanSliceCount(data chan []int, done chan struct{}) int {
count := 0
//接收统计
go func() {
for a := range data {
for _, v := range a {
count += v
}
}
close(done)
}()
//发送
for i := 0; i < max; i += block {
b := make([]int, block)
for n := 0; n < block; n++ {
b[n] = i + n
if n + i == max - 1 {
break
}
}
data <- b
}
close(data)
<- done
return count
}
性能测试代码:
func BenchmarkChanSliceCount(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
data := make(chan []int, bufSize)
done := make(chan struct{})
b.StartTimer()
_ = ChanSliceCount(data, done)
}
}
go test -v -bench . -benchmem -memprofile=chan_mem.out -cpuprofile=chan_cpu.out
BenchmarkChanBlockCount-4 50 34501745 ns/op 386 B/op 1 allocs/op
BenchmarkChanCount-4 1000 1742720 ns/op 44 B/op 1 allocs/op
BenchmarkChanSliceCount-4 2000 1139584 ns/op 4096042 B/op 1001 allocs/op
从测试结果看,性能的确有所提升,不过内存开销大了很多。
go tool pprof chan_mem.out
(pprof) top
Showing nodes accounting for 8754.51MB, 100% of 8756.55MB total
Dropped 13 nodes (cum <= 43.78MB)
flat flat% sum% cum cum%
8361.58MB 95.49% 95.49% 8361.58MB 95.49% prof_improve.ChanSliceCount
386.42MB 4.41% 99.90% 386.42MB 4.41% gprof_improve.BenchmarkChanCount
6.52MB 0.074% 100% 8368.09MB 95.56% prof_improve.BenchmarkChanSliceCount
0 0% 100% 8751MB 99.94% testing.(*B).launch
0 0% 100% 8754.51MB 100% testing.(*B).runN
(pprof)
从内存分配来看,使用slice内存开销巨大,但的确是快了点。总体而言,用数组打包进行批处理,最划算。
$ go tool pprof chan_cpu.out
、Type: cpu
Showing top 30 nodes out of 76
flat flat% sum% cum cum%
4.18s 35.01% 35.01% 4.18s 35.01% runtime.pthread_cond_wait
3.43s 28.73% 63.74% 3.43s 28.73% runtime.pthread_cond_signal
1.93s 16.16% 79.90% 1.93s 16.16% runtime.usleep
0.61s 5.11% 85.01% 0.61s 5.11% runtime.pthread_cond_timedwait_relative_np
0.33s 2.76% 87.77% 0.33s 2.76% runtime.pthread_mutex_lock
0.22s 1.84% 89.61% 0.26s 2.18% prof_improve.ChanSliceCount.func1
0.19s 1.59% 91.21% 0.65s 5.44% prof_improve.ChanSliceCount
0.17s 1.42% 92.63% 0.17s 1.42% runtime.procyield
0.11s 0.92% 93.55% 0.11s 0.92% runtime.memmove
0.09s 0.75% 94.30% 0.09s 0.75% runtime.memclrNoHeapPointers
0.07s 0.59% 94.89% 0.07s 0.59% runtime.(*waitq).dequeue
0.06s 0.5% 95.39% 0.06s 0.5% runtime.(*gcBitsArena).tryAlloc
0.03s 0.25% 95.64% 1.23s 10.30% prof_improve.ChanBlockCount
0.03s 0.25% 95.90% 1.40s 11.73% runtime.lock
0.03s 0.25% 96.15% 0.34s 2.85% runtime.unlock
0.02s 0.17% 96.31% 0.61s 5.11% runtime.newstack
0.01s 0.084% 96.40% 0.27s 2.26% runtime.(*mcache).refill
0.01s 0.084% 96.48% 1.36s 11.39% runtime.chansend
0.01s 0.084% 96.57% 4.86s 40.70% runtime.findrunnable
0.01s 0.084% 96.65% 0.07s 0.59% runtime.heapBits.initSpan
0.01s 0.084% 96.73% 0.35s 2.93% runtime.mallocgc
0.01s 0.084% 96.82% 4.80s 40.20% runtime.semasleep
0.01s 0.084% 96.90% 0.08s 0.67% runtime.sweepone
0.01s 0.084% 96.98% 0.11s 0.92% runtime.typedmemmove
SetFinalizer
Go语言自带垃圾回收机制(GC)。GC 自动运行搜索不再使用的变量,并将其释放。但是GC 在运行时会占用机器资源,会造成程序短时间的性能下降。
如果要手动进行 GC,可以使用 runtime.GC() 函数。手动 GC 只在某些特殊的情况下才有用,比如当内存资源不足时调用 runtime.GC() 。
finalizer(终止器
是与对象关联的一个函数,通过 runtime.SetFinalizer 来设置,如果某个对象定义了 finalizer,当它被 GC 时候,这个 finalizer 就会被调用,以完成一些特定的任务,例如发信号或者写日志等。
func SetFinalizer(obj interface{}, finalizer interface{})
- 参数 obj 必须是一个指向通过 new 申请的对象的指针,或者通过对复合字面值取址得到的指针。
- 参数 finalizer 是一个函数,它接受单个可以直接用 obj 类型值赋值的参数,也可以有任意个被忽略的返回值。
SetFinalizer 函数可以将 obj 的终止器设置为 finalizer,当垃圾收集器发现 obj 不能再直接或间接访问时,它会清理 obj 并调用 finalizer。
另外,obj 的终止器会在 obj不能直接或间接访问后的任意时间被调用执行,不保证终止器会在程序退出前执行,因此一般终止器只用于在长期运行的程序中释放关联到某对象的非内存资源
。例如,当一个程序丢弃一个 os.File 对象时没有调用其 Close 方法,该 os.File 对象可以使用终止器去关闭对应的操作系统文件描述符。
type A string
func cao() {
a := A("ssss")
c := &a
//对象绑定终止器
runtime.SetFinalizer(c, func(a *A) {
println("终止器触发。。。 ")
})
}
func main() {
cao() //调用之后不再被引用
for i := 0; i < 5; i++ {
time.Sleep(time.Second) //时间太短,终止器未必会触发
print(" ", i)
runtime.GC()
}
}
//0终止器触发。。。
// 1 2 3 4
其他
过多的系统调用
减少或合并系统调用,但是是合并的 syscall 延迟可能会上升。
例如使用 readv()
writev()
使用 read() 将数据读到不连续的内存,需要经过多次的系统调用 read() 方法。
使用 write() 将不连续的内存发送出去,要经过多次的系统调用 write() 方法。
如果要从文件中读一片连续的数据至进程的不同区域,有两种方案:
- 使用read()一次将它们读至一个较大的缓冲区中,然后将它们分成若干部分复制到不同的区域;
- 调用read()若干次分批将它们读至不同区域。
同样,如果想将程序中不同区域的数据块连续地写至文件,也必须进行类似的处理。
为了减少系统调用,UNIX 提供了另外两个函数 readv()
和writev()
,只需一次系统调用就可以实现在文件和进程的多个缓冲区之间传送数据,免除了多次系统调用或复制数据的开销。
对象复用
- sync.Pool 很多高性能的库都用到,复用对象,减少 GC 负担
- 复用 struct ,复用时,用零值覆盖一下即可,如 p = Person{}
- slice 可以复用(a = a[:0])
- map 不太好复用(得把所有 kv 全清空才行,成本可能比新建一个还要高)。比如 fasthttp 里,把本来应该是 map 的 header 结构变成了 slice,牺牲一点查询速度,换来了复用的方便。
减少指针类型变量逃逸
使用 go build -gcflags=”-m -m” 来分析逃逸。
如果要分析某个 package 内的逃逸情况,可以打全 package 名,例如
go build -gcflags="-m -m" github.com/bwmarrin/snowflake
go build -gcflags="-m -m" net/http
有些类型本身就是带指针的如 string, slice
- 比如一些 cache 服务,有几千万 entry,那么用 string 来做 key 和 value 可能成本就很高。
- 同样如果数据不是很大或是定长的,建议使用数组,而不是切片
用值类型代替指针类型
- *int -> struct {value int, isNull bool}
- string -> struct {value [12]byte, length int)
- 数值类型的 string -> int
- *Host -> Host
减少内存逃逸的方式
尽量少用 fmt.Print、fmt.Sprint 等 fmt 系列的函数。涉及到它们都会逃逸。
设计函数签名时,参数尽量少用 interface 。
少用闭包,被闭包引用的局部变量会逃逸到堆上。
PS:在需要要求性能时,再优化,或是平时稍微注意下就行,一般的业务代码瓶颈通常也不会在这,否则投入收益不匹配。
defer 使用的多,导致内存占用过多
defer 要在函数return的时候才会执行,例如一些资源清理操作,酌情考虑使用。
TODO
内存优化常用手段:
• 小对象结构体合并 • bytes.Buffer • slice、map 预创建 • 长调用栈 • 避免频繁创建临时对象 • 字符串拼接 strings.Builder • 减少不必要的 memory copy • 分析内存逃逸
reference
曹大的文章 https://cch123.github.io/perf_opt/
http://team.jiunile.com/blog/2020/05/go-performance.html
雨痕大佬 https://mp.weixin.qq.com/s/KgB1_n7AUULNJQar5zaZLw