Go中的一些优化笔记,简约而不简单
我们这里简单聊一下优化本身,然后我们直接从实际的示例开始。
为什么要优化呢?
当你资源占有较高的话会需要很大的成本,虽然现在服务器资源也不是很贵,但是你还是需要针对的做一些优化工作。
另外每个优化应该建立在一个benchmark的基础上,需要体现它给我们带来多大的收益。
下面主要从slice、string、struct、function、map、interface、channel、pointer等方面罗列了一些常见的优化点。
数组和slice优化篇
提前为slice分配内存
尽量使用第三个参数: make([]T, 0, len)
如果你事先不知道确切的数量并且slice是临时的,你可以设置得大一些,只要slice在运行时不会增长。
不要忘记使用“copy”
我们尽量不要在复制时使用 append,例如,在合并两个或多个slice时。
正确地使用迭代
如果我们有一个包含很多元素或比较大的元素的slice,我们会尝试使用“for”或 range 单个元素。通过这种方法,可以避免不必要的复制。
学会复用slice
如果我们需要对传入的slice进行某种操作并返回结果,我们可以直接return,但已经修改了。这样我们就可以避免了新的内存分配。
不要留下未使用的slice
如果我们需要从slice中切下一小块并仅使用它,其实主要部分也会保留下来。可以使用copy产生一个新的slice,而旧的对象让GC回收。
string-字符串优化篇
正确地进行拼接
如果拼接字符串可以在一个语句中完成,那么可以使用“+”,如果需要在循环中执行此操作,那么可以使用string.Builder
。通过“Grow”也可以预先指定builder
的大小。
使用转换优化
由于字符串是由字节组成的,因此有时这两种类型之间的转换可以避免内存分配。
使用池化技术
我们可以池化字符串,从而帮助编译器只存储一次相同的字符串。
避免内存分配
我们可以使用map来替代复合键,我们也可以使用[]byte
。尽量不要使用fmt
包,因为它的所有函数都使用了反射。
struct-结构体优化篇
避免复制大的struct
我们理解的小struct
,是指不超过 4 个字段的struct
,不超过一个机器字。
标准的copy案例
转换成interface 接收和发送到channel 替换map中的item 向slice添加元素 迭代(range)
避免通过指针来访问struct中的字段
解引用是比较昂贵的,我们可以尽量少做,尤其是在循环中。我们也会失去使用快速寄存器的能力。
使用小型的struct
这项工作由编译器优化的,这意味着它的工作量很小。
通过内存对齐来减小struct大小
我们可以对齐struct(根据字段的大小,以正确的顺序排列),从而可以减小struct本身的大小。
func-函数优化篇
使用内联函数或自己内联
我们尽量编写一些可供编译器内联的小函数——它很快,但自己从函数中嵌入代码则更快。对于热路径函数尤其如此。
什么情况下不会被内联?
recovery 函数 select 类型声明 defer goroutine for-range
明智地选择你的函数参数
我们尽量使用“小”参数,因为它们的拷贝会被特别优化。我们也尝试在拷贝和GC的负载的与增长堆栈之间保持平衡。避免使用大量的参数——让你的程序使用超快速的寄存器(寄存器的数量是有限的)
声明一个命名好的return结果
这似乎比在函数体中声明这些变量更高效。
保存函数中间的结果
帮助编译器优化你的代码,保存中间结果,然后会有更多的选择来优化你的代码。
谨慎使用“defer”
尽量不要使用 defer
,或者至少 不要在循环中使用defer
。
为“hot path”提供便利
避免在这些地方分配内存,尤其是短期对象。首先要检查的的就是最常见的分支(if,switch)。
这里 hot path在Go源码中[2]也出现多次,根据在 sync.Once 的上下文中,“hot path”是什么意思?[3]中的回答,这里翻译为热路径是非常频繁执行的指令序列。
map优化篇
提前分配内存
一切都和其他地方一样。初始化map时,指定其大小。
使用空结构作为值
struct{}
什么都不是,因此例如对信号值使用这种方法是非常有益的。
清空map
map只能增长,不能缩小。我们需要控制这一点——完全而明确地重置map。因为删除其所有元素无济于事。
尽量不要在键和值中使用指针
如果 map 不包含指针,那么 GC 就不会在它上面浪费宝贵的时间。而且要知道字符串也是指针——使用[]byte
而不是字符串作为键。
减少更改的次数
同样,我们不想使用指针,但我们可以使用 map 和 slice 的复合体,并将键存储在 map 中,将可以不受限制地更改的值存储在slice中。
interface优化篇
计算内存分配
请记住,要给一个接口赋值,你首先需要将其拷贝到某处,然后粘贴一个指针。关键字是拷贝。事实证明,装箱和拆箱的成本将近似于结构体的大小和一次分配。
选择最佳的类型
在某些情况下,装箱/拆箱期间不会进行内存分配。例如,比较小的和布尔值的变量和常量、具有一个简单字段的struct、指针(包括map、chan、func)
避免内存分配
与其他地方一样,我们尽量避免不必要的内存分配。例如,将一个接口分配给一个接口,而不是装箱两次。
仅在需要时使用
避免在小型、频繁调用的函数的参数和结果中使用接口。我们不需要额外的包装和拆包。减少使用接口方法调用的频率,哪怕只是因为它可以防止内联。
指针、chan、BCE(Bounds Check Elimination-边界检查) 优化篇
避免不必要的解引用
尤其是在循环中,因为事实证明它太昂贵了。解引用是我们不想以牺牲自己为代价执行的一系列必要操作。
channel使用效率是低效的
使用channel会比其他同步方法慢。另外,select 中的 case 越多,我们的程序就越慢。但是select、case + default是优化过了的。
尽量避免不必要的边界检查
这也很昂贵,我们应该尽一切可能避免它。例如,一次检查(获取)最大slice索引比多次检查更正确。最好是立即尝试获得极端的选项。
总结
在这篇文章中,我们看到了一些相同的优化规则。
帮助编译器做出正确的决定。在编译时分配内存,使用中间结果,并尽量保持代码的可读性。
不要忘记使用内置的分析和trace跟踪工具。
最后小土也祝你在优化的路上做到尽善尽美。
参考资料
Golang: simple optimization notes: https://medium.com/scum-gazeta/golang-simple-optimization-notes-70bc64673980
[2]hot path在Go源码中: https://cs.opensource.google/search?q=%22hot%20path%22&ss=go%2Fgo
[3]在 sync.Once 的上下文中,“hot path”是什么意思?: https://stackoverflow.com/questions/59174176/what-does-hot-path-mean-in-the-context-of-sync-once
推荐阅读