本文整理译自 Golang: simple optimization notes:https://medium.com/scum-gazeta/golang-simple-optimization-notes-70bc64673980
在云计算时代,我们经常创建 Serverless 应用(一种云原生开发模式,允许开发人员构建和运行应用程序,而无需管理服务器)。当我们的项目采用这种模式,那基础设施维护预算将排在首位。如果我们的服务负载很低,它实际上近乎是免费的。但是如果出现问题,你将为此付出很多!当谈到金钱时,你肯定会以某方式对它做出反应。
当你的 VPS 运行着多个服务应用,但其中一个有时会占用所有的资源,以至于都无法通过 ssh 访问服务器。你转到使用 Kubernetes 集群,为所有应用程序设置限制。随后看到一些应用程序被重新启动,因为 OOM-killer 解决了内存”泄漏“问题。
当然, OOM 并不总是泄漏问题,也可能是资源超支。泄漏问题大概率是由程序错误引起的,我们今天谈论的主题是如何尽量避免这种情况。
过多的资源消耗会伤害钱包,这意味着我们需要立即采取行动。
不要过早优化
现在让我们谈谈优化。希望你能明白为什么我们不要过早优化!
第一,优化可能是无用的工作。因为我们应该先研究整个应用程序,而你的代码很可能不会成为瓶颈。我们需要的是快速的结果,MVP(Minimum Viable Product,最简可行产品),然后才会考虑它的问题。
第二,优化都必须有所依据。也就是说,每次优化都应该建立在基准上,我们必须证明它给我们带来了多少利润。
第三,优化也许会带来复杂。你需要知道的是,大多数优化会使代码的可读性变差。你需要把握好这种平衡。
优化建议
现在我们按照 Go 中的标准实体分类,来给出一些实用建议。
1. 数组与切片
提前为切片分配内存
尽量使用第三个参数:make([]T, 0, len)
如果不知道元素确切的数量并且切片是短暂的,可以分配更大一点,保障切片在运行时不会增长。
不要忘记使用 copy
尽量不要在复制时使用 append,例如在合并两个或多个切片时。
正确迭代
一个包含许多元素或大元素的切片,使用 for 去获取单个元素。通过这种方法,将避免不必要的复制。
复用切片
如果对传入的切片进行某种操作并返回已经修改的结果,我们可以返回它。这样能避免新的内存分配。
不要留下不使用的切片部分
如果需要从切片中切下一小块并仅使用它,该切片的主要部分也将被保留。正确的做法是,为这小块切片使用新的副本,而将旧的切片扔给 GC。
2. 字符串
正确拼接
如果拼接字符串可以在一个语句中完成,那就使用 +
操作符。如果需要在循环中执行此操作,使用 string.Builder
,并使用它的 Grow
方法预先指定 Builder
的大小,减少内存分配次数。
转换优化
string 和 []byte 在底层结构上非常相近,有时这两种类型之间可以通过强转换来避免内存分配。
字符串驻留
可以池化字符串,从而帮助编译器只存储一次相同的字符串。
避免分配
我们可以使用 map(级联)而不是复合键,我们可以使用字节切片。尽量不使用 fmt
包,因为它所有的方法都用到了反射。
3. 结构体
避免拷贝大结构体
我们理解的小结构体是不超过4个字段不超过一个机器字大小。
一些典型的拷贝场景
投射到 interface
通道的接收和发送
替换 map 中的元素
向切片添加元素
迭代(range)
避免通过指针访问结构体字段
解引用是昂贵的,我们应该尽可能少地这样做,尤其是在循环中。同时它也失去了使用快速寄存器的能力。
处理小结构体
这项工作由编辑器进行优化,这意味着它很便宜。
使用对齐减小结构体大小
我们可以对齐结构体(根据字段的大小,以正确的顺序排列它们),以此减小结构体本身的大小。
4. 函数
使用内联函数或自己内联它们
尝试编写可供编译器内联的小函数,它会很快,甚至快过自己在函数中嵌入代码。对于热路径(hot path)尤其如此。
哪些不会内联
recovery
select 块
类型声明
defer
goroutine
for-range
合理地选择函数参数
尝试使用小参数,因为它们的复制将被优化。尝试复制和栈增长在GC负载保持平衡。避免大量参数,让你的程序使用快速寄存器(它们的数量是有限的)。
命名返回值
这似乎比在函数体中声明这些变量更高效。
保存中间结果
帮助编译器优化你的代码,保存中间结果,然后会有更多的选项来优化你的代码。
仔细地使用 defer
尽量不要使用 defer,或者至少不要在循环中使用它。
助力 hot path
避免在热路径分配内存,尤其是短生命对象。制作最常见分支(if,switch)
5. Map
提前分配内存
和 slice 一样,初始化 map 时,指定其大小。
使用空结构体为值
struct{} 什么都不是(不占内存),因此例如传递信号时,使用它是非常有益的。
清空 map
map 只能增长,不能缩小。我们需要重置 map 时,删除其所有元素是无济于事的。
尽量不在键和值中使用指针
如果 map 中不包含指针,那么 GC 就不会在上面浪费宝贵的时间。字符串也使用了指针,因此应该使用字节数组而不是字符串作为键。
减少修改次数
同样,我们不想使用指针,但我们可以使用 map 和 slice 的组合,将键存储在 map 中,将值存在 slice。这样我们就可以不受限制地更改值。
6. Interface
计算内存分配
请记住,要为接口分配值时,首先需要将其复制到某处,然后将指针黏贴给它。关键是复制。事实证明,接口的装箱和拆箱的成本将近似于结构体大小的一次分配。
选择最优类型
在某些情况下,接口的装箱和拆箱期间没有分配。例如,变量和常量的小值或布尔值、具有一个简单字段的结构体、指针(包括 map、channel、func)
避免内存分配
与其他地方一样,尽量避免不必要的分配。例如将一个接口分配给另一个接口,而不是装箱两次。
仅在需要时使用
避免在频繁调用的函数参数和返回结果中使用接口。我们不需要额外的拆装包操作。减少使用接口方法调用的频率,因为它会阻止内联。
7. 指针、通道、边界检查
避免不必要的解引用
尤其是在循环中,因为事实证明它太昂贵了。解引用是我们不想自费执行的操作。
使用通道效率低下
channel 同步比其他同步原语方法慢。另外, select 中的 case 越多,我们的程序就越慢。但是,select,case 加 default 有被优化。
避免不必要的边界检查
这也很昂贵,我们应该避免它。例如,只检查(获取)一次最大切片索引,而不是多次。最好立即尝试获得极端选项。
总结
在整篇文章中,我们看到了一些相同的优化规则。
帮助编译器做出正确的决定,它会感谢你的。在编译时分配内存,使用中间结果,并尽量保持你的代码可读。
我再次重申,对于隐式优化,基准是强制性的。如果因为编译器在不同版本之间变化太快,昨天工作的东西明天就不能工作,反之亦然。
不要忘记使用 Go 内置的分析和跟踪工具。
译者有话说
注意,作者的建议并不一定是对的。就像原文中有人评价,为什么不在每条建议下面列出优化代码。因为作者更希望开发人员把这些建议当做一张备忘单,知道这些瓶颈并主动去寻找如何做优化。
往期推荐
机器铃砍菜刀
欢迎添加小菜刀微信
加入Golang分享群学习交流!
感谢你的点赞和在看哦~