Go 1.18 全新的切片扩容机制

6,831 阅读3分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

引言

Go1.18之前切片的扩容是以容量1024为临界点,当旧容量 < 1024个元素,扩容变成2倍;当旧容量 > 1024个元素,那么会进入一个循环,每次增加25%直到大于期望容量。

然而这个扩容机制已经被Go 1.18弃用了,官方说新的扩容机制能更平滑地过渡。

测试代码

//go 1.18
func TestSliceGrowing(t *testing.T) {
   s := []int{}
   for i := 0; i < 4098; i++ {
      s = append(s, i)
      t.Log(len(s), cap(s))
   }
}

运行结果:

=== RUN   TestSliceGrowing
    slice_test.go:32: 1 1
    slice_test.go:32: 2 2
    slice_test.go:32: 3 4
    slice_test.go:32: 4 4
    slice_test.go:32: 5 8
    slice_test.go:32: 6 8
    slice_test.go:32: 7 8
    slice_test.go:32: 8 8
    slice_test.go:32: 9 16
    ......
    slice_test.go:32: 128 128
    slice_test.go:32: 129 256
    ......
    slice_test.go:32: 256 256
    slice_test.go:32: 257 512
    ......
    slice_test.go:32: 512 512
    slice_test.go:32: 513 848
    ......
    slice_test.go:32: 848 848
    slice_test.go:32: 849 1280
    ......
    slice_test.go:32: 1280 1280
    slice_test.go:32: 1281 1792
    ......
    slice_test.go:32: 1792 1792
    slice_test.go:32: 1793 2560
    ......
    slice_test.go:32: 2560 2560
    slice_test.go:32: 2561 3408
    ......
    slice_test.go:32: 3408 3408
    slice_test.go:32: 3409 5120  

Go 1.17版本切片扩容

Go 1.17切片扩容时会进行内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要 大于等于老 slice 容量的 2倍或者1.25倍。

  • 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
  • 当原 slice 容量 < 1024 的时候,新 slice 容量变成原来的 2 倍;
  • 当原 slice 容量 > 1024,进入一个循环,每次容量变成原来的1.25倍,直到大于期望容量。

公式如下:

newcap={cap if cap>oldcap2oldcap2 if oldcap<1024oldcap+oldcap4 if oldcap>1024newcap = \begin{cases} cap& \text{ if } cap > oldcap*2 \\ oldcap*2 & \text{ if } oldcap < 1024 \\ oldcap+\frac{oldcap}{4} & \text{ if } oldcap > 1024 \end{cases}
// runtime/slice.go
// et:表示slice的一个元素;old:表示旧的slice; cap:表示新切片需要的容量;
func growslice(et *_type, old slice, cap int) slice {
	if cap < old.cap {
		panic(errorString("growslice: cap out of range"))
	}

	if et.size == 0 {
		// append should not create a slice with nil pointer but non-zero len.
		// We assume that append doesn't need to preserve old.array in this case.
		return slice{unsafe.Pointer(&zerobase), old.len, cap}
	}

	newcap := old.cap
        // 两倍扩容
	doublecap := newcap + newcap
        // 新切片需要的容量大于两倍扩容的容量,则直接按照新切片需要的容量扩容
	if cap > doublecap {
		newcap = cap
	} else {
        // 原 slice 容量小于 1024 的时候,新 slice 容量按2倍扩容
		if old.cap < 1024 {
			newcap = doublecap
		} else { // 原 slice 容量超过 1024,新 slice 容量变成原来的1.25倍。
			// Check 0 < newcap to detect overflow
			// and prevent an infinite loop.
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			// Set newcap to the requested cap when
			// the newcap calculation overflowed.
			if newcap <= 0 {
				newcap = cap
			}
		}
	}

        // 后半部分还对 newcap 作了一个内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要 大于等于 老 slice 容量的 2倍或者1.25倍。
	var overflow bool
	var lenmem, newlenmem, capmem uintptr
	// Specialize for common values of et.size.
	// For 1 we don't need any division/multiplication.
	// For sys.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
	// For powers of 2, use a variable shift.
	switch {
	case et.size == 1:
		lenmem = uintptr(old.len)
		newlenmem = uintptr(cap)
		capmem = roundupsize(uintptr(newcap))
		overflow = uintptr(newcap) > maxAlloc
		newcap = int(capmem)
	case et.size == sys.PtrSize:
		lenmem = uintptr(old.len) * sys.PtrSize
		newlenmem = uintptr(cap) * sys.PtrSize
		capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
		overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
		newcap = int(capmem / sys.PtrSize)
	case isPowerOfTwo(et.size):
		var shift uintptr
		if sys.PtrSize == 8 {
			// Mask shift for better code generation.
			shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
		} else {
			shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
		}
		lenmem = uintptr(old.len) << shift
		newlenmem = uintptr(cap) << shift
		capmem = roundupsize(uintptr(newcap) << shift)
		overflow = uintptr(newcap) > (maxAlloc >> shift)
		newcap = int(capmem >> shift)
	default:
		lenmem = uintptr(old.len) * et.size
		newlenmem = uintptr(cap) * et.size
		capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
		capmem = roundupsize(capmem)
		newcap = int(capmem / et.size)
	}
}

Go 1.18版本切片扩容

Go1.18不再以1024为临界点,而是设定了一个值为256的threshold,以256为临界点;超过256,不再是每次扩容1/4,而是每次增加(旧容量+3*256)/4;

  • 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
  • 当原 slice 容量 < threshold 的时候,新 slice 容量变成原来的 2 倍;
  • 当原 slice 容量 > threshold,进入一个循环,每次容量增加(旧容量+3*threshold)/4。

公式如下:

threshold=256newcap={cap if cap>oldcap2oldcap2 if oldcap<thresholdoldcap+oldcap+3threshold4 if oldcap>threshold\begin{matrix} threshold = 256 \\ newcap = \begin{cases} cap& \text{ if } cap > oldcap*2 \\ oldcap*2 & \text{ if } oldcap < threshold \\ oldcap+\frac{oldcap+3*threshold}{4} & \text{ if } oldcap > threshold \end{cases} \end{matrix}

如下代码所示:

//1.18
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
  newcap = cap
} else {
  const threshold = 256
  if old.cap < threshold {
    newcap = doublecap
  } else {
    // Check 0 < newcap to detect overflow
    // and prevent an infinite loop.
    for 0 < newcap && newcap < cap {
      // Transition from growing 2x for small slices
      // to growing 1.25x for large slices. This formula
      // gives a smooth-ish transition between the two.
      newcap += (newcap + 3*threshold) / 4
    }
    // Set newcap to the requested cap when
    // the newcap calculation overflowed.
    if newcap <= 0 {
      newcap = cap
    }
  }
}

在1.18中,优化了切片扩容的策略,让底层数组大小的增长更加平滑: 通过减小阈值并固定增加一个常数,使得优化后的扩容的系数在阈值前后不再会出现从2到1.25的突变,该commit作者给出了几种原始容量下对应的“扩容系数”:

oldcap扩容系数
2562.0
5121.63
10241.44
20481.35
40961.30

可以看到,Go1.18的扩容策略中,随着容量的增大,其扩容系数是越来越小的,可以更好地节省内存。

我们可以试着求一个极限,当oldcap远大于256的时候,扩容系数将会变成1.25。

总结

总的来说,Go的设计者不断优化切片扩容的机制,其目的只有一个:就是控制让小的切片容量增长速度快一点,减少内存分配次数,而让大切片容量增长率小一点,更好地节省内存。

  • 如果只选择翻倍的扩容策略,那么对于较大的切片来说,现有的方法可以更好的节省内存。
  • 如果只选择每次系数为1.25的扩容策略,那么对于较小的切片来说扩容会很低效。
  • 之所以选择一个小于2的系数,在扩容时被释放的内存块会在下一次扩容时更容易被重新利用