likes
comments
collection
share

Go源码阅读:关于1.22版本切片扩容机制部分源码的变化

作者站长头像
站长
· 阅读数 12

Go源码阅读:关于1.22版本切片扩容机制部分源码的变化

🖋️作者: 千石🌟 🌐个人网站:cnqs.moe🚀 🤝支持方式:👍点赞、💖收藏、💬评论 💬欢迎各位在评论区🗣️交流,期待你的灵感碰撞🌈

引言

在和朋友闲聊中,偶然谈到了go的切片扩容机制,习惯性的翻开了go的源码,发现切片扩容机制的源码似乎进行了一次更新,研究后写下了这篇文章

Go语言中,切片(slices)是一种数据结构。切片提供了一种动态数组的抽象,使得数据集的处理既简便又高效。然而,随着Go语言的不断发展和优化,内部机制,特别是切片的扩容机制,也在不断变化。这些变化对于提升Go程序的性能至关重要。

切片扩容机制的演进

切片作为Go语言中的一个核心特性,其扩容机制自Go语言诞生之日起就一直在不断进化,以满足更高效的内存管理和性能优化的需求。从早期版本到现在的go1.22rc2,每一次的更新都是为了使这个灵活的数据结构更加高效和易于使用。

早期版本

在Go语言的早期版本中,切片的扩容策略相对简单直接。当向切片追加元素,且现有容量无法满足时,Go会创建一个新的切片,其容量通常是原切片容量的两倍。这种简单的策略在大多数情况下工作得很好,但它并不总是最优的选择。特别是在切片已经非常大或者追加的元素数量非常少时,这种“加倍”策略可能会导致内存的浪费。

逐步优化

随着Go语言的发展,切片的扩容机制开始引入更加复杂和智能的策略。这些策略旨在根据切片的当前大小和追加的元素数量来决定最合适的新容量,以此来平衡性能和内存使用。例如,对于较小的切片,仍然采取加倍扩容策略;但对于较大的切片,扩容因子可能会降低,以避免不必要的内存分配。

概括一下:

一、go1.18 之前:

  1. 如果期望容量大于当前容量的两倍就会使用期望容量;
  2. 如果当前切片的长度小于 1024 就会将容量翻倍;
  3. 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

二、go1.18 之后:

  1. 如果期望容量大于当前容量的两倍就会使用期望容量;
  2. 如果当前切片的长度小于阈值(默认 256)就会将容量翻倍;
  3. 如果当前切片的长度大于等于阈值(默认 256),就会每次增加 25% 的容量,基准是 newcap + 3*threshold,直到新容量大于期望容量;

Go 1.22切片扩容机制的新变化

简单概括一下:

扩容算法改进

Go 1.22使用新的nextslicecap算法计算新容量,并且优化了性能。

优化内存分配

Go 1.22在可能的情况下继续复用现有底层数组,减少内存分配。

优化写屏障

(本文暂时略过,后面补充)

Go 1.22仅对源切片指针字段执行写屏障

举个例子🌰

Go 1.21版本中,我们首先创建了一个初始容量为4的切片s1,然后追加3个元素,观察其扩容行为。

s1 := make([]int, 2, 4) fmt.Printf("Go 1.21 - Before append: %p %d %d\n", s1, len(s1), cap(s1)) // 打印s1的初始地址、长度和容量
s2 := append(s1, 1, 2, 3) fmt.Printf("Go 1.21 - After append: %p %d %d\n", s2, len(s2), cap(s2)) // 打印s2的地址、长度和容量,观察扩容情况

结果如下:

Go 1.21 - Before append: 0xc000092040 2 4  
Go 1.21 - After append: 0xc0000a2040 5 8

可以看出:在Go 1.21版本中,切片的扩容策略倾向于提前增加额外的容量,即使追加操作并没有立即用尽当前容量。

同时,内存地址发生改变

而 Go 1.22版本中,我们先是追加2个元素到s3,然后再追加一个元素到结果切片s4,以展示可能的优化扩容策略。

s3 := make([]int, 2, 4) fmt.Printf("Go 1.22 - Before append: %p %d %d\n", s3, len(s3), cap(s3)) // 打印s3的初始地址、长度和容量
s4 := append(s3, 1, 2) fmt.Printf("Go 1.22 - After first append: %p %d %d\n", s4, len(s4), cap(s4)) // 打印s4的地址、长度和容量,观察第一次扩容情况
s5 := append(s4, 1, 2, 3) fmt.Printf("Go 1.22 - After second append: %p %d %d\n", s5, len(s5), cap(s5)) // 打印s5的地址、长度和容量,观察第二次扩容情况

结果如下:

Go 1.22 - Before append: 0xc00009c040 2 4  
Go 1.22 - After first append: 0xc00009c040 4 4  
Go 1.22 - After second append: 0xc0000a2040 7 8

在Go 1.22版本中,初始切片s3的容量同样是4。首先追加2个元素,这时候长度变为4,容量仍然足够,所以容量没有变化。

接着,在已经满载的切片上再追加3个元素,使得总长度达到7。此时,切片的容量进行了扩展,从4扩展到8,以容纳新增的元素。

同时,在第一次扩容时,内存地址没有发生改变;而在第二次扩容后 (原有切片容量用完了),内存地址发生了改变

源码分析

扩容触发条件

当切片的append操作导致容量不足以容纳新元素时,将触发growslice函数:

growslice函数核心逻辑:

// 本部分源代码,下同:https://github.com/golang/go/blob/58a35fe55beca3747cc410c315d3354e4a3876d0/src/runtime/slice.go#L155
// growslice allocates new backing store for a slice.
// ...
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
    // 逻辑省略...
}
  • 参数解析oldPtr是旧切片的指针,newLen是扩容后的新长度,oldCap是旧切片的容量,num是追加的元素数量,et是元素的类型。
  • 内存安全检查:在扩容前,会进行内存安全检查,包括竞态检测、内存访问检测等,确保操作的安全性。
  • 新容量计算:根据旧容量和需要追加的元素数量,通过一系列逻辑计算出一个合适的新容量,旨在平衡内存使用和性能。
  • 内存分配与数据复制:分配新的内存空间用于存储扩容后的切片,并将旧切片的数据复制到新空间中。

智能扩容逻辑

growslice内部通过调用nextslicecap函数来智能计算新的容量,针对不同的场景采取最优的扩容策略:

// https://github.com/golang/go/blob/58a35fe55beca3747cc410c315d3354e4a3876d0/src/runtime/slice.go#L178
newcap := nextslicecap(newLen, oldCap)

nextslicecap函数的实现考虑了切片当前容量与新需求之间的关系,以及是否超过阈值 (256)

// https://github.com/golang/go/blob/58a35fe55beca3747cc410c315d3354e4a3876d0/src/runtime/slice.go#L274
const threshold = 256

扩容策略:

  1. 直接加倍:对于旧容量小于阈值(256)的切片,新容量将直接加倍。这是针对较小切片的优化,可以快速增加容量而减少扩容次数。
// https://github.com/golang/go/blob/58a35fe55beca3747cc410c315d3354e4a3876d0/src/runtime/slice.go#L275
if oldCap < threshold {
    return doublecap
}
  1. 渐进式增加:当切片的旧容量超过阈值(256)时,扩容策略从直接加倍转变为渐进式增加。新容量的计算方式是newcap += (newcap + 3*threshold) >> 2,即每次增加大约原来的1.25倍,直到满足或超过所需的新长度。这种方法为大切片提供了一种更细致的扩容策略,可以减少内存的过度分配。
// https://github.com/golang/go/blob/58a35fe55beca3747cc410c315d3354e4a3876d0/src/runtime/slice.go#L278
for {
    newcap += (newcap + 3*threshold) >> 2
    if uint(newcap) >= uint(newLen) {
       break
    }
}

特殊情况处理

零大小元素: 对于元素大小为0的切片,扩容时不实际分配新的内存空间,而是返回一个长度和容量都为新长度的切片,指向一个特殊的zerobase地址,这个地址不指向实际的内存空间,因为零大小元素不占用内存。

// https://github.com/golang/go/blob/58a35fe55beca3747cc410c315d3354e4a3876d0/src/runtime/slice.go#L172
if et.Size_ == 0 {
    return slice{unsafe.Pointer(&zerobase), newLen, newLen}
}

溢出检查: 在扩容过程中,growslice会检查新容量是否会导致整数溢出,以防止可能的安全隐患。

// https://github.com/golang/go/blob/58a35fe55beca3747cc410c315d3354e4a3876d0/src/runtime/slice.go#L180
var overflow bool
var lenmem, newlenmem, capmem uintptr
// ...
// #L217
capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
// ...
// #L236
if overflow || capmem > maxAlloc {
    panic(errorString("growslice: len out of range"))
}
  • 在计算新切片所需的内存大小时(capmem),函数使用math.MulUintptr来乘以元素大小和新容量。这个过程可能会导致整数溢出,特别是在32位系统上处理非常大的切片时。
  • overflow变量用于捕捉乘法操作是否溢出。
  • 如果计算出的内存大小溢出(即overflowtrue)或者计算出的内存大小超过了Go语言运行时允许的最大分配大小(maxAlloc),则会触发panic,防止进一步的不安全操作。

性能优化

使用了位移操作(>>)来优化计算过程

// https://github.com/golang/go/blob/58a35fe55beca3747cc410c315d3354e4a3876d0/src/runtime/slice.go#L282
newcap += (newcap + 3*threshold) >> 2