反射概述

反射是这样一种机制,它是可以让我们在程序运行时(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,而是一个相对缓慢的下降)。

slice(切片)是 go 里面非常常用的一种数据结构,它代表了一个变长的序列,序列中的每个元素都有相同的数据类型。 一个 slice 类型一般写作 []T,其中 T 代表 slice 中元素的类型;slice 的语法和数组很像,但是 slice 没有固定长度。

数组和切片的区别

数组有确定的长度,而切片的长度不固定,并且可以自动扩容。

数组的定义

go 中定义数组的方式有如下两种:

  1. 指定长度:
1
arr := [3]int{1, 2, 3}
  1. 不指定长度,由编译器推导出数组的长度:
1
arr := [...]{1, 2, 3}

上面这两种定义方式都定义了一个长度为 3 的数组。正如我们所见,长度是数组的一部分,定义数组的时候长度已经确定下来了

切片的定义

切片的定义方式跟数组很像,只不过定义切片的时候不用指定长度

1
s := []int{1, 2, 3}

在上面定义切片的代码中,我们可以看到其实跟数组唯一的区别就是少了个长度。 那其实我们可以把切片看作是一个无限长度的数组。 当然,实际上它并不是无限的,它只是在切片容纳不下新的元素的时候,会自动进行扩容,从而可以容纳更多的元素。

数组和切片的相似之处

正如我们上面看到的那样,数组和切片两者其实非常相似,在实际使用中,它们也是有些类似的。

比如,通过下标来访问元素:

1
2
3
4
5
6
7
arr := [3]int{1, 2, 3}
// 通过下标访问
fmt.Println(arr[1]) // 2

s := []int{1, 2, 3}
// 通过下标访问
fmt.Println(s[1]) // 2

数组的局限

我们知道了,数组的长度是固定的,这也就意味着如果我们想往数组里面增加一个元素会比较麻烦, 我们需要新建一个更大的数组,然后将旧的数据复制过去,然后将新的元素写进去,如:

1
2
3
4
5
6
7
8
9
10
11
// 往数组 arr 增加一个元素:4
arr := [3]int{1, 2, 3}
// 新建一个更大容量的数组
var arr1 [4]int
// 复制旧数组的数据
for i := 0; i < len(arr); i++ {
arr1[i] = arr[i]
}
// 加入新的元素:4
arr1[3] = 4
fmt.Println(arr1)

这样一来就非常的繁琐,如果我们使用切片,就可以省去这些步骤:

1
2
3
4
5
6
7
8
// 定义一个长度为 3 的数组
arr := [3]int{1, 2, 3}

// 从数组创建一个切片
s := arr[:]
// 增加一个元素
s = append(s, 4)
fmt.Println(s)

因为数组固定长度的缺点,实际使用中切片会使用得更加普遍。

重新理解 slice

在开始之前,我们来看看 slice 这个单词的意思:作为名词,slice 的意思有 片;部分;(切下的食物)薄片;,作为动词,slice 的意思有 切;把…切成(薄)片; 的意思。 从这个角度出发,我们可以把 slice 理解为从某个数组上 切下来的一部分(从这个角度看,slice 这个命名非常的形象)。我们可以看看下图:

slice_1_1

在这个图中,A 是一个保存了数字 1~7sliceB 是从 A切下来的一部分,而 B 只包含了 A 中的一部分数据。 我们可以把 B 理解为 A 的一个 视图B 中的数据是 A 中的数据的一个 引用,而不是 A 中数据的一个 拷贝 (也就是说,我们修改 B 的时候,A 中的数据也会被修改,当然会有例外,那就是 B 发生扩容的时候,再去修改 B 的话就影响不了 A 了)。

slice 的内存布局

现在假设我们有如下代码:

1
2
3
4
5
6
// 创建一个切片,长度为 3,容量为 7
var s = make([]int, 3, 7)
s[0] = 1
s[1] = 2
s[2] = 3
fmt.Println(s)

对应的内存布局如下:

slice_2_1

说明:

  • slice 底层其实也是数组,但是除了数组之外,还有两个字段记录切片的长度和容量,分别是 lencap
  • 上图中,slice 中的 array 就是切片的底层数组,因为它的长度不是固定的,所以使用了指针来保存,指向了另外一片内存区域。
  • len 表明了切片的长度,切片的长度也就是我们可以操作的下标,上面的切片长度为 3,这也就意味着我们切片可以操作的下标范围是 0~2。超出这个范围的下标会报错。
  • cap 表明了切片的容量,也就是切片扩容之前可以容纳的元素个数

切片容量存在的意义

对于我们日常开发来说,slice 的容量其实大多数时候不是我们需要关注的点,而且由于容量的存在,也给开发者带来了一定的困惑。 那么容量存在的意义是什么呢?意义就在于避免内存的频繁分配带来的性能下降(容量也就是提前分配的内存大小)。

比如,假如我们有一个切片,然后我们知道需要往它里面存放 1w 个元素, 如果我们不指定容量的话,那么切片就会在它存放不下新的元素的时候进行扩容, 这样一来,可能在我们存放这 1w 个元素的时候需要进行多次扩容, 这也就意味着需要进行多次的内存分配。这样就会影响应用的性能。

我们可以通过下面的例子来简单了解一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Benchmark1-20    	100000000	        11.68 ns/op
func Benchmark1(b *testing.B) {
var s []int
for i := 0; i < b.N; i++ {
s = append(s, 1)
}
}

// Benchmark2-20 134283985 7.482 ns/op
func Benchmark2(b *testing.B) {
var s []int = make([]int, 10, 100000000)
for i := 0; i < b.N; i++ {
s = append(s, 1)
}
}

在第一个例子中,没有给 slice 设置容量,这样它就只会在切片容纳不下新元素的时候才会进行扩容,这样就会需要进行多次扩容。 而第二个例子中,我们先给 slice 设置了一个足够大的容量,那么它就不需要进行频繁扩容了。

最终我们发现,在给切片提前设置容量的情况下,会有一定的性能提升。

切片常用操作

创建切片

我们可以从数组或切片生成新的切片:

注意:生成的切片不包含 end

1
target[start:end]

说明:

  • target 表示目标数组或者切片
  • start 对应目标对象的起始索引(包含)
  • end 对应目标对象的结束索引(不包含)

如:

1
2
3
4
5
6
7
s := []int{1, 2, 3}
s1 := s[1:2] // 包含下标 1,不包含下标 2
fmt.Println(s1) // [2]

arr := [3]int{1, 2, 3}
s2 := arr[1:2]
fmt.Println(s2) // [2]

在这种初始化方式中,我们可以省略 start

1
2
arr := [3]int{1, 2, 3}
fmt.Println(arr[:2]) // [1, 2]

省略 start 的情况下,就是从 target 的第一个元素开始。

我们也可以省略 end

1
2
arr := [3]int{1, 2, 3}
fmt.Println(arr[1:]) // [2, 3]

省略 end 的情况下,就是从 start 索引处的元素开始直到 target 的最后一个元素处。

除此之外,我们还可以指定新的切片的容量,通过如下这种方式:

1
target[start:end:cap]

例子:

1
2
3
arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
s := arr[1:4:5]
fmt.Println(s, len(s), cap(s)) // [2 3 4] 3 4

往切片中添加元素

我们前面说过了,如果我们想往数组里面增加元素,那么我们必须开辟新的内存,将旧的数组复制过去,然后才能将新的元素加入进去。

但是切片就相对简单,我们可以使用 append 这个内置函数来往切片中加入新的元素:

1
2
3
4
var a []int
a = append(a, 1) // 追加1个元素
a = append(a, 1, 2, 3) // 追加多个元素
a = append(a, []int{1,2,3}...) // 追加一个切片

切片复制

go 有一个内置函数 copy 可以将一个切片的内容复制到另外一个切片中:

1
copy(dst, src []int)

第一个参数 dst 是目标切片,第二个参数 src 是源切片,调用 copy 的时候会把 src 的内容复制到 dst 中。

示例:

1
2
3
4
5
6
7
8
9
10
var a []int
var b []int = []int{1, 2, 3}

// a 的容量为 0,容纳不下任何元素
copy(a, b)
fmt.Println(a) // []

a = make([]int, 3, 3) // 给 a 分配内存
copy(a, b)
fmt.Println(a) // [1 2 3]

需要注意的是,如果 dst 的长度比 src 的长度小,那么只会截取 src 的前面一部分。

从切片删除元素

虽然我们往切片追加元素的操作挺方便的,但是要从切片删除元素就相对麻烦一些了。go 语言本身没有提供从切片删除元素的方法。 如果我们要删除切片中的元素,只有构建出一个新的切片:

slice_3_1

对应代码:

1
2
3
4
5
6
7
8
9
10
var a = make([]int, 7, 7)
for i := 0; i < 7; i++ {
a[i] = i + 1
}
fmt.Println(a) // [1 2 3 4 5 6 7]

var b []int
b = append(b, a[:2]...) // [1 2]
b = append(b, a[5:]...) // [1 2 6 7]
fmt.Println(b) // [1 2 6 7]

在这个例子中,我们想从 a 中删除 3、4、5 这三个元素,也就是下标 2~4 的元素, 我们的做法是,新建了一个新的切片,然后将 3 前面的元素加入到这个新的切片中, 再将 5 后面的元素加入到这个新切片中。

最终得到的切片就是删除了 3、4、5 三个元素之后的切片了。

切片的容量到底是多少?

假设我们有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a = make([]int, 7, 7)
for i := 0; i < 7; i++ {
a[i] = i + 1
}
// [1 2 3 4 5 6 7]
fmt.Println(a)

s1 := a[:3]
// [1 2 3] 3 7
fmt.Println(s1, len(s1), cap(s1))

s2 := a[4:6]
// [5 6] 2 3
fmt.Println(s2, len(s2), cap(s2))

s1s2 可以用下图表示:

slice_3_2
  • s1 只能访问 array 的前三个元素,s2 只能访问 56 这两个元素。
  • s1 的容量是 7(底层数组的长度)
  • s2 的容量是 3,从 5 所在的索引处直到底层数组的末尾。

对于 s1s2,我们都没有指定它的容量,但是我们打印发现它们都有容量, 其实在切片中,我们从切片中生成一个新的切片的时候,如果我们不指定容量, 那新切片的容量就是 s[start:end] 中的 start 直到底层数组的最后一个元素的长度。

切片可以共享底层数组

切片最需要注意的点是,当我们从一个切片中创建新的切片的时候,两者会共享同一个底层数组, 如上图的那样,s1s2 都引用了同一个底层的数组不同的索引, s1 引用了底层数组的 0~2 下标范围,s2 引用了底层数组 4~5 下标范围。

这意味着,当我们修改 s1s2 的时候,原来的切片 a 也会发生改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var a = make([]int, 7, 7)
for i := 0; i < 7; i++ {
a[i] = i + 1
}
// [1 2 3 4 5 6 7]
fmt.Println(a)

s1 := a[:3]
// [1 2 3]
fmt.Println(s1)

s1[1] = 100
// [1 100 3 4 5 6 7]
fmt.Println(a)
// [1 100 3]
fmt.Println(s1)

在上面的例子中,s1 这个切片引用了和 a 一样的底层数组, 然后在我们修改 s1 的时候,a 也发生了改变。

切片扩容不会影响原切片

上一小节我们说了,切片可以共享底层数组。但是如果切片扩容的话,那就是一个全新的切片了

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = []int{1, 2, 3}
// [1 2 3] 3 3
fmt.Println(a, len(a), cap(a))

// a 容纳不下新的元素了,会进行扩容
b := append(a, 4)
// [1 2 3 4] 4 6
fmt.Println(b, len(b), cap(b))
b[1] = 100
// [1 2 3]
fmt.Println(a)
// [1 100 3 4]
fmt.Println(b)

在上面这个例子中,a 是一个长度和容量都是 3 的切片,这也就意味着,这个切片已经满了。 在这种情况下,我们再往其中追加元素的时候,就会进行扩容,生成一个新的切片。 因此,我们可以看到,我们修改了 b 的时候,并没有影响到 a

下面的例子就不一样了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 长度为 2,容量为 3
var a = make([]int, 2, 3)
a[0] = 1
a[1] = 2
// [1 2] 2 3
fmt.Println(a, len(a), cap(a))

// a 还可以容纳新的元素,不用扩容
b := append(a, 4)
// [1 2 4] 3 3
fmt.Println(b, len(b), cap(b))
b[1] = 100
// [1 100]
fmt.Println(a)
// [1 100 4]
fmt.Println(b)

在后面这个例子中,我们只是简单地改了一下 a 初始化的方式,改成了只放入两个元素,但是容量还是 3, 在这种情况下,a 可以再容纳一个元素,这样在 b := append(a, 4) 的时候,创建的 b 底层的数组其实跟 a 的底层数组依然是一样的。

所以,我们需要尤其注意代码中作为切片的函数参数,如果我们希望在被调函数中修改了切片之后,在 caller 里面也能看到效果的话,最好是传递指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func test1(s []int) {
s = append(s, 4)
}

func test2(s *[]int) {
*s = append(*s, 4)
}

func TestSlice(t *testing.T) {
var a = []int{1, 2, 3}
// [1 2 3] 3 3
fmt.Println(a, len(a), cap(a))
test1(a)
// [1 2 3] 3 3
fmt.Println(a, len(a), cap(a))

var b = []int{1, 2, 3}
// [1 2 3] 3 3
fmt.Println(b, len(b), cap(b))
test2(&b)
// [1 2 3 4] 4 6
fmt.Println(b, len(b), cap(b))
}

在上面的例子中,test1 接收的是值参数,所以在 test1 中切片发生扩容的时候,TestSlice 里面的 a 还是没有发生改变。 而 test2 接收的是指针参数,所以在 test2 中发生切片扩容的时候,TestSlice 里面的 b 也发生了改变。

总结

  • 数组跟切片的使用上有点类似,但是数组代表的是有固定长度的数据序列,而切片代表的是没有固定长度的数据序列。
  • 数组的长度是类型的一部分,有两种定义数组的方式:[2]int{1, 2}[...]int{1, 2}
  • 数组跟切片都可以通过下标来访问其中的元素,可以访问的下标范围都是 0 ~ len(x)-1x 表示的是数组或者切片。
  • 数组无法追加新的元素,切片可以追加任意数量的元素。
  • slice 的数据结构里面包含了:array 底层数组指针、len 切片长度、cap 切片容量。
  • 创建切片的时候,指定一个合适的容量可以减少内存分配的次数,从而在一定程度上提高程序性能。
  • 我们可以从数组或者切片创建一个新的切片:array[1:3] 或者 slice[1:3]
  • 使用 append 内置函数可以往切片中添加新的元素。
  • 使用 copy 内置函数可以将一个切片的内容复制到另外一个切片中。
  • 切片删除元素没有好的办法,只能截取被删除元素前后的数据,然后复制到一个新的切片中。
  • 假设我们通过 slice[start:end] 的方式从切片中创建一个新的切片,那么这个新的切片的容量是 cap(slice) - start,也就是,从 start 到底层数组最后一个元素的长度。
  • 使用切片的时候需要注意:切片之间会共享底层数组,其中一个切片修改了切片的元素的时候,也会反映到其他切片上。
  • 函数调用的时候,如果被调函数内发生扩容,调用者是无法知道的。如果我们不想错过在被调函数内切片的变化,我们可以传递指针作为参数。
0%