Go源码分析——Slice篇

channel、map、slice作为golang的核心三剑客,对于使用golang作为主语言完成开发工作的程序猿来说是非常重要的。了解其设计和源码是使用的基础,因此笔者本专题会对这三种数据结构的源码进行详细的介绍和解析…(算是集大家所长,加上自己的一点见解),若有帮助,求点赞关注。

Go源码分析专栏

Go源码解析——Channel篇

Go源码分析——Map篇

Go源码分析——Slice篇


文章目录

  • Go源码分析专栏
    • Go源码解析——Channel篇
    • Go源码分析——Map篇
    • Go源码分析——Slice篇
  • 1.前置知识
  • 2.slice struct
  • 3.初始化
    • 3.1 下标
    • 3.2 字面量
    • 3.3 关键字
  • 4.扩容-growslice
  • 5.slice截取
  • 6.深拷贝
  • 8.参考文档


1.前置知识

  • unsafe.Pointer
    该类型是一个指针类型,并且任何类型的指针值都可以转换为该类型,该类型可以转换为任何类型的指针值。更多介绍可以直接查看src/unsafe/unsafe.go中对Pointer的注释,非常详细
  • _type
    用于记录slice一些信息,其中的size字段就记录了该slice类型的大小(int64类型slice中size字段为8)
  • uintptr
    该类型是GoLang的内置类型,在64位机上可以理解为一个64位无符号整数,能够表示64位机上所有的地址
  • PtrSize
    src/runtime/internal/sys中定义的常量:
const PtrSize = 4 << (^uintptr(0) >> 63)  // 在64位机上值为 8
  • MaxUintptr
    runtime/internal/math/math.go中定义的常量:
const MaxUintptr = ^uintptr(0)   // 在64位机上值为0xffff ffff ffff ffff ffff ffff ffff ffff
  • maxAlloc
    runtime/malloc.go中定义的常量,根据源码注释:在64位机上取值为2^32 - 1
  • receenabled
    是否开启race竞态检测,默认为false,当使用build -race时为true
  • msanenabled
    使用build -msan时为true,windows amd64位机不支持该命令

2.slice struct

Go源码分析——Slice篇_第1张图片

 type slice struct {
   array unsafe.Pointer    // 指向数组的指针,一块连续内存,存放的是slice的数据
   len   int    // 记录slice当前长度
   cap   int    // 记录slice当前容量
}

3.初始化

Go 语言中包含三种初始化切片的方式:​

  1. 通过下标的方式获得数组或者切片的一部分;​
  2. 使用字面量初始化新的切片;​
  3. 使用关键字 make 创建切片:
arr[0:3] or slice[0:3]
slice := []int{1, 2, 3}
slice := make([]int, 10)

3.1 下标

使用下标创建切片是最原始也最接近汇编语言的方式,它是所有方法中最为底层的一种,编译器会将 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 操作会接受四个参数创建新的切片,元素类型、数组指针、切片大小和容量,这也是我们在数据结构一节中提到的切片的几个字段 ,需要注意的是使用下标初始化切片不会拷贝原数组或者原切片中的数据,它只会创建一个指向原数组的切片结构体,所以修改新切片的数据也会修改原切片。

3.2 字面量

当我们使用字面量 []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[:]
  1. 根据切片中的元素数量对底层数组的大小进行推断并创建一个数组;​
  2. 将这些字面量元素存储到初始化的数组中;​
  3. 创建一个同样指向 [3]int 类型的数组指针;​
  4. 将静态存储区的数组 vstat 赋值给 vauto 指针所在的地址;​
  5. 通过 [:] 操作获取一个底层使用 vauto 的切片;​
  6. 第 5 步中的 [:] 就是使用下标创建切片的方法,从这一点我们也能看出 [:] 操作是创建切片最底层的一种方法。

3.3 关键字

如果使用字面量的方式创建切片,大部分的工作都会在编译期间完成。但是当我们使用 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 类型的节点:​

  1. 切片的大小和容量是否足够小;​
  2. 切片是否发生了逃逸,最终在堆上初始化​
  3. 当切片发生逃逸或者非常大时,运行时需要 runtime.makeslice 在堆上初始化切片,如果当前的切片不会发生逃逸并且切片非常小的时候,make([]int, 3, 4) 会被直接转换成如下所示的代码:
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)
}

上述函数的主要工作是计算切片占用的内存空间并在堆上申请一片连续的内存,它使用如下的方式计算占用的内存:

内存空间=切片中元素大小×切片容量内存空间=切片中元素大小×切片容量

虽然编译期间可以检查出很多错误,但是在创建切片的过程中如果发生了以下错误会直接触发运行时错误并崩溃:

  1. 内存空间的大小发生了溢出;
  2. 申请的内存大于最大可分配的内存;
  3. 传入的长度小于 0 或者长度大于容量;

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%。

4.扩容-growslice

  • 重新申请内存,之后将数据赋值过来
  • 当原切片cap<1024时,<新cap> = 2 * <老cap>
  • 当原切片cap>1025时,<新cap> = 1.25*<老cap>
  • 之后进行内存对齐,内存对齐相关可参考【Golang】详解内存对齐

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}
}

5.slice截取

重点在于是否已经扩容,为扩容之前的修改会影响原切片

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,并且难以排查,大家在使用的时候一定注意。​

6.深拷贝

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
    }

源码解读见中文注释。

8.参考文档

  • GoLang学习之slice源码剖析
  • golang之slice切片源码解析_地鼠工程师的博客-CSDN博客
  • 「Golang」Slice源码讲解__ Echo_的博客-CSDN博客_golang slice源码
  • 【golang】slice源码分析
  • 在Go中如何转储一个方法的GOSSAFUNC图-51CTO.COM

你可能感兴趣的:(golang源码解析,golang,开发语言,后端)