返回类型为[]byte
的切片的堆区域。对于不包含任何具有指针类型字段的结构类型数组,也同样适用。
减少指针不仅减少垃圾回收的工作量,还会生存出”cache友好“的代码。读取内存会将数据从主存移到CPU cache中。Caches是优先的,因此必须清掉一些数据来腾出空间。cache清掉的数据可能会和程序的其它部分相关。由此产生的cache抖动可能会导致不可预期行为和突然改变生产服务的行为。
指针深入
减少指针使用通常意需要味着深入研究用于构建程序的类型的源代码。我们的服务Centrifuge保留了一个失败操作队列,来作为循环缓冲区重试去进行重试,其中包含一组如下所示的数据结构:
type retryQueue struct { buckets [][]retryItem // each bucket represents a 1 second interval currentTime time.Time currentOffset int } type retryItem struct { id ksuid.KSUID // ID of the item to retry time time.Time // exact time at which the item has to be retried }
数组buckets
的外部大小是一个常量值,但是[]retryItem
所包含的items会在运行时期改变。重试次数越多,这些slices就变越大。
深入来看一下retryItem
细节,我们了解到KSUID
是一个[20]byte
的同名类型,不包含指针,因此被逃逸规则排除在外。currentOffset
是一个int
值,是一个固定大小的原始值,也可以排除。下面看一下,time.Time
的实现:
type Time struct { sec int64 nsec int32 loc *Location // pointer to the time zone structure }
time.Time
结构内部包含一个loc
的指针。在retryItem
内部使用它导致了在每次变量通过堆区域时候,GC都会去标记struct上的指针。
我们发现这是在不可预期情况下级联效应的典型情况。通常情况下操作失败是很少见的。只有小量的内存去存这个retries的变量。当失败操作激增,retry队列会每秒增加到上千个,这会大大增加垃圾回收器的工作量。
对于这种特殊使用场景,time.Time
的time信息其实是不必要的。这些时间戳存在内存中,永远不会被序列化。可以重构这些数据结构以完全避免time类型出现。
type retryItem struct { id ksuid.KSUID nsec uint32 sec int64 } func (item *retryItem) time() time.Time { return time.Unix(item.sec, int64(item.nsec)) } func makeRetryItem(id ksuid.KSUID, time time.Time) retryItem { return retryItem{ id: id, nsec: uint32(time.Nanosecond()), sec: time.Unix(), }
现在retryItem
不包含任何指针。这样极大的减少了垃圾回收器的工作负载,编译器知道retryItem
的整个足迹。
请给我传切片(Slice)
slice使用很容易会产生低效分配代码。除非编译器知道slice的大小,否则slice(和maps)的底层数组会分配到堆上。我们来看一下一些方法,让slice在栈上分配而不是在堆上。
Centrifuge集中使用了Mysql。整个程序的效率严重依赖了Mysql driver的效率。在使用pprof
去分析了分配行为之后,我们发现Go MySQL driver代码序列化time.Time
值的代价十分昂贵。
分析器显示大部分堆分配都在序列化time.Time
的代码中。
相关代码在调用time.Time
的Format
这里,它返回了一个string
。等会儿,我们不是在说slices么?好吧,根据Go官方文档,一个string
其实就是个只读的bytes类型slices,加上一点额外的语言层面的支持。大多数分配规则都适用!
分析数据告诉我们大量分配,即12.38%都产生在运行的这个Format
方法里。这个Format
做了些什么?
事实证明,有一种更加有效的方式来做同样的事情。虽然Format()
方法方便容易,但是我们使用AppendFormat()
在分配器上会更轻松。观察源码库,我们注意到所有内部的使用都是AppendFormat()
而非Format()
,这是一个重要提示,AppendFormat()
的性能更高。
实际上,Format
方法仅仅是包装了一下AppendFormat
方法:
func (t Time) Format(layout string) string {
const bufSize = 64
var b []byte
max := len(layout) + 10
if max < bufSize { var buf [bufSize]byte b = buf[:0] } else { b = make([]byte, 0, max) } b = t.AppendFormat(b, layout) return string(b) }
更重要的是,AppendFormat()
给程序员提供更多分配控制。传递slice而不是像Format()
自己在内部分配。相比Format
,直接使用AppendFormat()
可以使用固定大小的slice分配,因此内存分配会在栈空间上面。
可以看一下我们给Go MySQL driver提的这个PR
首先注意到var a [64]byte
是一个大小固定的数组。编译期间我们知道它的大小,以及它的作用域仅在这个方法里,所以我们知道它会被分配在栈空间里。
但是这个类型不能传给AppendFormat()
,该方法只接受[]byte
类型。使用a[:0]
的表示法将固定大小的数组转换为由此数组所支持的b
表示的切片类型。这样可以通过编译器检查,并且会在栈上面分配内存。
更关键的是,AppendFormat()
,这个方法本身通过编译器栈分配检查。而之前版本Format()
,编译器不能确定需要分配的内存大小,所以不满足栈上分配规则。
这个小的改动大大减少了这部分代码的堆上分配!类似于我们在MySQL驱动里使用的“附加模式”。在这个PR里,KSUID
类型使用了Append()
方法。在热路径代码中,KSUID
使用Append()
模式处理大小固定的buffer而不是String()
方法,节省了类似的大量动态堆分配。 另外值得注意的是,strconv包使用了相同的append模式,用于将包含数字的字符串转换为数字类型。
接口类型
众所周知,接口类型上进行方法调用比struct类型上进行方法调用要昂贵的多。接口类型的方法调用通过动态调度执行。这严重限制了编译器确定代码在运行时执行方式的能力。到目前为止,我们已经在很大程度上讨论了类型固定的代码,以便编译器能够在编译时最好地理解它的行为。 接口类型抛弃了所有这些规则!
不幸的是接口类型在抽象层面非常