切片扩容 传递切片 切片扩容前后 append原理 是否发生了逃逸,最终在堆上初始化

实践:

 

将切片赋值为nil,仍旧可以append,地址发生了变化

	var list []*int
	log.Println("list==nil", list == nil)
	var lk sync.Mutex
	lk.Lock()
	i := 1
	list = append(list, &i)
	lk.Unlock()
	go func() {
		for {
			lk.Lock()
			i := 2
			list = append(list, &i)
			log.Println("a list", list," &list=",&list)
			lk.Unlock()
			<-time.After(time.Second)
		}
	}()
	go func() {
		for {
			var list2 []*int
			lk.Lock()
			list2 = append(list2, list...)
			list = nil
			log.Println("b list", list)
			lk.Unlock()
			for i, v := range list2 {
				log.Println(i, "---", v)
			}
			<-time.After(time.Second)
		}
	}()
	for {
		log.Println("wait")
		<-time.After(time.Second)
	}

  

 

 

	s := []int{1, 2}
	s = append(s, 1, 2, 3)          // 6 不是5
	s = append(s, 1, 2, 3, 4)       // 12
	s = append(s, 1, 2, 3, 4, 5, 6) // 24
	s = append(s, 1, 2, 3, 4, 5, 6) //  24
	if 11 > 2 {
		s := []int{}                    // 0
		s = append(s, 1)                // 1
		s = append(s, 1, 2)             // 3 不是4
		s = append(s, 1, 2, 3, 4)       // 8
		s = append(s, 1, 2, 3, 4, 5, 6) // 16
		s = append(s, 1, 2, 3, 4, 5, 6) // 32
		_ = s
	}
	if 11 > 2 {
		s := make([]int, 1024) // 1024
		s = append(s, 1)       // 1536=1024+512 (1.5)
		s = append(s, 1, 2)    // 1536
		for i := 0; i < 508; i++ {
			s = append(s, 1)
		} // 1536
		s = append(s, 1) // 1536
		s = append(s, 1) // 2304=1536+768 (1.5)
		_ = s
	}

  

 

cap值的差异 
 
2、
 
	var f1 func(*[]int) = func(i *[]int) {
		(*i)[0] = 123
		*i = append(*i, 3, 4)
		(*i)[1] = 456
	}
	var f2 func([]int) = func(i []int) {
		i[0] = 123
		i = append(i, 3, 4)
		i[1] = 456
	}
	s1 := []int{1, 2}
	s2 := []int{1, 2}
	f1(&s1)
	f2(s2)

	if 11 > 2 {
		var f1 func(*[]int) = func(i *[]int) {
			(*i)[0] = 123
			*i = append(*i, 2)
		}
		var f2 func([]int) = func(i []int) {
			i[0] = 123
			i = append(i, 2)
		}
		s1 := []int{1}
		s2 := []int{1}
		f1(&s1)
		f2(s2)

		/*
			切片扩容前,
			  传递切片:也修改了原切片
			  传递切片指针:也修改了原切片
			切片扩容后,
			  传递切片:没有修改原切片
			  传递切片指针:也修改了原切片
		*/
		print()
	}

  

 

 

 

    // 切片扩容前,
    //   传递切片:也修改了原切片
    //   传递切片指针:也修改了原切片
    // 切片扩容后,
    //   传递切片:没有修改原切片
    //   传递切片指针:也修改了原切片

 

 源码:
src\builtin\builtin.go
// The append built-in function appends elements to the end of a slice. If
// it has sufficient capacity, the destination is resliced to accommodate the
// new elements. If it does not, a new underlying array will be allocated.
// Append returns the updated slice. It is therefore necessary to store the
// result of append, often in the variable holding the slice itself:
//	slice = append(slice, elem1, elem2)
//	slice = append(slice, anotherSlice...)
// As a special case, it is legal to append a string to a byte slice, like this:
//	slice = append([]byte("hello "), "world"...)
func append(slice []Type, elems ...Type) []Type

  

 
 
小结:
1、
切片扩容时,当需要的容量超过原切片容量的两倍时,会直接使用需要的容量作为新容量。否则,当原切片长度小于1024时,新切片的容量会直接翻倍。而当原切片的容量大于等于1024时,会反复地增加25%,直到新容量超过所需要的容量。
 
 

3.2 切片 #

各位读者朋友,很高兴大家通过本博客学习 Go 语言,感谢一路相伴!《Go语言设计与实现》的纸质版图书已经上架京东,有需要的朋友请点击 链接 购买。

上一节介绍的数组在 Go 语言中没那么常用,更常用的数据结构是切片,即动态数组,其长度并不固定,我们可以向切片中追加元素,它会在容量不足时自动扩容。

在 Go 语言中,切片类型的声明方式与数组有一些相似,不过由于切片的长度是动态的,所以声明时只需要指定切片中的元素类型:

[]int
[]interface{}
Go

从切片的定义我们能推测出,切片在编译期间的生成的类型只会包含切片中的元素类型,即 int 或者 interface{} 等。cmd/compile/internal/types.NewSlice 就是编译期间用于创建切片类型的函数:

func NewSlice(elem *Type) *Type {
	if t := elem.Cache.slice; t != nil {
		if t.Elem() != elem {
			Fatalf("elem mismatch")
		}
		return t
	}

	t := New(TSLICE)
	t.Extra = Slice{Elem: elem}
	elem.Cache.slice = t
	return t
}
Go

上述方法返回结构体中的 Extra 字段是一个只包含切片内元素类型的结构,也就是说切片内元素的类型都是在编译期间确定的,编译器确定了类型之后,会将类型存储在 Extra 字段中帮助程序在运行时动态获取。

3.2.1 数据结构 #

编译期间的切片是 cmd/compile/internal/types.Slice 类型的,但是在运行时切片可以由如下的 reflect.SliceHeader 结构体表示,其中:

  • Data 是指向数组的指针;
  • Len 是当前切片的长度;
  • Cap 是当前切片的容量,即 Data 数组的大小:
type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}
Go

Data 是一片连续的内存空间,这片内存空间可以用于存储切片中的全部元素,数组中的元素只是逻辑上的概念,底层存储其实都是连续的,所以我们可以将切片理解成一片连续的内存空间加上长度与容量的标识。

golang-slice-struct

图 3-3 Go 语言切片结构体

从上图中,我们会发现切片与数组的关系非常密切,切片引入了一个抽象层,提供了对数组中部分连续片段的引用,而作为数组的引用,我们可以在运行区间可以修改它的长度和范围。当切片底层的数组长度不足时就会触发扩容,切片指向的数组可能会发生变化,不过在上层看来切片是没有变化的,上层只需要与切片打交道不需要关心数组的变化。

我们在上一节介绍过编译器在编译期间简化了获取数组大小、读写数组中的元素等操作:因为数组的内存固定且连续,多数操作都会直接读写内存的特定位置。但是切片是运行时才会确定内容的结构,所有操作还需要依赖 Go 语言的运行时,下面的内容会结合运行时介绍切片常见操作的实现原理。

3.2.2 初始化 #

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

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

使用下标 #

使用下标创建切片是最原始也最接近汇编语言的方式,它是所有方法中最为底层的一种,编译器会将 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
}
Go

通过 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
Go

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[:]
Go
  1. 根据切片中的元素数量对底层数组的大小进行推断并创建一个数组;
  2. 将这些字面量元素存储到初始化的数组中;
  3. 创建一个同样指向 [3]int 类型的数组指针;
  4. 将静态存储区的数组 vstat 赋值给 vauto 指针所在的地址;
  5. 通过 [:] 操作获取一个底层使用 vauto 的切片;

第 5 步中的 [:] 就是使用下标创建切片的方法,从这一点我们也能看出 [:] 操作是创建切片最底层的一种方法。

关键字 #

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

上述函数不仅会检查 len 是否传入,还会保证传入的容量 cap 一定大于或者等于 len。除了校验参数之外,当前函数会将 OMAKE 节点转换成 OMAKESLICE,中间代码生成的 cmd/compile/internal/gc.walkexpr 函数会依据下面两个条件转换 OMAKESLICE 类型的节点:

  1. 切片的大小和容量是否足够小;
  2. 切片是否发生了逃逸,最终在堆上初始化

当切片发生逃逸或者非常大时,运行时需要 runtime.makeslice 在堆上初始化切片,如果当前的切片不会发生逃逸并且切片非常小的时候,make([]int, 3, 4) 会被直接转换成如下所示的代码:

var arr [4]int
n := arr[:3]
Go

上述代码会初始化数组并通过下标 [:3] 得到数组对应的切片,这两部分操作都会在编译阶段完成,编译器会在栈上或者静态存储区创建数组并将 [:3] 转换成上一节提到的 OpSliceMake 操作。

分析了主要由编译器处理的分支之后,我们回到用于创建切片的运行时函数 runtime.makeslice,这个函数的实现很简单:

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 {
		mem, overflow := math.MulUintptr(et.size, uintptr(len))
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

	return mallocgc(mem, et, true)
}
Go

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

 

&lt;span id="MathJax-Span-2" class="mrow"&gt;&lt;span id="MathJax-Span-3" class="texatom"&gt;&lt;span id="MathJax-Span-4" class="mrow"&gt;&lt;span id="MathJax-Span-5" class="mo"&gt;内&lt;span id="MathJax-Span-6" class="texatom"&gt;&lt;span id="MathJax-Span-7" class="mrow"&gt;&lt;span id="MathJax-Span-8" class="mo"&gt;存&lt;span id="MathJax-Span-9" class="texatom"&gt;&lt;span id="MathJax-Span-10" class="mrow"&gt;&lt;span id="MathJax-Span-11" class="mo"&gt;空&lt;span id="MathJax-Span-12" class="texatom"&gt;&lt;span id="MathJax-Span-13" class="mrow"&gt;&lt;span id="MathJax-Span-14" class="mo"&gt;间&lt;span id="MathJax-Span-15" class="mo"&gt;=&lt;span id="MathJax-Span-16" class="texatom"&gt;&lt;span id="MathJax-Span-17" class="mrow"&gt;&lt;span id="MathJax-Span-18" class="mo"&gt;切&lt;span id="MathJax-Span-19" class="texatom"&gt;&lt;span id="MathJax-Span-20" class="mrow"&gt;&lt;span id="MathJax-Span-21" class="mo"&gt;片&lt;span id="MathJax-Span-22" class="texatom"&gt;&lt;span id="MathJax-Span-23" class="mrow"&gt;&lt;span id="MathJax-Span-24" class="mo"&gt;中&lt;span id="MathJax-Span-25" class="texatom"&gt;&lt;span id="MathJax-Span-26" class="mrow"&gt;&lt;span id="MathJax-Span-27" class="mo"&gt;元&lt;span id="MathJax-Span-28" class="texatom"&gt;&lt;span id="MathJax-Span-29" class="mrow"&gt;&lt;span id="MathJax-Span-30" class="mo"&gt;素&lt;span id="MathJax-Span-31" class="texatom"&gt;&lt;span id="MathJax-Span-32" class="mrow"&gt;&lt;span id="MathJax-Span-33" class="mo"&gt;大&lt;span id="MathJax-Span-34" class="texatom"&gt;&lt;span id="MathJax-Span-35" class="mrow"&gt;&lt;span id="MathJax-Span-36" class="mo"&gt;小&lt;span id="MathJax-Span-37" class="mo"&gt;&amp;times;&lt;span id="MathJax-Span-38" class="texatom"&gt;&lt;span id="MathJax-Span-39" class="mrow"&gt;&lt;span id="MathJax-Span-40" class="mo"&gt;切&lt;span id="MathJax-Span-41" class="texatom"&gt;&lt;span id="MathJax-Span-42" class="mrow"&gt;&lt;span id="MathJax-Span-43" class="mo"&gt;片&lt;span id="MathJax-Span-44" class="texatom"&gt;&lt;span id="MathJax-Span-45" class="mrow"&gt;&lt;span id="MathJax-Span-46" class="mo"&gt;容&lt;span id="MathJax-Span-47" class="texatom"&gt;&lt;span id="MathJax-Span-48" class="mrow"&gt;&lt;span id="MathJax-Span-49" class="mo"&gt;量内存空间=切片中元素大小×切片容量

 

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

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

runtime.makeslice 在最后调用的 runtime.mallocgc 是用于申请内存的函数,这个函数的实现还是比较复杂,如果遇到了比较小的对象会直接初始化在 Go 语言调度器里面的 P 结构中,而大于 32KB 的对象会在堆上初始化,我们会在后面的章节中详细介绍 Go 语言的内存分配器,这里就不展开分析了。

在之前版本的 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)
	...
	}
}
Go

OSLICEHEADER 操作会创建我们在上面介绍过的结构体 reflect.SliceHeader,其中包含数组指针、切片长度和容量,它是切片在运行时的表示:

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}
Go

正是因为大多数对切片类型的操作并不需要直接操作原来的 runtime.slice 结构体,所以 reflect.SliceHeader 的引入能够减少切片初始化时的少量开销,该改动不仅能够减少 ~0.2% 的 Go 语言包大小,还能够减少 92 个 runtime.panicIndex 的调用,占 Go 语言二进制的 ~3.5%1

3.2.3 访问元素 #

使用 len 和 cap 获取长度或者容量是切片最常见的操作,编译器将这它们看成两种特殊操作,即 OLEN 和 OCAPcmd/compile/internal/gc.state.expr 函数会在 SSA 生成阶段阶段将它们分别转换成 OpSliceLen 和 OpSliceCap

func (s *state) expr(n *Node) *ssa.Value {
	switch n.Op {
	case OLEN, OCAP:
		switch {
		case n.Left.Type.IsSlice():
			op := ssa.OpSliceLen
			if n.Op == OCAP {
				op = ssa.OpSliceCap
			}
			return s.newValue1(op, types.Types[TINT], s.expr(n.Left))
		...
		}
	...
	}
}
Go

访问切片中的字段可能会触发 “decompose builtin” 阶段的优化,len(slice) 或者 cap(slice) 在一些情况下会直接替换成切片的长度或者容量,不需要在运行时获取:

(SlicePtr (SliceMake ptr _ _ )) -> ptr
(SliceLen (SliceMake _ len _)) -> len
(SliceCap (SliceMake _ _ cap)) -> cap
Go

除了获取切片的长度和容量之外,访问切片中元素使用的 OINDEX 操作也会在中间代码生成期间转换成对地址的直接访问:

func (s *state) expr(n *Node) *ssa.Value {
	switch n.Op {
	case OINDEX:
		switch {
		case n.Left.Type.IsSlice():
			p := s.addr(n, false)
			return s.load(n.Left.Type.Elem(), p)
		...
		}
	...
	}
}
Go

切片的操作基本都是在编译期间完成的,除了访问切片的长度、容量或者其中的元素之外,编译期间也会将包含 range 关键字的遍历转换成形式更简单的循环,我们会在后面的章节中介绍使用 range 遍历切片的过程。

3.2.4 追加和扩容 #

使用 append 关键字向切片中追加元素也是常见的切片操作,中间代码生成阶段的 cmd/compile/internal/gc.state.append 方法会根据返回值是否会覆盖原变量,选择进入两种流程,如果 append 返回的新切片不需要赋值回原有的变量,就会进入如下的处理流程:

// append(slice, 1, 2, 3)
ptr, len, cap := slice
newlen := len + 3
if newlen > cap {
    ptr, len, cap = growslice(slice, newlen)
    newlen = len + 3
}
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)
Go

我们会先解构切片结构体获取它的数组指针、大小和容量,如果在追加元素后切片的大小大于容量,那么就会调用 runtime.growslice 对切片进行扩容并将新的元素依次加入切片。

如果使用 slice = append(slice, 1, 2, 3) 语句,那么 append 后的切片会覆盖原切片,这时 cmd/compile/internal/gc.state.append 方法会使用另一种方式展开关键字:

// slice = append(slice, 1, 2, 3)
a := &slice
ptr, len, cap := slice
newlen := len + 3
if uint(newlen) > uint(cap) {
   newptr, len, newcap = growslice(slice, newlen)
   vardef(a)
   *a.cap = newcap
   *a.ptr = newptr
}
newlen = len + 3
*a.len = newlen
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
Go

是否覆盖原变量的逻辑其实差不多,最大的区别在于得到的新切片是否会赋值回原变量。如果我们选择覆盖原有的变量,就不需要担心切片发生拷贝影响性能,因为 Go 语言编译器已经对这种常见的情况做出了优化。

golang-slice-append

图 3-4 向 Go 语言的切片追加元素

到这里我们已经清楚了 Go 语言如何在切片容量足够时向切片中追加元素,不过仍然需要研究切片容量不足时的处理流程。当切片的容量不足时,我们会调用 runtime.growslice 函数为切片扩容,扩容是为切片分配新的内存空间并拷贝原切片中元素的过程,我们先来看新切片的容量是如何确定的:

func growslice(et *_type, old slice, cap int) slice {
	newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		if old.len < 1024 {
			newcap = doublecap
		} else {
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			if newcap <= 0 {
				newcap = cap
			}
		}
	}
Go

在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

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

上述代码片段仅会确定切片的大致容量,下面还需要根据切片中的元素大小对齐内存,当数组中元素所占的字节大小为 1、8 或者 2 的倍数时,运行时会使用如下所示的代码对齐内存:

	var overflow bool
	var lenmem, newlenmem, capmem uintptr
	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):
		...
	default:
		...
	}
Go

runtime.roundupsize 函数会将待申请的内存向上取整,取整时会使用 runtime.class_to_size 数组,使用该数组中的整数可以提高内存的分配效率并减少碎片,我们会在内存分配一节详细介绍该数组的作用:

var class_to_size = [_NumSizeClasses]uint16{
    0,
    8,
    16,
    32,
    48,
    64,
    80,
    ...,
}
Go

在默认情况下,我们会将目标容量和元素大小相乘得到占用的内存。如果计算新容量时发生了内存溢出或者请求内存超过上限,就会直接崩溃退出程序,不过这里为了减少理解的成本,将相关的代码省略了。

	var overflow bool
	var newlenmem, capmem uintptr
	switch {
	...
	default:
		lenmem = uintptr(old.len) * et.size
		newlenmem = uintptr(cap) * et.size
		capmem, _ = math.MulUintptr(et.size, uintptr(newcap))
		capmem = roundupsize(capmem)
		newcap = int(capmem / et.size)
	}
	...
	var p unsafe.Pointer
	if et.kind&kindNoPointers != 0 {
		p = mallocgc(capmem, nil, false)
		memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
	} else {
		p = mallocgc(capmem, et, true)
		if writeBarrier.enabled {
			bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem)
		}
	}
	memmove(p, old.array, lenmem)
	return slice{p, old.len, newcap}
}
Go

如果切片中元素不是指针类型,那么会调用 runtime.memclrNoHeapPointers 将超出切片当前长度的位置清空并在最后使用 runtime.memmove 将原数组内存中的内容拷贝到新申请的内存中。这两个方法都是用目标机器上的汇编指令实现的,这里就不展开介绍了。

runtime.growslice 函数最终会返回一个新的切片,其中包含了新的数组指针、大小和容量,这个返回的三元组最终会覆盖原切片。

var arr []int64
arr = append(arr, 1, 2, 3, 4, 5)
Go

简单总结一下扩容的过程,当我们执行上述代码时,会触发 runtime.growslice 函数扩容 arr 切片并传入期望的新容量 5,这时期望分配的内存大小为 40 字节;不过因为切片中的元素大小等于 sys.PtrSize,所以运行时会调用 runtime.roundupsize 向上取整内存的大小到 48 字节,所以新切片的容量为 48 / 8 = 6。

3.2.5 拷贝切片 #

切片的拷贝虽然不是常见的操作,但是却是我们学习切片实现原理必须要涉及的。当我们使用 copy(a, b) 的形式对切片进行拷贝时,编译期间的 cmd/compile/internal/gc.copyany 也会分两种情况进行处理拷贝操作,如果当前 copy 不是在运行时调用的,copy(a, b) 会被直接转换成下面的代码:

n := len(a)
if n > len(b) {
    n = len(b)
}
if a.ptr != b.ptr {
    memmove(a.ptr, b.ptr, n*sizeof(elem(a))) 
}
Go

上述代码中的 runtime.memmove 会负责拷贝内存。而如果拷贝是在运行时发生的,例如:go copy(a, b),编译器会使用 runtime.slicecopy 替换运行期间调用的 copy,该函数的实现很简单:

func slicecopy(to, fm slice, width uintptr) int {
	if fm.len == 0 || to.len == 0 {
		return 0
	}
	n := fm.len
	if to.len < n {
		n = to.len
	}
	if width == 0 {
		return n
	}
	...

	size := uintptr(n) * width
	if size == 1 {
		*(*byte)(to.array) = *(*byte)(fm.array)
	} else {
		memmove(to.array, fm.array, size)
	}
	return n
}
Go

无论是编译期间拷贝还是运行时拷贝,两种拷贝方式都会通过 runtime.memmove 将整块内存的内容拷贝到目标的内存区域中:

golang-slice-copy

图 3-5 Go 语言切片的拷贝

相比于依次拷贝元素,runtime.memmove 能够提供更好的性能。需要注意的是,整块拷贝内存仍然会占用非常多的资源,在大切片上执行拷贝操作时一定要注意对性能的影响。

3.2.6 小结 #

切片的很多功能都是由运行时实现的,无论是初始化切片,还是对切片进行追加或扩容都需要运行时的支持,需要注意的是在遇到大切片扩容或者复制时可能会发生大规模的内存拷贝,一定要减少类似操作避免影响程序的性能。

3.2.7 延伸阅读 #

 
Go 语言切片的实现原理 | Go 语言设计与实现 https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-array-and-slice/
 
golang-notes/slice.md at master · cch123/golang-notes · GitHub https://github.com/cch123/golang-notes/blob/master/slice.md

slice 和 array

要说 slice,那实在是太让人熟悉了,从功能上讲 slice 支持追加,按索引引用,按索引范围生成新的 slice,自动扩容等,和 C++ 或 Java 中的 Vector 有些类似,但也有一些区别。

不过 Go 里的 slice 还有一个底层数组的概念,这一点和其它语言不同。

runtime/slice.go
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

slice 的底层结构定义非常直观,指向底层数组的指针,当前长度 len 和当前 slice 的 cap。

数据指针不一定就是指向底层数组的首部,也可以指腰上:

                                                                   
       []int{1,3,4,5}                                              
                                                                   
       struct {                                                    
         array unsafe.Pointer --------------+                      
         len int                            |                      
         cap int                            |                      
       }                                    |                      
                                            |                      
                                            v                      
                                                                   
                               +------|-------|------|------+-----+
                               |      |  1    |  3   | 4    |  5  |
                               |      |       |      |      |     |
                               +------|-------|------|------+-----+
                                                 [5]int            
                                                                   
                                                                   
                                                                   

我们可以轻松地推断出,是可以有多个 slice 指向同一个底层数组的。一般情况下,一个 slice 的 cap 取决于其底层数组的长度。如果在元素追加过程中,底层数组没有更多的空间了,那么这时候就需要申请更大的底层数组,并发生数据拷贝。这时候的 slice 的底层数组的指针地址也会发生改变,务必注意。

len 和 cap

Go 语言虽然将 len 和 cap 作为 slice 和 array 附带的 builtin 函数,但对这两个函数的调用实际上最终会被编译器直接计算出结果,并将值填到代码运行的位置上。所以 len 和 cap 更像是宏一样的东西,在 slice 和 array 的场景,会被直接展开为 sl->len 和 sl->cap 这样的结果。

源码分析

形如:

var a = make([]int, 10, 20)

的代码,会被编译器翻译为 runtime.makeslice。

func makeslice(et *_type, len, cap int) slice {
    maxElements := maxSliceCap(et.size)
    if len < 0 || uintptr(len) > maxElements {
        panic(errorString("makeslice: len out of range"))
    }

    if cap < len || uintptr(cap) > maxElements {
        panic(errorString("makeslice: cap out of range"))
    }

    p := mallocgc(et.size*uintptr(cap), et, true)
    return slice{p, len, cap}
}

如果是

var a = new([]int)

这样的代码,则会被翻译为:

func newobject(typ *_type) unsafe.Pointer {
    return mallocgc(typ.size, typ, true)
}

实在是太简单了,没啥可说的。mallocgc 函数会根据申请的内存大小,去对应的内存块链表上找合适的内存来进行分配,是 Go 自己改造的 tcmalloc 那一套。

内存拷贝:

func slicecopy(to, fm slice, width uintptr) int {
    if fm.len == 0 || to.len == 0 {
        return 0
    }

    n := fm.len
    if to.len < n {
        n = to.len
    }

    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)(to.array) = *(*byte)(fm.array) // known to be a byte pointer
    } else {
        memmove(to.array, fm.array, size)
    }
    return n
}

最终最关键的就是 memmove,所有平台的 memmove 都是用汇编实现的,每个平台会针对 memmove 的目标的长度,选择对应平台的优化指令来进行内存移动。比如 intel 平台就有大量的 VMOVDQU,VMOVDQA 指令。不过别人都帮我们实现好了,大概瞟一眼就行,这里截个片段:

.... 前面有很多看不懂的代码
    VMOVDQU    -0x40(SI), Y1
    VMOVDQU    -0x60(SI), Y2
    VMOVDQU    -0x80(SI), Y3
    SUBQ    $0x80, SI
....总之就是弃疗
    VMOVNTDQ    Y0, -0x20(DI)
    VMOVNTDQ    Y1, -0x40(DI)
    VMOVNTDQ    Y2, -0x60(DI)
....后面也有很多看不懂的代码

逻辑上稍微有点复杂的就只有 growslice,在对 slice 执行 append 操作时,如果 cap 不够用了,会导致 slice 扩容:

func growslice(et *_type, old slice, cap int) slice {

    if et.size == 0 {
        if cap < old.cap {
            panic(errorString("growslice: cap out of range"))
        }
        return slice{unsafe.Pointer(&zerobase), old.len, cap}
    }

    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        // 注意这里的 1024 阈值
        if old.len < 1024 {
            newcap = doublecap
        } else {
            // 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
            }
        }
    }

    var overflow bool
    var lenmem, newlenmem, capmem uintptr
    const ptrSize = unsafe.Sizeof((*byte)(nil))
    switch et.size {
    case 1:
        lenmem = uintptr(old.len)
        newlenmem = uintptr(cap)
        capmem = roundupsize(uintptr(newcap))
        overflow = uintptr(newcap) > _MaxMem
        newcap = int(capmem)
    case ptrSize:
        lenmem = uintptr(old.len) * ptrSize
        newlenmem = uintptr(cap) * ptrSize
        capmem = roundupsize(uintptr(newcap) * ptrSize)
        overflow = uintptr(newcap) > _MaxMem/ptrSize
        newcap = int(capmem / ptrSize)
    default:
        lenmem = uintptr(old.len) * et.size
        newlenmem = uintptr(cap) * et.size
        capmem = roundupsize(uintptr(newcap) * et.size)
        overflow = uintptr(newcap) > maxSliceCap(et.size)
        newcap = int(capmem / et.size)
    }

    if cap < old.cap || overflow || capmem > _MaxMem {
        panic(errorString("growslice: cap out of range"))
    }

    var p unsafe.Pointer
    if et.kind&kindNoPointers != 0 {
        p = mallocgc(capmem, nil, false)
        memmove(p, old.array, lenmem)
        // The append() that calls growslice is going to overwrite from old.len to cap (which will be the new length).
        // Only clear the part that will not be overwritten.
        memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
    } else {
        // Note: can't use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory.
        p = mallocgc(capmem, et, true)
        if !writeBarrier.enabled {
            memmove(p, old.array, lenmem)
        } else {
            for i := uintptr(0); i < lenmem; i += et.size {
                typedmemmove(et, add(p, i), add(old.array, i))
            }
        }
    }

    return slice{p, old.len, newcap}
}

扩容时会判断 slice 的 cap 是不是已经大于 1024,如果在 1024 之内,会按二倍扩容。超过的话就是 1.25 倍扩容了。

slice 扩容必然会导致内存拷贝,如果是性能敏感的系统中,尽可能地提前分配好 slice 是较好的选择。

var arr = make([]int, 0, 10)

值还是引用传递

网上有很多鬼扯的结论说 Go 的 slice 是按引用传递的,证据是类似下面这样的代码:

func main() {
    var a = make([]int, 10)
    doSomeHappyThings(a)
    fmt.Println(a)
}

func doSomeHappyThings(sl []int) {
    if len(sl) > 0 {
        sl[0] = 1
    }
}

把 a 传入到 doSomeHappyThings,然后 a 的第一个元素就被修改了,进而认为在 Go 中,slice 是引用传递的。

但实际上并不是这样的,从汇编层面来讲,Go 的 slice 实际上是把三个参数传到函数内部了,这就类似于我们写一段 c 代码:

void doSomeHappyThings(int * arr, int len, int cap) {
    if(len > 0) {
        arr[0] = 1
    }
}

所以如果你在函数内对这个 slice 进行 append 时导致了 slice 的扩容,那理论上外部是不受影响的,哪怕不扩容,也可能只影响底层数组,而不影响传入的 slice。举个例子:

func main() {
    var arr = make([]int,0,10)
    doSomeHappyThings(arr)
    fmt.Println(arr, len(arr), cap(arr), "after return")
}

func doSomeHappyThings(arr []int) {
    arr = append(arr, 1)
    fmt.Println(arr, "after append")
}

脏活编译器帮你做了一些,但也就导致有些结论不那么直观了。

当然,你可以尝试用汇编来写一个处理 slice 的函数。

a.s:

#include "textflag.h"

// func sum(sl []int64) int64
TEXT ·sum(SB),NOSPLIT, $0-32
    MOVQ $0, SI
    MOVQ sl+0(FP), BX // &sl[0], addr of the first elem
    MOVQ sl+8(FP), CX // len(sl)
  INCQ CX

start:
    DECQ CX       // CX--
    JZ   done
    ADDQ (BX), SI // SI += *BX
    ADDQ $8, BX   // 指针移动
    JMP  start

done:
    // 返回地址是 24 是怎么得来的呢?
    // 可以通过 go tool compile -S math.go 得知
    // 在调用 sum1 函数时,会传入三个值,分别为:
    // slice 的首地址、slice 的 len, slice 的 cap
    // 不过我们这里的求和只需要 len,但 cap 依然会占用参数的空间
    // 就是 16(FP)
    MOVQ SI, ret+24(FP)
    RET

a.go:

package main

import "fmt"

func sum(s []int64) int64

func main() {
    arr := []int64{1, 2, 3, 4, 10}
    sux := sum(arr)
    fmt.Println(sux)
}

reflect 与 slice

对于 slice 的操作,除了计算 slice 的内存布局来获取 len, cap, data 以外,同样也可以利用 reflect 来实现

    var b []byte = []byte("test")
    bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))
    _ = bh.Len // len
    _ = bh.Cap // cap
    _ = bh.Data // 底层数组指针

    // 案例: 0 拷贝转换 slice 为 string
    var str string
    sh := (*reflect.StringHeader)(unsafe.Pointer(&str))
    // 从头转换 test
    sh.Data = bh.Data
    sh.Len = bh.Len
    // 从中间截取转换 est
    sh.Data = (uintptr)(unsafe.Pointer(&b[1]))
    sh.Len = bh.Len - 1
 
 
 
 
https://mp.weixin.qq.com/s/VIssdutZpWhs1auMQlMJnw

切片传递的隐藏危机


提出疑问


在Go的源码库或者其他开源项目中,会发现有些函数在需要用到切片入参时,它采用是指向切片类型的指针,而非切片类型。这里未免会产生疑问:切片底层不就是指针指向底层数组数据吗,为何不直接传递切片,两者有什么区别

例如,在源码log包中,Logger对象上绑定了formatHeader方法,它的入参对象buf,其类型是*[]byte,而非[]byte

1func (l *Logger) formatHeader(buf *[]byte, t time.Time, file string, line int) {}

有以下例子

 1func modifySlice(innerSlice []string) {
2    innerSlice[0] = "b"
3    innerSlice[1] = "b"
4    fmt.Println(innerSlice)
5}
6
7func main() {
8    outerSlice := []string{"a", "a"}
9    modifySlice(outerSlice)
10    fmt.Print(outerSlice)
11}
12
13// 输出如下
14[b b]
15[b b]

我们将modifySlice函数的入参类型改为指向切片的指针

 1func modifySlice(innerSlice *[]string) {
2    (*innerSlice)[0] = "b"
3    (*innerSlice)[1] = "b"
4    fmt.Println(*innerSlice)
5}
6
7func main() {
8    outerSlice := []string{"a", "a"}
9    modifySlice(&outerSlice)
10    fmt.Print(outerSlice)
11}
12
13// 输出如下
14[b b]
15[b b]

在上面的例子中,两种函数传参类型得到的结果都一样,似乎没发现有什么区别。通过指针传递它看起来毫无用处,而且无论如何切片都是通过引用传递的,在两种情况下切片内容都得到了修改。

这印证了我们一贯的认知:函数内对切片的修改,将会影响到函数外的切片。但,真的是如此吗?

 


考证与解释


在《你真的懂string与[]byte的转换了吗》一文中,我们讲过切片的底层结构如下所示。

1type slice struct {
2    array unsafe.Pointer
3    len   int
4    cap   int
5}

array是底层数组的指针,len表示长度,cap表示容量。

我们对上文中的例子,做以下细微的改动。

 1func modifySlice(innerSlice []string) {
2    innerSlice = append(innerSlice, "a")
3    innerSlice[0] = "b"
4    innerSlice[1] = "b"
5    fmt.Println(innerSlice)
6}
7
8func main() {
9    outerSlice := []string{"a", "a"}
10    modifySlice(outerSlice)
11    fmt.Print(outerSlice)
12}
13
14// 输出如下
15[b b a]
16[a a]

神奇的事情发生了,函数内对切片的修改竟然没能对外部切片造成影响?

为了清晰地明白发生了什么,将打印添加更多细节。

 1func modifySlice(innerSlice []string) {
2    fmt.Printf("%p %v   %p\n", &innerSlice, innerSlice, &innerSlice[0])
3    innerSlice = append(innerSlice, "a")
4    innerSlice[0] = "b"
5    innerSlice[1] = "b"
6    fmt.Printf("%p %v %p\n", &innerSlice, innerSlice, &innerSlice[0])
7}
8
9func main() {
10    outerSlice := []string{"a", "a"}
11    fmt.Printf("%p %v   %p\n", &outerSlice, outerSlice, &outerSlice[0])
12    modifySlice(outerSlice)
13    fmt.Printf("%p %v   %p\n", &outerSlice, outerSlice, &outerSlice[0])
14}
15
16// 输出如下
170xc00000c060 [a a]   0xc00000c080
180xc00000c0c0 [a a]   0xc00000c080
190xc00000c0c0 [b b a] 0xc000022080
200xc00000c060 [a a]   0xc00000c080

在Go函数中,函数的参数传递均是值传递。那么,将切片通过参数传递给函数,其实质是复制了slice结构体对象,两个slice结构体的字段值均相等。正常情况下,由于函数内slice结构体的array和函数外slice结构体的array指向的是同一底层数组,所以当对底层数组中的数据做修改时,两者均会受到影响。

但是存在这样的问题:如果指向底层数组的指针被覆盖或者修改(copy、重分配、append触发扩容),此时函数内部对数据的修改将不再影响到外部的切片,代表长度的len和容量cap也均不会被修改。

为了让读者更清晰的认识到这一点,将上述过程可视化如下。

图片

图片

图片

图片

可以看到,当切片的长度和容量相等时,发生append,就会触发切片的扩容。扩容时,会新建一个底层数组,将原有数组中的数据拷贝至新数组,追加的数据也会被置于新数组中。切片的array指针指向新底层数组。所以,函数内切片与函数外切片的关联已经彻底斩断,它的改变对函数外切片已经没有任何影响了。

注意,切片扩容并不总是等倍扩容。为了避免读者产生误解,这里对切片扩容原则简单说明一下(源码位于src/runtime/slice.go 中的 growslice 函数):

切片扩容时,当需要的容量超过原切片容量的两倍时,会直接使用需要的容量作为新容量。否则,当原切片长度小于1024时,新切片的容量会直接翻倍。而当原切片的容量大于等于1024时,会反复地增加25%,直到新容量超过所需要的容量。

到此,我们终于知道为什么有些函数在用到切片入参时,它需要采用指向切片类型的指针,而非切片类型。

 1func modifySlice(innerSlice *[]string) {
2    *innerSlice = append(*innerSlice, "a")
3    (*innerSlice)[0] = "b"
4    (*innerSlice)[1] = "b"
5    fmt.Println(*innerSlice)
6}
7
8func main() {
9    outerSlice := []string{"a", "a"}
10    modifySlice(&outerSlice)
11    fmt.Print(outerSlice)
12}
13
14// 输出如下
15[b b a]
16[b b a]

请记住,如果你只想修改切片中元素的值,而不会更改切片的容量与指向,则可以按值传递切片,否则你应该考虑按指针传递。

 

图片

例题巩固


为了判断读者是否已经真正理解上述问题,我将上面的例子做了两个变体,读者朋友们可以自测。

测试一

 1func modifySlice(innerSlice []string) {
2    innerSlice[0] = "b"
3  innerSlice = append(innerSlice, "a")
4    innerSlice[1] = "b"
5    fmt.Println(innerSlice)
6}
7
8func main() {
9    outerSlice := []string{"a", "a"}
10    modifySlice(outerSlice)
11    fmt.Println(outerSlice)
12}

测试二

 1func modifySlice(innerSlice []string) {
2    innerSlice = append(innerSlice, "a")
3    innerSlice[0] = "b"
4    innerSlice[1] = "b"
5    fmt.Println(innerSlice)
6}
7
8func main() {
9    outerSlice:= make([]string, 0, 3)
10    outerSlice = append(outerSlice, "a", "a")
11    modifySlice(outerSlice)
12    fmt.Println(outerSlice)
13}

测试一答案

1[b b a]
2[b a]

测试二答案

1[b b a]
2[b b]

你做对了吗?

 

 

https://mp.weixin.qq.com/s/73rbXvVzmn3NQOn2mrQEhw

append扩容机制

 

append扩容机制

《切片传递的隐藏危机》一文,小菜刀有简单地提及到切片扩容的问题。在读者讨论群,有人举了以下例子,并想得到一个合理的回答。

1package main
2
3func main() {
4    s := []int{1,2}
5    s = append(s, 3,4,5)
6    println(cap(s))
7}
8
9// output: 6

为什么结果不是5,不是8,而是6呢?由于小菜刀在该文中关于扩容的描述不够准确,让读者产生了疑惑。因此本文想借此机会细致分析一下append函数及其背后的扩容机制。

我们知道,append是一种用户在使用时,并不需要引入相关包而可直接调用的函数。它是内置函数,其定义位于源码包 builtin 的builtin.go

 1// The append built-in function appends elements to the end of a slice. If
2// it has sufficient capacity, the destination is resliced to accommodate the
3// new elements. If it does not, a new underlying array will be allocated.
4// Append returns the updated slice. It is therefore necessary to store the
5// result of append, often in the variable holding the slice itself:
6//    slice = append(slice, elem1, elem2)
7//    slice = append(slice, anotherSlice...)
8// As a special case, it is legal to append a string to a byte slice, like this:
9//    slice = append([]byte("hello "), "world"...)
10func append(slice []Type, elems ...Type) []Type
11

append 会追加一个或多个数据至 slice 中,这些数据会存储至 slice 的底层数组。其中,底层数组长度是固定的,如果数组的剩余空间足以容纳追加的数据,则可以正常地将数据存入该数组。一旦追加数据后总长度超过原数组长度,原数组就无法满足存储追加数据的要求。此时会怎么处理呢?

同时我们发现,该文件中仅仅定义了函数签名,并没有包含函数实现的任何代码。这里我们不免好奇,append究竟是如何实现的呢?

 

编译过程

 

为了回答上述问题,我们不妨从编译入手。Go编译可分为四个阶段:词法与语法分析、类型检查与抽象语法树(AST)转换、中间代码生成和生成最后的机器码。

我们主要需要关注的是第二和第三阶段的代码,分别是位于src/cmd/compile/internal/gc/typecheck.go下的类型检查逻辑

1func typecheck1(n *Node, top int) (res *Node) {
2    ...
3    switch n.Op {
4    case OAPPEND:
5    ...
6}

位于src/cmd/compile/internal/gc/walk.go下的抽象语法树转换逻辑

 1func walkexpr(n *Node, init *Nodes) *Node {
2    ...
3    case OAPPEND:
4            // x = append(...)
5            r := n.Right
6            if r.Type.Elem().NotInHeap() {
7                yyerror("%v can't be allocated in Go; it is incomplete (or unallocatable)", r.Type.Elem())
8            }
9            switch {
10            case isAppendOfMake(r):
11                // x = append(y, make([]T, y)...)
12                r = extendslice(r, init)
13            case r.IsDDD():
14                r = appendslice(r, init) // also works for append(slice, string).
15            default:
16                r = walkappend(r, init, n)
17            }
18    ...
19}  

和位于src/cmd/compile/internal/gc/ssa.go下的中间代码生成逻辑

1// append converts an OAPPEND node to SSA.
2// If inplace is false, it converts the OAPPEND expression n to an ssa.Value,
3// adds it to s, and returns the Value.
4// If inplace is true, it writes the result of the OAPPEND expression n
5// back to the slice being appended to, and returns nil.
6// inplace MUST be set to false if the slice can be SSA'd.
7func (s *state) append(n *Node, inplace bool) *ssa.Value {
8    ...
9}

其中,中间代码生成阶段的state.append方法,是我们重点关注的地方。入参 inplace 代表返回值是否覆盖原变量。如果为false,展开逻辑如下(注意:以下代码只是为了方便理解的伪代码,并不是 state.append 中实际的代码)。同时,小菜刀注意到如果写成 append(s, e1, e2, e3) 不带接收者的形式,并不能通过编译,所以暂未明白它的场景在哪。

 1    // If inplace is false, process as expression "append(s, e1, e2, e3)": 
2   ptr, len, cap := s
3     newlen := len + 3
4     if newlen > cap {
5         ptr, len, cap = growslice(s, newlen)
6         newlen = len + 3 // recalculate to avoid a spill
7     }
8     // with write barriers, if needed:
9     *(ptr+len) = e1
10     *(ptr+len+1) = e2
11     *(ptr+len+2) = e3
12     return makeslice(ptr, newlen, cap)

如果是true,例如 slice = append(slice, 1, 2, 3) 语句,那么返回值会覆盖原变量。展开方式逻辑如下

 1    // If inplace is true, process as statement "s = append(s, e1, e2, e3)":
2
3     a := &s
4     ptr, len, cap := s
5     newlen := len + 3
6     if uint(newlen) > uint(cap) {
7        newptr, len, newcap = growslice(ptr, len, cap, newlen)
8        vardef(a)       // if necessary, advise liveness we are writing a new a
9        *a.cap = newcap // write before ptr to avoid a spill
10        *a.ptr = newptr // with write barrier
11     }
12     newlen = len + 3 // recalculate to avoid a spill
13     *a.len = newlen
14     // with write barriers, if needed:
15     *(ptr+len) = e1
16     *(ptr+len+1) = e2
17     *(ptr+len+2) = e3

不管 inpalce 是否为true,我们均会获取切片的数组指针、大小和容量,如果在追加元素后,切片新的大小大于原始容量,就会调用 runtime.growslice 对切片进行扩容,并将新的元素依次加入切片。

因此,通过append向元素类型为 int 的切片(已包含元素 1,2,3)追加元素 1, slice=append(slice,1)可分为两种情况。

图片

情况1,切片的底层数组还有可容纳追加元素的空间。

图片

情况2,切片的底层数组已无可容纳追加元素的空间,需调用扩容函数,进行扩容。

 

扩容函数

 

前面我们提到,追加操作时,当切片底层数组的剩余空间不足以容纳追加的元素,就会调用 growslice,其调用的入参 cap 为追加元素后切片的总长度。

growslice 的代码较长,我们可以根据逻辑分为三个部分。

  1. 初步确定切片容量

 1func growslice(et *_type, old slice, cap int) slice {
2  ...
3  newcap := old.cap
4    doublecap := newcap + newcap
5    if cap > doublecap {
6        newcap = cap
7    } else {
8        if old.len < 1024 {
9            newcap = doublecap
10        } else {
11            // Check 0 < newcap to detect overflow
12            // and prevent an infinite loop.
13            for 0 < newcap && newcap < cap {
14                newcap += newcap / 4
15            }
16            // Set newcap to the requested cap when
17            // the newcap calculation overflowed.
18            if newcap <= 0 {
19                newcap = cap
20            }
21        }
22    }
23  ...
24}  

在该环节中,如果需要的容量 cap 超过原切片容量的两倍 doublecap,会直接使用需要的容量作为新容量newcap。否则,当原切片长度小于1024时,新切片的容量会直接翻倍。而当原切片的容量大于等于1024时,会反复地增加25%,直到新容量超过所需要的容量。

  1. 计算容量所需内存大小

 1    var overflow bool
2    var lenmem, newlenmem, capmem uintptr
3
4    switch {
5    case et.size == 1:
6        lenmem = uintptr(old.len)
7        newlenmem = uintptr(cap)
8        capmem = roundupsize(uintptr(newcap))
9        overflow = uintptr(newcap) > maxAlloc
10        newcap = int(capmem)
11    case et.size == sys.PtrSize:
12        lenmem = uintptr(old.len) * sys.PtrSize
13        newlenmem = uintptr(cap) * sys.PtrSize
14        capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
15        overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
16        newcap = int(capmem / sys.PtrSize)
17    case isPowerOfTwo(et.size):
18        var shift uintptr
19        if sys.PtrSize == 8 {
20            // Mask shift for better code generation.
21            shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
22        } else {
23            shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
24        }
25        lenmem = uintptr(old.len) << shift
26        newlenmem = uintptr(cap) << shift
27        capmem = roundupsize(uintptr(newcap) << shift)
28        overflow = uintptr(newcap) > (maxAlloc >> shift)
29        newcap = int(capmem >> shift)
30    default:
31        lenmem = uintptr(old.len) * et.size
32        newlenmem = uintptr(cap) * et.size
33        capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
34        capmem = roundupsize(capmem)
35        newcap = int(capmem / et.size)
36    }

在该环节,通过判断切片元素的字节大小是否为1,系统指针大小(32位为4,64位为8)或2的倍数,进入相应所需内存大小的计算逻辑。

这里需要注意的是 roundupsize 函数,它根据输入期望大小 size ,返回 mallocgc 实际将分配的内存块的大小。

 1func roundupsize(size uintptr) uintptr {
2    if size < _MaxSmallSize {
3        if size <= smallSizeMax-8 {
4            return uintptr(class_to_size[size_to_class8[divRoundUp(size, smallSizeDiv)]])
5        } else {
6            return uintptr(class_to_size[size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]])
7        }
8    }
9
10  // Go的内存管理虚拟地址页大小为 8k(_PageSize)
11  // 当size的大小即将溢出时,就不采用向上取整的做法,直接用当前期望size值。
12    if size+_PageSize < size {
13        return size
14    }
15    return alignUp(size, _PageSize)
16}

根据内存分配中的大小对象原则,如果期望分配内存非大对象 ( <_MaxSmallSize ),即小于32k,则需要根据 divRoundUp 函数将待申请的内存向上取整,取整时会使用 class_to_size 以及 size_to_class8 和 size_to_class128 数组。这些数组方便于内存分配器进行分配,以提高分配效率并减少内存碎片。

1// _NumSizeClasses = 67 代表67种特定大小的对象类型
2var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112,...}

当期望分配内存为大对象时,会通过 alignUp 将该 size 的大小向上取值为虚拟页大小(_PageSize)的倍数。

  1. 内存分配

 1    if overflow || capmem > maxAlloc {
2        panic(errorString("growslice: cap out of range"))
3    }
4
5    var p unsafe.Pointer
6    if et.ptrdata == 0 {
7        p = mallocgc(capmem, nil, false)
8        memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
9    } else {
10        p = mallocgc(capmem, et, true)
11        if lenmem > 0 && writeBarrier.enabled {
12            bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem-et.size+et.ptrdata)
13        }
14    }
15    memmove(p, old.array, lenmem)
16
17    return slice{p, old.len, newcap}

如果在第二个环节中,造成了溢出或者期望分配的内存超过最大分配限制,会引起 panic

mallocgc 分配一个大小为前面计算得到的 capmem 对象。如果是小对象,则直接从当前G所在P的缓存空闲列表中分配;如果是大对象,则从堆上进行分配。同时,如果切片中的元素不是指针类型,那么会调用 memclrNoHeapPointers将超出切片当前长度的位置清空;如果是元素是指针类型,且原有切片元素个数不为0 并可以打开写屏障时,需要调用 bulkBarrierPreWriteSrcOnly  将旧切片指针标记隐藏,在新切片中保存为nil指针。

在最后使用memmove将原数组内存中的内容拷贝到新申请的内存中,并将新的内存指向指针p 和旧的长度值,新的容量值赋值给新的 slice 并返回。

注意,在 growslice 完成后,只是把旧有数据拷贝到了新的内存中去,且计算得到新的 slice 容量大小,并没有完成最终追加数据的操作。如果 slice 当前 len =3cap=3slice=append(slice,1),那它完成的工作如下图所示。

图片

growslice之后,此时新的slice已经拷贝了旧的slice数据,并且其底层数组有充足的剩余空间追加数据。后续只需拷贝追加数据至剩余空间,并修改 len 值即可,这一部分就不再深究了。

 

总结

 

这里回到文章开头中的例子

1package main
2
3func main() {
4    s := []int{1,2}
5    s = append(s, 3,4,5)
6    println(cap(s))
7}

由于初始 s 的容量是2,现需要追加3个元素,所以通过 append 一定会触发扩容,并调用 growslice 函数,此时他的入参 cap 大小为2+3=5。通过翻倍原有容量得到 doublecap = 2+2,doublecap 小于 cap 值,所以在第一阶段计算出的期望容量值 newcap=5。在第二阶段中,元素类型大小 int 和 sys.PtrSize 相等,通过 roundupsize 向上取整内存的大小到 capmem = 48 字节,所以新切片的容量newcap 为 48 / 8 = 6 ,成功解释!

在切片 append 操作时,如果底层数组已无可容纳追加元素的空间,则需扩容。扩容并不是在原有底层数组的基础上增加内存空间,而是新分配一块内存空间作为切片的底层数组,并将原有数据和追加数据拷贝至新的内存空间中。

在扩容的容量确定上,相对比较复杂,它与CPU位数、元素大小、是否包含指针、追加个数等都有关系。当我们看完扩容源码逻辑后,发现去纠结它的扩容确切值并没什么必要。

在实际使用中,如果能够确定切片的容量范围,比较合适的做法是:切片初始化时就分配足够的容量空间,在append追加操作时,就不用再考虑扩容带来的性能损耗问题。

 1func BenchmarkAppendFixCap(b *testing.B) {
2    for i := 0; i < b.N; i++ {
3        a := make([]int, 0, 1000)
4        for i := 0; i < 1000; i++ {
5            a = append(a, i)
6        }
7    }
8}
9
10func BenchmarkAppend(b *testing.B) {
11    for i := 0; i < b.N; i++ {
12        a := make([]int, 0)
13        for i := 0; i < 1000; i++ {
14            a = append(a, i)
15        }
16    }
17}

它们的压测结果如下,孰优孰劣,一目了然。

1 $ go test -bench=. -benchmem
2
3BenchmarkAppendFixCap-8          1953373               617 ns/op               0 B/op          0 allocs/op
4BenchmarkAppend-8                 426882              2832 ns/op           16376 B/op         11 allocs/op

 



 

 

posted @ 2021-10-13 21:10  papering  阅读(85)  评论(0编辑  收藏  举报