掘金 后端 ( ) • 2024-05-05 23:16

highlight: a11y-dark theme: z-blue

预分配切片以提高性能

以前,我经常使用 make(a, 10) 等方法预分配数据片,但随着时间的推移,我意识到自己犯了一个常见的错误,那就是习惯性地使用 append(),这往往会导致数据片中充满不需要的预分配零。

对于那些可能没见过这种操作的人,让我在这里用一个例子给你描绘一下:

Bad

a := make([]int, 5)
a = append(a, 1) // [0 0 0 0 0 1]

Good

b := make([]int, 0, 5)
b = append(b, 1) // [1]

在第一个示例中,我最终在实际数据之前得到了一个满是 0 的slice,这效率很低,对吗?为了避免这种情况,最好使用 make(a,0,10)。

如果在slice中追加了一个item,并且超出了slice的容量,会发生什么情况呢?这时 Go 需要在幕后做一些繁重的工作来 “重新扩容 ”slice:

  1. 首先,它会计算出切片的旧长度以及您要添加的新元素数量。

  2. 然后,它会决定新的容量。如果切片较小,它可能只是将当前容量增加一倍。对于较大的切片,通常会以较小的系数增加,以避免占用过多内存。

  3. 决定新容量后,系统会为调整后的切片分配内存。

  4. 如果切片元素中包含指针,Go 会采取额外步骤正确处理内存,以保持数据的完整性。

  5. 接下来,现有元素会被复制到新的内存位置,确保一切保持完整。

  6. 最后,函数会为你提供一个新的slice,它的长度和容量都经过调整,可以随时使用。

那么,这个故事的寓意是什么呢?只要你能预测需要多少空间,就去预先分配你的slice。

转换字符串时,首选 strconv 而不是 fmt

说到在 Go 中将数字转换为字符串,选择合适的工具确实会在性能方面带来很大的不同。现在,你可能会考虑使用 fmt.Sprint,但让我们来谈谈为什么 strconv 可能是这项特定任务的更好选择

strconv 软件包专为字符串转换而设计,这意味着它针对将数字转换为字符串等任务进行了优化。每一点性能提升和内存节省都非常重要,尤其是在处理大型应用程序时。

为了更清楚地了解情况,让我们来看看 fmt 和 strconv 之间的简单基准比较:

func BenchmarkFmt(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = fmt.Sprint(i)
    }
}

func BenchmarkStrconv(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = strconv.Itoa(i)
    }
}

当我们运行这些基准测试时,请查看性能差异:

BenchmarkFmt-8      23821753    50.17 ns/op    16 B/op  2 allocs/op
BenchmarkStrconv-8  100000000   11.47 ns/op     3 B/op  1 allocs/op

(不知道编译器做了什么优化,但两者的上下文是一样的)。

正如你所看到的,与 fmt.Sprint 相比,strconv.Itoa 在内存分配方面明显更快、更高效,但为什么会出现这种情况呢?

  • strconv.Itoa(..) 是专门为将整数转换为字符串而设计的,这种专业化使它比更通用的 fmt 函数执行得更快。
  • 另一方面,fmt.Sprint 及其变体需要执行一些额外的工作,它们使用反射来理解所处理的数据类型,并确定将其格式化为字符串的最佳方法。
func (p *pp) doPrint(a []any) {
	prevString := false
	for argNum, arg := range a {
+		isString := arg != nil && reflect.TypeOf(arg).Kind() == reflect.String
		...
+		p.printArg(arg, 'v') 
		...
	}
}

这种反射过程它会增加时间和内存开销,在处理大量数据或需要高性能时,这种开销可能会相当大。

对结构体中的字段从大到小排序

Go 结构中字段的排序方式看似是一个小细节,但实际上对程序的内存使用有很大影响,尤其是在我们称之为热路径的情况下,因为在这种情况下性能至关重要。

请看这个示例,我们比较了 A 和 OptimizedA 这两个结构体定义。

// 32 bytes
type A struct {
    A byte 
    B int32
    C byte
    D int64
    E byte
}

// 16 bytes
type OptimizedA struct {
    D int64
    B int32
    A byte
    C byte
    E byte
}

在 A 的情况下,结构体总共消耗 32 个字节,而优化后的 A 只消耗 16 个字节,这种差异来自结构体中字段的对齐和填充方式。

让我们进一步分解这些概念:

  • 对齐: 这是指数据类型根据其大小有特定的对齐要求。例如,int32 可能需要在 4 字节边界上对齐,这意味着它的起始内存地址应能被 4 整除。
  • 填充: 为了满足这些对齐要求,编译器通常会在结构字段之间插入未使用的空间(称为填充)。 为了更直观地理解,让我们来看看 A 的内部布局,由于字段是如何对齐和填充的,它实际上变成了 8x4 字节:

image.png

  • A(字节): 占用 1 个字节,但由于下一个字段 B 需要 4 字节对齐,因此在 A 之后有 3 个字节的填充,以便正确对齐 B。
  • B(int32): 在填充后直接占用 4 个字节,完全符合 4 字节边界。
  • C(字节): 占用 1 个字节,但为了对齐需要 8 字节对齐的 D,需要在 C 后面添加 7 个字节的填充。
  • D(int64): 该字段需要 8 个字节,它能很好地适应其插槽,无需在其后添加任何额外的填充。
  • E(字节): 这是最后一个字节,位于 D 之后。根据结构体的整体对齐需要,可能会在末尾添加额外的填充,以使整个结构体的大小与边界对齐。 就内存效率而言,OptimizedA 的字段结构非常巧妙

image.png

在此配置中:

  • D(int64): 位于开头。这种解决方案避免了前面的填充,因为填充通常只会占用空间,而没有任何实际好处。
  • B(int32):紧随其后,位于 4 字节边界上,这很自然地发生在 D 之后。
  • A、C、E(字节): 接下来是单字节类型。由于这些都是字节,因此无需在它们之间添加额外的填充。

我们在这里看到的是从最大字段到最小字段的策略性排序,这不仅仅是为了整洁,也是为了最大限度地减少填充。

减少填充意味着减小结构体的总大小,进而意味着减少内存使用量。

像我刚才提到的 betteralign 这样的工具,就能很好地发现字段对齐中的这些低效问题。它甚至可以自动提供重新排序的建议,以提高内存效率。

不过,重要的是要记住,**为了提高效率而对字段重新排序并不总是正确的做法。 ** 有时,保持一种能反映字段使用方式或其在应用程序大环境中重要性的顺序更有意义。这可以使代码更直观、更易于维护,即使它不是内存最紧凑的格式。

避免在循环中defer,否则你的内存可能会爆炸

defer 允许我们将一个函数安排在稍后的时间执行,特别是在当前函数即将返回之前:

func main() {
    defer fmt.Println("World")
    fmt.Println("Hello")
}

// Output:
// Hello
// World

不过,在循环中使用 defer 可能并不可取:

for _, file := range files {
    f, err := os.Open(file)
    if err != nil {
        ...
    }
    defer f.Close()
    
    ...
    return nil
}

为了便于解释,我们暂且不讨论处理调用 f.Close() 时可能出现的错误,我们已经在《处理延迟调用的错误以防止无声故障》一文中讨论过这个问题。

以下是在循环中使用延迟会产生问题的几个原因:

执行时间

所有这些延迟调用只有在整个函数返回时才会排队执行,而不是在循环的每次迭代之后。

如果我们的循环位于一个冗长的函数中,这种设置意味着我们的延迟任务都要到很久以后才会执行

因此,我们可能不会在不再需要这些资源时立即释放它们,而是会一直保留到函数执行结束。

内存爆炸的可能性

每个延迟语句都会增加内存堆栈。

在循环迭代成百上千次的情况下,我们可能会积累大量的延迟调用。

这些调用中的每个调用及其相关细节(如函数指针和参数)都需要存储起来,直到可以执行为止。

根据编译器对内存分配和优化的管理方式,这种存储可能发生在函数的堆栈框架内,也可能发生在堆上,但无论哪种方式,都可能导致内存爆炸。

解决方案

我们可以考虑一些策略来减轻影响。

如果您正在寻找一种更简单的,或者可以说是 “懒惰 ”的解决方案,您可能需要考虑使用匿名函数来封装延迟语句。

下面就是你可以采用的结构:

for _, file := range files {
    func (fileName string) error {
        f, err := os.Open(file)
        if err != nil {
            ...
        }

        defer f.Close()
        ...
    }(file)
}

或者,如果你想采用一种更有条理的方法,可以将逻辑提取到一个单独的函数或函数内函数中。

doSomething := func(filename string) error {
    f, err := os.Open(filename)
    if err != nil {
        ...
    }

    defer f.Close()
    ...
}

for _, file := range files {
    if err := doSomething(file); err != nil {
        ...
    }
}

我们可以将这一功能分离到一个命名函数中,或者谨慎地选择不使用 defer(请记住,即使发生panic, defer 仍会被调用,这在某些情况下可能是一个安全网)。

使用 strings.EqualFold 进行大小写不敏感的字符串比较

如果我们的目标是在不考虑大小写敏感性的情况下比较字符串,我们最初可能会考虑使用 strings.ToLower() 将两个字符串完全转换为小写,或使用 strings.ToUpper() 将两个字符串转换为大写,然后进行比较:

if strings.ToLower(a) == strings.ToLower(b) {
    ...
}

但 Go 提供了一种更简单、更有效的不区分大小写的字符串比较方法:strings.EqualFold。

该函数专为此类比较而设计,并提供了更优化的解决方案:

if strings.EqualFold(a, b) {
    ...
}

偏好使用 strings.EqualFold 不仅仅是因为它的简洁。它专门针对不区分大小写的比较进行了优化,这不仅简化了代码,还大大提高了性能。

“strings.EqualFold 是否比使用 ToLower 然后进行比较更快?

是的,绝对快。

如果我们比较一下性能数据,就会发现 strings.EqualFold 不仅简单,而且高效:

BenchmarkToLower-8      29140400    40.67 ns/op     8 B/op  1 allocs/op
BenchmarkEqualFold-8    208766617   5.718 ns/op     0 B/op  0 allocs/op

该功能不仅仅是将字符从大写转换为小写。

它考虑到了 Unicode 的复杂性,确保所有语言的比较都准确无误:

strings.EqualFold("Σ", "σ")           // true
strings.EqualFold("RESUMÉ", "resumé") // true

具体操作如下:

  • 快速路径: 它能快速逐个检查 ASCII 字符。
  • 慢速路径: 如果发现 Unicode 字符,它会转为详细比较,以准确处理这些字符。 例如,我们仍然可以使用 strings.ToLower 或 strings.ToUpper,它们都能很好地处理 Unicode 字符。

但现在还不行,在某些情况下,strings.EqualFold 可能无法涵盖所有情况,例如:

s1 := "Resumé" // Normal 'é'
s2 := "resume\u0301" // 'e' followed by a combining acute accent

虽然字符看起来相似,但编码却不同。

在这种情况下,仅使用 strings.EqualFold 可能无法解决问题,您需要整合更具体的处理方法,可能需要使用规范化技术:

import "golang.org/x/text/unicode/norm"

s1 := "Resumé"       // Normal 'é'
s2 := "resume\u0301" // 'e' followed by a combining acute accent

s1Normalized := norm.NFC.String(s1)
s2Normalized := norm.NFC.String(s2)

fmt.Println(s1Normalized == s2Normalized)                  // Output: false
fmt.Println(strings.EqualFold(s1Normalized, s2Normalized)) // Output: true

无任何分配的过滤器

在 Go 中过滤片段通常是为过滤后的元素创建一个新的片段。但这种方法会导致额外的内存分配:

var filtered []int

for _, v := range numbers {
    if isOdd(v) {
        filtered = append(filtered, v)
    }
}

但有一种更聪明的处理方法,就是 “就地 ”过滤切片,使用原始切片的底层数组,以避免额外的分配。

你不用重新开始一个新的片段,而是将过滤后的片段设置为零长度,并与数字共享相同的底层数组:

filtered := numbers[:0]

这种设置意味着过滤开始时没有元素,但使用与数字相同的数组。

for _, v := range numbers {
    if isOdd(v) {
        filtered = append(filtered, v)
    }
}

这里有一个棘手的问题:我们实际上并没有分配新的内存,我们要做的是填充原来引用的数组。因此,filtered 和 numbers 都会反映变化,但仅限于我们添加到 filtered 中的最后一个元素:

numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}

// 
filtered: [1 3 5 7 9]
numbers: [1 3 5 7 9 6 7 8 9]

这种方法非常方便,如果

  • 你可以改变原始数字数组,因为过滤后你不再需要它的原始形式。
  • 您正在处理大型数据集,而性能是关键因素,这主要是因为这种方法不会创建额外的分片,从而最大限度地减少了内存使用量。