Go常见错误集锦之不正确的初始化slice方式会降低性能

2023-01-31 15:36:23 浏览数 (1)

当使用 make 初始化一个切片时,我们必须提供一个长度参数和一个可选的容量参数。Go 研发者常犯的一个错误就是当使用 make 进行初始化时忘记传递这两个或其中的一个参数。

在下面的例子中,我们有 1 个 convert 函数,该函数将 Foo 类型的切片转换成 Bar 类型的切片。这两个切片拥有相同的元素个数。下面是第一版的实现:

代码语言:javascript复制
func convert(foos []Foo) []Bar {
    bars := make([]Bar, 0) ①

    for _, foo := range foos {
        bars = append(bars, fooToBar(foo)) ②
    }
    return bars
}

① 创建一个 Bar 类型的切片

② 将 Foo 类型的切片转换成 Bar 类型并加入到切片变量中

首先,我们使用 make([] Bar, 0) 初始化了一个空的 Bar 类型切片。然后,我们使用 append 函数将 Bar 元素添加到切片中。当我们在循环中不断的往 bars 切片中添加元素时,底层的内存空间是如何变化的呢?

  • 添加第 1 个元素的时候,会分配一个大小为 1 的数组来存储该元素
  • 添加第 2 个元素的时候,因为底层的数组已经没有空间了,所以 Go 会重新分配一个空间大小为 2 的新数组(原来数组的 2 倍),然后将原来的数组中的元素拷贝到新数组中上来,再将第 2 个元素插入。

当我们添加第 3、第 5、第 9 个元素时,会重复以上逻辑。假设要往里添加 1000 个元素,这种算法会分配 10 次内存,并将元素从 1 个数组拷贝到另一个数组。如果编译器对 slice 进行逃逸分析到堆栈上,还会影响 GC 的性能。

就性能而言,我们要帮助编译器进行改进。有以下两种方法:第一种是 在原来的代码中,在初始化 slice 的时候,提供一个容量参数:

代码语言:javascript复制
func convert(foos []Foo) []Bar {
    n := len(foos)
    bars := make([]Bar, 0, n) ①

    for _, foo := range foos {
        bars = append(bars, fooToBar(foo)) ②
    }
    return bars
}

① make 初始化中指定长度为 0,容量为 n

② 通过更新底层数组的方式来添加新元素

我们仅仅在初始化切片时 对容量进行了改变。在 Go 内部,会预分配一个能容纳 n 个元素的数组。因此,当添加 n 个元素后,底层的数组仍然是原来的那个数组。也就是说减少了内存分配的次数。

第二种方式是让 bars 切片的底层数组按固定长度的初始化:

代码语言:javascript复制
func convert(foos []Foo) []Bar {
    n := len(foos)
    bars := make([]Bar, n) ①

    for i, foo := range foos {
        bars[i] = fooToBar(foo) ②
    }
    return bars
}

① 使用一个给定长度的参数进行初始化

② 设置切片的第 i 个元素,而非使用 append。

因为我们使用了一个给定长度来初始化切片,n 个元素就已经被分配了内存空间并且都初始化成了 Foo 类型的零值。因此,通过 set 元素,而非 append,来设置 bars[i]。

哪种方式最好呢?下面是通过输入 100 万个元素的一个基准测试:

代码语言:javascript复制
BenchmarkConvert_EmptySlice-4 22 49739882 ns/op ①
BenchmarkConvert_GivenCapacity-4 86 13438544 ns/op ②
BenchmarkConvert_GivenLength-4 91 12800411 ns/op ③

① 第一种使用一个空切片

② 第二种使用给定的容量进行初始化并使用 append 来添加元素

③ 第三种使用给定长度进行初始化并使用 bars[i] 来进行更新元素值

第一种方案对性能影响最大。必须要不断的进行内存分配并拷将元素拷贝到新内存上,和第二种方式对比,慢了 400%。第二和第三种相比,可以看到第三种要比第二种快 4%,因为避免了重复调用 append 函数的开销。然而,第二种具有使用方便的优势。

将切片从一种类型转换到另一种类型是非常常见的操作。正如我们上面看到的,如果 slice 的长度是已知的,就没有理由使用一个空切片来初始化。解决方案就是可以使用一个给定长度或一个给定容量的参数来初始化切片。当使用给定长度的参数进行初始化时,通过给 slice 的索引赋值来更新对应的元素,如果是使用特定容量的初始化方式,则使用 append 来添加元素。这两种方式相比,前者会更快一些。

0 人点赞