0%

在我们看一些使用反射的代码的时候,会发现,reflect.ValueOfreflect.TypeOf 的参数有些地方使用的是指针参数,有些地方又不是指针参数, 但是好像这两者在使用上没什么区别,比如下面这样:

1
2
3
4
5
6
var a = 1
v1 := reflect.ValueOf(a)
v2 := reflect.ValueOf(&a)

fmt.Println(v1.Int()) // 1
fmt.Println(v2.Elem().Int()) // 1

它们的区别貌似只是需不需要使用 Elem() 方法,但这个跟我们是否传递指针给 reflect.ValueOf 其实关系不大, 相信没有人为了使用一下 Elem() 方法,就去传递指针给 reflect.ValueOf 吧。

那我们什么时候应该传递指针参数呢?

什么时候传递指针?

要回答这个问题,我们可以思考一下以下列出的几点内容:

  1. 是否要修改变量的值,要修改就要用指针
  2. 结构体类型:是否要修改结构体里的字段,要修改就要用指针
  3. 结构体类型:是否要调用指针接收值方法,要调用就要用指针
  4. 对于 chanmapslice 类型,我们传递值和传递指针都可以修改其内容
  5. 对于非 interface 类型,传递给 TypeOfValueOf 的时候都会转换为 interface 类型,如果本身就是 interface 类型,则不需转换。
  6. 指针类型不可修改,但是可以修改指针指向的值。(v := reflect.ValueOf(&a)v.CanSet()falsev.Elem().CanSet()true
  7. 字符串:我们可以对字符串进行替换,但不能修改字符串的某一个字符

大概总结下来,就是:如果我们想修改变量的内容,就传递指针,否则就传递值。对于某些复合类型如果其内部包含了底层数据的指针, 也是可以通过传值来修改其底层数据的,这些类型有 chanmapslice。 又或者如果我们想修改结构体类型里面的指针类型字段,传递结构体的拷贝也能实现。

1. 通过传递指针修改变量的值

对于一些基础类型的变量,如果我们想修改其内容,就要传递指针。这是因为在 go 里面参数传递都是值传递,如果我们不传指针, 那么在函数内部拿到的只是参数的拷贝,对其进行修改,不会影响到外部的变量(事实上在对这种反射值进行修改的时候会直接 panic)。

传值无法修改变量本身

1
2
x := 1
v := reflect.ValueOf(x)

在这个例子中,v 中保存的是 x 的拷贝,对这份拷贝在反射的层面上做修改其实是没有实际意义的,因为对拷贝进行修改并不会影响到 x 本身。 我们在通过反射来修改变量的时候,我们的预期行为往往是修改变量本身。鉴于实际的使用场景,go 的反射系统已经帮我们做了限制了, 在我们对拷贝类型的反射对象进行修改的时候,会直接 panic

reflect_1

传指针可以修改变量

1
2
x := 1
v := reflect.ValueOf(&x).Elem()

在这个例子中,我们传递了 x 的指针到 reflect.ValueOf 中,这样一来,v 指向的就是 x 本身了。 在这种情况下,我们对 v 的修改就会影响到 x 本身。

reflect_2

2. 通过传递指针修改结构体的字段

对于结构体类型,如果我们想修改其字段的值,也是要传递指针的。这是因为结构体类型的字段是值类型,如果我们不传递指针, reflect.ValueOf 拿到的也是一份拷贝,对其进行修改并不会影响到结构体本身。当然,这种情况下,我们修改它的时候也会 panic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type person struct {
Name string
Age int
}

p := person{
Name: "foo",
Age: 30,
}
// v 本质上是指向 p 的指针
v := reflect.ValueOf(&p)

// v.CanSet() 为 false,v 是指针,指针本身是不能修改的
// v.Elem() 是 p 本身,是可以修改的

fmt.Println(v.Elem().FieldByName("Name").CanSet()) // true
fmt.Println(v.Elem().FieldByName("Age").CanSet()) // true
reflect_3

3. 结构体:获取指针接收值方法

对于结构体而言,如果我们想通过反射来调用指针接收者方法,那么我们需要传递指针。

在开始讲解这一点之前,需要就以下内容达成共识:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type person struct {
}

func (p person) M1() {
}

func (p *person) M2() {
}

func TestPerson(t *testing.T) {
p := person{}
v1 := reflect.ValueOf(p)
v2 := reflect.ValueOf(&p)

assert.Equal(t, 1, v1.NumMethod())
assert.Equal(t, 2, v2.NumMethod())

// v1 和 v2 都有 M1 方法
assert.True(t, v1.MethodByName("M1").IsValid())
assert.True(t, v2.MethodByName("M1").IsValid())

// v1 没有 M2 方法
// v2 有 M2 方法
assert.False(t, v1.MethodByName("M2").IsValid())
assert.True(t, v2.MethodByName("M2").IsValid())
}

在上面的代码中,p 只有一个方法 M1,而 &p 有两个方法 M1M2但是在实际使用中,我们使用 p 来调用 M2 也是可以的p 之所以能调用 M2 是因为编译器帮我们做了一些处理,将 p 转换成了 &p,然后调用 M2

reflect_4

但是在反射的时候,我们是无法做到这一点的,这个需要特别注意。如果我们想通过反射来调用指针接收者的方法,就需要传递指针。

4. 变量本身包含指向数据的指针

最好不要通过值的反射对象来修改值的数据,就算有些类型可以实现这种功能。

对于 chanmapslice 这三种类型,我们可以通过 reflect.ValueOf 来获取它们的值, 但是这个值本身包含了指向数据的指针,因此我们依然可以通过反射系统修改其数据。但是,我们最好不这么用,从规范的角度,这是一种错误的操作。

通过值反射对象修改 chan、map 和 slice

reflect_5

在 go 中,chanmapslice 这几种数据结构中,存储数据都是通过一个 unsafe.Pointer 类型的变量来指向实际存储数据的内存。 这是因为,这几种类型能够存储的元素个数都是不确定的,都需要根据我们指定的大小和存储的元素类型来进行内存分配。

正因如此,我们复制 chanmapslice 的时候,虽然值被复制了一遍,但是存储数据的指针也被复制了, 这样我们依然可以通过拷贝的数据指针来修改其数据,如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func TestPointer1(t *testing.T) {
// 数组需要传递引用才能修改其元素
arr := [3]int{1, 2, 3}
v1 := reflect.ValueOf(&arr)
v1.Elem().Index(1).SetInt(100)
assert.Equal(t, 100, arr[1])

// chan 传值也可以修改其元素
ch := make(chan int, 1)
v2 := reflect.ValueOf(ch)
v2.Send(reflect.ValueOf(10))
assert.Equal(t, 10, <-ch)

// map 传值也可以修改其元素
m := make(map[int]int)
v3 := reflect.ValueOf(m)
v3.SetMapIndex(reflect.ValueOf(1), reflect.ValueOf(10))
assert.Equal(t, 10, m[1])

// slice 传值也可以修改其元素
s := []int{1, 2, 3}
v4 := reflect.ValueOf(s)
v4.Index(1).SetInt(20)
assert.Equal(t, 20, s[1])
}

slice 反射对象扩容的影响

但是,我们需要注意的是,对于 mapslice 类型,在其分配的内存容纳不下新的元素的时候,会进行扩容扩容之后,保存数据字段的指针就指向了一片新的内存了。 这意味着什么呢?这意味着,我们通过 mapslice 的值创建的反射值对象中拿到的那份数据指针已经跟旧的 mapslice 指向的内存不一样了。

reflect_6

说明:在上图中,我们在反射对象中往 slice 追加元素后,导致反射对象 slicearray 指针指向了一片新的内存区域了, 这个时候我们再对反射对象进行修改的时候,不会影响到原 slice。这也就是我们不能通过 slicemap 的拷贝的反射对象来修改 slicemap 的原因。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func TestPointer1(t *testing.T) {
s := []int{1, 2, 3}
v4 := reflect.ValueOf(s)
v4.Index(1).SetInt(20)
assert.Equal(t, 20, s[1])

// 这里发生了扩容
// v5 的 array 跟 s 的 array 指向的是不同的内存区域了。
v5 := reflect.Append(v4, reflect.ValueOf(4))
fmt.Println(s) // [1 20 3]
fmt.Println(v5.Interface().([]int)) // [1 20 3 4]

// 这里修改 v5 的时候影响不到 s 了
v5.Index(1).SetInt(30)
fmt.Println(s) // [1 20 3]
fmt.Println(v5.Interface().([]int)) // [1 30 3 4]
}

说明:在上面的代码中,v5 实际上是 v4 扩容后的切片,底层的 array 指针指向的是跟 s 不一样的 array 了, 因此在我们修改 v5 的时候,会发现原来的 s 并没有发生改变。

虽然通过值反射对象可以修改 slice 的数据,但是如果通过反射对象 append 元素到 slice 的反射对象的时候, 可能会触发 slice 扩容,这个时候再修改反射对象的时候,就影响不了原来的 slice 了。

slice 容量够的话是不是就可以正常追加元素了?

只能说,能,也不能。我们看看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func TestPointer000(t *testing.T) {
s1 := make([]int, 3, 6)
s1[0] = 1
s1[1] = 2
s1[2] = 3
fmt.Println(s1) // [1 2 3]

v6 := reflect.ValueOf(s1)
v7 := reflect.Append(v6, reflect.ValueOf(4))
// 虽然 s1 的容量足够大,但是 s1 还是看不到追加的元素
fmt.Println(s1) // [1 2 3]
fmt.Println(v7.Interface().([]int)) // [1 2 3 4]

// s1 和 s2 底层数组还是同一个
// array1 是 s1 底层数组的内存地址
array1 := (*(*reflect.SliceHeader)(unsafe.Pointer(&s1))).Data
s2 := v7.Interface().([]int)
// array2 是 s2 底层数组的内存地址
array2 := (*(*reflect.SliceHeader)(unsafe.Pointer(&s2))).Data
assert.Equal(t, array1, array2)

// 这是因为 s1 的长度并没有发生改变,
// 所以 s1 看不到追加的那个元素
fmt.Println(len(s1), cap(s1)) // 3 6
fmt.Println(len(s2), cap(s2)) // 4 6
}

在这个例子中,我们给 slice 分配了足够大的容量,但是我们通过反射对象来追加元素的时候, 虽然数据被正常追加到了 s1 底层数组,但是由于在反射对象以外的 s1len 并没有发生改变, 因此 s1 还是看不到反射对象追加的元素。所以上面说可以正常追加元素

但是,外部由于 len 没有发生改变,因此外部看不到反射对象追加的元素,所以上面也说不能正常追加元素

因此,虽然理论上修改的是同一片内存,我们依然不能通过传值的方式来通过反射对象往 slice 中追加元素。 但是修改 [0, len(s)) 范围内的元素在反射对象外部是可以看到的。

map 也不能通过值反射对象来修改其元素。

slice 类似,通过 map 的值反射对象来追加元素的时候,同样可能导致扩容, 扩容之后,保存数据的内存区域会发生改变。

但是,从另一个角度看,如果我们只是修改其元素的话,是可以正常修改的。

chan 没有追加

chanslicemap 有个不一样的地方,它的长度是我们创建 chan 的时候就已经固定的了, 因此,不存在扩容导致指向内存区域发生改变的问题。

因此,对于 chan 类型的元素,我们传 ch 或者 &chreflect.ValueOf 都可以实现修改 ch

结构体字段包含指针的情况

如果结构体里面包含了指针字段,我们也只是想通过反射对象来修改这个指针字段的话, 那么我们也还是可以通过传值给 reflect.ValueOf 来创建反射对象来修改这个指针字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type person struct {
Name *string
}

func TestPointerPerson(t *testing.T) {
name := "foo"
p := person{Name: &name}

v := reflect.ValueOf(p)
fmt.Println(v.Field(0).Elem().CanAddr())
fmt.Println(v.Field(0).Elem().CanSet())

name1 := "bar"
v.Field(0).Elem().Set(reflect.ValueOf(name1))
// p 的 Name 字段已经被成功修改
fmt.Println(*p.Name)
}

在这个例子中,我们虽然使用了 p 而不是 &p 来创建反射对象, 但是我们依然可以修改 Name 字段,因为反射对象拿到了 Name 的指针的拷贝, 通过这个拷贝是可以定位到 pName 字段本身指向的内存的。

但是我们依然是不能修改 p 中的其他字段。

5. interface 类型处理

对于 interface 类型的元素,我们可以将以下两种操作看作是等价的:

1
2
3
4
5
6
// v1 跟 v2 都拿到了 a 的拷贝
var a = 1
v1 := reflect.ValueOf(a)

var b interface{} = a
v2 := reflect.ValueOf(b)

我们可以通过下面的断言来证明:

1
2
3
4
assert.Equal(t, v1.Kind(), v2.Kind())
assert.Equal(t, v1.CanAddr(), v2.CanAddr())
assert.Equal(t, v1.CanSet(), v2.CanSet())
assert.Equal(t, v1.Interface(), v2.Interface())

当然,对于指针类型也是一样的:

1
2
3
4
5
6
// v1 跟 v2 都拿到了 a 的指针
var a = 1
v1 := reflect.ValueOf(&a)

var b interface{} = &a
v2 := reflect.ValueOf(b)

同样的,我们可以通过下面的断言来证明:

1
2
3
4
5
6
assert.Equal(t, v1.Kind(), v2.Kind())
assert.Equal(t, v1.Elem().Kind(), v2.Elem().Kind())
assert.Equal(t, v1.Elem().CanAddr(), v2.Elem().CanAddr())
assert.Equal(t, v1.Elem().Addr(), v2.Elem().Addr())
assert.Equal(t, v1.Interface(), v2.Interface())
assert.Equal(t, v1.Elem().Interface(), v2.Elem().Interface())

interface 底层类型是值

interface 类型的底层类型是值的时候,我们将其传给 reflect.ValueOf 跟直接传值是一样的。 是没有办法修改 interface 底层数据的值的(除了指针类型字段,因为反射对象也拿到了指针字段的地址):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type person struct {
Name *string
}

func TestInterface1(t *testing.T) {
name := "foo"
p := person{Name: &name}

// v 拿到的是 p 的拷贝
// 下面两行等价于 v := reflect.ValueOf(p)
var i interface{} = p
v := reflect.ValueOf(i)
assert.False(t, v.CanAddr())
assert.Equal(t, reflect.Struct, v.Kind())
assert.True(t, v.Field(0).Elem().CanAddr())
}

在上面这个例子中 v := reflect.ValueOf(i) 其实等价于 v := reflect.ValueOf(p), 因为在我们调用 reflect.ValueOf(p) 的时候,go 语言本身会帮我们将 p 转换为 interface{} 类型。 在我们赋值给 i 的时候,go 语言也会帮我们将 p 转换为 interface{} 类型。 这样再调用 reflect.ValueOf 的时候就不需要再做转换了。

reflect_7

interface 底层类型是指针

传递底层数据是指针类型的 interfacereflect.ValueOf 的时候,我们可以修改 interface 底层指针指向的值, 效果等同于直接传递指针给 reflect.ValueOf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func TestInterface(t *testing.T) {
var a = 1
v1 := reflect.ValueOf(&a)

var b interface{} = &a
v2 := reflect.ValueOf(b)

// v1 和 v2 本质上都接收了一个 interface 参数,
// 这个 interface 参数的数据部分都是 &a

v1.Elem().SetInt(10)
assert.Equal(t, 10, a)

// 通过 v1 修改 a 的值,v2 也能看到
assert.Equal(t, 10, v2.Elem().Interface())

// 同样的,通过 v2 修改 a 的值,v1 也能看到
v2.Elem().SetInt(20)
assert.Equal(t, 20, a)
assert.Equal(t, 20, v1.Elem().Interface())
}

不要再对接口类型取地址

能不能通过反射 Value 对象来修改变量只取决于,能不能根据反射对象拿到最初变量的内存地址。 如果拿到的只是原始值的拷贝,不管我们怎么做都无法修改原始值。

对于初学者另外一个令人困惑的地方可能是下面这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func TestInterface(t *testing.T) {
var a = 1
var i interface{} = a
v1 := reflect.ValueOf(&a)
v2 := reflect.ValueOf(&i)

// v1 和 v2 的类型都是 reflect.Ptr
assert.Equal(t, reflect.Ptr, v1.Kind())
assert.Equal(t, reflect.Ptr, v2.Kind())

// 但是两者的 Elem() 类型不同,
// v1 的 Elem() 是 reflect.Int,
// v2 的 Elem() 是 reflect.Interface
assert.Equal(t, reflect.Int, v1.Elem().Kind())
assert.Equal(t, reflect.Interface, v2.Elem().Kind())
}

困惑的源头在于,reflect.ValueOf() 这个函数的参数是 interface{} 类型的, 这意味着我们可以传递任意类型的值给它,包括指针类型的值。

正因如此,如果我们不懂得 reflect 包的工作原理的话, 就会传错变量到 reflect.ValueOf() 函数中,导致程序出错。

对于上面例子的 v2,它是一个指向 interface{} 类型的指针的反射对象,它也能找到最初的变量 a

但是能不能修改 a,还是取决于 a 是否是可寻址的。也就是最初传递给 i 的值是不是一个指针类型。

1
2
3
assert.Equal(t, "<*interface {} Value>", v2.String())
assert.Equal(t, "<interface {} Value>", v2.Elem().String())
assert.Equal(t, "<int Value>", v2.Elem().Elem().String())

在上面的例子中,我们传递给 i 的是 a 的值,而不是 a 的指针,所以 i 是不可寻址的,也就是说 v2 是不可寻址的。

reflect_8

上图说明:

  • i 是接口类型,它的数据部分是 a 的拷贝,它的类型部分是 int 类型。
  • &i 是指向接口的指针,它指向了上图的 eface
  • v2 是指向 eface 的指针的反射对象。
  • 最终,我们通过 v2 找到 i 这个接口,然后通过 i 找到 a 这个变量的拷贝

所以,绕了一大圈,我们最终还是修改不了 a 的值。到最后我们只拿到了 a 的拷贝。

6. 指针类型反射对象不可修改其指向地址

其实这一点上面有些地方也有涉及到,但是这里再强调一下。一个例子如下:

1
2
3
4
5
6
7
8
9
10
11
func TestPointer(t *testing.T) {
var a = 1
var b = &a
v := reflect.ValueOf(b)

assert.False(t, v.CanAddr())
assert.False(t, v.CanSet())

assert.True(t, v.Elem().CanAddr())
assert.True(t, v.Elem().CanSet())
}
reflect_9

说明:

  • v 是指向 &a 的指针的反射对象。
  • 通过这个反射对象的 Elem() 方法,我们可以找到原始的变量 a
  • 反射对象本身不能修改,但是它的 Elem() 方法返回的反射对象可以修改。

对于指针类型的反射对象,其本身不能修改,但是它的 Elem() 方法返回的反射对象可以修改。

7. 反射也不能修改字符串中的字符

这是因为,go 中的字符串本身是不可变的,我们无法像在 C 语言中那样修改其中某一个字符。 其实不止是 go,其实很多编程语言的字符串都是不可变的,比如 Java 中的 String 类型。

在 go 中,字符串是用一个结构体来表示的,大概长下面这个样子:

1
2
3
4
type StringHeader struct {
Data uintptr
Len int
}
  • Data 是指向字符串的指针。
  • Len 是字符串的长度(单位为字节)。

在 go 中 str[1] = 'a' 这样的操作是不允许的,因为字符串是不可变的。

相同的字符串只有一个实例

假设我们定义了两个相同的字符串,如下:

1
2
s1 := "hello"
s2 := "hello"

这两个字符串的值是相同的,但是它们的地址是不同的。那既然如此,为什么我们还是不能修改它的其中某一个字符呢? 这是因为,虽然 s1s2 的地址不一样,但是它们实际保存 hello 这个字符串的地址是一样的:

1
2
3
4
5
v1 := (*reflect.StringHeader)(unsafe.Pointer(&s1))
v2 := (*reflect.StringHeader)(unsafe.Pointer(&s2))

// 两个字符串实例保存字符串的内存地址是一样的
assert.Equal(t, v1.Data, v2.Data)

两个字符串内存表示如下:

reflect_10

所以,我们可以看到,s1s2 实际上是指向同一个字符串的指针,所以我们无法修改其中某一个字符。 因为如果允许这种行为存在的话,我们对其中一个字符串实例修改,也会影响到另外一个字符串实例。

字符串本身可以替换

虽然我们不能修改字符串中的某一个字符,但是我们可以通过反射对象把整个字符串替换掉:

1
2
3
4
5
6
7
8
9
10
func TestStirng(t *testing.T) {
s := "hello"

v := reflect.ValueOf(&s)
fmt.Println(v.Elem().CanAddr())
fmt.Println(v.Elem().CanSet())

v.Elem().SetString("world")
fmt.Println(s) // world
}

这里实际上是把 s 中保存字符串的地址替换成了指向 world 这个字符串的地址,而不是将 hello 指向的内存修改成 world

1
2
3
4
5
6
7
8
9
10
11
12
13
func TestStirng(t *testing.T) {
s := "hello"

oldAddr := (*reflect.StringHeader)(unsafe.Pointer(&s)).Data

v := reflect.ValueOf(&s)
v.Elem().SetString("world")

newAddr := (*reflect.StringHeader)(unsafe.Pointer(&s)).Data

// 修改之后,实际保存字符串的内存地址发生了改变
assert.NotEqual(t, oldAddr, newAddr)
}

这可以用下图表示:

reflect_10

总结

  • 如果我们需要通过反射对象来修改变量的值,那么我们必须得有办法拿到变量实际存储的内存地址。这种情况下,很多时候都是通过传递指针给 reflect.ValueOf() 方法来实现的。
  • 但是对于 chanmapslice 或者其他类似的数据结构,它们通过指针来引用实际存储数据的内存,这种数据结构是通过通过传值给 reflect.ValueOf() 方法来实现修改其中的元素的。因为这些数据结构的数据部分可以通过指针的拷贝来修改。
  • 但是 mapslice 有可能会扩容,如果通过反射对象来追加元素,可能导致追加失败。这是因为,通过反射对象追加元素的时候,如果扩容了,那么原来的内存地址就会失效,这样我们其实就修改不了原来的 mapslice 了。
  • 同样的,结构体传值来创建反射对象的时候,如果其中有指针类型的字段,那么我们也可以通过指针来修改其中的元素。但是其他字段也还是修改不了的。
  • 如果我们创建反射对象的参数是 interface 类型,那么能不能修改元素的变量还是取决于我们这个 interface 类型变量的数据部分是值还是指针。如果 interface 变量中存储的是值,那么我们就不能修改其中的元素了。如果 interface 变量中存储的是指针,就可以修改。
  • 我们无法修改字符串的某一个字符,通过反射也不能,因为字符串本身是不可变的。不同的 stirng 类型的变量,如果它们的值是一样的,那么它们会共享实际存储字符串的内存。
  • 但是我们可以直接用一个新的字符串替代旧的字符串。

但其实说了那么多,简单来说只有一点,就是我们只能通过反射对象来修改指针类型的变量。如果拿不到实际存储数据的指针,那么我们就无法通过反射对象来修改其中的元素了。

反射概述

反射是这样一种机制,它是可以让我们在程序运行时(runtime)访问、检测和修改对象本身状态或行为的一种能力。 比如,从一个变量推断出其类型信息、以及存储的数据的一些信息,又或者获取一个对象有什么方法可以调用等。 反射经常用在一些需要同时处理不同类型变量的地方,比如序列化、反序列化、ORM 等等,如标准库里面的 json.Marshal

反射基础 - go 的 interface 是怎么存储的?

在正式开始讲解反射之前,我们有必要了解一下 go 里的接口(interface)是怎么存储的。 关于这个问题,在我的另外一篇文章中已经做了很详细的讲解 go interface 设计与实现, 这里不再赘述。但还是简单说一下,go 的接口是由两部分组成的,一部分是类型信息,另一部分是数据信息,如:

1
2
var a = 1
var b interface{} = a

对于这个例子,b 的类型信息是 int,数据信息是 1,这两部分信息都是存储在 b 里面的。b 的内存结构如下:

reflect_1

在上图中,b 的类型实际上是 eface,它是一个空接口,它的定义如下:

1
2
3
4
type eface struct {
_type *_type
data unsafe.Pointer
}

也就是说,一个 interface{} 中实际上既包含了变量的类型信息,也包含了类型的数据。 正因为如此,我们才可以通过反射来获取到变量的类型信息,以及变量的数据信息。

反射对象 - reflect.Type 和 reflect.Value

知道了 interface{} 的内存结构之后,我们就可以开始讲解反射了。反射的核心是两个对象,分别是 reflect.Typereflect.Value。 它们分别代表了 go 语言中的类型和值。我们可以通过 reflect.TypeOfreflect.ValueOf 来获取到一个变量的类型和值。

1
2
3
4
5
var a = 1
t := reflect.TypeOf(a)

var b = "hello"
t1 := reflect.ValueOf(b)

我们去看一下 TypeOfValueOf 的源码会发现,这两个方法都接收一个 interface{} 类型的参数,然后返回一个 reflect.Typereflect.Value 类型的值。这也就是为什么我们可以通过 reflect.TypeOfreflect.ValueOf 来获取到一个变量的类型和值的原因。

reflect_2

反射定律

在 go 官方博客中关于反射的文章 laws-of-reflection 中,提到了三条反射定律:

  1. 反射可以将 interface 类型变量转换成反射对象。
  2. 反射可以将反射对象还原成 interface 对象。
  3. 如果要修改反射对象,那么反射对象必须是可设置的(CanSet)。

关于这三条定律,官方博客已经有了比较完整的阐述,感兴趣的可以去看一下官方博客的文章。这里简单阐述一下:

反射可以将 interface 类型变量转换成反射对象。

其实也就是上面的 reflect.Typereflect.Value,我们可以通过 reflect.TypeOfreflect.ValueOf 来获取到一个变量的反射类型和反射值。

1
2
3
var a = 1
typeOfA := reflect.TypeOf(a)
valueOfA := reflect.ValueOf(a)

反射可以将反射对象还原成 interface 对象。

我们可以通过 reflect.Value.Interface 来获取到反射对象的 interface 对象,也就是传递给 reflect.ValueOf 的那个变量本身。 不过返回值类型是 interface{},所以我们需要进行类型断言。

1
2
i := valueOfA.Interface()
fmt.Println(i.(int))

如果要修改反射对象,那么反射对象必须是可设置的(CanSet)。

我们可以通过 reflect.Value.CanSet 来判断一个反射对象是否是可设置的。如果是可设置的,我们就可以通过 reflect.Value.Set 来修改反射对象的值。 这其实也是非常场景的使用反射的一个场景,通过反射来修改变量的值。

1
2
3
4
var x float64 = 3.4
v := reflect.ValueOf(&x)
fmt.Println("settability of v:", v.CanSet()) // false
fmt.Println("settability of v:", v.Elem().CanSet()) // true

那什么情况下一个反射对象是可设置的呢?前提是这个反射对象是一个指针,然后这个指针指向的是一个可设置的变量。 在我们传递一个值给 reflect.ValueOf 的时候,如果这个值只是一个普通的变量,那么 reflect.ValueOf 会返回一个不可设置的反射对象。 因为这个值实际上被拷贝了一份,我们如果通过反射修改这个值,那么实际上是修改的这个拷贝的值,而不是原来的值。 所以 go 语言在这里做了一个限制,如果我们传递进 reflect.ValueOf 的变量是一个普通的变量,那么在我们设置反射对象的值的时候,会报错。 所以在上面这个例子中,我们传递了 x 的指针变量作为参数。这样,运行时就可以找到 x 本身,而不是 x 的拷贝,所以就可以修改 x 的值了。

但同时我们也注意到了,在上面这个例子中,v.CanSet() 返回的是 false,而 v.Elem().CanSet() 返回的是 true。 这是因为,v 是一个指针,而 v.Elem() 是指针指向的值,对于这个指针本身,我们修改它是没有意义的,我们可以设想一下, 如果我们修改了指针变量(也就是修改了指针变量指向的地址),那会发生什么呢?那样我们的指针变量就不是指向 x 了, 而是指向了其他的变量,这样就不符合我们的预期了。所以 v.CanSet() 返回的是 false

v.Elem().CanSet() 返回的是 true。这是因为 v.Elem() 才是 x 本身,通过 v.Elem() 修改 x 的值是没有问题的。

reflect_3

Elem 方法

不知道有多少读者和我一样,在初次使用 go 的反射的时候,被 Elem 这个方法搞得一头雾水。 Elem 方法的作用是什么呢?在回答这个问题之前,我们需要明确一点:reflect.Valuereflect.Type 这两个反射对象都有 Elem 方法,既然是不同的对象,那么它们的作用自然是不一样的。

reflect.Value 的 Elem 方法

reflect.ValueElem 方法的作用是获取指针指向的值,或者获取接口的动态值。也就是说,能调用 Elem 方法的反射对象,必须是一个指针或者一个接口。 在使用其他类型的 reflect.Value 来调用 Elem 方法的时候,会 panic:

1
2
3
4
5
6
7
var a = 1
// panic: reflect: call of reflect.Value.Elem on int Value
reflect.ValueOf(a).Elem()

// 不报错
var b = &a
reflect.ValueOf(b).Elem()

对于指针很好理解,其实作用类似解引用。而对于接口,还是要回到 interface 的结构本身,因为接口里包含了类型和数据本身,所以 Elem 方法就是获取接口的数据部分(也就是 ifaceeface 中的 data 字段)。

指针类型:

reflect_4

接口类型:

reflect_5

reflect.Type 的 Elem 方法

reflect.TypeElem 方法的作用是获取数组、chan、map、指针、切片关联元素的类型信息,也就是说,对于 reflect.Type 来说, 能调用 Elem 方法的反射对象,必须是数组、chan、map、指针、切片中的一种,其他类型的 reflect.Type 调用 Elem 方法会 panic

示例:

1
2
3
t1 := reflect.TypeOf([3]int{1, 2, 3}) // 数组 [3]int
fmt.Println(t1.String()) // [3]int
fmt.Println(t1.Elem().String()) // int

需要注意的是,如果我们要获取 map 类型 key 的类型信息,需要使用 Key 方法,而不是 Elem 方法。

1
2
3
m := make(map[string]string)
t1 := reflect.TypeOf(m)
fmt.Println(t1.Key().String()) // string

Interface 方法

这也是非常常用的一个方法,reflect.ValueInterface 方法的作用是获取反射对象的动态值。 也就是说,如果反射对象是一个指针,那么 Interface 方法会返回指针指向的值。

简单来说,如果 var i interface{} = x,那么 reflect.ValueOf(x).Interface() 就是 i 本身,只不过其类型是 interface{} 类型。

Kind

说到反射,不得不提的另外一个话题就是 go 的类型系统,对于开发者来说,我们可以基于基本类型来定义各种新的类型,如:

1
2
3
4
5
6
7
// Kind 是 int
type myIny int
// Kind 是 Struct
type Person struct {
Name string
Age int
}

但是不管我们定义了多少种类型,在 go 看来都是下面的基本类型中的一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type Kind uint

const (
Invalid Kind = iota
Bool
Int
Int8
Int16
Int32
Int64
Uint
Uint8
Uint16
Uint32
Uint64
Uintptr
Float32
Float64
Complex64
Complex128
Array
Chan
Func
Interface
Map
Pointer
Slice
String
Struct
UnsafePointer
)

也就是说,我们定义的类型在 go 的类型系统中都是基本类型的一种,这个基本类型就是 Kind。 也正因为如此,我们可以通过有限的 reflect.TypeKind 来进行类型判断。 也就是说,我们在通过反射来判断变量的类型的时候,只需要枚举 Kind 中的类型,然后通过 reflect.TypeKind 方法来判断即可。

Type 表示的是反射对象(Type 对象是某一个 Kind,通过 Kind() 方法可以获取 Type 的 Kind),Kind 表示的是 go 底层类型系统中的类型。

比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func display(path string, v reflect.Value) {
switch v.Kind() {
case reflect.Invalid:
fmt.Printf("%s = invalid\n", path)
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
display(fmt.Sprintf("%s[%d]", path, i), v.Index(i))
}
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
fieldPath := fmt.Sprintf("%s.%s", path, v.Type().Field(i).Name)
display(fieldPath, v.Field(i))
}
case reflect.Map:
for _, key := range v.MapKeys() {
display(fmt.Sprintf("%s[%s]", path, formatAny(key)), v.MapIndex(key))
}
case reflect.Pointer:
if v.IsNil() {
fmt.Printf("%s = nil\n", path)
} else {
display(fmt.Sprintf("(*%s)", path), v.Elem())
}
case reflect.Interface:
if v.IsNil() {
fmt.Printf("%s = nil\n", path)
} else {
fmt.Printf("%s.type = %s\n", path, v.Elem().Type())
display(path+".value", v.Elem())
}
default:
fmt.Printf("%s = %s\n", path, formatAny(v))
}
}

我们在开发的时候非常常用的结构体,在 go 的类型系统中,通通都是 Struct 这种类型的。

addressable

go 反射中最后一个很重要的话题是 addressable。在 go 的反射系统中有两个关于寻址的方法:CanAddrCanSet

CanAddr 方法的作用是判断反射对象是否可以寻址,也就是说,如果 CanAddr 返回 true,那么我们就可以通过 Addr 方法来获取反射对象的地址。 如果 CanAddr 返回 false,那么我们就不能通过 Addr 方法来获取反射对象的地址。对于这种情况,我们就无法通过反射对象来修改变量的值。

但是,CanAddrtrue 并不是说 reflect.Value 一定就能修改变量的值了。 reflect.Value 还有一个方法 CanSet,只有 CanSet 返回 true,我们才能通过反射对象来修改变量的值。

那么 CanAddr 背后的含义是什么呢?它意味着我们传递给 reflect.ValueOf 的变量是不是可以寻址的。 也就是说,我们的反射值对象拿到的是不是变量本身,而不是变量的副本。 如果我们是通过 &v 这种方式来创建反射对象的,那么 CanAddr 就会返回 true, 反之,如果我们是通过 v 这种方式来创建反射对象的,那么 CanAddr 就会返回 false

如果想更详细的了解可以参考一下鸟窝的这篇文章 go addressable 详解

获取类型信息 - reflect.Type

概述

reflect.Type 是一个接口,它代表了一个类型。我们可以通过 reflect.TypeOf 来获取一个类型的 reflect.Type 对象。 我们使用 reflect.Type 的目的通常是为了获取类型的信息,比如类型是什么、类型的名称、类型的字段、类型的方法等等。 又或者最常见的场景:结构体中的 jsontag,它是没有语义的,它的作用就是为了在序列化的时候,生成我们想要的字段名。 而这个 tag 就是需要通过反射来获取的。

通用的 Type 方法

在 go 的反射系统中,是使用 reflect.Type 这个接口来获取类型信息的。reflect.Type 这个接口有很多方法,下面这些方法是所有的类型通用的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// Type 是 Go 类型的表示。
//
// 并非所有方法都适用于所有类型。
// 在调用 kind 具体方法之前,先使用 Kind 方法找出类型的种类。因为调用一个方法如果类型不匹配会导致 panic
//
// Type 类型值是可以比较的,比如用 == 操作符。所以它可以用做 map 的 key
// 如果两个 Type 值代表相同的类型,那么它们一定是相等的。
type Type interface {
// Align 返回该类型在内存中分配时,以字节数为单位的字节数
Align() int

// FieldAlign 返回该类型在结构中作为字段使用时,以字节数为单位的字节数
FieldAlign() int

// Method 这个方法返回类型方法集中的第 i 个方法。
// 如果 i 不在[0, NumMethod()]范围内,就会 panic。
// 对于非接口类型 T 或 *T,返回的 Method 的 Type 和 Func 字段描述了一个函数,
// 其第一个参数是接收者,并且只能访问导出的方法。
// 对于一个接口类型,返回的 Method 的 Type 字段给出的是方法签名,没有接收者,Func字段为nil。
// 方法是按字典序顺序排列的。
Method(int) Method

// MethodByName 返回类型的方法集中具有该名称的方法和一个指示是否找到该方法的布尔值。
// 对于非接口类型 T 或 *T,返回的 Method 的 Type 和 Func 字段描述了一个函数,
// 其第一个参数是接收者。
// 对于一个接口类型,返回的 Method 的 Type 字段给出的是方法签名,没有接收者,Func字段为nil。
MethodByName(string) (Method, bool)

// NumMethod 返回使用 Method 可以访问的方法数量。
// 对于非接口类型,它返回导出方法的数量。
// 对于接口类型,它返回导出和未导出方法的数量。
NumMethod() int

// Name 返回定义类型在其包中的类型名称。
// 对于其他(未定义的)类型,它返回空字符串。
Name() string

// PkgPath 返回一个定义类型的包的路径,也就是导入路径,导入路径是唯一标识包的类型,如 "encoding/base64"。
// 如果类型是预先声明的(string, error)或者没有定义(*T, struct{}, []int,或 A,其中 A 是一个非定义类型的别名),包的路径将是空字符串。
PkgPath() string

// Size 返回存储给定类型的值所需的字节数。它类似于 unsafe.Sizeof.
Size() uintptr

// String 返回该类型的字符串表示。
// 字符串表示法可以使用缩短的包名。
// (例如,使用 base64 而不是 "encoding/base64")并且它并不能保证类型之间是唯一的。如果是为了测试类型标识,应该直接比较类型 Type。
String() string

// Kind 返回该类型的具体种类。
Kind() Kind

// Implements 表示该类型是否实现了接口类型 u。
Implements(u Type) bool

// AssignableTo 表示该类型的值是否可以分配给类型 u。
AssignableTo(u Type) bool

// ConvertibleTo 表示该类型的值是否可转换为 u 类型。
ConvertibleTo(u Type) bool

// Comparable 表示该类型的值是否具有可比性。
Comparable() bool
}

某些类型特定的 Type 方法

下面是某些类型特定的方法,对于这些方法,如果我们使用的类型不对,则会 panic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
type Type interface {
// Bits 以 bits 为单位返回类型的大小。
// 如果类型的 Kind 不属于:sized 或者 unsized Int, Uint, Float, 或者 Complex,会 panic。
Bits() int

// ChanDir 返回一个通道类型的方向。
// 如果类型的 Kind 不是 Chan,会 panic。
ChanDir() ChanDir

// IsVariadic 表示一个函数类型的最终输入参数是否为一个 "..." 可变参数。如果是,t.In(t.NumIn() - 1) 返回参数的隐式实际类型 []T.
// 更具体的,如果 t 代表 func(x int, y ... float64),那么:
// t.NumIn() == 2
// t.In(0)是 "int" 的 reflect.Type 反射类型。
// t.In(1)是 "[]float64" 的 reflect.Type 反射类型。
// t.IsVariadic() == true
// 如果类型的 Kind 不是 Func,IsVariadic 会 panic
IsVariadic() bool

// Elem 返回一个 type 的元素类型。
// 如果类型的 Kind 不是 Array、Chan、Map、Ptr 或 Slice,就会 panic
Elem() Type

// Field 返回一个结构类型的第 i 个字段。
// 如果类型的 Kind 不是 Struct,就会 panic。
// 如果 i 不在 [0, NumField()) 范围内也会 panic。
Field(i int) StructField

// FieldByIndex 返回索引序列对应的嵌套字段。它相当于对每一个 index 调用 Field。
// 如果类型的 Kind 不是 Struct,就会 panic。
FieldByIndex(index []int) StructField

// FieldByName 返回给定名称的结构字段和一个表示是否找到该字段的布尔值。
FieldByName(name string) (StructField, bool)

// FieldByNameFunc 返回一个能满足 match 函数的带有名称的 field 字段。布尔值表示是否找到。
FieldByNameFunc(match func(string) bool) (StructField, bool)

// In 返回函数类型的第 i 个输入参数的类型。
// 如果类型的 Kind 不是 Func 类型会 panic。
// 如果 i 不在 [0, NumIn()) 的范围内,会 panic。
In(i int) Type

// Key 返回一个 map 类型的 key 类型。
// 如果类型的 Kind 不是 Map,会 panic。
Key() Type

// Len 返回一个数组类型的长度。
// 如果类型的 Kind 不是 Array,会 panic。
Len() int

// NumField 返回一个结构类型的字段数目。
// 如果类型的 Kind 不是 Struct,会 panic。
NumField() int

// NumIn 返回一个函数类型的输入参数数。
// 如果类型的 Kind 不是Func.NumIn(),会 panic。
NumIn() int

// NumOut 返回一个函数类型的输出参数数。
// 如果类型的 Kind 不是 Func.NumOut(),会 panic。
NumOut() int

// Out 返回一个函数类型的第 i 个输出参数的类型。
// 如果类型的 Kind 不是 Func,会 panic。
// 如果 i 不在 [0, NumOut()) 的范围内,会 panic。
Out(i int) Type
}

创建 reflect.Type 的方式

我们可以通过下面的方式来获取变量的类型信息(创建 reflect.Type 的方式):

reflect_6

获取值信息 - reflect.Value

概述

reflect.Value 是一个结构体,它代表了一个值。 我们使用 reflect.Value 可以实现一些接收多种类型参数的函数,又或者可以让我们在运行时针对值的一些信息来进行修改。 常常用在接收 interface{} 类型参数的方法中,因为参数是接口类型,所以我们可以通过 reflect.ValueOf 来获取到参数的值信息。 比如类型、大小、结构体字段、方法等等。

同时,我们可以对这些获取到的反射值进行修改。这也是反射的一个重要用途。

reflect.Value 的方法

reflect.Value 这个 Sreuct 同样有很多方法:具体可以分为以下几类:

  1. 设置值的方法:Set*SetSetBoolSetBytesSetCapSetComplexSetFloatSetIntSetLenSetMapIndexSetPointerSetStringSetUint。通过这类方法,我们可以修改反射值的内容,前提是这个反射值得是合适的类型。CanSet 返回 true 才能调用这类方法
  2. 获取值的方法:InterfaceInterfaceDataBoolBytesComplexFloatIntStringUint。通过这类方法,我们可以获取反射值的内容。前提是这个反射值是合适的类型,比如我们不能通过 complex 反射值来调用 Int 方法(我们可以通过 Kind 来判断类型)。
  3. map 类型的方法:MapIndexMapKeysMapRangeMapSet
  4. chan 类型的方法:CloseRecvSendTryRecvTrySend
  5. slice 类型的方法:LenCapIndexSliceSlice3
  6. struct 类型的方法:NumFieldNumMethodFieldFieldByIndexFieldByNameFieldByNameFunc
  7. 判断是否可以设置为某一类型:CanConvertCanComplexCanFloatCanIntCanInterfaceCanUint
  8. 方法类型的方法:MethodMethodByNameCallCallSlice
  9. 判断值是否有效:IsValid
  10. 判断值是否是 nilIsNil
  11. 判断值是否是零值:IsZero
  12. 判断值能否容纳下某一类型的值:OverflowOverflowComplexOverflowFloatOverflowIntOverflowUint
  13. 反射值指针相关的方法:AddrCanAddrtrue 才能调用)、UnsafeAddrPointerUnsafePointer
  14. 获取类型信息:TypeKind
  15. 获取指向元素的值:Elem
  16. 类型转换:Convert

Len 也适用于 slicearraychanmapstring 类型的反射值。

创建 reflect.Value 的方式

我们可以通过下面的方式来获取变量的值信息(创建 reflect.Value 的方式):

reflect_7

总结

  • reflect 包提供了反射机制,可以在运行时获取变量的类型信息、值信息、方法信息等等。
  • go 中的 interface{} 实际上包含了两个指针,一个指向类型信息,一个指向值信息。正因如此,我们可以在运行时通过 interface{} 来获取变量的类型信息、值信息。
  • reflect.Type 代表一个类型,reflect.Value 代表一个值。通过 reflect.Type 可以获取类型信息,通过 reflect.Value 可以获取值信息。
  • 反射三定律:
    • 反射可以将 interface 类型变量转换成反射对象。
    • 反射可以将反射对象还原成 interface 对象。
    • 如果要修改反射对象,那么反射对象必须是可设置的(CanSet)。
  • reflect.Valuereflect.Type 里面都有 Elem 方法,但是它们的作用不一样:
    • reflect.TypeElem 方法返回的是元素类型,只适用于 array、chan、map、pointer 和 slice 类型的 reflect.Type
    • reflect.ValueElem 方法返回的是值,只适用于接口或指针类型的 reflect.Value
  • 通过 reflect.ValueInterface 方法可以获取到反射对象的原始变量,但是是 interface{} 类型的。
  • TypeKind 都表示类型,但是 Type 是类型的反射对象,Kind 是 go 类型系统中最基本的一些类型,比如 intstringstruct 等等。
  • 如果我们想通过 reflect.Value 来修改变量的值,那么 reflect.Value 必须是可设置的(CanSet)。同时如果想要 CanSet 为 true,那么我们的变量必须是可寻址的。
  • 我们有很多方法可以创建 reflect.Typereflect.Value,我们需要根据具体的场景来选择合适的方法。
  • reflect.Typereflect.Value 里面,都有一部分方法是通用的,也有一部分只适用于特定的类型。如果我们想要调用那些适用于特定类型的方法,那么我们必须先判断 reflect.Typereflect.Value 的类型(这里说的是 Kind),然后再调用。

本文基于 Go 1.19。

go 里面的 WaitGroup 是非常常见的一种并发控制方式,它可以让我们的代码等待一组 goroutine 的结束。 比如在主协程中等待几个子协程去做一些耗时的操作,如发起几个 HTTP 请求,然后等待它们的结果。

WaitGroup 示例

下面的代码展示了一个 goroutine 等待另外 2 个 goroutine 结束的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func TestWaitgroup(t *testing.T) {
var wg sync.WaitGroup
// 计数器 +2
wg.Add(2)

go func() {
sendHttpRequest("https://baidu.com")
// 计数器 -1
wg.Done()
}()

go func() {
sendHttpRequest("https://baidu.com")
// 计数器 -1
wg.Done()
}()

// 阻塞。计数器为 0 的时候,Wait 返回
wg.Wait()
}

// 发起 HTTP GET 请求
func sendHttpRequest(url string) (string, error) {
method := "GET"

client := &http.Client{}
req, err := http.NewRequest(method, url, nil)

if err != nil {
return "", err
}

res, err := client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()

body, err := io.ReadAll(res.Body)
if err != nil {
return "", err
}

return string(body), err
}

在这个例子中,我们做了如下事情:

  • 定义了一个 WaitGroup 对象 wg,调用 wg.Add(2) 将其计数器 +2
  • 启动两个新的 goroutine,在这两个 goroutine 中,使用 sendHttpRequest 函数发起了一个 HTTP 请求。
  • 在 HTTP 请求返回之后,调用 wg.Done 将计数器 -1
  • 在函数的最后,我们调用了 wg.Wait,这个方法会阻塞,直到 WaitGroup 的计数器的值为 0 才会解除阻塞状态。

WaitGroup 基本原理

WaitGroup 内部通过一个计数器来统计有多少协程被等待。这个计数器的值在我们启动 goroutine 之前先写入(使用 Add 方法), 然后在 goroutine 结束的时候,将这个计数器减 1(使用 Done 方法)。除此之外,在启动这些 goroutine 的协程中, 会调用 Wait 来进行等待,在 Wait 调用的地方会阻塞,直到 WaitGroup 内部的计数器减到 0。 也就实现了等待一组 goroutine 的目的

背景知识

在操作系统中,有多种实现进程/线程间同步的方式,如:test_and_setcompare_and_swap、互斥锁等。 除此之外,还有一种是信号量,它的功能类似于互斥锁,但是它能提供更为高级的方法,以便进程能够同步活动。

信号量

一个信号量(semaphore)S是一个整型变量,它除了初始化外只能通过两个标准的原子操作:wait()signal() 来访问。 操作 wait() 最初称为 P(荷兰语 proberen,测试);操作 signal() 最初称为 V(荷兰语 verhogen,增加),可按如下来定义 wait()

PV 原语。

1
2
3
4
5
wait(S) {
while (S <= 0)
; // 忙等待
S--;
}

可按如下来定义 signal()

1
2
3
signal(S) {
S++;
}

wait()signal() 操作中,信号量整数值的修改应不可分割地执行。也就是说,当一个进程修改信号量值时,没有其他进程能够同时修改同一信号量的值。

简单来说,信号量实现的功能是:

  • 当信号量>0 时,表示资源可用,则 wait 会对信号量执行减 1 操作。
  • 当信号量<=0 时,表示资源暂时不可用,获取信号量时,当前的进程/线程会阻塞,直到信号量为正时被唤醒。

WaitGroup 中的信号量

WaitGroup 中,使用了信号量来实现 goroutine 的阻塞以及唤醒:

  • 在调用 Wait 的地方,goroutine 会陷入阻塞,直到信号量大于等于 0 的时候解除阻塞状态,得以继续执行。
  • 在调用 Done 的时候,如果 WaitGroup 内的等待协程的计数器减到 0 的时候,信号量会进行递增,这样那些阻塞的协程会进行执行下去。

WaitGroup 数据结构

1
2
3
4
5
6
7
type WaitGroup struct {
noCopy noCopy

// 高 32 位为计数器,低 32 位为等待者数量
state atomic.Uint64
sema uint32
}

noCopy

我们发现,WaitGroup 中有一个字段 noCopy,顾名思义,它的目的是防止复制。 这个字段在运行时是没有什么影响的,但是我们通过 go vet 可以发现我们对 WaitGroup 的复制。 为什么不能复制呢?因为一旦复制,WaitGroup 内的计数器就不再准确了,比如下面这个例子:

1
2
3
4
5
6
7
8
9
10
func test(wg sync.WaitGroup) {
wg.Done()
}

func TestWaitGroup(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
test(wg)
wg.Wait()
}

go 里面的函数参数传递是值传递。调用 test(wg) 的时候将 WaitGroup 复制了一份。

在这个例子中,程序会永远阻塞下去,因为 test 中调用 wg.Done() 的时候,只是将 WaitGroup 副本的计数器减去了 1, 而 TestWaitGroup 里面的 WaitGroup 的计数器并没有发生改变,因此 Wait 会永远阻塞。

我们如果需要将 WaitGroup 作为参数,请传递指针:

1
2
3
func test(wg *sync.WaitGroup) {
wg.Done()
}

传递指针之后,我们在 test 中调用 wg.Done() 修改的就是 TestWaitGroup 里面同一个 WaitGroup。 从而,Wait 方法可以正常返回。

state

WaitGroup 里面的 state 是一个 64 位的 atomic.Uint64 类型,它的高 32 位用来保存 counter(也就是上面说的计数器),低 32 位用来保存 waiter(也就是阻塞在 Wait 上的 goroutine 数量。)

waitgroup_1

sema

WaitGroup 通过 sema 来记录信号量:

  • runtime_Semrelease 表示将信号量递增(对应信号量中的 signal 操作)
  • runtime_Semacquire 表示将信号量递减(对应信号量中的 wait 操作)

简单来说,在调用 runtime_Semacquire 的时候 goroutine 会阻塞,而调用 runtime_Semrelease 会唤醒阻塞在同一个信号量上的 goroutine。

WaitGroup 的三个基本操作

  • Add: 这会将 WaitGroup 里面的 counter 加上一个整数(也就是传递给 Add 的函数参数)。
  • Done: 这会将 WaitGroup 里面的 counter 减去 1。
  • Wait: 这会将 WaitGroup 里面的 waiter 加上 1,并且调用 Wait 的地方会阻塞。(有可能会有多个 goroutine 等待一个 WaitGroup

WaitGroup 的实现

Add 的实现

Add 做了下面两件事:

  1. delta 加到 state 的高 32 位上
  2. 如果 counter0 了,并且 waiter 大于 0,表示所有被等待的 goroutine 都完成了,而还有在等待的 goroutine,这会唤醒那些阻塞在 Wait 上的 goroutine。

源码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func (wg *WaitGroup) Add(delta int) {
// wg.state 的计数器加上 delta
//(加到 state 的高 32 上)
state := wg.state.Add(uint64(delta) << 32) // 高 32 位加上 delta
v := int32(state >> 32) // 高 32 位(counter)
w := uint32(state) // 低 32 位(waiter)
// 计数器不能为负数(加上 delta 之后不能为负数,最小只能到 0)
if v < 0 {
panic("sync: negative WaitGroup counter")
}
// 正常使用情况下,是先调用 Add 再调用 Wait 的,这种情况下,w 是 0,v > 0
if w != 0 && delta > 0 && v == int32(delta) {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
// v > 0,计数器大于 0
// w == 0,没有在 Wait 的协程
// 说明还没有到唤醒 waiter 的时候
if v > 0 || w == 0 {
return
}

// Add 负数的时候,v 会减去对应的数值,减到最后 v 是 0。
// 计数器是 0,并且有等待的协程,现在要唤醒这些协程。

// 存在等待的协程时,goroutine 已将计数器设置为0。
// 现在不可能同时出现状态突变:
// - Add 不能与 Wait 同时发生,
// - 如果看到计数器==0,则 Wait 不会增加等待的协程。
// 仍然要做一个廉价的健康检查,以检测 WaitGroup 的误用。
if wg.state.Load() != state { // 不能在 Add 的同时调用 Wait
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}

// 将等待的协程数量设置为 0。
wg.state.Store(0)
for ; w != 0; w-- {
// signal,调用 Wait 的地方会解除阻塞
runtime_Semrelease(&wg.sema, false, 0) // goyield
}
}

Done 的实现

WaitGroup 里的 Done 其实只是对 Add 的调用,但是它的效果是,将计数器的值减去 1。 背后的含义是:一个被等待的协程执行完毕了

Wait 的实现

Wait 主要功能是阻塞当前的协程:

  1. Wait 会先判断计数器是否为 0,为 0 说明没有任何需要等待的协程,那么就可以直接返回了。
  2. 如果计数器还不是 0,说明有协程还没执行完,那么调用 Wait 的地方就需要被阻塞起来,等待所有的协程完成。

源码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func (wg *WaitGroup) Wait() {
for {
// 获取当前计数器
state := wg.state.Load()
// 计数器
v := int32(state >> 32)
// waiter 数量
w := uint32(state)
// v 为 0,不需要等待,直接返回
if v == 0 {
// 计数器是 0,不需要等待
return
}

// 增加 waiter 数量。
// 调用一次 Wait,waiter 数量会加 1。
if wg.state.CompareAndSwap(state, state+1) {
// 这会阻塞,直到 sema (信号量)大于 0
runtime_Semacquire(&wg.sema) // goparkunlock
// state 不等 0
// wait 还没有返回又继续使用了 WaitGroup
if wg.state.Load() != 0 {
panic("sync: WaitGroup is reused before previous Wait has returned")
}
// 解除阻塞状态了,可以返回了
return
}
// 状态没有修改成功(state 没有成功 +1),开始下一次尝试。
}
}

总结

  • WaitGroup 使用了信号量来实现了并发资源控制,sema 字段表示信号量。
  • 使用 runtime_Semacquire 会使得 goroutine 阻塞直到计数器减少至 0,而使用 runtime_Semrelease 会使得信号量递增,这等于是通知之前阻塞在信号量上的协程,告诉它们可以继续执行了。
  • WaitGroup 作为参数传递的时候,需要传递指针作为参数,否则在被调用函数内对 Add 或者 Done 的调用,在 caller 里面调用的 Wait 会观测不到。
  • WaitGroup 使用一个 64 位的数来保存计数器(高 32 位)和 waiter(低 32 位,正在等待的协程的数量)。
  • WaitGroup 使用 Add 增加计数器,使用 Done 来将计数器减 1,使用 Wait 来等待 goroutine。Wait 会阻塞直到计数器减少到 0

在很多情况下,我们可能需要控制某一段代码只执行一次,比如做某些初始化操作,如初始化数据库连接等。 对于这种场景,go 为我们提供了 sync.Once 对象,它保证了某个动作只被执行一次。 当然我们也是可以自己通过 Mutex 实现 sync.Once 的功能,但是相比来说繁琐了那么一点, 因为我们不仅要自己去控制锁,还要通过一个标识来标志是否已经执行过。

Once 的实现

Once 的实现非常简单,如下,就只有 20 来行代码,但里面包含了 go 并发、同步的一些常见处理方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package sync

import (
"sync/atomic"
)

type Once struct {
done uint32
m Mutex
}

func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}

func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}

简要说明:

  • done 字段指示了操作是否已执行,也就是我们传递给 Do 的函数是否已经被执行。
  • Do 方法接收一个函数参数,这个函数参数只会被执行一次。
  • Once 内部是通过 Mutex 来实现不同协程之间的同步的。

使用示例

在下面的例子中,once.Do(test) 被执行了 3 次,但是最终 test 只被执行了一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package sync

import (
"fmt"
"sync"
"testing"
)

var once sync.Once
var a = 0

func test() {
a++
}

func TestOnce(t *testing.T) {
var wg sync.WaitGroup
wg.Add(3)

for i := 0; i < 3; i++ {
go func() {
// once.Do 会调用 3 次,但最终只会执行一次
once.Do(test)

wg.Done()
}()
}

wg.Wait()

fmt.Println(a) // 1
}

Once 的一些工作机制

  1. OnceDo 方法可以保证,在多个 goroutine 同时执行 Do 方法的时候, 在第一个抢占到 Do 执行权的 goroutine 执行返回之前,其他 goroutine 都会阻塞在 Once.Do 的调用上, 只有第一个 Do 调用返回的时候,其他 goroutine 才可以继续执行下去,并且其他所有的 goroutine 不会再执行传递给 Do 的函数。(如果是初始化的场景,这可以避免尚未初始化完成就执行其他的操作)

  2. 如果 Once.Do 发生 panic 的时候,传递给 Do 的函数依然被标记为已完成。后续对 Do 的调用也不会再执行传给 Do 的函数参数。

  3. 我们不能简单地通过 atomic.CompareAndSwapUint32 来决定是否执行 f(),因为在多个 goroutine 同时执行的时候,它无法保证 f() 只被执行一次。所以 Once 里面用了 Mutex,这样就可以有效地保护临界区。

1
2
3
4
// 错误实现,这不能保证 f 只被执行一次
if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
f()
}
  1. Once.Do 的函数参数是没有参数的,如果我们需要传递一些参数,可以再对 f 做一层包裹。
1
config.once.Do(func() { config.init(filename) })

Once 详解

hotpath

这里说的 hotpath 指的是 Once 里的第一个字段 done

1
2
3
4
5
type Once struct {
// hotpath
done uint32
m Mutex
}

Once 结构体的第一个字段是 done,这是因为 done 的访问是远远大于 Once 中另外一个字段 m 的, 放在第一个字段中,编译器就可以做一些优化,因为结构体的地址其实就是结构体第一个字段的地址, 这样一来,在访问 done 字段的时候,就不需要通过结构体地址 + 偏移量的方式来访问, 这在一定程度上提高了性能。

结构体地址计算示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type person struct {
name string
age int
}

func TestStruct(t *testing.T) {
var p = person{
name: "foo",
age: 10,
}
// p 和 p.name 的地址相同
// 0xc0000100a8, 0xc0000100a8
fmt.Printf("%p, %p\n", &p, &p.name)

// p.age 的地址
// 0xc0000100b8
fmt.Printf("%p\n", &p.age)
// p.age 的地址也可以通过:结构体地址 + age 字段偏移量 计算得出。
// 0xc0000100b8
fmt.Println(unsafe.Add(unsafe.Pointer(&p), unsafe.Offsetof(p.age)))
}

atomic.LoadUint32

1
2
3
4
5
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}

Do 方法中,是通过 atomic.LoadUint32 的方式来判断 done 是否等于 0 的, 这是因为,如果直接使用 done == 0 的方式的话,就有可能导致在 doSlow 里面对 done 设置为 1 之后, 在 Do 方法里面无法正常观测到。因此用了 atomic.LoadUint32

而在 doSlow 里面是可以通过 done == 0 来判断的,这是因为 doSlow 里面已经通过 Mutex 保护起来了。 唯一设置 done = 1 的地方就在临界区里面,所以 doSlow 里面通过 done == 0 来判断是完全没有问题的。

atomic.StoreUint32

1
2
3
4
5
6
7
8
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}

doSlow 方法中,设置 done 为 1 也是通过 atomic.StoreUint32 来设置的。 这样就可以保证在设置了 done 为 1 之后,可以及时被其他 goroutine 看到。

Mutex

doSlow 的实现里面,最终还是要通过 Mutex 来保护临界区, 通过 Mutex 可以实现 f 只被执行一次,并且其他的 goroutine 都可以使用这一次 f 的执行结果。 因为其他 goroutine 在第一次 f 调用未返回之前,都阻塞在获取 Mutex 锁的地方, 当它们获取到 Mutex 锁的时候,得以继续往下执行,但这个时候 f 已经执行完毕了, 所以当它们获取到 Mutex 锁之后其实什么也没有干。

但是它们的阻塞状态被解除了,可以继续往下执行。

总结

  • Once 保证了传入的函数只会执行一次,这常常用在一些初始化的场景、或者单例模式。
  • Once 可以保证所有对 Do 的并发调用都是安全的,所有对 Once.Do 调用之后的操作,一定会在第一次对 f 调用之后执行。(没有获取到 f 执行权的 goroutine 会阻塞)
  • 即使 Once.Do 里面的 f 出现了 panic,后续也不会再次调用 f

基于 Go 1.19。

go 的切片我们都知道可以自动地进行扩容,具体来说就是在切片的容量容纳不下新的元素的时候, 底层会帮我们为切片的底层数组分配更大的内存空间,然后把旧的切片的底层数组指针指向新的内存中:

slice_4_1

目前网上一些关于扩容倍数的文章都是基于相对旧版本的 Go 的,新版本中,现在切片扩容的时候并不是那种准确的小于多少容量的时候就 2 倍扩容, 大于多少容量的时候就 1.25 倍扩容,其实这个数值多少不是非常关键的,我们只需要知道的是: 在容量较小的时候,扩容的因子更大,容量大的时候,扩容的因子相对来说比较小

扩容的示例

我们先通过一个简单的示例来感受一下切片扩容是什么时候发生的:

1
2
3
4
5
var slice = []int{1, 2, 3}
fmt.Println(slice, len(slice), cap(slice))

slice = append(slice, 4)
fmt.Println(slice, len(slice), cap(slice))

在这个例子中,slice 切片初始化的时候,长度和容量都是 3(容量不指定的时候默认等于长度)。 因此切片已经容纳不下新的元素了,在我们往 slice 中追加一个新的元素的时候, 我们发现,slice 的长度和容量都变了, 长度增加了 1,而容量变成了原来的 2 倍。

slice_4_2

在 1.18 版本以后,旧的切片容量小于 256 的时候,会进行 2 倍扩容。

实际扩容倍数

其实最新的扩容规则在 1.18 版本中就已经发生改变了,具体可以参考一下这个 commitruntime: make slice growth formula a bit smoother

大概意思是:

在之前的版本中:对于 <1024 个元素,增加 2 倍,对于 >=1024 个元素,则增加 1.25 倍。 而现在,使用更平滑的增长因子公式。 在 256 个元素后开始降低增长因子,但要缓慢。

它还给了个表格,写明了不同容量下的增长因子:

starting cap growth factor
256 2.0
512 1.63
1024 1.44
2048 1.35
4096 1.30

从这个表格中,我们可以看到,新版本的切片库容,并不是在容量小于 1024 的时候严格按照 2 倍扩容,大于 1024 的时候也不是严格地按照 1.25 倍来扩容。

growslice 实现

在 go 中,切片扩容的实现是 growslice 函数,位于 runtime/slice.go 中。

growslice 有如下参数:

  • oldPtr: 旧的切片的底层数组指针。
  • newLen: 新的切片的长度(= oldLen + num)。
  • oldCap: 旧的切片的容量。
  • num: 添加的元素数。
  • et: 切片的元素类型(也即 element type)。

返回一个新的切片,这个返回的切片中,底层数组指针指向新分配的内存空间,长度等于 oldLen + num,容量就是底层数组的大小。

growslice 实现步骤

  1. 一些特殊情况判断:如 et.size == 0,切片元素不需要占用空间的情况下,直接返回。
  2. 根据 newLen 计算新的容量,保证新的底层数组至少可以容纳 newLen 个元素。
  3. 计算所需要分配的新的容量所需的内存大小。
  4. 分配新的切片底层数组所需要的内存。
  5. 将旧切片上的底层数组的数据复制到新的底层数组中。

注意:这个函数只是实现扩容,新增的元素没有在这个函数往切片中追加。

growslice 源码剖析

说明:

  1. 整数有可能会溢出,所以代码里面会判断 newLen < 0
  2. 如果切片的元素是空结构体或者空数组,那么 et.size == 0
  3. 在计算新切片的容量的时候,会根据切片的元素类型大小来做一些优化。
  4. 新切片容量所占用的内存大小为 capmem
  5. 新切片所需要的内存分配完成后,会将旧切片的数据复制到新切片中。
  6. 最后返回指向新的底层数组的切片,其长度为 newLen,容量为 newcap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// growtslice 为切片分配新的存储空间。
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
// oldLen 为旧的切片底层数组的长度
oldLen := newLen - num

// 分配的新的长度不能小于 0(整数溢出的时候会是负数)
if newLen < 0 {
panic(errorString("growslice: len out of range"))
}

// 如果结构或数组类型不包含大小大于零的字段(或元素),则其大小为零。
//(空数组、空结构体,type b [0]int、type zero struct{})
// 两个不同的零大小变量在内存中可能具有相同的地址。
if et.size == 0 {
// append 不应创建具有 nil 指针但长度非零的切片。
// 在这种情况下,我们假设 append 不需要保留 oldPtr。
return slice{unsafe.Pointer(&zerobase), newLen, newLen}
}

// newcap 是新切片底层数组的容量
newcap := oldCap
// 两倍容量
doublecap := newcap + newcap
if newLen > doublecap {
// 如果追加元素之后,新的切片长度比旧切片 2 倍容量还大,
// 则将新的切片的容量设置为跟长度一样
newcap = newLen
} else {
const threshold = 256
if oldCap < threshold {
// 旧的切片容量小于 256 的时候,
// 进行两倍扩容。
newcap = doublecap
} else {
// oldCap >= 256
// 检查 0<newcap 以检测溢出并防止无限循环。
for 0 < newcap && newcap < newLen {
// 从小切片的增长 2 倍过渡到大切片的增长 1.25 倍。
newcap += (newcap + 3*threshold) / 4
}
// 当 newcap 计算溢出时,将 newcap 设置为请求的上限。
if newcap <= 0 {
newcap = newLen
}
}
}

// 计算实际所需要的内存大小

// 是否溢出
var overflow bool
// lenmem 表示旧的切片长度所需要的内存大小
//(lenmem 就是将旧切片数据复制到新切片的时候指定需要复制的内存大小)
// newlenmem 表示新的切片长度所需要的内存大小
// capmem 表示新的切片容量所需要的内存大小
var lenmem, newlenmem, capmem uintptr

// 根据 et.size 做一些计算上的优化:
// 对于 1,我们不需要任何除法/乘法。
// 对于 goarch.PtrSize,编译器会将除法/乘法优化为移位一个常数。
// 对于 2 的幂,使用可变移位。
switch {
case et.size == 1: // 比如 []byte,所需内存大小 = size
lenmem = uintptr(oldLen)
newlenmem = uintptr(newLen)
capmem = roundupsize(uintptr(newcap))
overflow = uintptr(newcap) > maxAlloc
newcap = int(capmem)
case et.size == goarch.PtrSize: // 比如 []*int,所需内存大小 = size * ptrSize
lenmem = uintptr(oldLen) * goarch.PtrSize
newlenmem = uintptr(newLen) * goarch.PtrSize
capmem = roundupsize(uintptr(newcap) * goarch.PtrSize)
overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
newcap = int(capmem / goarch.PtrSize)
case isPowerOfTwo(et.size): // 比如 []int64,所需内存大小 = size << shift,也就是 size * 2^shift(2^shift 是 et.size)
var shift uintptr
if goarch.PtrSize == 8 {
// Mask shift for better code generation.
shift = uintptr(sys.TrailingZeros64(uint64(et.size))) & 63
} else {
shift = uintptr(sys.TrailingZeros32(uint32(et.size))) & 31
}
lenmem = uintptr(oldLen) << shift
newlenmem = uintptr(newLen) << shift
capmem = roundupsize(uintptr(newcap) << shift)
overflow = uintptr(newcap) > (maxAlloc >> shift)
newcap = int(capmem >> shift)
capmem = uintptr(newcap) << shift
default: // 没得优化,直接使用乘法了
lenmem = uintptr(oldLen) * et.size
newlenmem = uintptr(newLen) * et.size
capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
capmem = roundupsize(capmem)
newcap = int(capmem / et.size)
capmem = uintptr(newcap) * et.size
}

// 检查是否溢出,以及是否超过最大可分配内存
if overflow || capmem > maxAlloc {
panic(errorString("growslice: len out of range"))
}

// 分配实际所需要的内存
var p unsafe.Pointer
if et.ptrdata == 0 { // 不包含指针
// 分配 capmem 大小的内存,不清零
p = mallocgc(capmem, nil, false)
// 这里只清空从 add(p, newlenmem) 开始大小为 capmem-newlenmem 的内存,
// 也就是前面的 newlenmem 长度不清空。
// 因为最后的 capmem-newlenmem 这块内存,实际上是额外分配的容量。
// 前面的那部分会被旧切片的数据以及新追加的数据覆盖。
memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
} else {
// 分配 capmem 大小的内存,需要进行清零
p = mallocgc(capmem, et, true)
if lenmem > 0 && writeBarrier.enabled {
// Only shade the pointers in oldPtr since we know the destination slice p
// only contains nil pointers because it has been cleared during alloc.
bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(oldPtr), lenmem-et.size+et.ptrdata)
}
}
// 旧切片数据复制到新切片中,复制的内容大小为 lenmem
//(从 oldPtr 复制到 p)
memmove(p, oldPtr, lenmem)

return slice{p, newLen, newcap}
}

总结

go 的切片在容量较小的情况下,确实会进行 2 倍扩容,但是随着容量的增长,扩容的增长因子会逐渐降低。 新版本的 growslice 实现中,只有容量小于 256 的时候才会进行 2 倍扩容, 然后随着容量的增长,扩容的因子会逐渐降低(但并不是直接降到 1.25,而是一个相对缓慢的下降)。