当你的 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
包,因为它所有的方法都用到了反射。
我们理解的小结构体是不超过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{} 什么都不是(不占内存),因此例如传递信号时,使用它是非常有益的。
清空 mapmap 只能增长,不能缩小。我们需要重置 map 时,删除其所有元素是无济于事的。
尽量不在键和值中使用指针如果 map 中不包含指针,那么 GC 就不会在上面浪费宝贵的时间。字符串也使用了指针,因此应该使用字节数组而不是字符串作为键。
减少修改次数同样,我们不想使用指针,但我们可以使用 map 和 slice 的组合,将键存储在 map 中,将值存在 slice。这样我们就可以不受限制地更改值。
6. Interface计算内存分配请记住,要为接口分配值时,首先需要将其复制到某处,然后将指针黏贴给它。关键是复制。事实证明,接口的装箱和拆箱的成本将近似于结构体大小的一次分配。
选择最优类型在某些情况下,接口的装箱和拆箱期间没有分配。例如,变量和常量的小值或布尔值、具有一个简单字段的结构体、指针(包括 map、channel、func)
避免内存分配与其他地方一样,尽量避免不必要的分配。例如将一个接口分配给另一个接口,而不是装箱两次。
仅在需要时使用避免在频繁调用的函数参数和返回结果中使用接口。我们不需要额外的拆装包操作。减少使用接口方法调用的频率,因为它会阻止内联。
7. 指针、通道、边界检查避免不必要的解引用尤其是在循环中,因为事实证明它太昂贵了。解引用是我们不想自费执行的操作。
使用通道效率低下channel 同步比其他同步原语方法慢。另外, select 中的 case 越多,我们的程序就越慢。但是,select,case 加 default 有被优化。
避免不必要的边界检查这也很昂贵,我们应该避免它。例如,只检查(获取)一次最大切片索引,而不是多次。最好立即尝试获得极端选项。
总结在整篇文章中,我们看到了一些相同的优化规则。
帮助编译器做出正确的决定,它会感谢你的。在编译时分配内存,使用中间结果,并尽量保持你的代码可读。
我再次重申,对于隐式优化,基准是强制性的。如果因为编译器在不同版本之间变化太快,昨天工作的东西明天就不能工作,反之亦然。
不要忘记使用 Go 内置的分析和跟踪工具。