GO 运行时内部原理

Go slice 内部原理

slice 不是数组,是一个只有 24 字节的小结构体:指针 + 长度 + 容量。append 有时候是"原地写、大家共享同一块内存",有时候是"另起炉灶、彻底分家"——区别只在于容量还够不够。这篇文章配了一个和真实 go run 输出逐项比对过的内存模拟场景,演示一次真实存在的"隐形覆盖"事故是怎么发生的。

· 引用 Go 源码 commit 72aa6db7,文件 src/runtime/slice.go · 源码遵循 BSD-3-Clause 协议,版权归 The Go Authors 所有,下文均为简短引用并附原文链接
切片头 · 24 字节的 ptr/len/cap,复制切片只复制这个头 底层数组 · 真正存数据的地方,可能被多个切片同时指着 growslice · cap 不够时另起炉灶,从此和别人分家

01 · slice 到底是什么

三个字段,仅此而已

Go 里的 []T 在运行时就是这样一个结构体:

slice.go · L16-L20在 GitHub 上查看 ↗
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

把一个 slice 赋值给另一个变量、当参数传给函数,复制的都只是这 24 个字节(64 位系统上,一个指针 + 两个 int),底层数组不会被复制。这意味着两个 slice 变量完全可能指向同一块内存——通过下标改其中一个,另一个也会跟着变。而 append 会不会打破这种共享,完全取决于容量还够不够:够,就在原地写;不够,就分配一块新内存、拷贝过去、旧的不再共享。这个"够不够"的边界,正是本文要讲清楚的地方。

02 · 三个核心概念

切片头 / 底层数组 / growslice

切片头

指针 + len + cap 三个字段。len 是"当前能看到多少个元素",cap 是"不重新分配内存的前提下,最多能长到多少"。两个 slice 的头可以指向同一块底层数组的不同(或重叠的)区间。

底层数组

真正连续存放元素的内存。用 make、字面量或者一次扩容创建,创建之后大小固定——它自己不知道有几个 slice 头正指着它。

growslice

只有 len+num > cap 时才会被调用:申请一块新的、通常更大的内存,把旧数据搬过去,再把新元素写进去。搬完之后,这个 slice 的头指向新数组,和原来共享旧数组的其它 slice 彻底没关系了。

slice.go · L102-L118(makeslice)在 GitHub 上查看 ↗
func makeslice(et *_type, len, cap int) unsafe.Pointer {
	mem, overflow := math.MulUintptr(et.Size_, uintptr(cap))
	if overflow || mem > maxAlloc || len < 0 || len > cap {
		// NOTE: Produce a 'len out of range' error instead of a
		// 'cap out of range' error when someone does make([]T, bignumber).
		// 'cap out of range' is true too, but since the cap is only being
		// supplied implicitly, saying len is clearer.
		// See golang.org/issue/4085.
		mem, overflow := math.MulUintptr(et.Size_, uintptr(len))
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

	return mallocgc(mem, et, true)
}

03 · 现场直播

一次真实存在的"隐形覆盖"事故

下面这段场景先用 go run 跑出真实结果,再逐项比对生成的轨迹:两个 slice 共享同一个底层数组,其中一个的 append 会在你完全没有碰它的情况下,悄悄改变另一个的内容。

点击"下一步"或"播放"开始。
0 / 0
s1 当前覆盖的区间(len 以内) s2 当前覆盖的区间(len 以内) 这一步刚被写入/搬迁的格子

04 · 扩容公式

2 倍,还是 1.25 倍

要不要扩容、扩多大,由 nextslicecap 决定——这也是整篇文章里最常被问到的一段源码:

slice.go · L326-L358在 GitHub 上查看 ↗
func nextslicecap(newLen, oldCap int) int {
	newcap := oldCap
	doublecap := newcap + newcap
	if newLen > doublecap {
		return newLen
	}

	const threshold = 256
	if oldCap < threshold {
		return doublecap
	}
	for {
		// 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) >> 2

		// We need to check `newcap >= newLen` and whether `newcap` overflowed.
		// newLen is guaranteed to be larger than zero, hence
		// when newcap overflows then `uint(newcap) > uint(newLen)`.
		// This allows to check for both with the same comparison.
		if uint(newcap) >= uint(newLen) {
			break
		}
	}

	// Set newcap to the requested cap when
	// the newcap calculation overflowed.
	if newcap <= 0 {
		return newLen
	}
	return newcap
}

规则很直白:原容量小于 256 时直接翻倍;超过 256 之后,翻倍的代价开始变得明显,改成每次增长约 1.25 倍,用一个连续的公式在两种增长率之间做平滑过渡,而不是在 256 这个点上有个突兀的转折。用一个 []byte 实测(go run,Go 1.26)增长轨迹长这样:

go run 真实输出(逐次触发扩容时打印)已验证
len=  1 cap= 32  (grew from 0)
len= 33 cap= 64  (grew from 32)
len= 65 cap=128  (grew from 64)
len=129 cap=256  (grew from 128)
len=257 cap=512  (grew from 256)
len=513 cap=896  (grew from 512)

32→64→128→256→512 都还是干净的翻倍,直到 512→896——涨幅只有 1.75 倍而不是精确的 2 倍或 1.25 倍。这就引出下一节的内容:实际拿到的 cap,还要再被内存分配器修正一轮。

05 · 为什么 cap 有时候"不是整数倍"

size class 取整

nextslicecap 算出来的只是一个"期望容量"。真正申请内存时,growslice 还会调用 roundupsize,把字节数向上取整到内存分配器预先划分好的一个"规格"(size class)——这是 Go 内存分配器为了减少碎片、加速分配统一做的事,不只是切片在用。

slice.go · L237-L244(growslice 节选)在 GitHub 上查看 ↗
	default:
		lenmem = uintptr(oldLen) * et.Size_
		newlenmem = uintptr(newLen) * et.Size_
		capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
		capmem = roundupsize(capmem, noscan)
		newcap = int(capmem / et.Size_)
		capmem = uintptr(newcap) * et.Size_
	}

所以上一节 512→896 这一跳,其实是 nextslicecap 先算出约 832,再被 roundupsize 向上修正到分配器里最接近的规格 896。也正因为这样,永远不要假设 append 之后的 cap 一定等于你手算出来的那个数——它可能因为取整而更大。

06 · growslice 做了什么

分配、搬迁、脱钩

slice.go · L134-L165(growslice 文档注释)在 GitHub 上查看 ↗
// growslice allocates new backing store for a slice.
//
// arguments:
//
//	oldPtr = pointer to the slice's backing array
//	newLen = new length (= oldLen + num)
//	oldCap = original slice's capacity.
//	   num = number of elements being added
//	    et = element type
//
// return values:
//
//	newPtr = pointer to the new backing store
//	newLen = same value as the argument
//	newCap = capacity of the new backing store
//
// Requires that uint(newLen) > uint(oldCap).
// Assumes the original slice length is newLen - num
//
// A new backing store is allocated with space for at least newLen elements.
// Existing entries [0, oldLen) are copied over to the new backing store.
// Added entries [oldLen, newLen) are not initialized by growslice
// (although for pointer-containing element types, they are zeroed). They
// must be initialized by the caller.
// Trailing entries [newLen, newCap) are zeroed.
//
// growslice's odd calling convention makes the generated code that calls
// this function simpler. In particular, it accepts and returns the
// new length so that the old length is not live (does not need to be
// spilled/restored) and the new length is returned (also does not need
// to be spilled/restored).
//

对应模拟器里的那次扩容:s2 的 len 已经追上 cap(4),再 append 一个元素就必须先申请新数组,把 s2 当前能看到的那几个元素原样搬过去,新元素写在后面,剩下的容量留空(置零)。搬完之后 s2 的切片头整体换成指向新数组——它和 s1 之间那种"改一个影响另一个"的关系,到这一步彻底结束。

07 · copy 呢

内置函数 copy 只是老实地搬数据

append 不一样,copy(dst, src) 从不扩容、也从不分配内存——它只是把 min(len(dst), len(src)) 个元素从 src 搬到 dst 已有的空间里,搬不下的部分直接被忽略:

slice.go · L391-L406, L422-L429(节选)在 GitHub 上查看 ↗
// slicecopy is used to copy from a string or slice of pointerless elements into a slice.
func slicecopy(toPtr unsafe.Pointer, toLen int, fromPtr unsafe.Pointer, fromLen int, width uintptr) int {
	if fromLen == 0 || toLen == 0 {
		return 0
	}

	n := fromLen
	if toLen < n {
		n = toLen
	}

	if width == 0 {
		return n
	}

	size := uintptr(n) * width
	...
	if size == 1 { // common case worth about 2x to do here
		// TODO: is this still worth it with new memmove impl?
		*(*byte)(toPtr) = *(*byte)(fromPtr) // known to be a byte pointer
	} else {
		memmove(toPtr, fromPtr, size)
	}
	return n
}

这也是为什么"深拷贝一个 slice、和原来彻底脱离关系"的标准写法是 dst := make([]T, len(src)); copy(dst, src)——先用 make 要一块全新的内存,再用 copy 把值搬过去,两步都不涉及共享。

08 · 两个经典的坑

顺手提两个常见错误

三索引切片:主动斩断共享
full := make([]int, 10)
danger := full[2:5]       // cap = 8,别人 append 时可能悄悄写到 full[5] 往后
safe := full[2:5:5]       // cap = 3(= 5-2),len 已经等于 cap
                           // 别人一 append 就必然扩容,不会碰到 full 的其它部分

09 · 参考与说明

引用与这个模拟器做了哪些简化

☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电