Go:简单的优化笔记

作者:微信小助手

发布时间:2024-02-21T01:12:50


在云计算时代,我们经常创建 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 内置的分析和跟踪工具。

译者有话说

注意,作者的建议并不一定是对的。就像原文中有人评价,为什么不在每条建议下面列出优化代码。因为作者更希望开发人员把这些建议当做一张备忘单,知道这些瓶颈并主动去寻找如何做优化。