FanXing Blog

FanXing Blog

一个热爱编程的高中生,正在努力成为一位优秀的后端工程师~

Day1. Go语言精进之路:切片的原理并高效使用

由于近期忙于编写公司的项目,导致懈怠了 golang 的学习,故而开启 Go语言精进之路 系列文章,记录学习过程中的理解和笔记。


关于切片#

Slice,中文译作 切片,是 Go 语言中基于数组提供的一个抽象数据类型,在 Go 语言中,大部分需要使用数组的场景,皆可用切片代替。
在 Go 语言总,切片对数组实现了完美代替,并提供了更灵活、更高效的数据序列访问接口。

切片究竟是什么?#

在了解 Go 语言的 slice 之前,我们需要先了解数组。

切片是什么?#

在 Go 中,数组是一个固定长度、容纳相同类型元素的持续序列,因此,Go 数组有两个属性:元素类型数组长度,这两个类型都相同的数组是等价的。也就是:

var a [8]int
var b [8]int

这里 a 的数组长度为 8,元素类型则为 int,与 b 相同,所以可以说,ab 是等价的。

Go 是值语义的,那么就意味着,一个数组变量表示的是整个数组,而不是数组的第一个元素的指针 (这是 C 语言的做法)。而在 Go 语言中,传递数组使用的是值拷贝,那么就导致如果需要传递元素类型长度较大,或者元素较多的数组时,会有不小的性能损耗。

在 C 语言中,这样的场景可以使用数组指针类型来处理,但在 Go 语言中,一般使用切片处理。

切片与数组的关系#

切片与数组的关系,相当于是文件描述符文件 一样。在 Go 语言中,数组一般退居 "幕后",作为数据存储的 “底层”;而切片则走向 “前台”,为底层的存储提供了对外访问的接口。

所以,可以将切片称为数组的 “描述符”。而切片之所以能在不同函数之间任意传递,就是因为 “描述符” 的特性,不管底层的数据存储有多大,描述符的大小总是固定的。

切片的创建方式和 runtime 中的表示#

切片在 Go runtime 中的表示:

//$GOROOT/src/runtime/slice.go
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

可以看到,slice 中有三个元素,分别是:

  • array:指向下层数组某元素的指针,这个元素也是切片的第一个元素。
  • len:切片的长度, 也就是当前切片的元素个数
  • cap:切片的最大容量,cap >= len

在运行时中,每一个切片都是一个 runtime.slice 结构体的实例,可以用下面的语句创建一个切片:

s := make([]byte, 5)

切片运行时(创建新数组)

我们可以看到,在我们创建切片时,编译器会默认创建一个底层数组,在我们没有指定 cap 时,默认 cap = len

我们也可以通过对已有数组的操作,来创建一个切片,我们将其称为 数组的切片化

u := [10]byte{11, 12, 13, 14, 15, 16, 17, 18, 19, 20}
s := u[3:7]

切片运行时(切片化)

可以看到,切片 s 打开了一个数组 u 的操作窗口,s 的第一个元素为 u[3],通过 s 可以看到并操作的数组元素有 4 个。切片的 cap 取决于底层数组的长度,从 u[3] 到末尾一共有 7 个存储元素的槽位,所以 scap 为 7。

当然,我们也可以为一个数组创建多个操作数组的切片,但由于创建的多个切片都是同一个数组的描述符,所以对任意一个切片的修改都会反应到其他切片中。

我们也可以通过对切片的操作,来创建新的切片,这个操作称为 reslicing。同样的,基于老切片创建的新切片共享同一个底层数组,所以对新切片的修改也会反应到老切片中。

所以,当切片作为参数传递时,传递的是 runtime.slice,因此不管底层数组有多大,切片带来的性能损耗都是很小且恒定的,甚至可以忽略不计,这就是在函数中通常使用切片而不是数组的原因之一。另一个原因是切片可以提供比指针数组更加强大的功能,如下标访问、边界溢出校验、动态扩容。

切片的高级特性#

动态扩容#

切片是满足零值可用概念的,因为切片拥有一个高级特性:动态扩容,即便是零值切片,也可以通过 append 预定义函数进行元素赋值操作。

var k []int
k.append(k, 1)

k 被初始化为零值,所以 k 并没有绑定对应的底层数组。但是,在经过 append 操作后,k 显然已经绑定了属于它的底层数组。

下面,我将通过一系列实操,来展示每次 append 后的 lencap

var s []int
s = append(s, 1)
fmt.Println(len(s), cap(s)) // 1 1
s = append(s, 2)
fmt.Println(len(s), cap(s)) // 2 2
s = append(s, 3)
fmt.Println(len(s), cap(s)) // 3 4
s = append(s, 4)
fmt.Println(len(s), cap(s)) // 4 4
s = append(s, 5)
fmt.Println(len(s), cap(s)) // 5 8

可以看出,len 的值是线性增长的,但是 cap 则是不规则增长。这张图则形象的展示出了 append 操作是如何让切片动态扩容的:

image

  1. 我们可以看到,最初 s 为零值,此时 s 还没有绑定底层数组
  2. 通过 append 后,向切片 s 添加了一个元素 11,此时会分配一个底层数组 u1,数组长度为 1,然后将 s 内部的 array 指向 u1,所以此时,len = 1, cap = 1
  3. 然后再次 append,将 12 插入切片 s,但是此时切片显然已经没有空间再存储 12 了。所以就需要再次扩容。所以就创建了底层数组 u2,长度为 2(u1 数组长度 * 2),并将 u1 中的所有元素复制到 u2 中,最后将 array 指向 u2,并设置 len = 2, cap = 2
  4. 通过 appends 中再次插入一个元素 13,此时切片空间再次不足,需要再次扩容。所以,创建了新的底层数组 u3,长度为 4(u2 数组长度 * 2),并将 u2 的所有元素复制到 u3 中,然后将 array 指向 u3,并设置 len = 3, cap = 4
  5. 通过 appends 中插入元素 14,此时切片 cap = 4,空间充足。所以将 14 放在下一个元素的位置,也就是 u3 的末尾,并将 s 内部的 len 加 1,变为 4
  6. 最后再次通过 appends 插入 15,此时切片 len = 4, cap = 4,空间已经不足,再次扩容。所以,创建新的底层数组 u4,数组长度为 8(u3 数组长度 * 2),并将 u3 的所有元素复制到 u4 中,然后将 array 指向 u4,并设置 len = 5, cap = 8

可以发现,当切片的底层数组空间不足时,append 会根据需要去自动的将切片分配到新的底层数组,新数组长度会按照一定算法进行拓展(参见 $GOROOT/src/runtime/slice.go 中的 growslice 函数)。在新数组建立后,会将旧数组中的所有数据复制到新数组中,然后将 array 指向新数组,这样,新数组就是切片的底层数组,旧数组则会被 gc 回收掉。

但是这样,如果我们使用 数组的切片化 来创建的切片,一旦切片的 cap 触碰到上边界,再对切片进行 append 操作,那么该切片就和原数组解除绑定。

u := [6]int{1, 2, 3, 4, 5, 6}
s := u[2:4]
fmt.Printf("u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))
s = append(s, 7)
fmt.Printf("append 7: u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))
s = append(s, 8)
fmt.Printf("append 8: u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))
s = append(s, 9)
fmt.Printf("append 9: u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))

// output:
// u = [1 2 3 4 5 6], s = [3 4] len(s) = 2, cap(s) = 4
// append 7: u = [1 2 3 4 7 6], s = [3 4 7] len(s) = 3, cap(s) = 4
// append 8: u = [1 2 3 4 7 8], s = [3 4 7 8] len(s) = 4, cap(s) = 4
// append 9: u = [1 2 3 4 7 8], s = [3 4 7 8 9] len(s) = 5, cap(s) = 8
// append 10: u = [1 2 3 4 7 8], s = [3 4 7 8 9 10] len(s) = 6, cap(s) = 8

从输入结果可以看出,在添加元素 9 之后,cap(s) 就等于 8 了,也就是触发了切片的动态扩容,而此时的切片 s,就已经与数组 u 解绑了,也就是 s 不再是 u 的描述符了。

尽量使用 cap 参数创建切片#

append 操作是一个利器,它让切片支持了零值可用的理念,并让切片变得更加易用。

但是从原理我们可以看出,每次扩容,都需要从老底层数组中将全部元素复制到新的底层数组,这一步所需要消耗的资源其实还是不小的,尤其是元素很多的情况下。那么如何避免过多内存分配和复制元素所付出的代价呢?

有一个有效的方案是,在编写代码时,有意识的去估算切片的所需容量,并在切片创建时,传递 cap 参数给内置函数 make

s := make([]T, len, cap)

下面对两种情况进行性能测试:

const sliceSize = 10000

func BenchmarkSliceInitWithoutCap(b *testing.B) {
	for n := 0; n < b.N; n++ {
		sl := make([]int, 0)
		for i := 0; i < sliceSize; i++ {
			sl = append(sl, i)
		}
	}
}

func BenchmarkSliceInitWithCap(b *testing.B) {
	for n := 0; n < b.N; n++ {
		sl := make([]int, 0, sliceSize)
		for i := 0; i < sliceSize; i++ {
			sl = append(sl, i)
		}
	}
}

得出的结果是:

goos: windows
goarch: amd64
pkg: prometheus_for_go
cpu: AMD Ryzen 7 5800H with Radeon Graphics         
BenchmarkSliceInitWithoutCap
BenchmarkSliceInitWithoutCap-16    	   34340	     34443 ns/op
BenchmarkSliceInitWithCap
BenchmarkSliceInitWithCap-16       	  106251	     11122 ns/op
PASS

可以看出,使用 cap 参数的切片在 append 时的平均性能为 11122 ns/op,大概是不使用 cap 参数的三倍。

所以,在创建切片时,建议携带 cap 参数。

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。