LCY~~Golang底层原理学习笔记

1 源码调试

go源代码地址:GitHub - golang/go: The Go programming language

1.1 源码编译

现在的go语言大概有160多万行代码,go代码大概有150多万

-----------------------------------------------------------------------------------
Language                         files          blank        comment           code
-----------------------------------------------------------------------------------
Go                                4884         159160         255780        1551281
Assembly                           487          13723          20108         114398
C                                   76            807            630           4891
JSON                                12              0              0           1712
Markdown                             9            413              0           1459
Bourne Shell                        11            184            774           1352
Perl                                10            173            171           1109
Bourne Again Shell                  12            101            208            460
Python                               1            133            104            374
DOS Batch                            5             57              1            258
C/C++ Header                        12             71            202            230
Windows Resource File                4             23              0            143
RobotFramework                       1              0              0            106
C++                                  1              8              9             17
Objective C                          1              2              3             11
awk                                  1              1              6              7
make                                 4              3              7              7
Dockerfile                           1              3              3              6
Mathematica                          1              1              0              4
HTML                                 1              0              0              1
CSS                                  1              0              0              1
-----------------------------------------------------------------------------------
SUM:                              5535         174863         278006        1677827
-----------------------------------------------------------------------------------

我们可以把源代码下载下来,比如我们修改一下fmt.Println 的实现src/fmt/print.go

func Println(a ...any) (n int, err error) {println("before print")return Fprintln(os.Stdout, a...)
}

修改好源码后我们编译一下

# 需要设置一下GOROOT,要不然无法编译
export GOROOT=/home/xiaoyou/go_deep/go
cd src
./make.bash

编译好后,会在bin目录下生成二进制文件,我们写个最简单的helloword验证一下

1.2 中间代码

我们可以使用下面的命令将 Go 语言的源代码编译成汇编语言,然后通过汇编语言分析程序具体的执行过程

xiaoyou@xiaoyou:~/go_deep$ go build -gcflags -S main.go
# command-line-arguments
"".main STEXT size=103 args=0x0 locals=0x40 funcid=0x00x0000 00000 (/home/xiaoyou/go_deep/main.go:5)  TEXT    "".main(SB), ABIInternal, $64-00x0000 00000 (/home/xiaoyou/go_deep/main.go:5)  CMPQ    SP, 16(R14)0x0004 00004 (/home/xiaoyou/go_deep/main.go:5)  PCDATA  $0, $-20x0004 00004 (/home/xiaoyou/go_deep/main.go:5)  JLS     920x0006 00006 (/home/xiaoyou/go_deep/main.go:5)  PCDATA  $0, $-10x0006 00006 (/home/xiaoyou/go_deep/main.go:5)  SUBQ    $64, SP0x000a 00010 (/home/xiaoyou/go_deep/main.go:5)  MOVQ    BP, 56(SP)0x000f 00015 (/home/xiaoyou/go_deep/main.go:5)  LEAQ    56(SP), BP0x0014 00020 (/home/xiaoyou/go_deep/main.go:5)  FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)0x0014 00020 (/home/xiaoyou/go_deep/main.go:5)  FUNCDATA        $1, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)0x0014 00020 (/home/xiaoyou/go_deep/main.go:5)  FUNCDATA        $2, "".main.stkobj(SB)0x0014 00020 (/home/xiaoyou/go_deep/main.go:6)  MOVUPS  X15, ""..autotmp_9+40(SP)rel 2+0 t=24 type.string+0rel 2+0 t=24 type.*os.File+0rel 29+4 t=15 type.string+0rel 41+4 t=15 ""..stmp_0+0rel 53+4 t=15 os.Stdout+0rel 60+4 t=15 go.itab.*os.File,io.Writer+0rel 78+4 t=7 fmt.Fprintln+0rel 97+4 t=7 runtime.morestack_noctxt+0
os.(*File).close STEXT dupok size=86 args=0x8 locals=0x10 funcid=0x160x0000 00000 (<autogenerated>:1)        TEXT    os.(*File).close(SB), DUPOK|WRAPPER|ABIInternal, $16-80x0000 00000 (<autogenerated>:1)        CMPQ    SP, 16(R14)0x0004 00004 (<autogenerated>:1)        PCDATA  $0, $-20x0004 00004 (<autogenerated>:1)        JLS     520x0006 00006 (<autogenerated>:1)        PCDATA  $0, $-1

如果我们想了解GO的详细编译过程,可以使用下面的命令来获取汇编指令的优化过程

xiaoyou@xiaoyou:~/go_deep$ GOSSAFUNC=main go build main.go
# runtime
dumped SSA to /home/xiaoyou/go_deep/ssa.html
# command-line-arguments
dumped SSA to ./ssa.html

上述命令会在当前文件夹下生成一个 ssa.html 文件,我们打开这个文件后就能看到汇编代码优化的每一个步骤

上述 HTML 文件是可以交互的,当我们点击网页上的汇编指令时,页面会使用相同的颜色在 SSA 中间代码生成的不同阶段标识出相关的代码行,更方便开发者分析编译优化的过程。

2 编译原理

Go 语言编译器的源代码在 src/cmd/compile 目录中,目录下的文件共同组成了 Go 语言的编译器。

编译器的包括前端和后端,编译器的前端一般承担着词法分析、语法分析、类型检查和中间代码生成几部分工作,而编译器后端主要负责目标代码的生成和优化,也就是将中间代码翻译成目标机器能够运行的二进制机器码。

Go 的编译器在逻辑上可以被分成四个阶段:词法与语法分析、类型检查和 AST 转换、通用 SSA 生成和最后的机器代码生成。

2.1 预备知识

2.1.1 抽象语法树

抽象语法树(Abstract Syntax Tree、AST),是源代码语法的结构的一种抽象表示,它用树状的方式表示编程语言的语法结构1。以表达式 2 * 3 + 7 为例,编译器的语法分析阶段会生成如下图所示的抽象语法树。


作为编译器常用的数据结构,抽象语法树抹去了源代码中不重要的一些字符 - 空格、分号或者括号等等。编译器在执行完语法分析之后会输出一个抽象语法树,这个抽象语法树会辅助编译器进行语义分析,我们可以用它来确定语法正确的程序是否存在一些类型不匹配的问题。

2.1.2 静态单赋值

静态单赋值(Static Single Assignment、SSA)是中间代码的特性,如果中间代码具有静态单赋值的特性,那么每个变量就只会被赋值一次2。在实践中,我们通常会用下标实现静态单赋值。

x := 1
x := 2
y := x
// SSA
x_1 := 1
x_2 := 2
y_1 := x_2

我们可以清晰地发现变量 y_1x_1 是没有任何关系的,所以在机器码生成时就可以省去 x := 1 的赋值,通过减少需要执行的指令优化这段代码。因为 SSA 的主要作用是对代码进行优化,所以它是编译器后端3的一部分;

2.1.3 指令集

可以使用下面的命令查看指令集。

$ uname -m
x86_64

x86 是目前比较常见的指令集,除了 x86 之外,还有 arm 等指令集。不同的处理器使用了不同的架构和机器语言,所以很多编程语言为了在不同的机器上运行需要将源代码根据架构翻译成不同的机器代码。

2.2 词法与语法分析

词法分析的作用就是解析源代码文件,它将文件中的字符串序列转换成 Token 序列,方便后面的处理和解析,我们一般会把执行词法分析的程序称为词法解析器(lexer)。

而语法分析的输入是词法分析器输出的 Token 序列,语法分析器会按照顺序解析 Token 序列,该过程会将词法分析生成的 Token 按照编程语言定义好的文法(Grammar)自下而上或者自上而下的规约,

词法分析会返回一个不包含空格、换行等字符的 Token 序列,例如:package, json, import, (, io, ), …,而语法分析会把 Token 序列转换成有意义的结构体,即语法树:

"json.go": SourceFile {PackageName: "json",ImportDecl: []Import{"io",},TopLevelDecl: ...
}

Token 到上述抽象语法树(AST)的转换过程会用到语法解析器,每一个 AST 都对应着一个单独的 Go 语言文件,这个抽象语法树中包括当前文件属于的包名、定义的常量、结构体和函数等

Go 语言的语法解析器使用的是 LALR(1)6 的文法

语法解析的过程中发生的任何语法错误都会被语法解析器发现并将消息打印到标准输出上,整个编译过程也会随着错误的出现而被中止。

详细过程参考:解析器眼中的 Go 语言 | Go 语言设计与实现 (draveness.me)

2.3 类型检查

当拿到一组文件的抽象语法树之后,Go 语言的编译器会对语法树中定义和使用的类型进行检查,类型检查会按照以下的顺序分别验证和处理不同类型的节点:

  1. 常量、类型和函数名及类型;
  2. 变量的赋值和初始化;
  3. 函数和闭包的主体;
  4. 哈希键值对的类型;
  5. 导入函数体;
  6. 外部的声明;

通过对整棵抽象语法树的遍历,我们在每个节点上都会对当前子树的类型进行验证,以保证节点不存在类型错误,所有的类型错误和不匹配都会在这一个阶段被暴露出来,其中包括:结构体对接口的实现。

类型检查阶段不止会对节点的类型进行验证,还会展开和改写一些内建的函数,例如 make 关键字在这个阶段会根据子树的结构被替换成 runtime.makeslice 或者 runtime.makechan 等函数。


类型检查这一过程在整个编译流程中还是非常重要的,Go 语言的很多关键字都依赖类型检查期间的展开和改写。

类型检查包括静态类型检查和动态类型检查

  • 静态类型检查是基于对源代码的分析来确定运行程序类型安全的过程3,如果我们的代码能够通过静态类型检查,那么当前程序在一定程度上可以满足类型安全的要求。
  • 动态类型检查是在运行时确定程序类型安全的过程,动态类型检查能为工程师提供更多的操作空间,让我们能在运行时获取一些类型相关的上下文并根据对象的类型完成一些动态操作。

只使用动态类型检查的编程语言叫做动态类型编程语言,常见的动态类型编程语言就包括 JavaScript、Ruby 和 PHP。

静态类型检查和动态类型检查不是完全冲突和对立的,很多编程语言都会同时使用两种类型检查,例如:Java 不仅在编译期间提前检查类型发现类型错误,还为对象添加了类型信息,在运行时使用反射根据对象的类型动态地执行方法增强灵活性并减少冗余代码。

详细过程参考:Go 语言如何进行类型检查 | Go 语言设计与实现 (draveness.me)

2.4 中间代码生成

前面介绍的词法与语法分析以及类型检查两个部分都属于编译器前端,它们负责对源代码进行分析并检查其中存在的词法和语法错误,经过这两个阶段生成的抽象语法树已经不存在语法错误了,Go 语言的编译器就会将输入的抽象语法树转换成中间代码。

在类型检查之后,编译器会通过 cmd/compile/internal/gc.compileFunctions 编译整个 Go 语言项目中的全部函数,这些函数会在一个编译队列中等待几个 Goroutine 的消费,并发执行的 Goroutine 会将所有函数对应的抽象语法树转换成中间代码。


由于 Go 语言编译器的中间代码使用了 SSA 的特性,所以在这一阶段我们能够分析出代码中的无用变量和片段并对代码进行优化。

中间代码的生成过程是从 AST 抽象语法树到 SSA 中间代码的转换过程,在这期间会对语法树中的关键字再进行改写,很多 Go 语言中的关键字和内置函数都是在这个阶段被转换成运行时包中方法的,改写后的语法树会经过多轮处理转变成最后的 SSA 中间代码。

详细过程参考:详解 Go 语言中间代码生成 | Go 语言设计与实现 (draveness.me)

2.5 机器码生成

Go 语言源代码的 src/cmd/compile/internal 目录中包含了很多机器码生成相关的包,不同类型的 CPU 分别使用了不同的包生成机器码,其中包括 amd64、arm、arm64、mips、mips64、ppc64、s390x、x86 和 wasm

机器码的生成过程其实是对 SSA 中间代码的降级(lower)过程,在 SSA 中间代码降级的过程中,编译器将一些值重写成了目标 CPU 架构的特定值,降级的过程处理了所有机器特定的重写规则并对代码进行了一定程度的优化;

机器码的生成在 Go 的编译器中主要由两部分协同工作:

  • cmd/compile/internal/ssa 主要负责对 SSA 中间代码进行降级、执行架构特定的优化和重写并生成 cmd/compile/internal/obj.Prog 指令;

  • cmd/internal/obj 作为汇编器会将这些指令转换成机器码完成这次编译;

机器码生成作为 Go 语言编译的最后一步,其实已经到了硬件和机器指令这一层,我们只需要对这个过程有所了解,补全知识上的盲点,在遇到问题时能够快速定位即可。

2.6 编译器入口

Go 语言的编译器入口在 src/cmd/compile/internal/gc/main.go 文件中,其中 cmd/compile/internal/gc.Main 就是 Go 语言编译器的主程序,该函数会先获取命令行传入的参数并更新编译选项和配置,随后会调用 cmd/compile/internal/gc.parseFiles 对输入的文件进行词法与语法分析得到对应的抽象语法树:

func Main(archInit func(*Arch)) {...lines := parseFiles(flag.Args())

得到抽象语法树后会分九个阶段对抽象语法树进行更新和编译,就像我们在上面介绍的,抽象语法树会经历类型检查、SSA 中间代码生成以及机器码生成三个阶段。

3 数据结构

3.1 基本数据类型

3.2 数组与切片

3.2.1 数组

编译期间的数组类型是由上述的 cmd/compile/internal/types.NewArray 函数生成的,该类型包含两个字段,分别是元素类型 Elem 和数组的大小 Bound,这两个字段共同构成了数组类型,而当前数组是否应该在堆栈中初始化也在编译期就确定了。

func NewArray(elem *Type, bound int64) *Type {if bound < 0 {Fatalf("NewArray: invalid bound %v", bound)}t := New(TARRAY)t.Extra = &Array{Elem: elem, Bound: bound}t.SetNotInHeap(elem.NotInHeap())return t
}

3.2.1.1 数组初始化

数组初始化的两种方式

arr1 := [3]int{1, 2, 3}
arr2 := [...]int{1, 2, 3}

两种不同的声明方式会导致编译器做出完全不同的处理

3.2.1.1.1 上限推导

如果我们使用第一种方式 [10]T,那么变量的类型在编译进行到类型检查阶段就会被提取出来,随后使用 cmd/compile/internal/types.NewArray创建包含数组大小的 cmd/compile/internal/types.Array 结构体。

如果我们使用 [...]T 的方式声明数组时,编译器会在cmd/compile/internal/gc.typecheckcomplit 函数中对该数组的大小进行推导

func typecheckcomplit(n *Node) (res *Node) {...if n.Right.Op == OTARRAY && n.Right.Left != nil && n.Right.Left.Op == ODDD {n.Right.Right = typecheck(n.Right.Right, ctxType)if n.Right.Right.Type == nil {n.Type = nilreturn n}elemType := n.Right.Right.Type
// 调用这个函数来获取数组的长度length := typecheckarraylit(elemType, -1, n.List.Slice(), "array literal")n.Op = OARRAYLIT// 初始化数组n.Type = types.NewArray(elemType, length)n.Right = nilreturn n}...switch t.Etype {case TARRAY:typecheckarraylit(t.Elem(), t.NumElem(), n.List.Slice(), "array literal")n.Op = OARRAYLITn.Right = nil}
}

[...]T{1, 2, 3}[3]T{1, 2, 3} 在运行时是完全等价的,[...]T 这种初始化方式也只是 Go 语言为我们提供的一种语法糖,当我们不想计算数组中的元素个数时可以通过这种方法减少一些工作量。

3.2.1.1.2 语句转换

对于一个由字面量组成的数组,根据数组元素数量的不同,编译器会在负责初始化字面量的 cmd/compile/internal/gc.anylit 函数中做两种不同的优化

  1. 当元素数量小于或者等于 4 个时,会直接将数组中的元素放置在栈上;
  2. 当元素数量大于 4 个时,会将数组中的元素放置到静态区并在运行时取出
// 直接在栈上初始化
var arr [3]int
arr[0] = 1
arr[1] = 2
arr[2] = 3
// 静态区初始化
var arr [5]int
statictmp_0[0] = 1
statictmp_0[1] = 2
statictmp_0[2] = 3
statictmp_0[3] = 4
statictmp_0[4] = 5
arr = statictmp_0

3.2.1.2 访问和赋值

无论是在栈上还是静态存储区,数组在内存中都是一连串的内存空间,我们通过指向数组开头的指针、元素的数量以及元素类型占的空间大小表示数组。这两个字段缺一不可。


数组访问越界是非常严重的错误,Go 语言中可以在编译期间的静态类型检查判断数组越界,cmd/compile/internal/gc.typecheck1 会验证访问数组的索引。

Go 语言运行时在发现数组、切片和字符串的越界操作会由运行时的 runtime.panicIndexruntime.goPanicIndex 触发程序的运行时错误并导致崩溃退出。

3.2.1.2.1 数组访问

elem := arr[i] 对应的中间代码如下,在这段中间代码中我们发现 Go 语言为数组的访问操作生成了判断数组上限的指令 IsInBounds 以及当条件不满足时触发程序崩溃的 PanicBounds 指令

b1:...v22 (6) = LocalAddr <*[3]int> {arr} v2 v20v23 (6) = IsInBounds <bool> v21 v11
If v23 → b2 b3 (likely) (6)b2: ← b1-v26 (6) = PtrIndex <*int> v22 v21 v27 (6) = Copy <mem> v20v28 (6) = Load <int> v26 v27 (elem[int])...
Ret v30 (+7)b3: ← b1-v24 (6) = Copy <mem> v20v25 (6) = PanicBounds <mem> [0] v21 v11 v24
Exit v25 (6)

编译器会将 PanicBounds 指令转换成上面提到的 runtime.panicIndex 函数,当数组下标没有越界时,编译器会先获取数组的内存地址和访问的下标、利用 PtrIndex 计算出目标元素的地址,最后使用 Load 操作将指针中的元素加载到内存中。

当然只有当编译器无法对数组下标是否越界无法做出判断时才会加入 PanicBounds 指令交给运行时进行判断,在使用字面量整数访问数组下标时会生成非常简单的中间代码,当我们将上述代码中的 arr[i] 改成 arr[2] 时,就会得到如下所示的代码:

v21 (5) = LocalAddr <*[3]int> {arr} v2 v20
v22 (5) = PtrIndex <*int> v21 v14
v23 (5) = Load <int> v22 v20 (elem[int])
3.2.1.2.2 数组赋值

数组的赋值和更新操作 a[i] = 2 也会生成 SSA 生成期间计算出数组当前元素的内存地址,然后修改当前内存地址的内容,这些赋值语句会被转换成如下所示的 SSA 代码:

b1:...v21 (5) = LocalAddr <*[3]int> {arr} v2 v19v22 (5) = PtrIndex <*int> v21 v13v23 (5) = Store <mem> {int} v22 v20 v19...

赋值的过程中会先确定目标数组的地址,再通过 PtrIndex 获取目标元素的地址,最后使用 Store 指令将数据存入地址中,从上面的这些 SSA 代码中我们可以看出 上述数组寻址和赋值都是在编译阶段完成的,没有运行时的参与。

3.2.2 切片

数组一般用的不是很多,更常用的是切片,即动态数组,其长度并不固定,我们可以向切片中追加元素,它会在容量不足时自动扩容。

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 = treturn t
}

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

3.2.2.1 数据结构

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

type SliceHeader struct {Data uintptrLen  intCap  int
}
  • Data 是指向数组的指针;

  • Len 是当前切片的长度;

  • Cap 是当前切片的容量,即Data 数组的大小:

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

3.2.2.2 切片初始化

切片的初始化方式如下

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

使用下标创建切片是最原始也最接近汇编语言的方式,它是所有方法中最为底层的一种,编译器会将 arr[0:3] 或者 slice[0:3] 等语句转换成 OpSliceMake 操作

SliceMake 操作会接受四个参数(元素类型、数组指针、切片大小和容量)创建新的切片,需要注意的是使用下标初始化切片不会拷贝原数组或者原切片中的数据,它只会创建一个指向原数组的切片结构体,所以修改新切片的数据也会修改原切片。

arr := [...]int{1, 2, 3, 4, 5}
arr1 := arr[0:3]
arr1[0] = 5
fmt.Println(arr)
fmt.Println(arr1)
// 上面这段代码运行后结果如下
[5 2 3 4 5]
[5 2 3]
3.2.2.2.2 使用字面量

当我们使用字面量 []int{1, 2, 3} 创建新的切片时,cmd/compile/internal/gc.slicelit 函数会在编译期间将它展开成如下所示的代码片段:

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

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

3.2.2.2.3 make创建切片

如果使用字面量的方式创建切片,大部分的工作都会在编译期间完成。

但是当我们使用 make 关键字创建切片时,很多工作都需要运行时的参与;调用方必须向 make 函数传入切片的大小以及可选的容量,类型检查期间的 cmd/compile/internal/gc.typecheck1 函数会校验入参。

如果当前的切片不会发生逃逸并且切片非常小的时候,make([]int, 3, 4) 会被直接转换成如下所示的代码:

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

上述代码会初始化数组并通过下标 [: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)
}

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

最后调用的 runtime.mallocgc 是用于申请内存的函数,这个函数的实现比较复杂,如果遇到了比较小的对象会直接初始化在 Go 语言调度器里面的 P 结构中,而大于 32KB 的对象会在堆上初始化。

而构建结构体 reflect.SliceHeader 的工作交给了 runtime.makeslice 的调用方(cmd/compile/internal/gc.typecheck1),该函数仅会返回指向底层数组的指针,调用方会在编译期间构建切片结构体。

3.2.2.3 访问元素

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

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

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

除了获取切片的长度和容量之外,访问切片中元素使用的 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)...}...}
}

3.2.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)

我们会先解构切片结构体获取它的数组指针、大小和容量,如果在追加元素后切片的大小大于容量,那么就会调用 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会直接选择在在原来的变量上直接修改。

当切片的容量不足时,我们会调用 runtime.growslice 函数为切片扩容,代码如下:

func growslice(et *_type, old slice, cap int) slice {newcap := old.capdoublecap := newcap + newcapif 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}}
}

运行时根据切片的当前容量选择不同的策略进行扩容:

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

上述代码片段仅会确定切片的大致容量,当数组中元素所占的字节大小为 1、8 或者 2 的倍数时,还会进行内存对齐。

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

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

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

3.2.2.5 切片拷贝

切片的拷贝虽然不是常见的操作,但是却是我们学习切片实现原理必须要涉及的。当我们使用 copy(a, b) 的形式对切片进行拷贝时,编译期间的 cmd/compile/internal/gc.copyany 也会分两种情况进行处理拷贝操作。

  1. 如果当前 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)))
}

上述代码中的 runtime.memmove 会负责拷贝内存。

  1. 而如果拷贝是在运行时发生的,例如:go copy(a, b),编译器会使用 runtime.slicecopy 替换运行期间调用的 copy

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

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

3.3 字符串

Go 语言中的字符串只是一个只读的字节数组,下图展示了 "hello" 字符串在内存中的存储方式:


如果是代码中存在的字符串,编译器会将其标记成只读数据 SRODATA。只读只意味着字符串会分配到只读的内存空间,但是 Go 语言只是不支持直接修改 string 类型变量的内存空间,我们仍然可以通过在 string[]byte 类型之间反复转换实现修改这一目的:

  1. 先将这段内存拷贝到堆或者栈上;
  2. 将变量的类型转换成 []byte 后并修改字节数据;
  3. 将修改后的字节数组转换回 string

3.3.1 数据结构

字符串在 Go 语言中的接口其实非常简单,每一个字符串在运行时都会使用如下的 reflect.StringHeader 表示,其中包含指向字节数组的指针和数组的大小:

type StringHeader struct {Data uintptrLen  int
}

因为字符串作为只读的类型,我们并不会直接向字符串直接追加元素改变其本身的内存空间,所有在字符串上的写入操作都是通过拷贝实现的。

3.3.2 字符串拼接

Go 语言拼接字符串会使用 + 符号,编译器会将该符号对应的 OADD 节点转换成 OADDSTR 类型的节点,随后在 cmd/compile/internal/gc.walkexpr 中调用 cmd/compile/internal/gc.addstr 函数,这个函数能帮助我们在编译期间选择合适的函数对字符串进行拼接,该函数会根据带拼接的字符串数量选择不同的逻辑:

  • 如果小于或者等于 5 个,那么会调用 concatstring{2,3,4,5} 等一系列函数;

  • 如果超过 5 个,那么会选择 runtime.concatstrings 传入一个数组切片;

func addstr(n *Node, init *Nodes) *Node {c := n.List.Len()buf := nodnil()args := []*Node{buf}for _, n2 := range n.List.Slice() {args = append(args, conv(n2, types.Types[TSTRING]))}var fn stringif c <= 5 {fn = fmt.Sprintf("concatstring%d", c)} else {fn = "concatstrings"t := types.NewSlice(types.Types[TSTRING])slice := nod(OCOMPLIT, nil, typenod(t))slice.List.Set(args[1:])args = []*Node{buf, slice}}cat := syslook(fn)r := nod(OCALL, cat, nil)r.List.Set(args)...return r
}

其实无论使用 concatstring{2,3,4,5} 中的哪一个,最终都会调用 runtime.concatstrings

在正常情况下,运行时会调用 copy 将输入的多个字符串拷贝到目标字符串所在的内存空间。新的字符串是一片新的内存空间,与原来的字符串也没有任何关联,一旦需要拼接的字符串非常大,拷贝带来的性能损失是无法忽略的。

3.3.3 字符串类型转换

当我们使用 Go 语言解析和序列化 JSON 等数据格式时,经常需要将数据在 string[]byte 之间来回转换,类型转换的开销并没有想象的那么小,我们经常会看到 runtime.slicebytetostring 等函数出现在火焰图1中,成为程序的性能热点。

从字节数组到字符串的转换需要使用 runtime.slicebytetostring 函数,例如:string(bytes)

func slicebytetostring(buf *tmpBuf, b []byte) (str string) {l := len(b)if l == 0 {return ""}if l == 1 {stringStructOf(&str).str = unsafe.Pointer(&staticbytes[b[0]])stringStructOf(&str).len = 1return}var p unsafe.Pointerif buf != nil && len(b) <= len(buf) {p = unsafe.Pointer(buf)} else {p = mallocgc(uintptr(len(b)), nil, false)}stringStructOf(&str).str = pstringStructOf(&str).len = len(b)memmove(p, (*(*slice)(unsafe.Pointer(&b))).array, uintptr(len(b)))return
}

处理过后会根据传入的缓冲区大小决定是否需要为新字符串分配一片内存空间,runtime.stringStructOf 会将传入的字符串指针转换成 runtime.stringStruct 结构体指针,然后设置结构体持有的字符串指针 str 和长度 len,最后通过 runtime.memmove 将原 []byte 中的字节全部复制到新的内存空间中。

当我们想要将字符串转换成 []byte 类型时,需要使用 runtime.stringtoslicebyte 函数,该函数的实现非常容易理解:

func stringtoslicebyte(buf *tmpBuf, s string) []byte {var b []byteif buf != nil && len(s) <= len(buf) {*buf = tmpBuf{}b = buf[:len(s)]} else {b = rawbyteslice(len(s))}copy(b, s)return b
}

上述函数会根据是否传入缓冲区做出不同的处理:

  • 当传入缓冲区时,它会使用传入的缓冲区存储 []byte

  • 当没有传入缓冲区时,运行时会调用 runtime.rawbyteslice 创建新的字节切片并将字符串中的内容拷贝过去;

字符串和 []byte 中的内容虽然一样,但是字符串的内容是只读的,我们不能通过下标或者其他形式改变其中的数据,而 []byte 中的内容是可以读写的。不过无论从哪种类型转换到另一种都需要拷贝数据,而内存拷贝的性能损耗会随着字符串和 []byte 长度的增长而增长。

3.4 哈希表

3.4.1 设计原理

哈希表是计算机科学中的最重要数据结构之一,这不仅因为它 O(1) 的读写性能非常优秀,还因为它提供了键值之间的映射。想要实现一个性能优异的哈希表,需要注意两个关键点 —— 哈希函数和冲突解决方法。

3.4.1.1 哈希函数

实现哈希表的关键点在于哈希函数的选择,哈希函数的选择在很大程度上能够决定哈希表的读写性能。在理想情况下,哈希函数应该能够将不同键映射到不同的索引上,这要求哈希函数的输出范围大于输入范围,但是由于键的数量会远远大于映射的范围,所以在实际使用时,这个理想的效果是不可能实现的。

比较实际的方式是让哈希函数的结果能够尽可能的均匀分布,然后通过工程上的手段解决哈希碰撞的问题。哈希函数映射的结果一定要尽可能均匀,结果不均匀的哈希函数会带来更多的哈希冲突以及更差的读写性能。


如果使用结果分布较为均匀的哈希函数,那么哈希的增删改查的时间复杂度为 O(1);但是如果哈希函数的结果分布不均匀,那么所有操作的时间复杂度可能会达到 O(n),由此看来,使用好的哈希函数是至关重要的。

3.4.1.2 冲突解决

所以在使用哈希表时一定会遇到冲突,哪怕我们使用了完美的哈希函数,当输入的键足够多也会产生冲突。然而多数的哈希函数都是不够完美的,所以仍然存在发生哈希碰撞的可能,这时就需要一些方法来解决哈希碰撞的问题,常见方法的就是开放寻址法和拉链法。


3.4.2 数据结构

Go 语言运行时同时使用了多个数据结构组合表示哈希表,其中 runtime.hmap 是最核心的结构体,我们先来了解一下该结构体的内部字段:

type hmap struct {count     intflags     uint8B         uint8noverflow uint16hash0     uint32buckets    unsafe.Pointeroldbuckets unsafe.Pointernevacuate  uintptrextra *mapextra
}type mapextra struct {overflow    *[]*bmapoldoverflow *[]*bmapnextOverflow *bmap
}
  1. count 表示当前哈希表中的元素数量;
  2. B 表示当前哈希表持有的 buckets 数量,但是因为哈希表中桶的数量都 2 的倍数,所以该字段会存储对数,也就是 len(buckets) == 2^B
  3. hash0 是哈希的种子,它能为哈希函数的结果引入随机性,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入;
  4. oldbuckets 是哈希在扩容时用于保存之前 buckets 的字段,它的大小是当前 buckets 的一半;

如上图所示哈希表 runtime.hmap 的桶是 runtime.bmap。每一个 runtime.bmap 都能存储 8 个键值对,当哈希表中存储的数据过多,单个桶已经装满时就会使用 extra.nextOverflow 中桶存储溢出的数据。

上述两种不同的桶在内存中是连续存储的,我们在这里将它们分别称为正常桶和溢出桶,上图中黄色的 runtime.bmap 就是正常桶,绿色的 runtime.bmap 是溢出桶,溢出桶是在 Go 语言还使用 C 语言实现时使用的设计3,由于它能够减少扩容的频率所以一直使用至今。

桶的结构体 runtime.bmap 在 Go 语言源代码中的定义只包含一个简单的 tophash 字段,tophash 存储了键的哈希的高 8 位,通过比较不同键的哈希的高 8 位可以减少访问键值对次数以提高性能:

type bmap struct {tophash [bucketCnt]uint8
}

在运行期间,runtime.bmap 结构体其实不止包含 tophash 字段,因为哈希表中可能存储不同类型的键值对,而且 Go 语言也不支持泛型,所以键值对占据的内存空间大小只能在编译时进行推导。runtime.bmap 中的其他字段在运行时也都是通过计算内存地址的方式访问的。

随着哈希表存储的数据逐渐增多,我们会扩容哈希表或者使用额外的桶存储溢出的数据,不会让单个桶中的数据超过 8 个,不过溢出桶只是临时的解决方案,创建过多的溢出桶最终也会导致哈希的扩容。

3.4.3 map初始化

Go 语言初始化哈希的两种方法 — 通过字面量或者运行时。

3.4.3.1 字面量

目前的现代编程语言基本都支持使用字面量的方式初始化哈希,一般都会使用 key: value 的语法来表示键值对,Go 语言中也不例外:

hash := map[string]int{"1": 2,"3": 4,"5": 6,
}

我们需要在初始化哈希时声明键值对的类型,这种使用字面量初始化的方式最终都会通过 cmd/compile/internal/gc.maplit 初始化,我们来分析一下该函数初始化哈希的过程:

当哈希表中的元素数量少于或者等于 25 个时,编译器会将字面量初始化的结构体转换成以下的代码,将所有的键值对一次加入到哈希表中:

hash := make(map[string]int, 3)
hash["1"] = 2
hash["3"] = 4
hash["5"] = 6

一旦哈希表中元素的数量超过了 25 个,编译器会创建两个数组分别存储键和值,这些键值对会通过如下所示的 for 循环加入哈希:

hash := make(map[string]int, 26)
vstatk := []string{"1", "2", "3", ... , "26"}
vstatv := []int{1, 2, 3, ... , 26}
for i := 0; i < len(vstak); i++ {hash[vstatk[i]] = vstatv[i]
}

3.4.3.2 运行时

当创建的哈希被分配到栈上并且其容量小于 BUCKETSIZE = 8 时,Go 语言在编译阶段会使用如下方式快速初始化哈希,这也是编译器对小容量的哈希做的优化:

var h *hmap
var hv hmap
var bv bmap
h := &hv
b := &bv
h.buckets = b
h.hash0 = fashtrand0()

除了上述特定的优化之外,无论 make 是从哪里来的,只要我们使用 make 创建哈希,Go 语言编译器都会在类型检查期间将它们转换成 runtime.makemap,使用字面量初始化哈希也只是语言提供的辅助工具,最后调用的都是 runtime.makemap

func makemap(t *maptype, hint int, h *hmap) *hmap {mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)if overflow || mem > maxAlloc {hint = 0}if h == nil {h = new(hmap)}h.hash0 = fastrand()B := uint8(0)for overLoadFactor(hint, B) {B++}h.B = Bif h.B != 0 {var nextOverflow *bmaph.buckets, nextOverflow = makeBucketArray(t, h.B, nil)if nextOverflow != nil {h.extra = new(mapextra)h.extra.nextOverflow = nextOverflow}}return h
}

这个函数会按照下面的步骤执行:

  1. 计算哈希占用的内存是否溢出或者超出能分配的最大值;
  2. 调用 runtime.fastrand 获取一个随机的哈希种子;
  3. 根据传入的 hint 计算出需要的最小需要的桶的数量;
  4. 使用 runtime.makeBucketArray 创建用于保存桶的数组;

runtime.makeBucketArray 会根据传入的 B 计算出的需要创建的桶数量并在内存中分配一片连续的空间用于存储数据:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qiHzIzEk-1673624658543)(Dimage\13.png)]

在正常情况下,正常桶和溢出桶在内存中的存储空间是连续的,只是被 runtime.hmap 中的不同字段引用,当溢出桶数量较多时会通过 runtime.newobject 创建新的溢出桶。

3.4.4 读写操作

哈希表的访问一般都是通过下标或者遍历进行的:

_ = hash[key]for k, v := range hash {// k, v
}

这两种方式虽然都能读取哈希表的数据,但是使用的函数和底层原理完全不同。前者需要知道哈希的键并且一次只能获取单个键对应的值,而后者可以遍历哈希中的全部键值对,访问数据时也不需要预先知道哈希的键。

而删除字典中的数据需要使用关键字 delete

hash[key] = value
hash[key] = newValue
delete(hash, key)

3.4.4.1 map访问操作

在编译的类型检查期间,hash[key] 以及类似的操作都会被转换成哈希的 OINDEXMAP 操作,中间代码生成阶段会在 cmd/compile/internal/gc.walkexpr 函数中将这些 OINDEXMAP 操作转换成如下的代码:

v     := hash[key] // => v     := *mapaccess1(maptype, hash, &key)
v, ok := hash[key] // => v, ok := mapaccess2(maptype, hash, &key)

赋值语句左侧接受参数的个数会决定使用的运行时方法:

  • 当接受一个参数时,会使用 runtime.mapaccess1,该函数仅会返回一个指向目标值的指针;

  • 当接受两个参数时,会使用 runtime.mapaccess2,除了返回目标值之外,它还会返回一个用于表示当前键对应的值是否存在的 bool 值:

runtime.mapaccess1 会先通过哈希表设置的哈希函数、种子获取当前键对应的哈希,再通过 runtime.bucketMaskruntime.add 拿到该键值对所在的桶序号和哈希高位的 8 位数字。

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {alg := t.key.alghash := alg.hash(key, uintptr(h.hash0))m := bucketMask(h.B)b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))top := tophash(hash)
bucketloop:for ; b != nil; b = b.overflow(t) {for i := uintptr(0); i < bucketCnt; i++ {if b.tophash[i] != top {if b.tophash[i] == emptyRest {break bucketloop}continue}k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))if alg.equal(key, k) {v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))return v}}}return unsafe.Pointer(&zeroVal[0])
}

bucketloop 循环中,哈希会依次遍历正常桶和溢出桶中的数据,它会先比较哈希的高 8 位和桶中存储的 tophash,后比较传入的和桶中的值以加速数据的读写。用于选择桶序号的是哈希的最低几位,而用于加速访问的是哈希的高 8 位,这种设计能够减少同一个桶中有大量相等 tophash 的概率影响性能。

如上图所示,每一个桶都是一整片的内存空间,当发现桶中的 tophash 与传入键的 tophash 匹配之后,我们会通过指针和偏移量获取哈希中存储的键 keys[0] 并与 key 比较,如果两者相同就会获取目标值的指针 values[0] 并返回。

另一个同样用于访问哈希表中数据的 runtime.mapaccess2 只是在 runtime.mapaccess1 的基础上多返回了一个标识键值对是否存在的 bool

上面的过程是在正常情况下,访问哈希表中元素时的表现,然而与数组一样,哈希表可能会在装载因子过高或者溢出桶过多时进行扩容,哈希表扩容并不是原子过程,在扩容的过程中保证哈希的访问就不讲了。

3.4.7 map写入操作

当形如 hash[k] 的表达式出现在赋值符号左侧时,该表达式也会在编译期间转换成 runtime.mapassign 函数的调用,该函数与 runtime.mapaccess1 比较相似,我们将其分成几个部分依次分析,首先是函数会根据传入的键拿到对应的哈希和桶:

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {alg := t.key.alghash := alg.hash(key, uintptr(h.hash0))h.flags ^= hashWritingagain:bucket := hash & bucketMask(h.B)b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))top := tophash(hash)

然后通过遍历比较桶中存储的 tophash 和键的哈希,如果找到了相同结果就会返回目标位置的地址。其中 inserti 表示目标元素的在桶中的索引,insertkval 分别表示键值对的地址,获得目标地址之后会通过算术计算寻址获得键值对 kval

    var inserti *uint8var insertk unsafe.Pointervar val unsafe.Pointer
bucketloop:for {for i := uintptr(0); i < bucketCnt; i++ {if b.tophash[i] != top {if isEmpty(b.tophash[i]) && inserti == nil {inserti = &b.tophash[i]insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))}if b.tophash[i] == emptyRest {break bucketloop}continue}k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))if !alg.equal(key, k) {continue}val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))goto done}ovf := b.overflow(t)if ovf == nil {break}b = ovf}

上述的 for 循环会依次遍历正常桶和溢出桶中存储的数据,整个过程会分别判断 tophash 是否相等、key 是否相等,遍历结束后会从循环中跳出。

如果当前桶已经满了,哈希会调用 runtime.hmap.newoverflow 创建新桶或者使用 runtime.hmap 预先在 noverflow 中创建好的桶来保存数据,新创建的桶不仅会被追加到已有桶的末尾,还会增加哈希表的 noverflow 计数器。

if inserti == nil {newb := h.newoverflow(t, b)inserti = &newb.tophash[0]insertk = add(unsafe.Pointer(newb), dataOffset)val = add(insertk, bucketCnt*uintptr(t.keysize))}typedmemmove(t.key, insertk, key)*inserti = toph.count++done:return val
}

如果当前键值对在哈希中不存在,哈希会为新键值对规划存储的内存地址,通过 runtime.typedmemmove 将键移动到对应的内存空间中并返回键对应值的地址 val

如果当前键值对在哈希中存在,那么就会直接返回目标区域的内存地址,哈希并不会在 runtime.mapassign 这个运行时函数中将值拷贝到桶中,该函数只会返回内存地址,真正的赋值操作是在编译期间插入的:

00018 (+5) CALL runtime.mapassign_fast64(SB)
00020 (5) MOVQ 24(SP), DI               ;; DI = &value
00026 (5) LEAQ go.string."88"(SB), AX   ;; AX = &"88"
00027 (5) MOVQ AX, (DI)                 ;; *DI = AX

runtime.mapassign_fast64runtime.mapassign 函数的逻辑差不多,我们需要关注的是后面的三行代码,其中 24(SP) 是该函数返回的值地址,我们通过 LEAQ 指令将字符串的地址存储到寄存器 AX 中,MOVQ 指令将字符串 "88" 存储到了目标地址上完成了这次哈希的写入。

3.4.8 map扩容

前面在介绍哈希的写入过程时其实省略了扩容操作,随着哈希表中元素的逐渐增加,哈希的性能会逐渐恶化,所以我们需要更多的桶和更大的内存保证哈希的读写性能:

runtime.mapassign 函数会在以下两种情况发生时触发哈希的扩容:

  1. 装载因子已经超过 6.5;( k/v 对数目除以哈希桶的数目(含溢出桶))
  2. 哈希使用了太多溢出桶;

不过因为 Go 语言哈希的扩容不是一个原子的过程,所以 runtime.mapassign 还需要判断当前哈希是否已经处于扩容状态,避免二次扩容造成混乱。

根据触发的条件不同扩容的方式分成两种(等量扩容和翻倍扩容),如果这次扩容是溢出的桶太多导致的,那么这次扩容就是等量扩容,sameSizeGrow 是一种特殊情况下发生的扩容,当我们持续向哈希中插入数据并将它们全部删除时,如果哈希表中的数据量没有超过阈值,就会不断积累溢出桶造成缓慢的内存泄漏4。runtime: limit the number of map overflow buckets 引入了 sameSizeGrow 通过复用已有的哈希扩容机制解决该问题,一旦哈希中出现了过多的溢出桶,它会创建新桶保存数据,垃圾回收会清理老的溢出桶并释放内存5。

扩容的入口是 runtime.hashGrow

func hashGrow(t *maptype, h *hmap) {bigger := uint8(1)if !overLoadFactor(h.count+1, h.B) {bigger = 0h.flags |= sameSizeGrow}oldbuckets := h.bucketsnewbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)h.B += biggerh.flags = flagsh.oldbuckets = oldbucketsh.buckets = newbucketsh.nevacuate = 0h.noverflow = 0h.extra.oldoverflow = h.extra.overflowh.extra.overflow = nilh.extra.nextOverflow = nextOverflow
}

哈希在扩容的过程中会通过 runtime.makeBucketArray 创建一组新桶和预创建的溢出桶,随后将原有的桶数组设置到 oldbuckets 上并将新的空桶设置到 buckets 上,溢出桶也使用了相同的逻辑更新,下图展示了触发扩容后的哈希:

等量扩容创建的新桶数量只是和旧桶一样,该函数中只是创建了新的桶,并没有对数据进行拷贝和转移。哈希表的数据迁移的过程在是 runtime.evacuate 中完成的,它会对传入桶中的元素进行再分配。

runtime.evacuate 会将一个旧桶中的数据分流到两个新桶,所以它会创建两个用于保存分配上下文的 runtime.evacDst 结构体,这两个结构体分别指向了一个新桶:

如果这是等量扩容,那么旧桶与新桶之间是一对一的关系,所以两个 runtime.evacDst 只会初始化一个。而当哈希表的容量翻倍时,每个旧桶的元素会都分流到新创建的两个桶中。

runtime.evacuate 最后会调用 runtime.advanceEvacuationMark 增加哈希的 nevacuate 计数器并在所有的旧桶都被分流后清空哈希的 oldbucketsoldoverflow

之前在分析哈希表访问函数 runtime.mapaccess1 时其实省略了扩容期间获取键值对的逻辑,当哈希表的 oldbuckets 存在时,会先定位到旧桶并在该桶没有被分流时从中获取键值对。

因为旧桶中的元素还没有被 runtime.evacuate 函数分流,其中还保存着我们需要使用的数据,所以旧桶会替代新创建的空桶提供数据。

我们在 runtime.mapassign 函数中也省略了一段逻辑,当哈希表正在处于扩容状态时,每次向哈希表写入值时都会触发 runtime.growWork 增量拷贝哈希表中的内容:

当然除了写入操作之外,删除操作也会在哈希表扩容期间触发 runtime.growWork,触发的方式和代码与这里的逻辑几乎完全相同,都是计算当前值所在的桶,然后拷贝桶中的元素。

我们简单总结一下哈希表扩容的设计和原理,哈希在存储元素过多时会触发扩容操作,每次都会将桶的数量翻倍,扩容过程不是原子的,而是通过 runtime.growWork 增量触发的,在扩容期间访问哈希表时会使用旧桶,向哈希表写入数据时会触发旧桶元素的分流。除了这种正常的扩容之外,为了解决大量写入、删除造成的内存泄漏问题,哈希引入了 sameSizeGrow 这一机制,在出现较多溢出桶时会整理哈希的内存减少空间的占用。

3.4.9 map 删除

在编译期间,delete 关键字会被转换成操作为 ODELETE 的节点,而 cmd/compile/internal/gc.walkexpr 会将 ODELETE 节点转换成 runtime.mapdelete 函数簇中的一个,包括 runtime.mapdeletemapdelete_faststrmapdelete_fast32mapdelete_fast64

这些函数的实现其实差不多,我们挑选其中的 runtime.mapdelete 分析一下。哈希表的删除逻辑与写入逻辑很相似,只是触发哈希的删除需要使用关键字,如果在删除期间遇到了哈希表的扩容,就会分流桶中的元素,分流结束之后会找到桶中的目标元素完成键值对的删除工作。

我们其实只需要知道 delete 关键字在编译期间经过类型检查和中间代码生成阶段被转换成 runtime.mapdelete 函数簇中的一员,用于处理删除逻辑的函数与哈希表的 runtime.mapassign 几乎完全相同,不太需要刻意关注。

3.4.10 并发安全map

Go 的 map 不是并发安全的,这是设计人员经过长期讨论做出的决定:因为大部分使用 map 的场景不需要并发读写,如果将 map 设计为并发安全的,将降低大多数程序的性能。

只有在更新 map 的时候,map 才是并发不安全的,全部是读操作的并发是安全的。Go 1.6 对 map 的并发读写进行了更明确的规定 (opens new window):当一个协程正在对 map 进行写操作的时候,不能有其它协程在对同一个 map 进行操作,读和写都不行。Go 的 runtime 会对 map 的并发读写进行监测,如果发现不安全的操作直接 crash。

如果想使用并发安全的map可以使用sync.Map,用法如下

package main
import ("sync""fmt"
)func main() {//开箱即用var sm sync.Map//store 方法,添加元素sm.Store(1,"a")//Load 方法,获得valueif v,ok:=sm.Load(1);ok{fmt.Println(v)}//LoadOrStore方法,获取或者保存//参数是一对key:value,如果该key存在且没有被标记删除则返回原先的value(不更新)和true;不存在则store,返回该value 和falseif vv,ok:=sm.LoadOrStore(1,"c");ok{fmt.Println(vv)}if vv,ok:=sm.LoadOrStore(2,"c");!ok{fmt.Println(vv)}//遍历该map,参数是个函数,该函数参的两个参数是遍历获得的key和value,返回一个bool值,当返回false时,遍历立刻结束。sm.Range(func(k,v interface{})bool{fmt.Print(k)fmt.Print(":")fmt.Print(v)fmt.Println()return true})
}

4 语言基础

4.1 函数

4.1.1 函数调用

4.1.1.1 C语言的函数调用

当我们在 x86_64 的机器上使用 C 语言中调用函数时,参数都是通过寄存器和栈传递的,其中:

  • 六个以及六个以下的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递;

  • 六个以上的参数会使用栈传递,函数的参数会以从右到左的顺序依次存入栈中;

而函数的返回值是通过 eax 寄存器进行传递的,由于只使用一个寄存器存储返回值,所以 C 语言的函数不能同时返回多个值。

4.1.1.2 go语言的函数调用

Go 语言使用栈传递参数和接收返回值,所以它只需要在栈上多分配一些内存就可以返回多个值。

4.1.1.3 对比

C 语言和 Go 语言在设计函数的调用惯例时选择了不同的实现。C 语言同时使用寄存器和栈传递参数,使用 eax 寄存器传递返回值;而 Go 语言使用栈传递参数和返回值。我们可以对比一下这两种设计的优点和缺点:

  • C 语言的方式能够极大地减少函数调用的额外开销,但是也增加了实现的复杂度;

    • CPU 访问栈的开销比访问寄存器高几十倍3;
  • 需要单独处理函数参数过多的情况;

  • Go 语言的方式能够降低实现的复杂度并支持多返回值,但是牺牲了函数调用的性能;

    • 不需要考虑超过寄存器数量的参数应该如何传递;
  • 不需要考虑不同架构上的寄存器差异;

  • 函数入参和出参的内存空间需要在栈上进行分配;

Go 语言使用栈作为参数和返回值传递的方法是综合考虑后的设计,选择这种设计意味着编译器会更加简单、更容易维护。

4.1.2 参数传递

除了函数的调用惯例之外,Go 语言在传递参数时是传值还是传引用也是一个有趣的问题,不同的选择会影响我们在函数中修改入参时是否会影响调用方看到的数据。我们先来介绍一下传值和传引用两者的区别:

  • 传值:函数调用时会对参数进行拷贝,被调用方和调用方两者持有不相关的两份数据;

  • 传引用:函数调用时会传递参数的指针,被调用方和调用方两者持有相同的数据,任意一方做出的修改都会影响另一方。

不同语言会选择不同的方式传递参数,Go 语言选择了传值的方式,无论是传递基本类型、结构体还是指针,都会对传递的参数进行拷贝

首先是整数和数组,这个验证过程就不说了,直接说结论:Go 语言的整型和数组类型都是值传递的,也就是在调用函数时会对内容进行拷贝。需要注意的是如果当前数组的大小非常的大,这种传值的方式会对性能造成比较大的影响。

而引用类型实际上也是值传递的,只是因为引用类型里面有指针,所以会导致我们修改数据的时候会影响到原类型,下面这里举个简单例子。

func changeArray(arr [3]int) {arr[0] = 6
}func changeSlice(arr []int) {arr[0] = 6
}func main() {arr1 := [3]int{1, 2, 3}arr2 := []int{1, 2, 3}changeArray(arr1)changeSlice(arr2)fmt.Println(arr1)fmt.Println(arr2)
}// 输出
[1 2 3]
[6 2 3]

验证切片是值传递也很简单,下面这个append虽然给底层数组添加了值,但是实际上因为len没有改变,所以这个切片的值还是没有变化

func changeSlice(arr []int) {arr = append(arr, 4)
}func main() {arr1 := []int{1, 2, 3}changeSlice(arr1)fmt.Println(arr1)
}
// [1 2 3]

最后我们验证一下结构体和指针的传递方式

type MyStruct struct {i int
}func myFunction(a MyStruct, b *MyStruct) {a.i = 31b.i = 41fmt.Printf("in my_function - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)
}func main() {a := MyStruct{i: 30}b := &MyStruct{i: 40}fmt.Printf("before calling - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)myFunction(a, b)fmt.Printf("after calling  - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)
}// 函数的输出
$ go run main.go
before calling - a=({30}, 0xc000018178) b=(&{40}, 0xc00000c028)
in my_function - a=({31}, 0xc000018198) b=(&{41}, 0xc00000c038)
after calling  - a=({30}, 0xc000018178) b=(&{41}, 0xc00000c028)

从上述运行的结果我们可以得出如下结论:

  • 传递结构体时:会拷贝结构体中的全部内容;

  • 传递结构体指针时:会拷贝结构体指针;

在GO语言中,结构体在内存中是一片连续的空间,我们可以通过下面这个代码来进行验证

type MyStruct struct {i intj int
}func myFunction(ms *MyStruct) {ptr := unsafe.Pointer(ms)for i := 0; i < 2; i++ {c := (*int)(unsafe.Pointer((uintptr(ptr) + uintptr(8*i))))*c += i + 1fmt.Printf("[%p] %d\n", c, *c)}
}func main() {a := &MyStruct{i: 40, j: 50}myFunction(a)fmt.Printf("[%p] %v\n", a, a)
}// 运行后结果如下
$ go run main.go
[0xc000018180] 41
[0xc000018188] 52
[0xc000018180] &{41 52}

将指针作为参数传入某个函数时,函数内部会复制指针,也就是会同时出现两个指针指向原有的内存空间,所以 Go 语言中传指针也是传值。

所以在传递数组或者内存占用非常大的结构体时,我们应该尽量使用指针作为参数类型来避免发生数据拷贝进而影响性能。

4.2 接口

Go 语言中的接口是一组方法的签名,它是 Go 语言的重要组成部分。使用接口能够让我们写出易于测试的代码,然而很多工程师对 Go 的接口了解都非常有限,也不清楚其底层的实现原理,这成为了开发高性能服务的阻碍。

4.2.1 接口概述

在计算机科学中,接口是计算机系统中多个组件共享的边界,不同的组件能够在边界上交换信息1。

如下图所示,接口的本质是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。

人能够同时处理的信息非常有限,定义良好的接口能够隔离底层的实现,让我们将重点放在当前的代码片段中。SQL 就是接口的一个例子,当我们使用 SQL 语句查询数据时,其实不需要关心底层数据库的具体实现,我们只在乎 SQL 返回的结果是否符合预期。

下面我们看一个GO的接口代码

type error interface {Error() string
}type RPCError struct {Code    int64Message string
}func (e *RPCError) Error() string {return fmt.Sprintf("%s, code=%d", e.Message, e.Code)
}

上述代码根本就没有 error 接口的影子,这是为什么呢?Go 语言中接口的实现都是隐式的,我们只需要实现 Error() string 方法就实现了 error 接口。Go 语言实现接口的方式与 Java 完全不同:

  • 在 Java 中:实现接口需要显式地声明接口并实现所有方法;

  • 在 Go 中:实现接口的所有方法就隐式地实现了接口;

4.2.2 接口类型

接口也是 Go 语言中的一种类型,它能够出现在变量的定义、函数的入参和返回值中并对它们进行约束,不过 Go 语言中有两种略微不同的接口,一种是带有一组方法的接口,另一种是不带任何方法的 interface{}

Go 语言使用 runtime.iface 表示第一种接口,使用 runtime.eface 表示第二种不包含任何方法的接口 interface{},两种接口虽然都使用 interface 声明,但是由于后者在 Go 语言中很常见,所以在实现时使用了特殊的类型。

需要注意的是,与 C 语言中的 void * 不同,interface{} 类型不是任意类型。如果我们将类型转换成了 interface{} 类型,变量在运行期间的类型也会发生变化,获取变量类型时会得到 interface{}

我们可以通过一个例子理解Go 语言的接口类型不是任意类型这一句话,下面的代码在 main 函数中初始化了一个 *TestStruct 类型的变量,由于指针的零值是 nil,所以变量 s 在初始化之后也是 nil

package maintype TestStruct struct{}func NilOrNot(v interface{}) bool {return v == nil
}func main() {var s *TestStructfmt.Println(s == nil)      // #=> truefmt.Println(NilOrNot(s))   // #=> false
}

出现上述现象的原因是 —— 调用 NilOrNot 函数时发生了隐式的类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换。在类型转换时,*TestStruct 类型会转换成 interface{} 类型,转换后的变量不仅包含转换前的变量,还包含变量的类型信息 TestStruct,所以转换后的变量与 nil 不相等。

4.2.3 接口和指针

接口在定义一组方法时没有对实现的接收者做限制,所以我们会看到某个类型实现接口的两种方式


因为结构体类型和指针类型是不同的,就像我们不能向一个接受指针的函数传递结构体一样,在实现接口时这两种类型也不能划等号。虽然两种类型不同,但是上图中的两种实现不可以同时存在,Go 语言的编译器会在结构体类型和指针类型都实现一个方法时报错 “method redeclared”。

Cat 结构体来说,它在实现接口时可以选择接受者的类型,即结构体或者结构体指针,在初始化时也可以初始化成结构体或者指针。

type Cat struct {}
type Duck interface { ... }func (c  Cat) Quack {}  // 使用结构体实现接口
func (c *Cat) Quack {}  // 使用结构体指针实现接口var d Duck = Cat{}      // 使用结构体初始化变量
var d Duck = &Cat{}     // 使用结构体指针初始化变量

实现接口的类型和初始化返回的类型两个维度共组成了四种情况,然而这四种情况不是都能通过编译器的检查:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0VUrpmQf-1673624658546)(Dimage\1.bmp)]

type Cat struct{}func (c Cat) Quack() {fmt.Println("meow")
}func main() {var c Duck = &Cat{}c.Quack()
}

上面这种情况是可以初始化的,作为指针的 &Cat{} 变量能够隐式地获取到指向的结构体,所以能在结构体上调用 WalkQuack 方法。我们可以将这里的调用理解成 C 语言中的 d->Walk()d->Speak(),它们都会先获取指向的结构体再执行对应的方法。

但是如果我们将上述代码中方法的接受者和初始化的类型进行交换,代码就无法通过编译了:

type Duck interface {Quack()
}type Cat struct{}func (c *Cat) Quack() {fmt.Println("meow")
}func main() {var c Duck = Cat{}c.Quack()
}$ go build interface.go
./interface.go:20:6: cannot use Cat literal (type Cat) as type Duck in assignment:Cat does not implement Duck (Quack method has pointer receiver)

如上图所示,无论上述代码中初始化的变量 cCat{} 还是 &Cat{},使用 c.Quack() 调用方法时都会发生值拷贝:

  • 如上图左侧,对于 &Cat{} 来说,这意味着拷贝一个新的 &Cat{} 指针,这个指针与原来的指针指向一个相同并且唯一的结构体,所以编译器可以隐式的对变量解引用(dereference)获取指针指向的结构体;

  • 如上图右侧,对于 Cat{} 来说,这意味着 Quack 方法会接受一个全新的 Cat{},因为方法的参数是 *Cat,编译器不会无中生有创建一个新的指针;即使编译器可以创建新指针,这个指针指向的也不是最初调用该方法的结构体;

4.2.4 数据结构

Go 语言根据接口类型是否包含一组方法将接口类型分成了两类:

  • 使用 runtime.iface 结构体表示包含方法的接口

  • 使用 runtime.eface 结构体表示不包含任何方法的 interface{} 类型;

runtime.eface 结构体在 Go 语言中的定义是这样的:

type eface struct { // 16 字节_type *_typedata  unsafe.Pointer
}

由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针。

另一个用于表示接口的结构体是 runtime.iface,这个结构体中有指向原始数据的指针 data,不过更重要的是 runtime.itab 类型的 tab 字段。

type iface struct { // 16 字节tab  *itabdata unsafe.Pointer
}

接下来我们将详细分析 Go 语言接口中的这两个类型,即 runtime._typeruntime.itab

4.2.4.1 类型结构体

runtime._type 是 Go 语言类型的运行时表示。下面是运行时包中的结构体,其中包含了很多类型的元信息,例如:类型的大小、哈希、对齐以及种类等。

type _type struct {size       uintptrptrdata    uintptrhash       uint32tflag      tflagalign      uint8fieldAlign uint8kind       uint8equal      func(unsafe.Pointer, unsafe.Pointer) boolgcdata     *bytestr        nameOffptrToThis  typeOff
}
  • size 字段存储了类型占用的内存空间,为内存空间的分配提供信息;

  • hash 字段能够帮助我们快速确定类型是否相等;

  • equal 字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从 typeAlg 结构体中迁移过来的4;

4.2.4.2 itab结构体

runtime.itab 结构体是接口类型的核心组成部分,每一个 runtime.itab 都占 32 字节,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter_type 两个字段表示:

type itab struct { // 32 字节inter *interfacetype_type *_typehash  uint32_     [4]bytefun   [1]uintptr
}

除了 inter_type 两个用于表示类型的字段之外,上述结构体中的另外两个字段也有自己的作用:

  • hash 是对 _type.hash 的拷贝,当我们想将 interface 类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型 runtime._type 是否一致;

  • fun 是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以 fun 数组中保存的元素数量是不确定的;

我们会在类型断言中介绍 hash 字段的使用,在动态派发一节中介绍 fun 数组中存储的函数指针是如何被使用的。

4.2.5 类型断言

4.2.5.1 非空接口断言

首先分析接口中包含方法的情况,Duck 接口一个非空的接口,我们来分析从 Duck 转换回 Cat 结构体的过程:

func main() {var c Duck = &Cat{Name: "draven"}switch c.(type) {case *Cat:cat := c.(*Cat)cat.Quack()}
}

我们将编译得到的汇编指令分成两部分分析,第一部分是变量的初始化,第二部分是类型断言,第一部分的代码如下:

00000 TEXT        "".main(SB), ABIInternal, $32-0
...
00029 XORPS        X0, X0
00032 MOVUPS        X0, ""..autotmp_4+8(SP)
00037 LEAQ        go.string."draven"(SB), AX
00044 MOVQ        AX, ""..autotmp_4+8(SP)
00049 MOVQ        $6, ""..autotmp_4+16(SP)

0037 ~ 0049 三个指令初始化了 Duck 变量,Cat 结构体初始化在 SP+8 ~ SP+24 上。因为 Go 语言的编译器做了一些优化,所以代码中没有runtime.iface 的构建过程,不过对于这一节要介绍的类型断言和转换没有太多的影响。下面进入类型转换的部分:

00058 CMPL  go.itab.*"".Cat,"".Duck+16(SB), $593696792;; if (c.tab.hash != 593696792) {
00068 JEQ   80                          ;;
00070 MOVQ  24(SP), BP                  ;;      BP = SP+24
00075 ADDQ  $32, SP                     ;;      SP += 32
00079 RET                               ;;      return;; } else {
00080 LEAQ  ""..autotmp_4+8(SP), AX     ;;      AX = &Cat{Name: "draven"}
00085 MOVQ  AX, (SP)                    ;;      SP = AX
00089 CALL  "".(*Cat).Quack(SB)         ;;      SP.Quack()
00094 JMP   70                          ;;      ...;;      BP = SP+24;;      SP += 32;;      return;; }

switch语句生成的汇编指令会将目标类型的 hash 与接口变量中的 itab.hash 进行比较

  • 如果两者相等意味着变量的具体类型是 Cat,我们会跳转到 0080 所在的分支完成类型转换。
  1. 获取 SP+8 存储的 Cat 结构体指针;
  2. 将结构体指针拷贝到栈顶;
  3. 调用 Quack 方法;
  4. 恢复函数的栈并返回;
  • 如果接口中存在的具体类型不是 Cat,就会直接恢复栈指针并返回到调用方;

上图展示了调用 Quack 方法时的堆栈情况,其中 Cat 结构体存储在 SP+8 ~ SP+24 上,Cat 指针存储在栈顶并指向上述结构体。

4.2.5.2 空接口断言

当我们使用空接口类型 interface{} 进行类型断言时,如果不关闭 Go 语言编译器的优化选项,生成的汇编指令是差不多的。编译器会省略将 Cat 结构体转换成 runtime.eface 的过程:

func main() {var c interface{} = &Cat{Name: "draven"}switch c.(type) {case *Cat:cat := c.(*Cat)cat.Quack()}
}

如果禁用编译器优化,上述代码会在类型断言时就不是直接获取变量中具体类型的 runtime._type,而是从 eface._type 中获取,汇编指令仍然会使用目标类型的 hash 与变量的类型比较。

4.2.6 动态派发

动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程,它是面向对象语言中的常见特性6。Go 语言虽然不是严格意义上的面向对象语言,但是接口的引入为它带来了动态派发这一特性,调用接口类型的方法时,如果编译期间不能确认接口的类型,Go 语言会在运行期间决定具体调用该方法的哪个实现。

在如下所示的代码中,main 函数调用了两次 Quack 方法:

  1. 第一次以 Duck 接口类型的身份调用,调用时需要经过运行时的动态派发;
  2. 第二次以 *Cat 具体类型的身份调用,编译期就会确定调用的函数:
func main() {var c Duck = &Cat{Name: "draven"}c.Quack()c.(*Cat).Quack()
}

4.3 反射

reflect实现了运行时的反射能力,包括动态修改变量、判断类型是否实现了某些接口以及动态调用方法等功能。能够让程序操作不同类型的对象 1。反射包中有两对非常重要的函数和类型,两个函数分别是:

  • reflect.TypeOf能获取类型信息;

  • reflect.ValueOf能获取数据的运行时表示;

两个类型是 reflect.Typereflect.Value,它们与函数是一一对应的关系:

类型 reflect.Type 是反射包定义的一个接口,我们可以使用 reflect.TypeOf 函数获取任意变量的类型,reflect.Type 接口中定义了一些有趣的方法,MethodByName 可以获取当前类型对应方法的引用、Implements 可以判断当前类型是否实现了某个接口:

type Type interface {Align() intFieldAlign() intMethod(int) MethodMethodByName(string) (Method, bool)NumMethod() int...Implements(u Type) bool...
}

反射包中 reflect.Value 的类型与 reflect.Type 不同,它被声明成了结构体。这个结构体没有对外暴露的字段,但是提供了获取或者写入数据的方法:

type Value struct {// 包含过滤的或者未导出的字段
}func (v Value) Addr() Value
func (v Value) Bool() bool
func (v Value) Bytes() []byte
...

反射包中的所有方法基本都是围绕着 reflect.Typereflect.Value 两个类型设计的。我们通过 reflect.TypeOfreflect.ValueOf 可以将一个普通的变量转换成反射包中提供的 reflect.Typereflect.Value,随后就可以使用反射包中的方法对它们进行复杂的操作。

4.3.1 反射三大法则

运行时反射是程序在运行期间检查其自身结构的一种方式。反射带来的灵活性是一把双刃剑,反射作为一种元编程方式可以减少重复代码2,但是过量的使用反射会使我们的程序逻辑变得难以理解并且运行缓慢。我们在这一节中会介绍 Go 语言反射的三大法则3,其中包括:

  1. **interface{}** 变量可以反射出反射对象;
  2. 从反射对象可以获取 **interface{}** 变量;
  3. 要修改反射对象,其值必须可设置;

4.3.1.1 第一法则

反射的第一法则是我们能将 Go 语言的 interface{} 变量转换成反射对象。当我们执行 reflect.ValueOf(1) 时,虽然看起来是获取了基本类型 int 对应的反射类型,但是由于 reflect.TypeOfreflect.ValueOf 两个方法的入参都是 interface{} 类型,所以在方法执行的过程中发生了类型转换。

reflect.TypeOf 获取了变量 author 的类型,reflect.ValueOf 获取了变量的值 draven。如果我们知道了一个变量的类型和值,那么就意味着我们知道了这个变量的全部信息。

package mainimport ("fmt""reflect"
)func main() {author := "draven"fmt.Println("TypeOf author:", reflect.TypeOf(author))fmt.Println("ValueOf author:", reflect.ValueOf(author))
}$ go run main.go
TypeOf author: string
ValueOf author: draven

4.3.1.2 第二法则

反射的第二法则是我们可以从反射对象可以获取 interface{} 变量。既然能够将接口类型的变量转换成反射对象,那么一定需要其他方法将反射对象还原成接口类型的变量,reflect 中的 reflect.Value.Interface 就能完成这项工作

不过调用 reflect.Value.Interface 方法只能获得 interface{} 类型的变量,如果想要将其还原成最原始的状态还需要经过如下所示的显式类型转换:

v := reflect.ValueOf(1)
v.Interface().(int)

4.3.1.3 第三法则

Go 语言反射的最后一条法则是与值是否可以被更改有关,如果我们想要更新一个 reflect.Value,那么它持有的值一定是可以被更新的,假设我们有以下代码:

func main() {i := 1v := reflect.ValueOf(i)v.SetInt(10)fmt.Println(i)
}

运行上述代码会导致程序崩溃并报出 “reflect: reflect.flag.mustBeAssignable using unaddressable value” 错误,仔细思考一下就能够发现出错的原因:由于 Go 语言的函数调用都是传值的,所以我们得到的反射对象跟最开始的变量没有任何关系,那么直接修改反射对象无法改变原始变量,程序为了防止错误就会崩溃。

想要修改原变量只能使用如下的方法:

func main() {i := 1v := reflect.ValueOf(&i)v.Elem().SetInt(10)fmt.Println(i)
}$ go run reflect.go
10
  1. 调用 reflect.ValueOf 获取变量指针;
  2. 调用 reflect.Value.Elem 获取指针指向的变量;
  3. 调用 reflect.Value.SetInt 更新变量的值:

4.3.2 类型和值

Go 语言的 interface{} 类型在语言内部是通过 reflect.emptyInterface 结体表示的,其中的 rtype 字段用于表示变量的类型,另一个 word 字段指向内部封装的数据:

type emptyInterface struct {typ  *rtypeword unsafe.Pointer
}

用于获取变量类型的 reflect.TypeOf 函数将传入的变量隐式转换成 reflect.emptyInterface 类型并获取其中存储的类型信息 reflect.rtype

func TypeOf(i interface{}) Type {eface := *(*emptyInterface)(unsafe.Pointer(&i))return toType(eface.typ)
}func toType(t *rtype) Type {if t == nil {return nil}return t
}

reflect.rtype 是一个实现了 reflect.Type 接口的结构体。

用于获取接口值 reflect.Value 的函数 reflect.ValueOf 实现也非常简单,在该函数中我们先调用了 reflect.escapes 保证当前值逃逸到堆上,然后通过 reflect.unpackEface 从接口中获取 reflect.Value 结构体:

func ValueOf(i interface{}) Value {if i == nil {return Value{}}escapes(i)return unpackEface(i)
}func unpackEface(i interface{}) Value {e := (*emptyInterface)(unsafe.Pointer(&i))t := e.typif t == nil {return Value{}}f := flag(t.Kind())if ifaceIndir(t) {f |= flagIndir}return Value{t, e.word, f}
}

reflect.unpackEface 会将传入的接口转换成 reflect.emptyInterface,然后将具体类型和指针包装成 reflect.Value 结构体后返回。

4.3.3更新变量

当我们想要更新 reflect.Value 时,就需要调用 reflect.Value.Set 更新反射对象,该方法会调用 reflect.flag.mustBeAssignablereflect.flag.mustBeExported 分别检查当前反射对象是否是可以被设置的以及字段是否是对外公开的:

func (v Value) Set(x Value) {v.mustBeAssignable()x.mustBeExported()var target unsafe.Pointerif v.kind() == Interface {target = v.ptr}x = x.assignTo("reflect.Set", v.typ, target)typedmemmove(v.typ, v.ptr, x.ptr)
}

reflect.Value.Set 会调用 reflect.Value.assignTo 并返回一个新的反射对象,这个返回的反射对象指针会直接覆盖原反射变量。

4.3.4 方法调用

如果我们想要通过 reflect 包利用反射在运行期间执行方法不是一件容易的事情,下面的十几行代码就使用反射来执行 Add(0, 1) 函数:

func Add(a, b int) int { return a + b }func main() {v := reflect.ValueOf(Add)if v.Kind() != reflect.Func {return}t := v.Type()argv := make([]reflect.Value, t.NumIn())for i := range argv {if t.In(i).Kind() != reflect.Int {return}argv[i] = reflect.ValueOf(i)}result := v.Call(argv)if len(result) != 1 || result[0].Kind() != reflect.Int {return}fmt.Println(result[0].Int()) // #=> 1
}

使用反射来调用方法非常复杂,原本只需要一行代码就能完成的工作,现在需要十几行代码才能完成,但这也是在静态语言中使用动态特性需要付出的成本。

func (v Value) Call(in []Value) []Value {v.mustBe(Func)v.mustBeExported()return v.call("Call", in)
}

reflect.Value.Call 是运行时调用方法的入口,它通过两个 MustBe 开头的方法确定了当前反射对象的类型是函数以及可见性,随后调用 reflect.Value.call 完成方法调用,这个私有方法的执行过程会分成以下的几个部分:

  1. 检查输入参数以及类型的合法性;
  2. 将传入的 reflect.Value 参数数组设置到栈上;
  3. 通过函数指针和输入参数调用函数;
  4. 从栈上获取函数的返回值;

详细过程参考:Go 语言反射的实现原理 | Go 语言设计与实现 (draveness.me)

Golang底层原理学习笔记(一)相关推荐

  1. Spring5底层原理 学习笔记(二)AOP篇

    文章目录 AOP实现之ajc编译器 AOP实现之agent类加载 AOP实现之动态代理 jdk动态代理 演示 模拟实现动态代理 动态生成代理类需要使用到asm的api,这里就不展开了 Jdk对于反射调 ...

  2. 【vn.py学习笔记(二)】vn.py底层接口 学习笔记

    [vn.py学习笔记(二)]vn.py底层接口 学习笔记 1 CTP API的工作原理 1.1 CTP介绍 1.2 API功能介绍 1.3 CTP API文件 1.4 API 通用规则 2 CTP A ...

  3. Mybatis底层原理学习(二):从源码角度分析一次查询操作过程

    在阅读这篇文章之前,建议先阅读一下我之前写的两篇文章,对理解这篇文章很有帮助,特别是Mybatis新手: 写给mybatis小白的入门指南 mybatis底层原理学习(一):SqlSessionFac ...

  4. MOOC人工智能原理学习笔记1

    人工智能原理学习笔记1 The Foundations of AI: Philosophy Mathematics Economics Neuroscience Psychology Computer ...

  5. 自控原理学习笔记-反馈控制系统的动态模型(4)-频率特性函数Nyquist图及Bode图

    自控原理学习笔记 自控原理学习笔记专栏 文章目录 1.频率特性函数 1.1 图形表示方法: 1.2 零极点位置和暂态增益图 1.2.1 复轨迹曲线 1.2.3 例子 1.3 计算系统响应 2.开环频率 ...

  6. 自控原理学习笔记-系统稳定性分析(2)-环路分析及Nyquist-Bode判据

    自控原理学习笔记 自控原理学习笔记专栏 文章目录 3. 环路分析 3.1环路分析基本思想: 3.2 稳定程度的性能指标(相对稳定) 3.3 环路整形 4.Nyquist判据 4.1 与幅角原理关系 4 ...

  7. [编译原理学习笔记2-2] 程序语言的语法描述

    [编译原理学习笔记2-2] 程序语言的语法描述 文章目录 [编译原理学习笔记2-2] 程序语言的语法描述 [2.3.1] 上下文无关文法 [2.3.2] 语法分析树与二义性 [2.3.3] 形式语言鸟 ...

  8. 自控原理学习笔记-反馈控制系统的动态模型(1)

    自控原理学习笔记 1.导论 2.反馈控制系统的动态模型(1) 3.反馈控制系统的动态模型(2) 3.反馈控制系统的动态模型(3) 4.反馈控制系统的动态模型(4) 5.反馈控制系统的动态模型(5) 文 ...

  9. golang游戏开发学习笔记-开发一个简单的2D游戏(基础篇)

    此文写在golang游戏开发学习笔记-创建一个能自由探索的3D世界之后,感兴趣可以先去那篇文章了解一些基础知识,在这篇文章里我们要创建一个简单的2D游戏场景以及配套的人物,并实现人物运动和碰撞检测功能 ...

最新文章

  1. 自定义GridView分页模板
  2. 等差数列连续异或模板
  3. 著名加密库收集 Encrypt
  4. [20171109]缓存命中率神话.txt
  5. matlab数据序列的几种滤波器
  6. AI:2020年6月23日北京智源大会演讲分享之智能信息检索与挖掘专题论坛——09:55-10:40刘欢教授《Challenges in Combating Disinformation》
  7. forkjoin_应用ForkJoin –从最佳到快速
  8. 【HDU1325】Is It A Tree?(并查集基础题)
  9. 【POI】读取Excel表中的数据
  10. 作为餐饮店长最需要什么能力?
  11. pager-taglib 使用说明
  12. Asp.net MVC权限设计思考 (二)逻辑部分实现
  13. Discuz插件,批量Discuz采集发布插件
  14. 分布式系统负载均衡策略分析与研究
  15. 数电实验三:组合逻辑电路分析与设计
  16. oracle没有网卡驱动,联想台式机网卡驱动,手把手教你联想台式机网卡驱动
  17. PCB检查流程checklist
  18. oracle pdb启动日志,案例:Oracle 12C 数据库pdb丢失数据文件后的完整恢复过程
  19. 【安全资讯】2021年值得关注的10大网络安全工具
  20. Learn OpenGL 笔记5.11 Anti Aliasing(抗锯齿)

热门文章

  1. python 主力资金_真正的主力进场加仓指标源码
  2. [推荐]微软推出MSE 2010 Beta中文版 穆穆-movno1
  3. RD、RT以及VRF是什么?
  4. ale插件 vim_vim ale插件详情
  5. 机械硬盘升级固态硬盘
  6. Linux编写C++程序
  7. 你关心的2023年PMP的考试时间和地点在这里
  8. Rai StudiesQuick Start Site for JAVA Developers
  9. 软考有什么用 非计算机专业,软考考的是什么?软考有用吗?
  10. g2o学习记录(1)安装和运行其下面的unit_test项目