channel、map、slice作为golang的核心三剑客,对于使用golang作为主语言完成开发工作的程序猿来说是非常重要的。了解其设计和源码是使用的基础,因此笔者本专题会对这三种数据结构的源码进行详细的介绍和解析…(算是集大家所长,加上自己的一点见解),若有帮助,求点赞关注。
const PtrSize = 4 << (^uintptr(0) >> 63) // 在64位机上值为 8
const MaxUintptr = ^uintptr(0) // 在64位机上值为0xffff ffff ffff ffff ffff ffff ffff ffff
type slice struct {
array unsafe.Pointer // 指向数组的指针,一块连续内存,存放的是slice的数据
len int // 记录slice当前长度
cap int // 记录slice当前容量
}
Go 语言中包含三种初始化切片的方式:
arr[0:3] or slice[0:3]
slice := []int{1, 2, 3}
slice := make([]int, 10)
使用下标创建切片是最原始也最接近汇编语言的方式,它是所有方法中最为底层的一种,编译器会将 arr[0:3] 或者 slice[0:3] 等语句转换成 OpSliceMake 操作,我们可以通过下面的代码来验证一下:
// ch03/op_slice_make.go
package opslicemake
func newSlice() []int {
arr := [3]int{1, 2, 3}
slice := arr[0:1]return slice
}
通过 GOSSAFUNC 变量编译上述代码可以得到一系列 SSA 中间代码,其中 slice := arr[0:1] 语句在 “decompose builtin” 阶段对应的代码如下所示:
v27 (+5) = SliceMake <[]int> v11 v14 v17
name &arr[*[3]int]: v11
name slice.ptr[*int]: v11
name slice.len[int]: v14
name slice.cap[int]: v17
SliceMake 操作会接受四个参数创建新的切片,元素类型、数组指针、切片大小和容量,这也是我们在数据结构一节中提到的切片的几个字段 ,需要注意的是使用下标初始化切片不会拷贝原数组或者原切片中的数据,它只会创建一个指向原数组的切片结构体,所以修改新切片的数据也会修改原切片。
当我们使用字面量 []int{1, 2, 3} 创建新的切片时,cmd/compile/internal/gc.slicelit 函数会在编译期间将它展开成如下所示的代码片段:
var vstat [3]int
vstat[0] = 1
vstat[1] = 2
vstat[2] = 3
var vauto *[3]int = new([3]int)
*vauto = vstat
slice := vauto[:]
如果使用字面量的方式创建切片,大部分的工作都会在编译期间完成。但是当我们使用 make 关键字创建切片时,很多工作都需要运行时的参与;调用方必须向 make 函数传入切片的大小以及可选的容量,类型检查期间的 cmd/compile/internal/gc.typecheck1 函数会校验入参:
func typecheck1(n *Node, top int) (res *Node) {switch n.Op {...case OMAKE:
args := n.List.Slice()
i := 1switch t.Etype {case TSLICE:if i >= len(args) {yyerror("missing len argument to make(%v)", t)return n
}
l = args[i]
i++var r *Node
if i < len(args) {
r = args[i]}...if Isconst(l, CTINT) && r != nil && Isconst(r, CTINT) && l.Val().U.(*Mpint).Cmp(r.Val().U.(*Mpint)) > 0 {yyerror("len larger than cap in make(%v)", t)return n
}
n.Left = l
n.Right = r
n.Op = OMAKESLICE
}...}
}
上述函数不仅会检查 len 是否传入,还会保证传入的容量 cap 一定大于或者等于 len。除了校验参数之外,当前函数会将 OMAKE 节点转换成 OMAKESLICE,中间代码生成的 cmd/compile/internal/gc.walkexpr 函数会依据下面两个条件转换 OMAKESLICE 类型的节点:
var arr [4]int
n := arr[:3]
上述代码会初始化数组并通过下标 [:3] 得到数组对应的切片,这两部分操作都会在编译阶段完成,编译器会在栈上或者静态存储区创建数组并将 [:3] 转换成上一节提到的 OpSliceMake 操作。
分析了主要由编译器处理的分支之后,我们回到用于创建切片的运行时函数 runtime.makeslice,这个函数的实现很简单:
// input: et: slice类型元信息,slice长度,slice容量
func makeslice(et *_type, len, cap int) unsafe.Pointer {
// 调用MulUintptr函数:获取创建该切片需要的内存,是否溢出(超过2^64)
// 2^64是64位机能够表示的最大内存地址
mem, overflow := math.MulUintptr(et.size, uintptr(cap))
// 如果溢出 | 超过能够分配的最大内存(2^32 - 1) | 非法输入, 报错并返回
if overflow || mem > maxAlloc || len < 0 || len > cap {
mem, overflow := math.MulUintptr(et.size, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen()
}
panicmakeslicecap()
}
// 调用mallocgc函数分配一块连续内存并返回该内存的首地址
// 该函数实现涉及到了go语言内存管理,比较复杂,不是本文的主题
// 后面会单独介绍
return mallocgc(mem, et, true)
}
上述函数的主要工作是计算切片占用的内存空间并在堆上申请一片连续的内存,它使用如下的方式计算占用的内存:
内存空间=切片中元素大小×切片容量内存空间=切片中元素大小×切片容量
虽然编译期间可以检查出很多错误,但是在创建切片的过程中如果发生了以下错误会直接触发运行时错误并崩溃:
runtime.makeslice 在最后调用的 runtime.mallocgc 是用于申请内存的函数,这个函数的实现还是比较复杂,如果遇到了比较小的对象会直接初始化在 Go 语言调度器里面的 P 结构中,而大于 32KB 的对象会在堆上初始化。
在之前版本的 Go 语言中,数组指针、长度和容量会被合成一个 runtime.slice 结构,但是从 cmd/compile: move slice construction to callers of makeslice 提交之后,构建结构体 reflect.SliceHeader 的工作就都交给了 runtime.makeslice 的调用方,该函数仅会返回指向底层数组的指针,调用方会在编译期间构建切片结构体:
func typecheck1(n *Node, top int) (res *Node) {switch n.Op {...case OSLICEHEADER:switch
t := n.Type
n.Left = typecheck(n.Left, ctxExpr)
l := typecheck(n.List.First(), ctxExpr)
c := typecheck(n.List.Second(), ctxExpr)
l = defaultlit(l, types.Types[TINT])
c = defaultlit(c, types.Types[TINT])
n.List.SetFirst(l)
n.List.SetSecond(c)...}
}
OSLICEHEADER 操作会创建我们在上面介绍过的结构体 reflect.SliceHeader,其中包含数组指针、切片长度和容量,它是切片在运行时的表示:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
正是因为大多数对切片类型的操作并不需要直接操作原来的 runtime.slice 结构体,所以 reflect.SliceHeader 的引入能够减少切片初始化时的少量开销,该改动不仅能够减少 ~0.2% 的 Go 语言包大小,还能够减少 92 个 runtime.panicIndex 的调用,占 Go 语言二进制的 ~3.5%。
slice相比于array的一大优点就是可以根据使用情况动态的进行扩容,来适应随时增加的数据,在追加时,通过调用append函数来针对slice进行尾部追加,如果此时slice的cap值小于当前len加上append中传入值的数量,那么就会出发扩容操作,append函数没有明确的函数体,而是通过编译期间被转换。当append发现需要扩容时,则会调用runtime.growslice方法,该方法源代码如下(以去除一些无用代码):
func growslice(et *_type, old slice, cap int) slice {
// 如果需求的容量小于就容量则报错
// 理论上来讲不应该出现这个问题
if cap < old.cap {
panic(errorString("growslice: cap out of range"))
}
// append 没法创建一个nil指针的但是len不为0的切片
if et.size == 0 {
return slice{unsafe.Pointer(&zerobase), old.len, cap}
}
newcap := old.cap
doublecap := newcap + newcap
// 如果需求容量大于双倍的旧容量那就直接使用需求容量
if cap > doublecap {
newcap = cap
} else {
// 如果当前len小于1024则容量直接翻倍,否则按照1.25倍去递增直到满足需求容量
if old.len < 1024 {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
if newcap <= 0 {
newcap = cap
}
}
}
var overflow bool
var lenmem, newlenmem, capmem uintptr
// 在扩容时不能单单按照len来判断扩容所需要的内存长度
// 还要根据切片的元素类型去进行内存对齐
// 当元素的占用字节数为1,8 或者2的倍数时会进行内存对对齐
// 内存对齐策略按照向上取整方式进行
// 取整的目标时go内存分配策略中67个class分页中的大小进行取整
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)
}
// 如果所需要的内存超过了最大可分配内存则panic
if overflow || capmem > maxAlloc {
panic(errorString("growslice: cap out of range"))
}
var p unsafe.Pointer
// 如果当前元素类型不是指针,则会将超出切片当前长度的位置清空
// 并在最后使用 将原数组内存中的内容拷贝到新申请的内存中。
if et.ptrdata == 0 {
p = mallocgc(capmem, nil, false)
memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
} else {
// 如果是指针会根据进行gc方面对其进行加以保护以免空指针在分配期间被gc回收
p = mallocgc(capmem, et, true)
if lenmem > 0 && writeBarrier.enabled {
bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem-et.size+et.ptrdata)
}
}
memmove(p, old.array, lenmem)
//该函数最终会返回一个新的切片
return slice{p, old.len, newcap}
}
重点在于是否已经扩容,为扩容之前的修改会影响原切片
package main
import "fmt"
func main() {
slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := slice[2:5]
s2 := s1[2:7]
fmt.Printf("len=%-4d cap=%-4d slice=%-1v n", len(slice), cap(slice), slice)
fmt.Printf("len=%-4d cap=%-4d s1=%-1v n", len(s1), cap(s1), s1)
fmt.Printf("len=%-4d cap=%-4d s2=%-1v n", len(s2), cap(s2), s2)
}
// 输出
// len=10 cap=10 slice=[0 1 2 3 4 5 6 7 8 9]
// len=3 cap=8 s1=[2 3 4]
// len=5 cap=6 s2=[4 5 6 7 8]
s1 的长度变成 3,cap 变为 8(默认截取到最大容量), 但是 s2 截取 s1 的第 2 到第 7 个元素,左闭右开,很多人想问,s1 根本没有那么元素啊,但是实际情况是 s2 截取到了,并且没有发生数组越界,原因就是 s2 实际截取的是底层数组,目前 slice、s1、s2 都是共用的同一个底层数组。
我们看到往 s2 里 append 数据影响到了 slice,正是因为两者底层数组是一样的;但是既然都是共用的同一底层数组,s1 为什么没有 100?我们继续进行操作:
fmt.Println("--------append 200----------------")
s2 = append(s2, 200)
输出结果是:
--------append 200----------------
len=10 cap=10 slice=[0 1 2 3 4 5 6 7 8 100]
len=3 cap=8 s1=[2 3 4]
len=7 cap=12 s2=[4 5 6 7 8 100 200]
我们看到继续往 s2 中 append 一个 200,但是只有 s2 发生了变化,slice 并未改变,为什么呢?对,是因为在 append 完 100 后,s2 的容量已满,再往 s2 中 append,底层数组发生复制,系统分配了一块新的内存地址给 s2,s2 的容量也翻倍了。
我们继续操作:
fmt.Println("--------modify s1----------------")
s1[2] = 20
输出会是什么样呢?
--------modify s1----------------
len=10 cap=10 slice=[0 1 2 3 20 5 6 7 8 100]
len=3 cap=8 s1=[2 3 20]
len=7 cap=12 s2=[4 5 6 7 8 100 200]
这就很容易理解了,我们对 s1 进行更新,影响了 slice,因为两者共用的还是同一底层数组,s2 未发生改变是因为在上一步时底层数组已经发生了变化;
以此来看,slice 截取的坑确实很多,极容易出现 bug,并且难以排查,大家在使用的时候一定注意。
copy是深拷贝
如果源切片或者目标切片有一个长度为0,那么就不需要拷贝
对 slice 进行的截取,新的 slice 和原始 slice 共用同一个底层数组,因此可以看作是对 slice 的浅拷贝,那么在 go 中如何实现对 slice 的深拷贝呢?那么就要依赖 golang 提供的 copy 函数了,我们用一段程序来简单看下如何实现深拷贝:
func main() {
// Creating slices
slice1 := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
var slice2 []int
slice3 := make([]int, 5)
// Before copying
fmt.Println("------------before copy-------------")
fmt.Printf("len=%-4d cap=%-4d slice1=%vn", len(slice1), cap(slice1), slice1)
fmt.Printf("len=%-4d cap=%-4d slice2=%vn", len(slice2), cap(slice2), slice2)
fmt.Printf("len=%-4d cap=%-4d slice3=%vn", len(slice3), cap(slice3), slice3)
// Copying the slices
copy_1 := copy(slice2, slice1)
fmt.Println()
fmt.Printf("len=%-4d cap=%-4d slice1=%vn", len(slice1), cap(slice1), slice1)
fmt.Printf("len=%-4d cap=%-4d slice2=%vn", len(slice2), cap(slice2), slice2)
fmt.Println("Total number of elements copied:", copy_1)
}
首先定义了三个 slice,然后将 slice1 copy 到 slice2,我们来看下输出结果:
------------before copy-------------
len=10 cap=10 slice1=[0 1 2 3 4 5 6 7 8 9]
len=0 cap=0 slice2=[]
len=5 cap=5 slice3=[0 0 0 0 0]
len=10 cap=10 slice1=[0 1 2 3 4 5 6 7 8 9]
len=0 cap=0 slice2=[]
Total number of elements copied: 0
我们发现 slice1 的内容并未 copy 到 slice2,为什么呢?我们再试下将 slice1 copy 到 slice3,如下:
copy_2 := copy(slice3, slice1)
输出结果:
len=10 cap=10 slice1=[0 1 2 3 4 5 6 7 8 9]
len=5 cap=5 slice3=[0 1 2 3 4]
Total number of elements copied: 5
我们看到 copy 成功,slice3 和 slice2 唯一的区别就是 slice3 的容量为 5,而 slice2 容量为 0,那么是否是深拷贝呢,我们修改 slice3 的内容看下:
slice3[0] = 100
我们再看下输出结果:
len=10 cap=10 slice1=[0 1 2 3 4 5 6 7 8 9]
len=5 cap=5 slice3=[100 1 2 3 4]
我们可以看到修改 slice3 后,slice1 的值并未改变,可见 copy 实现的是深拷贝。由此可见,copy 函数为 slice 提供了深拷贝能力,但是需要在拷贝前申请内存空间。参照 makeslice 和 growslice 我们对本节一开始的程序进行反汇编,得到汇编代码(部分)如下:
0x0080 00128 (slice.go:10) CALL runtime.makeslice(SB)
0x0085 00133 (slice.go:10) PCDATA $0, $1
0x0085 00133 (slice.go:10) MOVQ 24(SP), AX
0x008a 00138 (slice.go:10) PCDATA $1, $2
0x008a 00138 (slice.go:10) MOVQ AX, ""..autotmp_75+96(SP)
0x008f 00143 (slice.go:11) PCDATA $0, $4
0x008f 00143 (slice.go:11) MOVQ ""..autotmp_74+104(SP), CX
0x0094 00148 (slice.go:11) CMPQ AX, CX
0x0097 00151 (slice.go:11) JEQ 176
0x0099 00153 (slice.go:11) PCDATA $0, $5
0x0099 00153 (slice.go:11) MOVQ AX, (SP)
0x009d 00157 (slice.go:11) PCDATA $0, $0
0x009d 00157 (slice.go:11) MOVQ CX, 8(SP)
0x00a2 00162 (slice.go:11) MOVQ $40, 16(SP)
0x00ab 00171 (slice.go:11) CALL runtime.memmove(SB)
0x00b0 00176 (slice.go:12) MOVQ $10, (SP)
0x00b8 00184 (slice.go:12) CALL runtime.convT64(SB)
我们发现 copy 函数其实是调用 runtime.memmove,其实我们在研究 runtime/slice.go 文件中的源码的时候,会发现有一个 slicecopy 函数,这个函数最终就是调用 runtime.memmove 来实现 slice 的 copy 的,我们看下源码:
func slicecopy(to, fm slice, width uintptr) int {
// 如果源切片或者目标切片有一个长度为0,那么就不需要拷贝,直接 return
if fm.len == 0 || to.len == 0 {
return 0
}
// n 记录下源切片或者目标切片较短的那一个的长度
n := fm.len
if to.len < n {
n = to.len
}
// 如果入参 width = 0,也不需要拷贝了,返回较短的切片的长度
if width == 0 {
return n
}
//如果开启竞争检测
if raceenabled {
callerpc := getcallerpc()
pc := funcPC(slicecopy)
racewriterangepc(to.array, uintptr(n*int(width)), callerpc, pc)
racereadrangepc(fm.array, uintptr(n*int(width)), callerpc, pc)
}
if msanenabled {
msanwrite(to.array, uintptr(n*int(width)))
msanread(fm.array, uintptr(n*int(width)))
}
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)(to.array) = *(*byte)(fm.array) // known to be a byte pointer
} else {
//如果不止一个元素,那么就从 fm.array 地址开始,拷贝到 to.array 地址之后,拷贝个数为size
memmove(to.array, fm.array, size)
}
return n
}
源码解读见中文注释。