0%

go 语言设计与实现 学习笔记

go 语言设计与实现 学习笔记

https://draveness.me/golang/

编译

go的编译过程包括了: 词法与语法分析、类型检查和 AST 转换、通用 SSA 生成和最后的机器代码生成 四个部分. 在类型检查阶段对make进行改写替换成实际底层对应的slice、map、chan创建方法. 中间代码从AST到SSA的过程中进行了几十次迭代优化, 执行50多个过程.

数组

如果数组中元素的个数小于或者等于4 个,那么所有的变量会直接在栈上初始化,如果数组元素大于 4 个,变量就会在静态存储区初始化然后拷贝到栈上,这些转换后的代码才会继续进入中间代码生成和机器码生成两个阶段,最后生成可以执行的二进制文件. -> 可能的原因是为了优化编译时间, 在静态区创建后就不用再次设置值, 而直接在栈上创建会需要再设置一次初始值.

无论是在栈上还是静态存储区,数组在内存中其实就是一连串的内存空间,表示数组的方法就是一个指向数组开头的指针、数组中元素的数量以及数组中元素类型占的空间大小,如果我们不知道数组中元素的数量,访问时就可能发生越界,而如果不知道数组中元素类型的大小,就没有办法知道应该一次取出多少字节的数据,如果没有这些信息,我们就无法知道这片连续的内存空间到底存储了什么数据.

Go 语言对于数组的访问还是有着比较多的检查的,它不仅会在编译期间提前发现一些简单的越界错误并插入用于检测数组上限的函数调用panicIndex,而在运行期间这些插入的函数会负责保证不会发生越界错误。

切片

切片内元素的类型是在编译期间确定的,编译器确定了类型之后,会将类型存储在 Extra 字段中帮助程序在运行时动态获取

编译期间的切片是 Slice 类型的,但是在运行时切片由如下的 SliceHeader 结构体表示,其中 Data 字段是指向数组的指针,Len 表示当前切片的长度,而 Cap 表示当前切片的容量,也就是 Data 数组的大小:

1
2
3
4
5
type SliceHeader struct {
Data uintptr
Len int
Cap int
}

切片可以理解为一个对数组的抽象层.

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

  1. 通过下标的方式获得数组或者切片的一部分;是最原始也最接近汇编语言的方式. 如 arr[0:3], 其直接创建slice的结构体, 并设置data, len, cap数值.
  2. 使用字面量初始化新的切片;首先创建底层数组并赋值每个值, 其后创建一个指针指向静态存储中的该数组, 最后通过[:]获得切片结构, 可见[:]是创建切片的最底层方法之一.
  3. 使用关键字 make 创建切片, 很多工作都需要运行时的参与, 编译器在类型检查时保证传入的容量 cap 一定大于或者等于 len. 当切片发生逃逸或者非常大时,我们需要 runtime.makeslice 函数在堆上初始化. 如果当前的切片不会发生逃逸并且切片非常小的时候,如make([]int, 3, 4)会被直接转换成如下所示的代码:
    1
    2
    var arr [4]int
    n := arr[:3]
    makeslice会最后调用mallocgc, 其是用于申请内存的函数,这个函数的实现还是比较复杂,如果遇到了比较小的对象会直接初始化在 Go 语言调度器里面的 P 结构中,而大于 32KB 的一些对象会在堆上初始化.

因为大多数对切片类型的操作并不需要直接操作原 slice 结构体,所以 SliceHeader 的引入能够减少切片初始化时的少量开销,这个改动能够减少 约0.2% 的 Go 语言包大小并且能够减少 92 个 panicindex 的调用,占整个 Go 语言二进制的 3.5%.

len 和 cap 被 Go 语言的编译器看成是两种特殊的操作,即 OLEN 和 OCAP,它们会在 SSA 生成阶段被 cmd/compile/internal/gc.epxr 函数转换成 OpSliceLen 和 OpSliceCap 操作

range遍历优化

使用 range 遍历切片时也会在编译期间转换成形式更简单的代码. 编译器会在编译期间将所有 for/range 循环变成经典的for循环.对于所有的 range 循环,Go 语言都会在编译期将原切片或者数组赋值给一个新的变量 ha,在赋值的过程中就发生了拷贝,所以我们遍历的切片已经不是原始的切片变量了。而遇到这种同时遍历索引和元素的 range 循环时,Go 语言会额外创建一个新的 v2 变量存储切片中的元素,循环中使用的这个变量 v2 会在每一次迭代被重新赋值而覆盖,在赋值时也发生了拷贝.

go中append操作会根据是否覆盖原变量的情况进行代码优化:

不覆盖:

1
2
3
4
5
6
7
8
9
10
11
// 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)

覆盖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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

主要优化就是对于不覆盖的场景返回一个新的slice结构, 对于覆盖的场景就直接修改原来的slice结构中cap len data的值. 这样就不需要创建一个新的结构体了.

growslice 方法负责在cap不够新的len时进行扩容:

  1. 如果期望容量大于当前容量的两倍就会使用期望容量;
  2. 如果当前切片的长度小于 1024 就会将容量翻倍;
  3. 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func growslice(et *_type, old slice, cap int) slice {
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
if newcap <= 0 {
newcap = cap
}
}
}
...
// 确定了切片的容量之后,就可以计算切片中新数组占用的内存了,计算的方法就是将目标容量和元素大小相乘,计算新容量时可能会发生溢出或者请求的内存超过上限,在这时就会直接 panic
...
// 最终会返回一个新的 slice 结构
return slice{p, old.len, newcap}
}

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

copy

copy(a, b) 的形式对切片进行拷贝时,编译期间的 cmd/compile/internal/gc.copyany 函数也会分两种情况进行处理,如果当前 copy 不是在运行时调用的,copy(a, b) 会被直接转换成下面的代码:

1
2
3
4
5
6
7
n := len(a)
if n > len(b) {
n = len(b)
}
if a.ptr != b.ptr {
memmove(a.ptr, b.ptr, n*sizeof(elem(a)))
}

memmove 会负责对内存进行拷贝,在其他情况下,编译器会使用 runtime.slicecopy 函数替换运行期间调用的 copy.

memmove 是将整块内存中的内容拷贝到目标的内存区域, 这也就是为什么copy的dest需要指定和source一样或者更大的cap, 如果dest没有初始化足够的cap, 那么拷贝只会将两者容量中最小的容量的数据进行拷贝, 这就产生了时常忽略的错误.

哪怕使用 memmove对内存成块进行拷贝,但是这个操作还是会占用非常多的资源,在大切片上执行拷贝操作时一定要注意性能影响。

注意:

如果slice的底层cap容量是够用的, 创建新的slice进行append后再对原slice进行append,会覆盖前一次append的值. 执行如下代码会发现,b的底层数据被覆盖成了4而不是3.

1
2
3
4
a := make([]int, 4, 8)
b := append(a, 3, 3, 3)
a = append(a, 4, 4, 4)
println(b[4]) // 4

哈希表 map

哈希函数往往都是不完美的,输出的范围是有限的,所以一定会发生哈希碰撞,这时就需要一些方法来解决哈希碰撞的问题,常见方法的就是开放寻址法和拉链法.

开放寻址法核心思想是对数组中的元素依次探测和比较以判断目标键值对是否存在于哈希表中,如果我们使用开放寻址法来实现哈希表,那么在支撑哈希表的数据结构就是数组. 当我们向当前哈希表写入新的数据时发生了冲突,就会将键值对写入到下一个不为空的位置.

当需要查找某个键对应的值时,就会从索引的位置开始对数组进行线性探测,找到目标键值对或者空内存就意味着这一次查询操作的结束。

开放寻址法

开放寻址法中对性能影响最大的就是装载因子,它是数组中元素的数量与数组大小的比值,随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会同时影响哈希表的读写性能,当装载率超过 70% 之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%,整个哈希表就会完全失效,这时查找和插入任意元素的时间复杂度都是 O(n) 的,它们可能需要遍历数组中全部的元素,所以在实现哈希表时一定要时刻关注装载因子的变化

拉链法

实现拉链法一般会使用数组加上链表, 有一些语言会在拉链法的哈希中引入红黑树以优化性能, 它的实现比较开放地址法稍微复杂一些,但是平均查找的长度也比较短,各个用于存储节点的内存都是动态申请的,可以节省比较多的存储空间.

哈希函数返回的哈希会帮助我们选择一个桶(链表),和开放地址法一样,选择桶的方式就是直接对哈希返回的结果取模. 在遍历链表的过程中会遇到以下两种情况:

  1. 找到键相同的键值对 —— 更新键对应的值;
  2. 没有找到键相同的键值对 —— 在链表的末尾追加新键值对

在一个性能比较好的哈希表中,每一个桶中都应该有 0-1 个元素,有时会有 2-3 个. 其装载因子 := 元素数量 / 桶数量

与开放地址法一样,拉链法的装载因子越大,哈希的读写性能就越差,在一般情况下使用拉链法的哈希表装载因子都不会超过 1,当哈希表的装载因子较大(常见java 0.75?)时就会触发哈希的扩容,创建更多的桶来存储哈希中的元素,保证性能不会出现严重的下降

go中使用hmap存储哈希, 其中B字段表示当前哈希表持有的 buckets 数量,B最大为256,但是因为哈希表中桶的数量都 2 的倍数,所以该字段会存储对数,也就是 len(buckets) == 2^B; hash0 是哈希的种子,它能为哈希函数的结果引入随机性,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入.

B的uint8类型,最大为255。所以最大有2^255个桶,每个桶可装8个kv,还有额外的溢出桶最大容量在4.63x10^77以上.

哈希表 hmap 的桶就是 bmap, 每一个 bmap 都能存储 8 个键值对,当哈希表中存储的数据过多,单个桶无法装满时就会使用 extra.overflow 中桶存储溢出的数据, 这两者分别叫做正常桶和溢出桶.
溢出桶能减少扩容的频率.

1
2
3
4
5
6
7
8
9
10
// bmap 结构体其实不止包含 tophash 字段,由于哈希表中可能存储不同类型的键值对并且 Go 语言也不支持泛型,所以键值对占据的内存空间大小只能在编译时进行推导,这些字段在运行时也都是通过计算内存地址的方式直接访问的,所以它的定义中就没有包含这些字段
type bmap struct {
// tophash 存储了键的哈希的高 8 位,通过比较不同键的哈希的高 8 位可以减少访问键值对次数以提高性能
tophash [bucketCnt]uint8
// 以下都是隐藏字段
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}

初始化

通过gc.maplit 初始化哈希.

例如:

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

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

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

一旦哈希表中元素的数量超过了 25 个,就会在编译期间创建两个数组分别存储键和值的信息,两个切片 vstatk 和 vstatv 还会被编辑器继续展开,这些键值对会通过一个如下所示的 for 循环加入目标的哈希:

1
2
3
4
5
6
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]
}

Go 语言编译器都会在类型检查期间将make转换成对 runtime.makemap 的调用

这个函数的执行过程会分成以下几个部分:

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

当桶的数量小于 16(2^4) 时,由于数据较少、使用溢出桶的可能性较低,这时就会省略创建溢出桶的过程以减少额外开销;当桶的数量多于 16(2^4) 时,就会额外创建 2^(B−4) 个溢出桶,根据上述代码,我们能确定在正常情况下,正常桶和溢出桶在内存中的存储空间是连续的,只是被 hmap 中的不同字段引用,当溢出桶数量较多时会通过 runtime.newobject 创建新的溢出桶

访问

对map的访问会根据是否进行存在判断,执行mapaccess1和mapaccess2(判断key存在: v, ok := hash[key]形式)
runtime.mapaccess1 函数会先通过哈希表设置的哈希函数、种子获取当前键对应的哈希,再通过 bucketMask 和 add 函数拿到该键值对所在的桶序号和哈希最上面的 8 位数字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
alg := t.key.alg
hash := 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,每一个桶都存储键对应的 tophash,每一次读写操作都会与桶中所有的 tophash 进行比较,用于选择桶序号的是哈希的最低几位,而用于加速访问的是哈希的高 8 位,这种设计能够减少同一个桶中有大量相等 tophash 的概率(相邻的数据哈希的高位可能很接近或相等, 通过低位选桶可以避免其被分到同一个桶的概率, 这样在同一个桶中使用高8位进行访问加速就很少出现top==tophash[i]的可能, 这样就减少了去取得存储的数据与key进行比较的次数, 因为key可以很长很大, 直接进行比较会很消耗性能, 通过高8位的比较可以加速比较速度)。当发现桶中的 tophash 与传入键的 tophash 匹配之后,我们会通过指针和偏移量获取哈希中存储的键 keys[0] 并与 key 比较,如果两者相同就会获取目标值的指针 values[0] 并返回. runtime.mapaccess2 只是在 runtime.mapaccess1 的基础上多返回了一个标识键值对是否存在的布尔值.

与数组一样,哈希表可能会在装载因子过高或者溢出桶过多时进行扩容,哈希表的扩容并不是一个原子的过程. 这时并发读写就可能产生问题. 需要进行临界区访问控制.

写入

依次遍历正常桶和溢出桶中存储的数据,依次执行:判断 tophash 是否相等、key 是否相等的过程,遍历结束后会从循环中跳出.

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

如果当前键值对在哈希中不存在,哈希为新键值对规划存储的内存地址,通过 typedmemmove 将键移动到对应的内存空间中并返回键对应值的地址 val,如果当前键值对在哈希中存在,那么就会直接返回目标区域的内存地址。哈希并不会在 mapassign 这个运行时函数中将值拷贝到桶中,该函数只会返回内存地址,真正的赋值操作是在编译期间插入的:

1
2
3
4
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_fast64 与 runtime.mapassign 函数的实现差不多,我们需要关注的是后面的三行代码,24(SP) 就是该函数返回的值地址,我们通过 LEAQ 指令将字符串的地址存储到寄存器 AX 中,MOVQ 指令将字符串 “88” 存储到了目标地址上完成了这次哈希的写入

扩容

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

  1. 装载因子已经超过 6.5; 进行翻倍扩容.
  2. 哈希使用了太多溢出桶; 进行等量扩容. 一旦哈希中出现了过多的溢出桶,它就会创建新桶保存数据,垃圾回收会清理老的溢出桶并释放内存. 产生场景: 当我们持续向哈希中插入数据并将它们全部删除时,如果哈希表中的数据量没有超过阈值,就会不断积累溢出桶造成缓慢的内存泄漏

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

扩容会使用runtime.evacuate 将一个旧桶中的数据分流到两个新桶. 等量扩容只会有一个.

当哈希表正在处于扩容状态时,每次向哈希表写入值时都会触发 runtime.growWork 对哈希表的内容进行增量拷贝

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

删除

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

哈希表的删除逻辑与写入逻辑非常相似,只是触发哈希的删除需要使用关键字,如果在删除期间遇到了哈希表的扩容,就会对即将操作的桶进行分流,分流结束之后会找到桶中的目标元素完成键值对的删除工作

mapextra中overflow和oldoverflow, 是方便 gc 的,若 key 和 value 都不是指针,则 gc 不需要遍历 buckets,但这会导致 overflow 被回收,为了避免这种情况,在外层保存了 overflow. bucket中的overflow是 uintptr类型,不会被gc标记,在清扫时就被当成白色对象回收了

函数传参值传递对于slice、map、chan的解释

map、channel这两种类型的值其实是指向runtime.hmap与runtime.hchan的指针。

而slice类型就是runtime.sliceHeader类型。

所以传递slice,因为值传递,所以拷贝了另一个runtime.sliceHeader作为形参;若函数内对slice做了修改需要返回;

而传递map,仍然是值传递,但map的值就是指向runtime.hmap的地址,所以函数内做了修改不需要返回就能生效.

字符串

Go 语言中的字符串其实是一个只读的字节数组, 在运行时我们其实还是可以将这段内存拷贝到堆或者栈上,将变量的类型转换成 []byte 之后就可以进行

每一个字符串在运行时都会使用如下的 StringHeader 结构体表示,在运行时包的内部其实有一个私有的结构 stringHeader,它有着完全相同的结构只是用于存储数据的 Data 字段使用了 unsafe.Pointer 类型. 其结构和silce很像, 缺少了cap, 因为是不可变的.

1
2
3
4
type StringHeader struct {
Data uintptr
Len int
}

在 Go 语言中,有两种字面量方式可以声明一个字符串,一种是使用双引号,另一种是使用反引号

go的+号拼接字符串时: 如果需要拼接的字符串小于或者等于 5 个,那么就会直接调用 concatstring{2,3,4,5} 等一系列函数,如果超过 5 个就会直接选择 runtime.concatstrings 传入一个数组切片.参见cmd/compile/internal/gc.addstr 最终调用concatstrings 对传入的切片参数进行遍历,先过滤空字符串并计算拼接后字符串的长度, 如果非空字符串的数量为 1 并且当前的字符串不在栈上就可以直接返回该字符串,不需要进行额外的任何操作。在正常情况下,运行时会调用 copy 将输入的多个字符串拷贝到目标字符串所在的内存空间中,新的字符串是一片新的内存空间,与原来的字符串也没有任何关联,一旦需要拼接的字符串非常大,拷贝带来的性能损失就是无法忽略的

[]byte与string互转

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

string转[]byte时, 会使用传入的缓冲区写入,没有传入则会创建一个缓冲区. 之后调用copy将数据拷贝进新的[]byte中. 所以string与[]byte的互相转化是会因为数据长度的增加而增加性能开销的.

函数调用

C与GO语言参数和返回值

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

  1. 六个以及六个以下的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递;
  2. 六个以上的参数会使用栈传递,函数的参数会以从右到左的顺序依次存入栈中;

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

go编译汇编: go tool compile -S -N -l main.go 如果编译时不使用 -N -l 参数,编译器会对汇编代码进行优化

Go 语言使用栈传递参数和接收返回值.

C 语言同时使用寄存器和栈传递参数,使用 eax 寄存器传递返回值;而 Go 语言使用栈传递参数和返回值。
对比一下这两种设计的优点和缺点:

  1. C 语言的方式能够极大地减少函数调用的额外开销,但是也增加了实现的复杂度;
  • CPU 访问栈的开销比访问寄存器高几十倍3;
  • 需要单独处理函数参数过多的情况;
  1. Go 语言的方式能够降低实现的复杂度并支持多返回值,但是牺牲了函数调用的性能;
  • 不需要考虑超过寄存器数量的参数应该如何传递;
  • 不需要考虑不同架构上的寄存器差异;
  • 函数入参和出参的内存空间需要在栈上进行分配;

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

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

  • 传递结构体时:会对结构体中的全部内容进行拷贝;
  • 传递结构体指针时:会对结构体指针进行拷贝;

接口

Go 语言中有两种略微不同的接口,一种是带有一组方法的接口(用iface表示),另一种是不带任何方法的 interface{}(用eface结构表示).

1
2
3
4
5
6
7
8
type eface struct { // 16 bytes
_type *_type
data unsafe.Pointer
}
type iface struct { // 16 bytes
tab *itab
data unsafe.Pointer
}

_type 是 Go 语言类型的运行时表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
type _type struct {
size uintptr //size 字段存储了类型占用的内存空间,为内存空间的分配提供信息;
ptrdata uintptr
hash uint32 // hash 字段能够帮助我们快速确定类型是否相等;
tflag tflag
align uint8
fieldAlign uint8
kind uint8
equal func(unsafe.Pointer, unsafe.Pointer) bool // equal 字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从 typeAlg 结构体中迁移过来的
gcdata *byte
str nameOff
ptrToThis typeOff
}

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

1
2
3
4
5
6
7
type itab struct { // 32 bytes
inter *interfacetype
_type *_type
hash uint32 // hash 是对 _type.hash 的拷贝,当我们想将 interface 类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型 _type 是否一致;
_ [4]byte
fun [1]uintptr // fun 是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以 fun 数组中保存的元素数量是不确定的;
}

//go:noinline可以用于禁止内联编译

go的四种接口和方法对象组合中只有使用指针实现接口,使用结构体初始化变量无法通过编译.

Go 语言的接口类型不是任意类型.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

type TestStruct struct{}

func NilOrNot(v interface{}) bool {
return v == nil
}

func main() {
var s *TestStruct
fmt.Println(s == nil) // #=> true
fmt.Println(NilOrNot(s)) // #=> false
}
// 调用 NilOrNot 函数时发生了隐式的类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换。在类型转换时,*TestStruct 类型会转换成 interface{} 类型,转换后的变量不仅包含转换前的变量,还包含变量的类型信息 TestStruct,所以转换后的变量与 nil 不相等

go在进行类型断言的时候通过hash值比较进行类型判断.

动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程,它是一种在面向对象语言中常见的特性. 如果编译期间不能确认接口的类型,Go 语言会在运行期间决定具体调用该方法的哪个实现.

在关闭编译器优化的情况下,动态派发生成的指令会带来 ~18% 左右的额外性能开销。开启编译器优化后,动态派发的额外开销会降低至 约5%. 与使用接口带来的好处相比,动态派发的额外开销往往可以忽略.

使用结构体来实现接口带来的开销会大于使用指针实现,而动态派发在结构体上的表现非常差,这也提醒我们应当尽量避免使用结构体类型实现接口. 动态派发的过程只是放大了参数拷贝带来的影响

反射

reflect.TypeOf 能获取类型信息,reflect.ValueOf 能获取数据的运行时表示, 类型 Type 是反射包定义的一个接口, MethodByName 可以获取当前类型对应方法的引用、Implements 可以判断当前类型是否实现了某个接口, 反射包中 Value 被声明成了结构体

Go 语言反射的三大法则3,其中包括:

  1. 从 interface{} 变量可以反射出反射对象; interface{} -> 反射对象
  2. 从反射对象可以获取 interface} 变量; 反射对象 -> interface{} , 想换成原始类型还得显式转换.
  3. 要修改反射对象,其值必须可设置;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
i := 1
v := reflect.ValueOf(i)
v.SetInt(10) // 此处panic
fmt.Println(i)
}
// panic: reflect: reflect.flag.mustBeAssignable using unaddressable value
func main() {
i := 1
v := reflect.ValueOf(&i)
v.Elem().SetInt(10) // 想要修改反射对象的值就得先获取指针.
fmt.Println(i)
}
// 10

go中const变量是无法获取到地址的. 就无法通过以上反射修改其值.

reflect.TypeOf 函数的实现原理其实并不复杂,它只是将一个 interface{} 变量转换成了内部的 emptyInterface 表示,然后从中获取相应的类型信息.

当我们想要将一个变量转换成反射对象时,Go 语言会在编译期间完成类型转换的工作,将变量的类型和值转换成了 interface{} 并等待运行期间使用 reflect 包获取接口中存储的信息。

如果接口中不包含任何方法,就意味着这是一个空的接口,任意类型都自动实现该接口.

由于方法都是按照字母序存储的,reflect.implements 会维护两个用于遍历接口和类型方法的索引 i 和 j 判断类型是否实现了接口,因为最多只会进行 n 次比较(类型的方法数量),所以整个过程的时间复杂度是 O(n).

当我们想要在 Go 语言中清空一个切片或者哈希表时,我们一般都会使用以下的方法将切片中的元素置零,但是依次去遍历切片和哈希表看起来是非常耗费性能的事, 实际上编译器会直接使用runtime.memclrNoHeapPointers 清空切片中的数据,生成的代码会经过编译器优化.

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
arr := []int{1, 2, 3}
for i := range arr {
arr[i] = 0
}
// 优化后: 因为是连续空间, 所以直接清空一片空间.
if len(a) != 0 {
hp = &a[0]
hn = len(a)*sizeof(elem(a))
memclrNoHeapPointers(hp, hn)
i = len(a) - 1
}
}

for 和 range

循环同时使用 for 和 range 两个关键字,编译器会在编译期间将所有 for/range 循环变成的经典循环.

遍历哈希表时会使用 runtime.mapiterinit 函数初始化遍历开始的元素.Go 团队在设计哈希表的遍历时就不想让使用者依赖固定的遍历顺序,所以引入了随机数保证遍历的随机性, Go 团队在设计哈希表的遍历时就不想让使用者依赖固定的遍历顺序,所以引入了随机数保证遍历的随机性.

从桶中找到下一个遍历的元素时在大多数情况下都会直接操作内存获取目标键值的内存地址,不过如果哈希表处于扩容期间就会调用 runtime.mapaccessK 函数获取键值对. 遍历完正常桶后会依次遍历溢出桶.

字符串遍历时会将其转为rune类型.

形如 for v := range ch {} 的语句最终会被转换成如下的格式:

1
2
3
4
5
6
7
ha := a
hv1, hb := <-ha
for ; hb != false; hv1, hb = <-ha {
v1 := hv1
hv1 = nil
...
}

该循环会使用 <-ch 从管道中取出等待处理的值,这个操作会调用 runtime.chanrecv2 并阻塞当前的协程,当 runtime.chanrecv2 返回时会根据布尔值 hb 判断当前的值是否存在,如果不存在就意味着当前的管道已经被关闭了,如果存在就会为 v1 赋值并清除 hv1 变量中的数据,然后会重新陷入阻塞等待新数据

Select

在多个文件或者 Channel 发生状态改变之前,select 会一直阻塞当前线程或者 Goroutine.

  1. select 能在 Channel 上进行非阻塞的收发操作;如果存在default,则会在不存在可以收发的channel后, 执行default,default可以不阻塞goroutine. 当存在可以收发的channel时会处理该channel的case.
  2. select 在遇到多个 Channel 同时响应时会随机挑选 case 执行;

select 在 Go 语言的源代码中不存在对应的结构体,但是 select 控制结构中的 case 却使用 runtime.scase 结构体来表示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type scase struct {
c *hchan // 存储 case 中使用的 Channel
elem unsafe.Pointer // 接收或者发送数据的变量地址
kind uint16 // 表示 runtime.scase 的种类, 总共4种
pc uintptr
releasetime int64
}
// runtime.scase的种类:
const (
caseNil = iota
caseRecv
caseSend
caseDefault
)

select 语句在编译期间会被转换成 OSELECT 节点。每一个 OSELECT 节点都会持有一组 OCASE 节点,如果 OCASE 的执行条件是空,那就意味着这是一个 default 节点

对于select的4种不同情况的编译器处理与优化:

  1. 空的 select 语句会直接阻塞当前的 Goroutine,导致 Goroutine 进入无法被唤醒的永久休眠状态. 直接将类似 select {} 的空语句转换成调用 runtime.block 函数
  2. 如果当前的 select 条件只包含一个 case,那么就会将 select 改写成 if 条件语句
    改写成类似v, ok := <-ch // case ch <- v 这样. 这会判断ch是否关闭, 不关闭尝试读取数据.
  3. 当 select 中仅包含两个 case,并且其中一个是 default 时,Go 语言的编译器就会认为这是一次非阻塞的收发操作. 当 case 中表达式的类型是 OSEND 时,编译器会使用 if/else 语句和 runtime.selectnbsend 函数改写代码,它提供了向 Channel 非阻塞地发送数据的能力. 同样接收也会改写成if/else语句.
  4. 其他情况的常规流程下, 先将所有的 case 转换成包含 Channel 以及类型等信息的 runtime.scase 结构体, 再调用运行时函数 runtime.selectgo 从多个准备就绪的 Channel 中选择一个可执行的 runtime.scase 结构体, 最后通过 for 循环生成一组 if 语句,在语句中判断自己是不是被选中的 case.

其中由selectgo函数负责选择待执行的case. runtime.selectgo 函数首先会进行执行必要的初始化操作并决定处理 case 的两个顺序 — 轮询顺序 pollOrder(加入随机性) 和加锁顺序 lockOrder(按照 Channel 的地址排序后确定加锁顺序). 之后它会分三个阶段查找或者等待某个 Channel 准备就绪:

  1. 查找是否已经存在准备就绪的 Channel,即可以执行收发操作;
  2. 将当前 Goroutine 加入 Channel 对应的收/发队列上并等待其他 Goroutine 的唤醒;
  3. 当前 Goroutine 被唤醒之后找到满足条件的 Channel 并进行处理;

其会根据不同情况通过 goto 跳转到函数内部的不同标签执行相应的逻辑, 包括:

  • bufrecv:可以从缓冲区读取数据;
  • bufsend:可以向缓冲区写入数据;
  • recv:可以从休眠的发送方获取数据;
  • send:可以向休眠的接收方发送数据;
  • rclose:可以从关闭的 Channel 读取 EOF;
  • sclose:向关闭的 Channel 发送数据;
  • retc:结束调用并返回;

随机的轮询顺序可以避免 Channel 的饥饿问题,保证公平性;而根据 Channel 的地址顺序(字母顺序排序)确定加锁顺序能够避免死锁的发生。

根据 Channel 的地址顺序确定加锁顺序如何做到避免死锁的?

lockOrder 的主要作用是避免死锁,如果两个 Goroutine 都需要锁定 ChannelA 和 ChannelB 才能执行任务,当两者尝试去依照不同的顺序进行锁定时,就可能发生死锁,以下是同时发生的:

Goroutine1 先锁定 A,这时发现 B 被锁定了,它是会有 A 的锁,等待 B 的释放
Goroutine2 先锁定 B,这时发现 A 被锁定了,它会持有 B 的锁,等待 A 的释放
如果锁定的顺序相同,这种情况就不会出现了,Goroutine1 和 2 都按照字母序来锁定 Channel,先获得 A 的 Goroutine 就可以先执行

selectgo的三个阶段工作

  1. 查找已经准备就绪的 Channel。
    其会循环会遍历所有的 case 并找到需要被唤起的 runtime.sudog 结构,在这个阶段,我们会根据 case 的四种类型分别处理:
  • caseNil:当前 case 不包含 Channel;
    这种 case 会被跳过;
  • caseRecv:当前 case 会从 Channel 中接收数据;
    • 如果当前 Channel 的 sendq 上有等待的 Goroutine,就会跳到 recv 标签并从缓冲区读取数据,同时将等待的 Goroutine 中的数据放入到缓冲区中相同的位置;
    • 如果当前 Channel 的缓冲区不为空,就会跳到 bufrecv 标签处从缓冲区获取数据;
    • 如果当前 Channel 已经被关闭,就会跳到 rclose 做一些清除的收尾工作;
  • caseSend:当前 case 会向 Channel 发送数据;
    • 如果当前 Channel 已经被关,闭就会直接跳到 sclose 标签,触发 panic 尝试中止程序;
    • 如果当前 Channel 的 recvq 上有等待的 Goroutine,就会跳到 send 标签向 Channel 发送数据;
    • 如果当前 Channel 的缓冲区存在空闲位置,就会将待发送的数据存入缓冲区;
  • caseDefault:当前 case 为 default 语句;
    表示前面的所有 case 都没有被执行,这里会解锁所有 Channel 并返回,意味着当前 select 结构中的收发都是非阻塞的;
  1. 如果不能立刻找到活跃的 Channel 就会进入循环的下一阶段,按照需要将当前的 Goroutine 加入到 Channel 的 sendq 或者 recvq 队列中. 除了将当前 Goroutine 对应的 runtime.sudog 结构体加入队列之外,这些 runtime.sudog 结构体都会被串成链表附着在 Goroutine 上。在入队之后会调用 runtime.gopark 函数挂起当前 Goroutine 等待调度器的唤醒。

  2. 等到 select 中的一些 Channel 准备就绪之后,当前 Goroutine 就会被调度器唤醒。这时会继续执行 runtime.selectgo 函数的第三阶段,从 runtime.sudog 结构体中获取数据. 会先获取当前 Goroutine 接收到的参数 sudog 结构,我们会依次对比所有 case 对应的 sudog 结构找到被唤醒的 case(链表遍历),获取该 case 对应的索引并返回.

由于当前的 select 结构找到了一个 case 执行,那么剩下 case 中没有被用到的 sudog 就会被忽略并且释放掉。为了不影响 Channel 的正常使用,我们还是需要将这些废弃的 sudog 从 Channel 中出队。

当我们在循环中发现缓冲区中有元素或者缓冲区未满时就会通过 goto 关键字跳转到 bufrecv 和 bufsend 两个代码段执行channel的数据收发, 这会调用 Channel 运行时函数 runtime.send 和 runtime.recv,这两个函数会直接与处于休眠状态的 Goroutine 打交道. 有两个特殊情况:
1. 从一个关闭 Channel 中接收数据会直接清除 Channel 中的相关内容;
2. 向一个关闭的 Channel 发送数据就会直接 panic 造成程序崩溃:

defer

样例代码:

1
2
3
4
5
6
7
8
func createPost(db *gorm.DB) error {
tx := db.Begin()
defer tx.Rollback() // 哪怕事务真的执行成功了,那么调用 tx.Commit() 之后再执行 tx.Rollback() 也不会影响已经提交的事务
if err := tx.Create(&Post{Author: "Draveness"}).Error; err != nil {
return err
}
return tx.Commit().Error
}

向 defer 关键字传入的函数会在函数返回之前运行, 会在调用时就执行, 解决办法就是使用给defer匿名函数,这样虽然传入时值拷贝,但是拷贝的是函数的指针, 在返回时才执行函数, 就不会导致defer语句中数据在调用时就被生成.

defer的数据结构:

1
2
3
4
5
6
7
8
9
10
type _defer struct {
siz int32 // 参数和结果的内存大小
started bool
sp uintptr // 栈指针
pc uintptr // 调用方程序计数器
fn *funcval // 传入的函数指针
_panic *_panic // 触发延迟调用的结构体, 可能为空.
link *_defer // 所有的结构体都会通过 link 字段串联成链表
... // 其他垃圾回收使用的字段
}

defer 在编译器看来也是函数调用, 将 defer 关键字都转换成 runtime.deferproc 函数(负责创建新的延迟调用), 还在所有调用 defer 的函数结尾插入了 runtime.deferreturn (负责在函数调用结束时执行所有的延迟调用).

在deferproc中会创建/获得_defer结构体,设置它的函数指针 fn、程序计数器 pc 和栈指针 sp 并将相关的参数拷贝到相邻的内存空间中, 最后调用runtime.return0 (避免无线递归调用deferreturn,唯一一个不会触发延迟调用的函数)

有三种途径获得_defer结构体: 1. 从调度器的延迟调用缓存池 sched.deferpool 中取出结构体并将该结构体追加到当前 Goroutine 的缓存池中;2. 从 Goroutine 的延迟调用缓存池 pp.deferpool 中取出结构体;3. 通过 runtime.mallocgc 创建一个新的结构体

之后将_defer机构体追加到链表的最前端. 执行会从链表最前端开始执行, 这就是为啥后进先执行的原因. 执行时调用 runtime.jmpdefer 函数传入需要执行的函数和参数,跳转到defer的代码执行, 在执行结束之后跳转回 runtime.deferreturn, 其中会多次判断当前 Goroutine 的 _defer 链表中是否有未执行的剩余结构,在所有的延迟函数调用都执行完成之后,该函数才会返回.

defer可以读取/修改函数的命名返回值:

1
2
3
4
5
func c() (i int) {
defer func() { i++ }()
return 1
}
// 返回2

panic 和 recover

  • panic 只会触发当前 Goroutine 的延迟函数调用, 不会触发调用方的(goroutine实际上都是平行的存在, 不存在树状父子关系);
  • recover 只有在 defer 函数中调用才会生效;
  • panic 允许在 defer 中嵌套多次调用, 多次嵌套调用 panic 也不会影响 defer 函数的正常执行;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    func main() {
    defer fmt.Println("in main")
    defer func() {
    defer func() {
    panic("panic again and again")
    }()
    panic("panic again")
    }()

    panic("panic once")
    }
    // in main
    // panic: panic once
    // panic: panic again
    // panic: panic again and again

调用panic会创建结构:

1
2
3
4
5
6
7
8
9
10
11
type _panic struct {
argp unsafe.Pointer // 指向 defer 调用时参数的指针
arg interface{} // 调用 panic 时传入的参数
link *_panic // 指向了更早调用的 runtime._panic 结构, 多个panic之间通过 link 的关联形成一个链表
recovered bool // recovered 表示当前 runtime._panic 是否被 recover 恢复
aborted bool // aborted 表示当前的 panic 是否被强行终止
// 结构体中的 pc、sp 和 goexit 三个字段都是为了修复 runtime.Goexit 的问题引入的2。该函数能够只结束调用该函数的 Goroutine 而不影响其他的 Goroutine,但是该函数会被 defer 中的 panic 和 recover 取消,引入这三个字段的目的就是为了解决这个问题。
pc uintptr
sp unsafe.Pointer
goexit bool
}

gopanic的执行过程:

  1. 创建新的 runtime._panic 结构并添加到所在 Goroutine _panic 链表的最前面;
  2. 在循环中不断从当前 Goroutine 的 _defer 中链表获取 runtime._defer 并调用 runtime.reflectcall 运行延迟调用函数;
  3. 调用 runtime.fatalpanic 中止整个程序,其中打印 panic 消息之后会通过 runtime.exit 退出当前程序并返回错误码(正常退出也是这个函数).

其中还包括 恢复程序的 recover 分支中的代码; 通过内联优化 defer 调用性能的代码 ; 修复 runtime.Goexit 异常情况的代码

编译器会将关键字 recover 转换成 runtime.gorecover 如果当前 Goroutine 没有调用 panic,那么该函数会直接返回 nil.

runtime.recovery 在调度过程中会将函数的返回值设置成 1。从 runtime.deferproc 的注释中我们会发现,当 runtime.deferproc 函数的返回值是 1 时,编译器生成的代码会直接跳转到调用方函数返回之前并执行 runtime.deferreturn,跳转到 runtime.deferreturn 函数之后,程序就已经从 panic 中恢复了并执行正常的逻辑

程序崩溃和恢复的过程总结:

  1. 编译器会负责做转换关键字的工作;
    • 将 panic 和 recover 分别转换成 runtime.gopanic 和 runtime.gorecover;
    • 将 defer 转换成 deferproc 函数;
    • 在调用 defer 的函数末尾调用 deferreturn 函数;
  2. 在运行过程中遇到 gopanic 方法时,会从 Goroutine 的链表依次取出 _defer 结构体并执行;
  3. 如果调用延迟执行函数时遇到了 gorecover 就会将 _panic.recovered 标记成 true 并返回 panic 的参数;
    • 在这次调用结束之后,gopanic 会从 _defer 结构体中取出程序计数器 pc 和栈指针 sp 并调用 recovery 函数进行恢复程序;
    • recovery 会根据传入的 pc 和 sp 跳转回 deferproc;
    • 编译器自动生成的代码会发现 deferproc 的返回值不为 0,这时会跳回 deferreturn 并恢复到正常的执行流程;
  4. 如果没有遇到 gorecover 就会依次遍历所有的 _defer 结构,并在最后调用 fatalpanic 中止程序、打印 panic 的参数并返回错误码 2;

make 和 new

make 的作用是初始化内置的数据结构,也就是我们在前面提到的切片、哈希表和 Channel;
new 的作用是根据传入的类型分配一片内存空间并返回指向这片内存空间的指针;

在类型检查阶段, make 关键字的 OMAKE 节点根据参数类型的不同转换成了 OMAKESLICE、OMAKEMAP 和 OMAKECHAN 三种不同类型

在中间代码生成阶段, neew会通过cmd/compile/internal/gc.callnew 函数会将关键字转换成 ONEWOBJ 类型的节点.

无论是直接使用 new,还是使用 var 初始化变量,它们在编译器看来就是 ONEWOBJ 和 ODCL 节点.

如果通过 var 或者 new 创建的变量不需要在当前作用域外生存,例如不用作为返回值返回给调用方,那么就不需要初始化在堆上

上下文 Context

context.Context 是 Go 语言在 1.7 版本中引入标准库的接口,用于传递调用链上游的一些信息,该接口定义了四个需要实现的方法,其中包括:

  1. Deadline — 返回 context.Context 被取消的时间,也就是完成工作的截止日期;
  2. Done — 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消之后关闭,多次调用 Done 方法会返回同一个 Channel;
  3. Err — 返回 context.Context 结束的原因,它只会在 Done 返回的 Channel 被关闭时才会返回非空的值;
    • 如果 context.Context 被取消,会返回 Canceled 错误;
    • 如果 context.Context 超时,会返回 DeadlineExceeded 错误;
  4. Value — 从 context.Context 中获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;
    1
    2
    3
    4
    5
    6
    7
    type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
    }
    ```
    context 包中提供的 context.Background、context.TODO、context.WithDeadline 和 context.WithValue 函数会返回实现该接口的私有结构体

每一个 context.Context 都会从最顶层的 Goroutine 一层一层传递到最下层。context.Context 可以在上层 Goroutine 执行出现错误时,将信号及时同步给下层。多个 Goroutine 同时订阅 ctx.Done() 管道中的消息,一旦接收到取消信号就立刻停止当前正在执行的工作.

context.Background、context.TODO都是返回的emptyCtx, 而emptyCtx是实现Context接口的基本空组件. context.Background 和 context.TODO 函数其实也只是互为别名,没有太大的差别。它们只是在使用和语义上稍有不同:

  • context.Background 是上下文的默认值,所有其他的上下文都应该从它衍生(Derived)出来;
  • context.TODO 应该只在不确定应该使用哪种上下文时使用;

在多数情况下,如果当前函数没有上下文作为入参,我们都会使用 context.Background 作为起始的上下文向下传递。

Context接口中,Done方法的返回值是一个只读channel,当取消信号发出时,这个channel被关闭.

针对多个goroutine监听同一个channel做相同操作的场景, 不用发多个信号, 直接关闭channel是最好的办法

即使使用了context, 所有信号的发出、接受和处理,包括goroutine树形结构的维护都是需要我们自己在代码中手动去做的,context并不能替我们完成, Context的主要作用有两个:传递取消信号(包括定时取消和手动取消),和传递数据。但这两种功能都需要我们时刻记得要把context传递下去,而且对于取消信号,还需要手动监听手动处理。context并不会帮我们把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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}

func propagateCancel(parent Context, child canceler) {
done := parent.Done()
if done == nil {
return // 父上下文不会触发取消信号,当前函数会直接返回
}
select {
case <-done:
child.cancel(false, parent.Err()) // 父上下文已经被取消
return
default:
}

if p, ok := parentCancelCtx(parent); ok {
// 如果父上下文没有被取消,child 会被加入 parent 的 children 列表中,等待 parent 释放取消信号
p.mu.Lock()
if p.err != nil {
child.cancel(false, p.err)
} else {
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 默认情况下会同时监听父子的Done channel
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err()) // 在父关闭时取消子的上下文
case <-child.Done():
}
}()
}
}
// 关闭上下文中的 Channel 并向所有的子上下文同步取消信号
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return
}
c.err = err
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
for child := range c.children {
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()

if removeFromParent {
removeChild(c.Context, c)
}
}

context.WithDeadline 方法在创建 context.timerCtx 的过程中,判断了父上下文的截止日期与当前日期,并通过 time.AfterFunc 创建定时器,当时间超过了截止日期后会调用 context.timerCtx.cancel 方法同步取消信号。WithTimeout其实是直接调用WithDeadline的. context.timerCtx 结构体内部不仅通过嵌入了context.cancelCtx 结构体继承了相关的变量和方法,还通过持有的定时器 timer 和截止时间 deadline 实现了定时取消这一功能. context.timerCtx.cancel 方法不仅调用了 context.cancelCtx.cancel,还会停止持有的定时器减少不必要的资源浪费

1
2
3
4
5
6
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.

deadline time.Time
}

context.WithValue 函数能从父上下文中创建一个子上下文,传值的子上下文使用 context.valueCtx 类型, 这样就生成了树状的层层包裹的结构. 通过Value方法查找上下文中的键值

1
2
3
4
5
6
7
8
9
10
11
type valueCtx struct {
Context
key, val interface{}
}

func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key) // 如果 context.valueCtx 中存储的键值对与 context.valueCtx.Value 方法中传入的参数不匹配,就会从父上下文中查找该键对应的值直到在某个父上下文中返回 nil 或者查找到对应的值. PS: 这里返回nil是因为最顶层是emptyCtx, 其Value直接返回nil
}

同步原语与锁

Mutex

1
2
3
4
type Mutex struct {
state int32 // 表示当前互斥锁的状态, 最低三位分别表示 mutexLocked、mutexWoken 和 mutexStarving,剩下的位置用来表示当前有多少个 Goroutine 等待互斥锁的释放. 在默认情况下,互斥锁的所有状态位都是 0
sema uint32 // 用于控制锁状态的信号量,
}

sync.Mutex 有两种模式 — 正常模式和饥饿模式. 相比于饥饿模式,正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时.

  • 在正常模式下,锁的等待者会按照先进先出的顺序获取锁。但是刚被唤起的 Goroutine 与新创建的 Goroutine 竞争时,大概率会获取不到锁,为了减少这种情况的出现,一旦 Goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式,防止部分 Goroutine 被『饿死』.
  • 饥饿模式是在 Go 语言 1.9 版本引入的优化,引入的目的是保证互斥锁的公平性(Fairness). 在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。如果一个 Goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms,那么当前的互斥锁就会被切换回正常模式。

加锁和解锁过程,它们分别使用 sync.Mutex.Lock 和 sync.Mutex.Unlock 方法. 这两个方法就是CAS自旋方式.

在多核的 CPU 上,自旋可以避免 Goroutine 的切换,使用恰当会对性能带来很大的增益,但是使用的不恰当就会拖慢整个程序,所以 Goroutine 进入自旋的条件非常苛刻:

  1. 互斥锁只有在普通模式才能进入自旋;
  2. sync.runtime_canSpin 在以下条件下需要返回 true:
    • 运行在多 CPU 的机器上;
    • 当前 Goroutine 为了获取该锁进入自旋的次数小于四次;
    • 当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空;

一旦当前 Goroutine 能够进入自旋就会调用sync.runtime_doSpin 和 runtime.procyield 并执行 30 次的 PAUSE 指令,该指令只会占用 CPU 并消耗 CPU 时间

处理了自旋相关的特殊逻辑之后,互斥锁会根据上下文计算当前互斥锁最新的状态。几个不同的条件分别会更新 state 字段中存储的不同信息 — mutexLocked、mutexStarving、mutexWoken 和 mutexWaiterShift, 计算了新的互斥锁状态之后,就会使用 CAS 函数 atomic.CompareAndSwapInt32 更新该状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
new := old
if old&mutexStarving == 0 {
new |= mutexLocked
}
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift
}
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}
if awoke {
new &^= mutexWoken
}

在饥饿模式下,当前 Goroutine 会获得互斥锁,如果等待队列中只存在当前 Goroutine,互斥锁还会从饥饿模式中退出

解锁:

  1. 如果该函数返回的新状态等于 0,当前 Goroutine 就成功解锁了互斥锁;
  2. 如果该函数返回的新状态不等于 0,这段代码会调用 sync.Mutex.unlockSlow 方法开始慢速解锁

Mutex加锁与解锁总结

加锁:

  • 如果互斥锁处于初始化状态,就会直接通过置位 mutexLocked 加锁;
  • 如果互斥锁处于 mutexLocked 并且在普通模式下工作,就会进入自旋,执行 30 次 PAUSE 指令消耗 CPU 时间等待锁的释放;
  • 如果当前 Goroutine 等待锁的时间超过了 1ms,互斥锁就会切换到饥饿模式;
  • 互斥锁在正常情况下会通过 sync.runtime_SemacquireMutex 函数将尝试获取锁的 Goroutine 切换至休眠状态,等待锁的持有者唤醒当前 Goroutine;
  • 如果当前 Goroutine 是互斥锁上的最后一个等待的协程或者等待的时间小于 1ms,当前 Goroutine 会将互斥锁切换回正常模式;

解锁:

  • 当互斥锁已经被解锁时,那么调用 sync.Mutex.Unlock 会直接抛出异常;
  • 当互斥锁处于饥饿模式时,会直接将锁的所有权交给队列中的下一个等待者,等待者会负责设置 mutexLocked 标志位;
  • 当互斥锁处于普通模式时,如果没有 Goroutine 等待锁的释放或者已经有被唤醒的 Goroutine 获得了锁,就会直接返回;在其他情况下会通过 sync.runtime_Semrelease 唤醒对应的 Goroutine;

读写锁RWMutex

1
2
3
4
5
6
7
type RWMutex struct {
w Mutex // 复用互斥锁提供的能力
writerSem uint32 // 用于写等待读
readerSem uint32 // 用于读等待写
readerCount int32 // 存储了当前正在执行的读操作的数量
readerWait int32 // 表示当写操作被阻塞时等待的读操作个数
}

获取写锁时会先阻塞写锁的获取,后阻塞读锁的获取,这种策略能够保证读操作不会被连续的写操作『饿死』。

小结:

  • 调用 sync.RWMutex.Lock 尝试获取写锁时;
    • 每次 sync.RWMutex.RUnlock 都会将 readerWait 其减一,当它归零时该 Goroutine 就会获得写锁;
    • 将 readerCount 减少 rwmutexMaxReaders 个数以阻塞后续的读操作;
  • 调用 sync.RWMutex.Unlock 释放写锁时,会先通知所有的读操作,然后才会释放持有的互斥锁;

WaitGroup

1
2
3
4
type WaitGroup struct {
noCopy noCopy // 保证 sync.WaitGroup 不会被开发者通过再赋值的方式拷贝
state1 [3]uint32 // 存储着状态和信号量
}

sync.noCopy 是一个特殊的私有结构体,tools/go/analysis/passes/copylock 包中的分析器会在编译期间检查被拷贝的变量中是否包含 sync.noCopy 结构体,如果包含该结构体就会在运行时报出错误.

虽然 sync.WaitGroup.Add 方法传入的参数可以为负数,但是计数器只能是非负数,一旦出现负数就会发生程序崩溃。当调用计数器归零,也就是所有任务都执行完成时,就会通过 sync.runtime_Semrelease 唤醒处于等待状态的所有 Goroutine。

小结:

  • sync.WaitGroup 必须在 sync.WaitGroup.Wait 方法返回之后才能被重新使用;
  • sync.WaitGroup.Done 只是对 sync.WaitGroup.Add 方法的简单封装,我们可以向 sync.WaitGroup.Add 方法传入任意负数(需要保证计数器非负)快速将计数器归零以唤醒其他等待的 Goroutine;
  • 可以同时有多个 Goroutine 等待当前 sync.WaitGroup 计数器的归零,这些 Goroutine 会被同时唤醒;

Once

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Once struct {
done uint32 // 用于标识代码块是否执行过的 done
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()
}
}

注意:

  • sync.Once.Do 方法中传入的函数只会被执行一次,哪怕函数中发生了 panic;
  • 两次调用 sync.Once.Do 方法传入不同的函数也只会执行第一次调用的函数;

Cond

Go 语言标准库中的 sync.Cond 一个条件变量,它可以让一系列的 Goroutine 都在满足特定条件时被唤醒。每一个 sync.Cond 结构体在初始化时都需要传入一个互斥锁.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Cond struct {
noCopy noCopy // 用于保证结构体不会在编译期间拷贝
L Locker // 用于保护内部的 notify 字段,Locker 接口类型的变量
notify notifyList // 一个 Goroutine 的链表,它是实现同步机制的核心结构
checker copyChecker // 用于禁止运行期间发生的拷贝
}
type notifyList struct {
//head 和 tail 分别指向的链表的头和尾,wait 和 notify 分别表示当前正在等待的和已经通知到的 Goroutine
wait uint32
notify uint32

lock mutex
head *sudog
tail *sudog
}

Go 语言切换 Goroutine 时经常会使用的方法: 调用 runtime.goparkunlock 将当前 Goroutine 陷入休眠状态, 它会直接让出当前处理器的使用权并等待调度器的唤醒.

sync.Cond 不是一个常用的同步机制,在遇到长时间条件无法满足时,与使用 for {} 进行忙碌等待相比,sync.Cond 能够让出处理器的使用权。在使用的过程中我们需要注意以下问题:

  1. sync.Cond.Wait 方法在调用之前一定要使用获取互斥锁,否则会触发程序崩溃;
  2. sync.Cond.Signal 方法唤醒的 Goroutine 都是队列最前面、等待最久的 Goroutine;
  3. sync.Cond.Broadcast 会按照一定顺序广播通知等待的全部 Goroutine;

扩展原语

Go 语言还在子仓库 sync 中提供了四种扩展原语,x/sync/errgroup.Group、x/sync/semaphore.Weighted、x/sync/singleflight.Group 和 x/sync/syncmap.Map,其中的 x/sync/syncmap.Map 在 1.9 版本中被移植到了标准库中

  1. ErrGroup: 如果返回错误, 这一组G中至少有一个错误, 如果返回空,则所有G成功执行. x/sync/errgroup.Group
    1
    2
    3
    4
    5
    6
    type Group struct {
    cancel func() // 创建 context.Context 时返回的取消函数,用于在多个 Goroutine 之间同步取消信号
    wg sync.WaitGroup // 用于等待一组 Goroutine 完成子任务的同步原语
    errOnce sync.Once // 用于保证只接收一个子任务返回的错误;
    err error
    }
  • x/sync/errgroup.Group 在出现错误或者等待结束后都会调用 context.Context 的 cancel 方法同步取消信号;
  • 只有第一个出现的错误才会被返回,剩余的错误都会被直接抛弃;
  1. 带权重的信号量 x/sync/semaphore.Weighted
    1
    2
    3
    4
    5
    6
    type Weighted struct {
    size int64 // 当前信号量的上限
    cur int64 // 计数器, 计数范围0-size
    mu sync.Mutex
    waiters list.List // 其中存储着等待获取资源的 Goroutine
    }
  • x/sync/semaphore.Weighted.Acquire 和 x/sync/semaphore.Weighted.TryAcquire 方法都可以用于获取资源,前者会阻塞地获取信号量,后者会非阻塞地获取信号量;
  • x/sync/semaphore.Weighted.Release 方法会按照 FIFO 的顺序唤醒可以被唤醒的 Goroutine;
  • 如果一个 Goroutine 获取了较多地资源,由于 x/sync/semaphore.Weighted.Release 的释放策略可能会等待比较长的时间;
  1. x/sync/singleflight.Group 是 Go 语言扩展包中提供了另一种同步原语,它能够在一个服务中抑制对下游的多次重复请求。

在资源的获取非常昂贵时(例如:访问缓存、数据库),就很适合使用 x/sync/singleflight.Group 对服务进行优化。我们来了解一下它的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type service struct {
requestGroup singleflight.Group
}

func (s *service) handleRequest(ctx context.Context, request Request) (Response, error) {
v, err, _ := requestGroup.Do(request.Hash(), func() (interface{}, error) {
rows, err := // select * from tables
if err != nil {
return nil, err
}
return rows, nil
})
if err != nil {
return nil, err
}
return Response{
rows: rows,
}, nil
}

因为请求的哈希在业务上一般表示相同的请求,所以上述代码使用它作为请求的键。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Group struct {
mu sync.Mutex
m map[string]*call
}

type call struct {
wg sync.WaitGroup
// val 和 err 字段都只会在执行传入的函数时赋值一次并在 sync.WaitGroup.Wait 返回时被读取
val interface{}
err error
// dups 和 chans 两个字段分别存储了抑制的请求数量以及用于同步结果的 Channel
dups int
chans []chan<- Result
}
  • x/sync/singleflight.Group.Do 和 x/sync/singleflight.Group.DoChan 一个用于同步阻塞调用传入的函数,一个用于异步调用传入的参数并通过 Channel 接收函数的返回值;
  • x/sync/singleflight.Group.Forget 方法可以通知 x/sync/singleflight.Group 在持有的映射表中删除某个键,接下来对该键的调用就不会等待前面的函数返回了;
  • 一旦调用的函数返回了错误,所有在等待的 Goroutine 也都会接收到同样的错误;

计时器

Go 1.10 之前的计时器都使用最小四叉堆实现. 所有计时器的结构体都会共享全局的 []*timer即最小四叉堆. G会在时间驱动事件: 计时器到期/加入触发时间更早的新计时器时被唤醒. 由于操作需要获取全局唯一的呼哧锁互斥锁,会影响计时器性能.

Go 1.10 将全局的四叉堆分割成了 64 个更小的四叉堆5。在理想情况下,四叉堆的数量应该等于处理器的数量,但是这需要实现动态的分配过程,所以经过权衡最终选择初始化 64 个四叉堆,以牺牲内存占用的代价换取性能的提升。

如果当前机器上的处理器 P 的个数超过了 64,多个处理器上的计时器就可能存储在同一个桶中。每一个计时器桶都由一个运行 runtime.timerproc#76f4fd8 函数的 Goroutine 处理. 将全局计时器分片的方式,虽然能够降低锁的粒度,提高计时器的性能,但是 runtime.timerproc#76f4fd8 造成的处理器和线程之间频繁的上下文切换却成为了影响计时器性能的首要因素。

在1,14的实现中,计时器桶已经被移除,所有的计时器都以最小四叉堆的形式存储在处理器 runtime.p 中。目前计时器都交由处理器的网络轮询器和调度器触发,这种方式能够充分利用本地性、减少线上上下文的切换开销.

运行时使用状态机的方式处理全部的计时器,其中包括 10 种状态和 7 种操作。

timerNoStatus 还没有设置状态
timerWaiting 等待触发
timerRunning 运行计时器函数
timerDeleted 被删除
timerRemoving 正在被删除
timerRemoved 已经被停止并从堆中删除
timerModifying 正在被修改
timerModifiedEarlier 被修改到了更早的时间
timerModifiedLater 被修改到了更晚的时间
timerMoving 已经被修改正在被移动

  • “timerRunning、timerRemoving、timerModifying” “和” “timerMoving” “—“ “停留的时间都比较短;”
  • “timerWaiting、timerRunning、timerDeleted、timerRemoving、timerModifying、timerModifiedEarlier、timerModifiedLater” “和” “timerMoving” “—“ “计时器在处理器的堆上;”
  • “timerNoStatus” “和” “timerRemoved” “—“ “计时器不在堆上;”
  • “timerModifiedEarlier” “和” “timerModifiedLater” “—“ “计时器虽然在堆上,但是可能位于错误的位置上,需要重新排序;”

runtime.addtimer — 向当前处理器增加新的计时器;
runtime.deltimer — 将计时器标记成 timerDeleted 删除处理器中的计时器;
runtime.modtimer — 网络轮询器会调用该函数修改计时器;
runtime.resettimer — 修改已经失效的计时器的到期时间,将其变成活跃的计时器;
runtime.cleantimers — 清除队列头中的计时器,能够提升程序创建和删除计时器的性能;
runtime.adjusttimers — 调整处理器持有的计时器堆,包括移动会稍后触发的计时器、删除标记为 timerDeleted 的计时器;
runtime.runtimer — 检查队列头中的计时器,在其准备就绪时运行该计时器;

go会在两个模块中触发计时器:

  1. 调度器调度时会检查处理器中的计时器是否准备就绪;
  2. 系统监控会检查是否有未执行的到期计时器;

channel

目前的 Channel 收发操作均遵循了先入先出(FIFO)的设计,具体规则如下:

  1. 先从 Channel 读取数据的 Goroutine 会先接收到数据;
  2. 先向 Channel 发送数据的 Goroutine 会得到先发送数据的权利;

无锁(lock-free)队列更准确的描述是使用乐观并发控制的队列。乐观并发控制也叫乐观锁.乐观并发控制本质上是基于验证的协议,我们使用原子指令 CAS(compare-and-swap 或者 compare-and-set)在多线程中同步数据,无锁队列的实现也依赖这一原子指令。

Channel 在运行时的内部表示是 runtime.hchan,该结构体中包含了一个用于保护成员变量的互斥锁,从某种程度上说,Channel 是一个用于同步和通信的有锁队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type hchan struct {
qcount uint
dataqsiz uint
buf unsafe.Pointer
elemsize uint16
closed uint32
elemtype *_type
sendx uint
recvx uint
recvq waitq
sendq waitq

lock mutex
}

qcount — Channel 中的元素个数;
dataqsiz — Channel 中的循环队列的长度;
buf — Channel 的缓冲区数据指针;
sendx — Channel 的发送操作处理到的位置;
recvx — Channel 的接收操作处理到的位置;
elemsize 和 elemtype 分别表示当前 Channel 能够收发的元素类型和大小;sendq 和 recvq 存储了当前 Channel 由于缓冲区空间不足而阻塞的 Goroutine 列表,这些等待队列使用双向链表 runtime.waitq 表示,链表中所有的元素都是 runtime.sudog 结构:

1
2
3
4
type waitq struct {
first *sudog
last *sudog
}

根据 Channel 中收发元素的类型和缓冲区的大小初始化 runtime.hchan 结构体和缓冲区:

  • 如果当前 Channel 中不存在缓冲区,那么就只会为 runtime.hchan 分配一段内存空间;
  • 如果当前 Channel 中存储的类型不是指针类型,就会为当前的 Channel 和底层的数组分配一块连续的内存空间;
  • 在默认情况下会单独为 runtime.hchan 和缓冲区分配内存;

向 Channel 发送数据时遇到的几种情况:

  1. 如果当前 Channel 的 recvq 上存在已经被阻塞的 Goroutine,那么会直接将数据发送给当前的 Goroutine 并将其设置成下一个运行的 Goroutine;
  2. 如果 Channel 存在缓冲区并且其中还有空闲的容量,我们就会直接将数据直接存储到当前缓冲区 sendx 所在的位置上;
  3. 如果不满足上面的两种情况,就会创建一个 runtime.sudog 结构并将其加入 Channel 的 sendq 队列中,当前 Goroutine 也会陷入阻塞等待其他的协程从 Channel 接收数据;

发送数据的过程中包含几个会触发 Goroutine 调度的时机:

  1. 发送数据时发现 Channel 上存在等待接收数据的 Goroutine,立刻设置处理器的 runnext 属性,但是并不会立刻触发调度;
  2. 发送数据时并没有找到接收方并且缓冲区已经满了,这时就会将自己加入 Channel 的 sendq 队列并调用 runtime.goparkunlock 触发 Goroutine 的调度让出处理器的使用权;

Channel 中接收数据时可能会发生的五种情况:

  1. 如果 Channel 为空,那么就会直接调用 runtime.gopark 挂起当前 Goroutine;
  2. 如果 Channel 已经关闭并且缓冲区没有任何数据,runtime.chanrecv 函数会直接返回;
  3. 如果 Channel 的 sendq 队列中存在挂起的 Goroutine,就会将 recvx 索引所在的数据拷贝到接收变量所在的内存空间上并将 sendq 队列中 Goroutine 的数据拷贝到缓冲区;
  4. 如果 Channel 的缓冲区中包含数据就会直接读取 recvx 索引对应的数据;
  5. 在默认情况下会挂起当前的 Goroutine,将 runtime.sudog 结构加入 recvq 队列并陷入休眠等待调度器的唤醒;

Channel 接收数据时,会触发 Goroutine 调度的两个时机:

  1. 当 Channel 为空时;
  2. 当缓冲区中不存在数据并且也不存在数据的发送者时;

关闭channel就是将recvq 和 sendq 两个队列中的数据加入到 Goroutine 列表 gList 中,同时清除所有sudog上未被处理的元素,最后为gList中的所有G调用 runtime.goready 触发调度.

go的channel中会有一个readG和writeG的队列结构, 保存了读写该chan的goroutine的指针信息, 这样在chan中数据交互时通过runtime去调度对应的G进入运行队列. 所以对于无buffer的chan, 如果写端先执行会一直等待直到读端读取数据才会写入, 并且这里还对读写的位置进行了优化, 写就直接写到等待的G的地址空间中.

调度器

处理器持有一个由可运行的 Goroutine 组成的运行队列 runq,还反向持有一个线程。调度器在调度时会从处理器的队列中选择队列头的 Goroutine 放到线程 M 上执行. 处理器 P 是线程M和 Goroutine 的中间层.

Go 调度器对 Goroutine 的上下文切换约为 0.2us,相比于操作系统的线程上下文切换消耗约1us左右时间来说, 减少了 80% 的额外开销

在系统监控中,如果一个 Goroutine 的运行时间超过 10ms, 就会触发抢占.

基于协作的抢占式调度的工作原理:

  1. 编译器会在调用函数前插入 runtime.morestack;
  2. Go 语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms 时发出抢占请求 StackPreempt;
  3. 当发生函数调用时,可能会执行编译器插入的 runtime.morestack 函数,它调用的 runtime.newstack 会检查 Goroutine 的 stackguard0 字段是否为 StackPreempt;
  4. 如果 stackguard0 是 StackPreempt,就会触发抢占让出当前线程;

Go 语言在 1.14 版本中实现了非协作的抢占式调度,以增加触发抢占式调度的时间点以减少存在的边缘情况. 目前的抢占式调度也只会在垃圾回收扫描任务时触发.

抢占式调度过程:

  1. 程序启动时,在 runtime.sighandler 函数中注册 SIGURG 信号的处理函数 runtime.doSigPreempt;
  2. 在触发垃圾回收的栈扫描时会调用 runtime.suspendG 挂起 Goroutine,该函数会执行下面的逻辑:
  3. 将 _Grunning 状态的 Goroutine 标记成可以被抢占,即将 preemptStop 设置成 true;
  4. 调用 runtime.preemptM 触发抢占;
  5. runtime.preemptM 会调用 runtime.signalM 向线程发送信号 SIGURG;
  6. 操作系统会中断正在运行的线程并执行预先注册的信号处理函数 runtime.doSigPreempt;
  7. runtime.doSigPreempt 函数会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用 runtime.sigctxt.pushCall;
  8. runtime.sigctxt.pushCall 会修改寄存器并在程序回到用户态时执行 runtime.asyncPreempt;
  9. 汇编指令 runtime.asyncPreempt 会调用运行时函数 runtime.asyncPreempt2;
  10. runtime.asyncPreempt2 会调用 runtime.preemptPark;
  11. runtime.preemptPark 会修改当前 Goroutine 的状态到 _Gpreempted 并调用 runtime.schedule 让当前函数陷入休眠并让出线程,调度器会选择其它的 Goroutine 继续执行;

选择 SIGURG 作为触发异步抢占的信号的原因:

  1. 该信号需要被调试器透传;
  2. 该信号不会被内部的 libc 库使用并拦截;
  3. 该信号可以随意出现并且不触发任何后果;
  4. 我们需要处理多个平台上的不同信号;

STW 和栈扫描是一个可以抢占的安全点(Safe-points),所以 Go 语言在这里先加入抢占功能。基于信号的抢占式调度只解决了垃圾回收和栈扫描时存在的问题,它到目前为止没有解决全部问题,但是这种真抢占式调度时调度器走向完备的开始.

非均匀内存访问(Non-uniform memory access,NUMA)调度器现在只是 Go 语言的提案,因为该提案过于复杂,而目前的调度器的性能已经足够优异,所以暂时没有实现该提案。该提案的原理就是通过拆分全局资源,让各个处理器能够就近获取,减少锁竞争并增加数据的局部性。

在目前的运行时中,线程、处理器、网络轮询器、运行队列、全局内存分配器状态、内存分配缓存和垃圾收集器都是全局资源。

NUMA调度器实现: 堆栈、全局运行队列和线程池会按照 NUMA 节点进行分区,网络轮询器和计时器会由单独的处理器持有.
qownnotes-media-JmLqSj

https://img.draveness.me/2020-02-02-15805792666185-go-numa-scheduler-architecture.png

G — 表示 Goroutine,它是一个待执行的任务;
M — 表示操作系统的线程,它由操作系统的调度器调度和管理;
P — 表示处理器,它可以被看做运行在线程上的本地调度器;

G中包含有ID: goid ,该字段对开发者不可见,Go 团队认为引入 ID 会让部分 Goroutine 变得更特殊,从而限制语言的并发能力

G的状态:

  • _Gidle 刚刚被分配并且还没有被初始化
  • _Grunnable 没有执行代码,没有栈的所有权,存储在运行队列中
  • _Grunning 可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P
  • _Gsyscall 正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
  • _Gwaiting 由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
  • _Gdead 没有被使用,没有执行代码,可能有分配的栈
  • _Gcopystack 栈正在被拷贝,没有执行代码,不在运行队列上
  • _Gpreempted 由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
  • _Gscan GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在

qownnotes-media-JRjXla

调度器最多可以创建 10000 个线程M,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。 在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数.

M的结构体中存在两个字段: g0 是持有调度栈的 Goroutine,curg 是在当前线程上运行的用户 Goroutine. g0 是一个运行时中比较特殊的 Goroutine,它会深度参与运行时的调度过程,包括 Goroutine 的创建、大内存分配和 CGO 函数的执行。

因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以 Go 语言程序的处理器P数量一定会等于 GOMAXPROCS

P的状态有:

  1. _Pidle 处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
  2. _Prunning 被线程 M 持有,并且正在执行用户代码或者调度器
  3. _Psyscall 没有执行用户代码,当前线程陷入系统调用
  4. _Pgcstop 被线程 M 持有,当前处理器由于垃圾回收被停止
  5. _Pdead 当前处理器已经不被使用

在调度器初始函数执行的过程中会将 maxmcount 设置成 10000,这也就是一个 Go 语言程序能够创建的最大线程数.

在调度器启动时,会堆allp中处理器数量进行操作保证与期望的数量GOMAXPROCS相等. 之后将除 allp[0] 之外的处理器 P 全部设置成 _Pidle 并加入到全局的空闲队列中;

在创建G时, runtime.newproc1 会从处理器或者调度器的缓存中获取新的结构体(复用已有的或者创建新的),也可以调用 runtime.malg 函数创建新的结构体

新创建的G可能会在全局的运行队列上也可能在处理器P本地的运行队列上. 如果P的runnext指向的G为空, 直接设置该G为P的下一个执行任务, 如果next存在且本地队列还有空间则加入本地队列, 否则加入全局队列

P的本地的运行队列是一个使用数组构成的环形链表,由p的结构体中runq表示, 它最多可以存储 256 个待执行任务G, 超过数量的G都会被放到全局队列(由调度器持有)中. 进入IO阻塞的G可能被放到全局的队列中, 这就使得同一个G可能在不同的P上执行.

Go的调度循环:
runtime.schedule 函数的会从不同地方查找待执行的 Goroutine:

  1. 为了保证公平,当全局运行队列中有待执行的 Goroutine 时,通过 schedtick 保证有一定几率会从全局的运行队列中查找对应的 Goroutine;
  2. 从处理器本地的运行队列中查找待执行的 Goroutine;
  3. 如果前两种方法都没有找到 Goroutine,就会通过 runtime.findrunnable 进行阻塞地查找 Goroutine;当前函数一定会返回一个可执行的 Goroutine,如果当前不存在就会阻塞等待。

findrunnable执行以下过程:

  1. 从本地运行队列、全局运行队列中查找;
  2. 从网络轮询器中查找是否有 Goroutine 等待运行;
  3. 通过 runtime.runqsteal 函数尝试从其他随机的处理器中窃取待运行的 Goroutine,在该过程中还可能窃取处理器中的计时器;

go的调度器执行逻辑循环: schedule -> execute -> gogo -> goexit0 ->schedule …

Go 语言的调用惯例: 正常的函数调用都会使用 CALL 指令,该指令会将调用方的返回地址加入栈寄存器 SP 中,然后跳转到目标函数;当目标函数返回后,会从栈中查找调用的地址并跳转回调用方继续执行剩下的代码

触发调度的几个路径:

  • 主动挂起 — runtime.gopark -> runtime.park_m ->schedule
  • 系统调用 — runtime.exitsyscall -> runtime.exitsyscall0 ->schedule
  • 协作式调度 — runtime.Gosched -> runtime.gosched_m -> runtime.goschedImpl ->schedule
  • 系统监控 — runtime.sysmon -> runtime.retake -> runtime.preemptone ->schedule

runtime.gopark函数会将当前 Goroutine 暂停,被暂停的任务不会放回运行队列,当 Goroutine 等待的特定条件满足后,运行时会调用 runtime.goready 将因为调用 runtime.gopark 而陷入休眠的 Goroutine 唤醒(设置_Grunnable并加入运行队列等待调度器调度).

为了处理特殊的系统调用,我们甚至在 Goroutine 中加入了 _Gsyscall 状态

Go 语言通过 syscall.Syscallsyscall.RawSyscall 等使用汇编语言编写的方法封装了操作系统提供的所有系统调用. RawSyscall用于不需要运行时参与的系统调用, 以提升性能, 只有立刻返回的系统调用才能使用RawSyscall, 如SYS_EPOLL_WAIT(超时时间为 0)、SYS_TIME、SYS_EPOLL_CREATE等

陷入系统调用时 runtime.reentersyscall 进行的操作:

  1. 禁止线程上发生的抢占,防止出现内存不一致的问题;
  2. 保证当前函数不会触发栈分裂或者增长;
  3. 保存当前的程序计数器 PC 和栈指针 SP 中的内容;
  4. 将 Goroutine 的状态更新至 _Gsyscall;
  5. 将 Goroutine 的处理器和线程暂时分离并更新处理器的状态到 _Psyscall;
  6. 释放当前线程上的锁;

该方法 runtime.reentersyscall 会使处理器P和线程M的分离,当前线程会陷入系统调用等待返回,当前线程上的锁被释放后,会有其他 Goroutine 抢占处理器资源P.

系统调用结束以后, 会调用退出系统调用的函数 runtime.exitsyscall 为当前 Goroutine 重新分配资源.
如果调用 exitsyscallfast 函数不成功就使用相对较慢的exitsyscall0, 最终调用schedule触发调度.

exitsyscallfast 中包含两个不同的分支:

  1. 如果 Goroutine 的原处理器处于 _Psyscall 状态,就会直接调用 wirep 将 Goroutine 与处理器进行关联;
  2. 如果调度器中存在闲置的处理器,就会调用 acquirep 函数使用闲置的处理器处理当前 Goroutine;

exitsyscall0 会将当前 Goroutine 切换至 _Grunnable 状态,并移除线程 M 和当前 Goroutine 的关联:

  1. 当我们通过 pidleget 获取到闲置的处理器时就会在该处理器上执行 Goroutine;
  2. 在其它情况下,我们会将当前 Goroutine 放到全局的运行队列中,等待调度器的调度;

runtime.Gosched 就是主动让出处理器, 它会将Goroutine 的状态到 _Grunnable,让出当前的处理器并将 Goroutine 重新放回全局队列, 最后触发schedule.

runtime.LockOSThread 和 runtime.UnlockOSThread 让我们有能力绑定 Goroutine 和线程,
Goroutine 应该在调用操作系统服务或者依赖线程状态的非 Go 语言库时调用 runtime.LockOSThread 函数

在LockOSThread中会设置线程的 lockedg 字段和 Goroutine 的 lockedm 字段,这两行代码会绑定线程和 Goroutine.

Go 语言程序创建的线程M数可能会多于 GOMAXPROCS . 当线程M处于阻塞状态后,如果其他线程都处于running状态, 这个时候会创建一个新的线程来运行当前处理器P队列中的Gorountine, 触发的时机可能包括 — 垃圾回收、系统监控. Go 语言本身对于程序中运行的线程上限为10000. Go 语言在启动线程时会设置 PTHREAD_CREATE_DETACHED,当线程执行完成后会自动回收和重用。 Go使用pthread_attr_setdetachstate(3THR) 告知系统pthread库不需要Join该线程的结束.

协程不是银弹, 在调用一些 C 语言的库时,例如 C 图形库,就需要绑定线程,协程在这时可能就不适用了。

一定存在极端的边界条件让使用 Goroutine 和直接使用线程性能差不多,例如程序中全都是 I/O 操作,不过在实际的服务中很难出现,程序的执行都会同时使用到 CPU 和 I/O 两种资源.

  • io 与系统调用不能混淆,一个 io 过程可能包括多次系统调用。
  • (经过一个系统调用发现文件描述符还未可用而)阻塞的 io 首先会导致 G 的挂起,此时 G 与 M 分离,且不在任何 P 的运行队列中,当前的 P 会调度下一个 G,这个阶段不涉及新线程的创建。
  • 被 io 挂起的 G 由网络轮询器维护,直到文件描述符可用。
  • 网络轮询器既会被(在独立线程中的)系统监控 Goroutine 触发,也会被其他各个活跃线程上的 Goroutine 触发。
  • 当文件描述符可用时,G 会重新加入到原来的 P 中等待被调度。
  • 当 G 被重新调度时,会重新发起读/写系统调用。
  • 当 G 进行系统调用的时候,对应的 M 和 P 也阻塞在系统调用,并不会立刻发生抢占,只有当这个阻塞持续时间过长(10 ms)时,才会将 P(及之上的其他 G)抢占并分配到空闲的 M 上,此时如果没有空闲的,才会创建新的线程。

线程 M 会持有自己的 g0, 全局也有一个 g0

网络轮询器

网络轮询器不仅用于监控网络 I/O,还能用于监控文件的 I/O,它利用了操作系统提供的 I/O 多路复用模型来提升 I/O 设备的利用率以及程序的性能

操作系统中包含阻塞 I/O、非阻塞 I/O、信号驱动 I/O 与异步 I/O 以及 I/O 多路复用五种 I/O 模型

I/O 多路复用被用来处理同一个事件循环中的多个 I/O 事件。I/O 多路复用需要使用特定的系统调用,最常见的系统调用就是 select,该函数可以同时监听最多 1024 个文件描述符的可读或者可写状态. 除了标准的 select 函数之外,操作系统中还提供了一个比较相似的 poll 函数,它使用链表存储文件描述符,摆脱了 1024 的数量上限.

多路复用函数会阻塞的监听一组文件描述符,当文件描述符的状态转变为可读或者可写时,select 会返回可读或者可写事件的个数,应用程序就可以在输入的文件描述符中查找哪些可读或者可写,然后执行相应的操作。select 有比较多的限制:

  1. 监听能力有限 — 最多只能监听 1024 个文件描述符;
  2. 内存拷贝开销大 — 需要维护一个较大的数据结构存储文件描述符,该结构需要拷贝到内核中;
  3. 时间复杂度 O(n) — 返回准备就绪的事件个数后,需要遍历所有的文件描述符;

Go实现了对不同操作系统上的I/O多路复用操作的多个版本的网络轮询模块, 如epoll、kqueue、evport、solaries.在编译时通过目标平台选择特定的代码分支.

各系统的网络轮询器接口都实现以下函数:

1
2
3
4
5
func netpollinit() // 初始化轮询器 通过sync.Once保证只调用一次
func netpollopen(fd uintptr, pd *pollDesc) int32 //创建事件,加入监听
func netpoll(delta int64) gList // 返回一组准备就绪的G, delta>0则无限期等待文件描述符就绪, ==0则非阻塞轮询网络, <0则阻塞特定时间轮询
func netpollBreak() // 唤醒轮询器
func netpollIsPollDescriptor(fd uintptr) bool // 判断文件描述符是否被轮询器使用.

操作系统中 I/O 多路复用函数会监控文件描述符的可读或者可写,而 Go 语言网络轮询器会监听 runtime.pollDesc 结构体的状态,该结构会封装操作系统的文件描述符. runtime.pollDesc 结构体会使用 link 字段串联成一个链表存储在 runtime.pollCache. runtime.pollCache 是运行时包中的全局变量,运行时会在第一次调用 runtime.pollCache.alloc 方法时初始化总大小约为 4KB 的 runtime.pollDesc 结构体,runtime.persistentalloc 会保证这些数据结构初始化在不会触发垃圾回收的内存中,让这些数据结构只能被内部的 epoll 和 kqueue 模块引用.调用 runtime.pollCache.free 方法释放已经用完的 runtime.pollDesc 结构,它会直接将结构体插入链表的最前面.

目前的计时器由网络轮询器管理和触发

进入IO等待

当我们在文件描述符上执行读写操作时,如果文件描述符不可读或者不可写,当前 Goroutine 就会执行 runtime.poll_runtime_pollWait 检查 runtime.pollDesc 的状态并调用 runtime.netpollblock 等待文件描述符的可读或者可写, netpollblock会使用运行时提供的 runtime.gopark 让出当前线程,将 Goroutine 转换到休眠状态并等待运行时的唤醒.

轮询等待

Go 语言的运行时会在调度或者系统监控中调用 runtime.netpoll 轮询网络:

  1. 根据传入的 delay 计算 epoll 系统调用需要等待的时间;delay 的单位是纳秒
  2. 调用 epollwait 等待可读或者可写事件的发生;
  3. 在循环中依次处理 epollevent 事件;将G加入运行队列

网络轮询器并不是由运行时中的某一个线程独立运行的,运行时中的调度和系统调用会通过 runtime.netpoll 与网络轮询器交换消息,而系统监控则是专用的线程来等待epoll返回,获取待执行的 Goroutine 列表,并将待执行的 Goroutine 加入运行队列等待处理。所有的文件 I/O、网络 I/O 和计时器都是由网络轮询器管理的

系统监控

在支持多任务的操作系统中,守护进程(Daemon)是在后台运行的计算机程序。守护进程不会由用户直接操作,它一般会在操作系统启动时自动运行。Kubernetes 的 DaemonSet 和 Go 语言的系统监控都使用类似设计提供一些通用的功能. 在系统监控内部启动了一个不会中止的循环,在循环的内部会轮询网络、抢占长期运行或者处于系统调用的 Goroutine 以及触发垃圾回收.

当 Go 语言程序启动时,运行时会在第一个 Goroutine 中调用 runtime.main 启动主程序,该函数会在系统栈中创建新的线程.

1
2
3
4
5
6
7
8
9
func main() {
...
if GOARCH != "wasm" { // webassembly不创建sysmon
systemstack(func() {
newm(sysmon, nil)
})
}
...
}

运行时执行系统监控不需要处理器P,通过系统调用 clone 创建一个新的线程, 系统监控的 Goroutine 会直接在创建的线程M上运行,

系统监控在每次循环开始时都会通过 usleep 挂起当前线程,该函数的参数是微秒,运行时会遵循以下的规则决定休眠时间:

  1. 初始的休眠时间是 20μs;
  2. 最长的休眠时间是 10ms;
  3. 当系统监控在 50 个循环中都没有唤醒 Goroutine 时,休眠时间在每个循环都会倍增;

当程序趋于稳定之后,系统监控的触发时间就会稳定在 10ms。会在循环中完成以下的工作:

  • 检查死锁
  • 运行计时器 — 获取下一个需要被触发的计时器;
  • 轮询网络 — 获取需要处理的到期文件描述符;
  • 抢占处理器 — 抢占运行时间较长的或者处于系统调用的 Goroutine;
  • 垃圾回收 — 在满足条件时触发垃圾收集回收内存;

检查死锁逻辑:

  1. 收集数据: 存在的M(根据下一个线程id和释放线程数计算)和G数量,空闲的M数量,锁定的M数量,进入系统调用的M数量, P上计时器是否存在. 得到正在运行的M数量
  2. 若正在运行的M数量>0则无死锁, 若<0则状态不一致, 若==0则进行进一步判断
  3. 若存在 Goroutine 处于 _Grunnable、_Grunning 和 _Gsyscall 状态时,意味着程序发生了死锁;
  4. 当所有的 Goroutine 都处于 _Gidle、_Gdead 和 _Gcopystack 状态时,意味着主程序调用了 runtime.goexit;
  5. 当存在等待的 Goroutine 并且不存在正在运行的 Goroutine, 若处理器中存在等待的计时器,那么所有的 Goroutine 陷入休眠状态是合理的,不过如果不存在等待的计时器,运行时就会直接报错并退出程序

当前调度器需要执行垃圾回收或者所有处理器都处于闲置状态时,如果没有需要触发的计时器,那么系统监控可以暂时陷入休眠.休眠的时间会依据强制 GC 的周期 forcegcperiod 和计时器下次触发的时间确定,runtime.notesleep 会使用信号量同步系统监控即将进入休眠的状态。当系统监控被唤醒之后,我们会重新计算当前时间和下一个计时器需要触发的时间、调用 runtime.noteclear 通知系统监控被唤醒并重置休眠的间隔。如果在这之后,我们发现下一个计时器需要触发的时间小于当前时间,这也就说明所有的线程可能正在忙于运行 Goroutine,系统监控会启动新的线程来触发计时器,避免计时器的到期时间有较大的偏差.

如果上一次轮询网络已经过去了 10ms,那么系统监控还会在循环中轮询网络,检查是否有待执行的文件描述符

系统调用会在循环中调用 runtime.retake 函数抢占处于运行或者系统调用中的处理器,该函数会遍历运行时的全部处理器. 每个处理器都存储了一个 runtime.sysmontick 结构体:

1
2
3
4
5
6
7
type sysmontick struct {
// 分别存储了处理器的调度次数、处理器上次调度时间、系统调用的次数以及系统调用的时间
schedtick uint32
schedwhen int64
syscalltick uint32
syscallwhen int64
}

runtime.retake 中的循环包含了两种不同的抢占逻辑:

  1. 当处理器处于 _Prunning 或者 _Psyscall 状态时,如果上一次触发调度的时间已经过去了 10ms,会通过 runtime.preemptone 通知当前G去停止执行,让出P给其他G;
  2. 当处理器处于 _Psyscall(系统调用) 状态时,在满足以下情况下会调用 runtime.handoffp 让出处理器的使用权:处理器的运行队列不为空或者不存在空闲处理器时且系统调用时间超过了 10ms 时;

如果需要触发垃圾回收,sysmon会将用于垃圾回收的 Goroutine 加入全局队列,让调度器选择合适的处理器去执行.

内存分配器

编程语言的内存分配器一般包含两种分配方法,一种是线性分配器(Sequential Allocator,Bump Allocator),另一种是空闲链表分配器(Free-List Allocator)

  1. 当我们在编程语言中使用线性分配器,我们只需要在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置. 有较快的执行速度,以及较低的实现复杂度;但是线性分配器无法在内存被释放时重用内存.线性分配器的使用需要配合具有拷贝特性的垃圾回收算法,所以 C 和 C++ 等需要直接对外暴露指针的语言就无法使用该策略
  2. 空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表. 时间复杂度就是 O(n). 选择策略有:
  • 首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
  • 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
  • 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
  • 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;

Go的分配策略类似隔离适应,将内存分割成由 4、8、16、32 字节的内存块组成的链表, 根据申请大小找到对应的链表查找空闲内存.

线程缓存分配(Thread-Caching Malloc,TCMalloc)是用于分配内存的的机制,它比 glibc 中的 malloc 函数还要快很多2。Go 语言的内存分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心理念是使用多级缓存根据将对象根据大小分类,并按照类别实施不同的分配策略。

Go 语言的内存分配器会根据申请分配的内存大小选择不同的处理逻辑,运行时根据对象的大小将对象分成微对象、小对象和大对象三种

  1. 微对象 (0, 16B)
  2. 小对象 [16B, 32KB]
  3. 大对象 (32KB, +∞)

程序中的绝大多数对象的大小都在 32KB 以下

内存分配器不仅会区别对待大小不同的对象,还会将内存分成不同的级别分别管理,TCMalloc 和 Go 运行时分配器都会引入线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存.

线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。当线程缓存不能满足需求时,就会使用中心缓存作为补充解决小对象的内存分配问题;在遇到 32KB 以上的对象时,内存分配器就会选择页堆直接分配大量的内存。

Go 语言程序的 1.10 版本在启动时会初始化整片虚拟内存区域,三个区域 spans、bitmap 和 arena 分别预留了 512MB、16GB 以及 512GB 的虚拟内存空间

  • spans 区域存储了指向内存管理单元 runtime.mspan 的指针,每个内存单元会管理几页的内存空间,每页大小为 8KB;
  • bitmap 用于标识 arena 区域中的那些地址保存了对象,位图中的每个字节都会表示堆区中的 32 字节是否包含空闲;
  • arena 区域是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象;

对于任意一个地址,我们都可以根据 arena 的基地址计算该地址所在的页数并通过 spans 数组获得管理该片内存的管理单元 runtime.mspan,spans 数组中多个连续的位置可能对应同一个 runtime.mspan。

Go 语言在垃圾回收时会根据指针的地址判断对象是否在堆中,并通过上一段中介绍的过程找到管理该对象的 runtime.mspan。这些都建立在堆区的内存是连续的这一假设上。这种设计虽然简单并且方便,但是在 C 和 Go 混合使用时会导致程序崩溃:

  1. 分配的内存地址会发生冲突,导致堆的初始化和扩容失败;
  2. 没有被预留的大块内存可能会被分配给 C 语言的二进制,导致扩容后的堆不连续;

稀疏内存是 Go 语言在 1.11 中提出的方案,使用稀疏的内存布局不仅能移除堆大小的上限,还能解决 C 和 Go 混合使用时的地址空间冲突问题。 不过因为基于稀疏内存的内存管理失去了内存的连续性这一假设,这也使内存管理变得更加复杂.

运行时使用二维的 runtime.heapArena 数组管理所有的内存,每个单元都会管理 64MB 的内存空间.不同平台和架构的二维数组大小可能完全不同,如果我们的 Go 语言服务在 Linux 的 x86-64 架构上运行,二维数组的一维大小会是 1,而二维大小是 4,194,304,因为每一个指针占用 8 字节的内存空间,所以元信息的总大小为 32MB。由于每个 runtime.heapArena 都会管理 64MB 的内存,整个堆区最多可以管理 256TB 的内存,这比之前的 512GB 多好几个数量级。代价是大约会增加 1% 的垃圾回收开销

因为所有的内存最终都是要从操作系统中申请的,所以 Go 语言的运行时构建了操作系统的内存管理抽象层,该抽象层将运行时管理的地址空间分成以下的四种状态:

  1. None 内存没有被保留或者映射,是地址空间的默认状态
  2. Reserved 运行时持有该地址空间,但是访问该内存会导致错误
  3. Prepared 内存被保留,一般没有对应的物理内存访问该片内存的行为是未定义的可以快速转换到 Ready 状态
  4. Ready 可以被安全访问

qownnotes-media-AxKSRs

  • runtime.sysAlloc 会从操作系统中获取一大块可用的内存空间,可能为几百 KB 或者几 MB;
  • runtime.sysFree 会在程序发生内存不足(Out-of Memory,OOM)时调用并无条件地返回内存;
  • runtime.sysReserve 会保留操作系统中的一片内存区域,对这片内存的访问会触发异常;
  • runtime.sysMap 保证内存区域可以快速转换至准备就绪;
  • runtime.sysUsed 通知操作系统应用程序需要使用该内存区域,需要保证内存区域可以安全访问;
  • runtime.sysUnused 通知操作系统虚拟内存对应的物理内存已经不再需要了,它可以重用物理内存;
  • runtime.sysFault 将内存区域转换成保留状态,主要用于运行时的调试;

qownnotes-media-LZFCTM

所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局,每一个处理器都会被分配一个线程缓存 runtime.mcache 用于处理微对象和小对象的分配,它们会持有内存管理单元 runtime.mspan。

每个类型的内存管理单元都会管理特定大小的对象,当内存管理单元中不存在空闲对象时,它们会从 runtime.mheap 持有的 134 个中心缓存 runtime.mcentral 中获取新的内存单元,中心缓存属于全局的堆结构体 runtime.mheap,它会从操作系统中申请内存。

在 amd64 的 Linux 操作系统上,runtime.mheap 会持有 4,194,304 runtime.heapArena,每一个 runtime.heapArena 都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB

runtime.mspan 是 Go 语言内存管理的基本单元,串联后形成双向链表. 每个 runtime.mspan 都管理 npages 个大小为 8KB 的页,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍

运行时会使用 runtime.mSpanStateBox 结构体存储内存管理单元的状态 runtime.mSpanState:
该状态可能处于 mSpanDead、mSpanInUse、mSpanManual 和 mSpanFree 四种情况。当 runtime.mspan 在空闲堆中,它会处于 mSpanFree 状态;当 runtime.mspan 已经被分配时,它会处于 mSpanInUse、mSpanManual 状态,这些状态会在遵循以下规则发生转换:

  • 在垃圾回收的任意阶段,可能从 mSpanFree 转换到 mSpanInUse 和 mSpanManual;
  • 在垃圾回收的清除阶段,可能从 mSpanInUse 和 mSpanManual 转换到 mSpanFree;
  • 在垃圾回收的标记阶段,不能从 mSpanInUse 和 mSpanManual 转换到 mSpanFree;

设置 runtime.mspan 结构体状态的读写操作必须是原子性的避免垃圾回收造成的线程竞争问题。

Go 语言的内存管理模块中一共包含 67 种跨度类, 每个类决定了内存管理单元中存储的对象大小和个数.
运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象

runtime.mcache 是 Go 语言中的线程缓存,它会与线程上的处理器一一绑定,主要用来缓存用户程序申请的微小对象。每一个线程缓存都持有 67 * 2 个 runtime.mspan,这些内存管理单元都存储在结构体的 alloc 字段中

访问中心缓存中的内存管理单元需要使用互斥锁

微对象 (0, 16B) — 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;
小对象 [16B, 32KB] — 依次尝试使用线程缓存、中心缓存和堆分配内存;
大对象 (32KB, +∞) — 直接在堆上分配内存;

微分配器可以将多个较小的内存分配请求合入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收
微分配器管理的对象不可以是指针类型,

垃圾回收器

三色标记:

  • 白色对象 — 潜在的垃圾,其内存可能会被垃圾收集器回收;
  • 黑色对象 — 活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;
  • 灰色对象 — 活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;

根是灰色, 从灰色对象的集合中选择一个灰色对象并将其标记成黑色;
将黑色对象指向的所有对象都标记成灰色,保证该对象和被该对象引用的对象都不会被回收;
重复上述两个步骤直到对象图中不存在灰色对象;之后垃圾收集器可以回收这些白色的垃圾.

想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性(Tri-color invariant)中的任意一种:

  • 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
  • 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径;

Go 语言中使用的两种写屏障技术,分别是 Dijkstra 提出的插入写屏障(保证强三色不变性,有存活可能的对象都标记成灰色)和 Yuasa 提出的删除写屏障

Go 语言运行时的默认配置会在堆内存达到上一次垃圾收集的 2 倍时,触发新一轮的垃圾收集,这个行为可以通过环境变量 GOGC 调整,在默认情况下它的值为 100,即增长 100% 的堆内存才会触发 GC

因为并发垃圾收集器会与程序一起运行,所以它无法准确的控制堆内存的大小,并发收集器需要在达到目标前触发垃圾收集,这样才能够保证内存大小的可控. 垃圾收集调步算法是跟随 v1.5 一同引入的,该算法的目标是优化堆的增长速度和垃圾收集器的 CPU 利用率,而在 v1.10 版本中又对该算法进行了优化,将原有的目的堆大小拆分成了软硬两个目标

垃圾收集的根对象一般包括全局变量和栈对象.

Go 语言在 v1.8 组合 Dijkstra 插入写屏障和 Yuasa 删除写屏障构成了如下所示的混合写屏障,该写屏障会将被覆盖的对象标记成灰色并在当前栈没有扫描时将新对象也标记成灰色, 在垃圾收集的标记阶段,我们还需要将创建的所有新对象都标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收

GO使用使用三色抽象、并发增量回收、混合写屏障、调步算法以及用户程序协助等机制将垃圾收集的暂停时间优化至毫秒级以下

垃圾收集的多个阶段过程:

  1. 清理终止阶段;
    1. 暂停程序,所有的处理器在这时会进入安全点(Safe point);
    2. 如果当前垃圾收集循环是强制触发的,我们还需要处理还未被清理的内存管理单元;
  2. 标记阶段;
    1. 将状态切换至 _GCmark、开启写屏障、用户程序协助(Mutator Assiste)并将根对象入队;
    2. 恢复执行程序,标记进程和用于协助的用户程序会开始并发标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象(栈+堆)都会被直接标记成黑色;
    3. 开始扫描根对象,包括所有 Goroutine 的栈、全局对象以及不在堆中的运行时数据结构,扫描 Goroutine 栈期间会暂停当前处理器;
    4. 依次处理灰色队列中的对象,将对象标记成黑色并将它们指向的对象标记成灰色;
    5. 使用分布式的终止算法检查剩余的工作,发现标记阶段完成后进入标记终止阶段;
  3. 标记终止阶段;
    1. 暂停程序、将状态切换至 _GCmarktermination 并关闭辅助标记的用户程序;
    2. 清理处理器上的线程缓存;
  4. 清理阶段;
    1. 将状态切换至 _GCoff 开始清理阶段,初始化清理状态并关闭写屏障;
    2. 恢复用户程序,所有新创建的对象会标记成白色;
    3. 后台并发清理所有的内存管理单元,当 Goroutine 申请新的内存管理单元时就会触发清理(惰性清除);

栈内存管理

栈寄存器在是 CPU 寄存器中的一种,它的主要作用是跟踪函数的调用栈,Go 语言的汇编代码中包含 BP 和 SP 两个栈寄存器,它们分别存储了栈的基址指针和栈顶的地址. BP 和 SP 之间的内存就是当前函数的调用栈.(栈向下增长, 栈区内存都是从高地址向低地址扩展的). 申请或者释放栈内存时只需要修改 SP 寄存器的值.

不同cpu架构的线程默认栈大小不同, linux x86_64是2MB, RLIMIT_STACK的系统参数可以修改线程栈大小.

线程和进程都是代码执行的上下文(Context of Execution).

在 C 语言中,栈上的变量被函数作为返回值返回给调用方是一个常见的错误. 即需要分配到堆上的对象分配到了栈上 , 导致了悬挂指针.

在编译器优化中,逃逸分析(Escape analysis)是用来决定指针动态作用域的方法。Go 语言的编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配,其中包括使用 new、make 和字面量等方法隐式分配的内存,Go 语言的逃逸分析遵循以下两个不变性:

  1. 指向栈对象的指针不能存在于堆中;
  2. 指向栈对象的指针不能在栈对象回收后存活;

通过以下几个步骤实现静态分析的全过程:

  1. 构建带权重的有向图,其中顶点 cmd/compile/internal/gc.EscLocation 表示被分配的变量,边 cmd/compile/internal/gc.EscEdge 表示变量之间的分配关系,权重表示寻址和取地址的次数;
  2. 遍历对象分配图并查找违反两条不变性的变量分配关系,如果堆上的变量指向了栈上的变量,那么栈上的变量就需要分配在堆上;
  3. 记录从函数的调用参数到堆以及返回值的数据流,增强函数参数的逃逸分析;

为了保证内存的绝对安全,编译器可能会将一些变量错误地分配到堆上,但是因为这些对也会被垃圾收集器处理,所以不会造成内存泄露以及悬挂指针等安全问题

GO在1.3实现连续栈以后在1.4中将初始栈大小降为 2KB.

分段栈机制虽然能够按需为当前 Goroutine 分配内存并且及时减少内存的占用,但是它也存在两个比较大的问题:

  1. 如果当前 Goroutine 的栈几乎充满,那么任意的函数调用都会触发栈的扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为热分裂问题(Hot split);
  2. 一旦 Goroutine 使用的内存越过了分段栈的扩缩容阈值,运行时就会触发栈的扩容和缩容,带来额外的工作量;

连续栈可以解决分段栈中存在的两个问题,其核心原理就是每当程序的栈空间不足时,初始化一片更大的栈空间并将原栈中的所有值都迁移到新的栈中,新的局部变量或者函数调用就有了充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:

  1. 内存空间中分配更大的栈内存空间;
  2. 将旧栈中的所有内容复制到新的栈中;
  3. 将指向旧栈对应变量的指针重新指向新栈; 由于指向栈对象的指针不能存在于堆中,所以指向栈中变量的指针只能在栈上,只需要调整栈中的所有变量就可以保证内存的安全
  4. 销毁并回收旧栈的内存空间;

因为需要拷贝变量和调整指针,连续栈增加了栈扩容时的额外开销,通过在 GC 期间如果 Goroutine 使用了栈内存的四分之一,那就将其内存减少一半,这样在栈内存几乎充满时也只会扩容一次,不会因为函数调用频繁扩缩容. 这一机制来减少扩/缩容的次数.

我们可以认为 Go 语言的Goroutine栈内存都是分配在线程堆上的

从调度器和内存分配的经验来看,如果运行时只使用全局变量来分配内存的话,势必会造成线程之间的锁竞争进而影响程序的执行效率,栈内存由于与线程关系比较密切,所以我们在每一个线程缓存 runtime.mcache 中都加入了栈缓存减少锁竞争影响。

运行时使用全局的 runtime.stackpool 和线程缓存中的空闲链表分配 32KB 以下的栈内存,使用全局的 runtime.stackLarge 和堆内存分配 32KB 以上的栈内存,提高本地分配栈内存的性能。

G的栈分配:

  1. 如果栈空间较小,使用全局栈缓存或者线程缓存上固定大小的空闲链表分配内存;
  2. 如果栈空间较大,从全局的大栈缓存 runtime.stackLarge 中获取内存空间;
  3. 如果栈空间较大并且 runtime.stackLarge 空间不足,在堆上申请一片大小足够内存空间;

编译器会在 cmd/internal/obj/x86.stacksplit 函数中为函数调用插入 runtime.morestack 运行时检查,它会在几乎所有的函数调用之前检查当前 Goroutine 的栈内存是否充足,如果当前栈需要扩容,我们会保存一些栈的相关信息并调用 runtime.newstack 检查是否需要被抢占调度并创建新的栈

如果要触发栈的缩容,新栈的大小会是原始栈的一半,不过如果新栈的大小低于程序的最低限制 2KB,那么缩容的过程就会停止。运行时只会在栈内存使用不足 1/4 时进行缩容 – 减少频繁缩栈

插件系统

Linux 中的共享对象会使用 ELF 格式并提供了一组操作动态链接器的接口:

1
2
3
4
5
void *dlopen(const char *filename, int flag);
char *dlerror(void);
void *dlsym(void *handle, const char *symbol);
int dlclose(void *handle);
// dlopen函数会根据传入的文件名加载对应的动态库并返回一个句柄(Handle);我们可以直接使用 dlsym 函数在该句柄中搜索特定的符号,也就是函数或者变量,它会返回该符号被加载到内存中的地址。因为待查找的符号可能不存在于目标动态库中,所以在每次查找后我们都应该调用 dlerror 查看当前查找的结果

插件是一个带有公开函数和变量的 main 包,我们需要使用如下所示的命令编译插件:

1
go build -buildmode=plugin ...

该命令会生成一个共享对象 .so 文件. 当该文件被加载到 Go 语言程序时会使用下面的结构体 plugin.Plugin 表示,该结构体中包含文件的路径以及包含的符号等信息:

1
2
3
4
5
type Plugin struct {
pluginpath string
syms map[string]interface{}
...
}

plugin包中使用的两个 C 语言函数 pluginOpen 和 pluginLookup;pluginOpen 只是简单包装了一下C标准库中的 dlopen 和 dlerror 函数并在加载成功后返回指向动态库的句柄. 这让它们的函数签名看起来更像是 Go 语言中的函数签名,方便在 Go 语言中调用.

Open操作会执行以下步骤:

  1. 准备 C 语言函数 pluginOpen 的参数;
  2. 通过 cgo 调用 C 语言函数 pluginOpen 并初始化加载的模块;
  3. 查找加载模块中的 init 函数并调用该函数;
  4. 通过插件的文件名和符号列表构建 plugin.Plugin 结构体并返回;

Lookup方法在Open的返回中查找符号Symbol,其是interface{}的别名,可以将其转换为变量或函数.

使用plugin时,plugin经常要和主程序同时(更确切的说是同一环境下)build才行。如果主程序有改动或者build的路径更换,plugin不同时更新的话,加载plugin时就会报某个package版本错误的问题,导致加载失败。

工程上的很多决定就是以 Benchmark 为导向的,设定一个测试用例,然后尽可能地提高效率,当然用例到最后也只是一个参考, 所以测试用例的覆盖和模拟准确度很重要.

代码生成

图灵完备的一个重要特性是计算机程序可以生成另一个程序. Go 语言中的测试就使用了代码生成机制,go test 命令会扫描包中的测试用例并生成程序、编译并执行它们

元编程(Metaprogramming)是一种编程技术,在这种技术中,计算机程序能够将程序视为它们的数据。元编程其实是一种使用代码生成代码的方式,无论是编译期间生成代码,还是在运行时改变代码的行为都是其一种.

Go 语言的代码生成机制会读取包含预编译指令的注释,然后执行注释中的命令读取包中的文件,它们将文件解析成抽象语法树并根据语法树生成新的 Go 语言代码和文件,生成的代码会在项目的编译期间与其他代码一起编译和运行。

1
//go:generate command argument...

go generate 不会被 go build 等命令自动执行,该命令需要显式的触发,手动执行该命令时会在文件中扫描上述形式的注释并执行后面的执行命令,需要注意的是 go:generate 和前面的 // 之间没有空格,这种不包含空格的注释一般是 Go 语言的编译器指令,而我们在代码中的正常注释都应该保留这个空格

官方案例:

1
2
3
4
5
6
7
8
9
10
11
12
// pill.go
package painkiller

//go:generate stringer -type=Pill
type Pill int
const (
Placebo Pill = iota
Aspirin
Ibuprofen
Paracetamol
Acetaminophen = Paracetamol
)

调用 go generate 命令后生成pill_string.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
// Code generated by "stringer -type=Pill"; DO NOT EDIT.

package painkiller

import "strconv"

func _() { // 生成一个签名为 _ 的函数,通过编译器保证枚举类型的值不会改变
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[Placebo-0]
_ = x[Aspirin-1]
_ = x[Ibuprofen-2]
_ = x[Paracetamol-3]
}
// 这里重点学习这种节省空间的定义方法,通过同一个底层字符串来优化数据获取效率.
const _Pill_name = "PlaceboAspirinIbuprofenParacetamol"

var _Pill_index = [...]uint8{0, 7, 14, 23, 34}

func (i Pill) String() string {
if i < 0 || i >= Pill(len(_Pill_index)-1) {
return "Pill(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Pill_name[_Pill_index[i]:_Pill_index[i+1]]
}

对比另一个实现,可见其主要是使用了查表法的思想.

1
2
3
4
5
6
7
8
9
10
11
12
13
func (p Pill) String() string {
switch p {
case Placebo:
return "Placebo"
case Aspirin:
return "Aspirin"
case Ibuprofen:
return "Ibuprofen"
case Paracetamol: // == Acetaminophen
return "Paracetamol"
}
return fmt.Sprintf("Pill(%d)", p)
}

可参考blog stringer的代码实现

JSON标准库

Go 语言 JSON 标准库编码和解码的过程大量地运用了反射这一特性.

Go 语言的字段一般都是驼峰命名法,JSON 中下划线的命名方式相对比较常见. 使用标签这一特性直接建立键与字段之间的映射关系.

常见的两个标签是 string 和 omitempty,前者表示当前的整数或者浮点数是由 JSON 中的字符串表示的,而另一个字段 omitempty 会在字段为零值时,直接在生成的 JSON 中忽略对应的键值对.

编/解码中会依次递归的尝试判断数据的类型种类,并在成功时返回,即一个个试看到底是哪种数据.

无论是序列化还是反序列化,都会遵循自顶向下的编码和解码过程,使用递归的方式处理 JSON 对象

JSON 的标准里其实只有 number 类型,float64 是 Go 做的统一转换,如果想转回 int 类型需要用 struct 给标准库一个提示用于反序列化

HTTP标准库

HTTP/3 在 UDP 协议上实现了新的传输层协议 QUIC 并使用 QUIC 传输数据,这也意味着 HTTP 既可以跑在 TCP 上,也可以跑在 UDP 上

作为文本传输协议,HTTP 协议的协议头都是文本数据,HTTP 请求头的首行会包含请求的方法、路径和协议版本,接下来是多个 HTTP 协议头以及携带的负载:

1
2
3
4
5
6
7
8
9
10
11
GET / HTTP/1.1
User-Agent: Mozilla/4.0 (compatible; MSIE5.01; Windows NT)
Host: draveness.me
Accept-Language: en-us
Accept-Encoding: gzip, deflate
Content-Length: <length>
Connection: Keep-Alive

<html>
...
</html>

TCP 协议是面向连接的、可靠的、基于字节流的传输层通信协议,应用层交给 TCP 协议的数据并不会以消息为单位向目的主机传输,这些数据在某些情况下会被组合成一个数据段发送给目标的主机。因为 TCP 协议是基于字节流的,所以基于 TCP 协议的应用层协议都需要自己划分消息的边界.在应用层协议中,最常见的两种解决方案就是基于长度或者基于终结符(Delimiter)。HTTP 协议其实同时实现了上述两种方案,在多数情况下 HTTP 协议都会在协议头中加入 Content-Length 表示负载的长度,消息的接收者解析到该协议头之后就可以确定当前 HTTP 请求/响应结束的位置,分离不同的 HTTP 消息. 当 HTTP 使用块传输(Chunked Transfer)机制时,HTTP 头中就不再包含 Content-Length 了,它会使用负载大小为 0 的 HTTP 消息作为终结符表示消息的边界.

客户端 net/http.Client 是级别较高的抽象,它提供了 HTTP 的一些细节,包括 Cookies 和重定向;而 net/http.Transport 结构体会处理 HTTP/HTTPS 协议的底层实现细节,其中会包含连接重用、构建请求以及发送请求等功能.

Http客户端请求会使用连接池获取和初始化连接. TCP连接是四元组唯一(目的IP:目的端口:源IP:源端口), 相同的四元组的连接可以被复用的.

我们可以在标准库的 net/http.Transport 中调用 net/http.Transport.RegisterProtocol 方法为不同的协议注册 net/http.RoundTripper 的实现

服务路由URL与处理逻辑通过哈希映射, HTTP 服务器在处理请求时就会使用该哈希查找处理器

如果当前 HTTP 服务接收到了海量的请求,会在内部创建大量的 Goroutine,这可能会使整个服务质量明显降低无法处理请求.

数据库标准库

Go 语言的标准库 database/sql 就为访问关系型数据提供了通用的接口,这样不同数据库只要实现标准库中的接口,应用程序就可以通过标准库中的方法访问

结构化查询语言(Structured Query Language、SQL)是在关系型数据库系统中使用的领域特定语言(Domain-Specific Language、DSL)

所有关系型数据库都需要实现的驱动接口:

1
2
3
4
5
6
7
8
9
type Driver interface {
Open(name string) (Conn, error)
}

type Conn interface {
Prepare(query string) (Stmt, error)
Close() error
Begin() (Tx, error)
}

database/sql.Register 方法可以注册自定义的数据库驱动, MySQL 驱动会在 init 中调用上述方法将实现 database/sql/driver.Driver 接口的结构体注册到全局的驱动列表中. 之后通过驱动名获得结构体. 结构体 database/sql.DB 在刚刚初始化时不会包含任何的数据库连接,它持有的数据库连接池会在真正应用程序申请连接时在单独的 Goroutine 中获取

Go的sql库是面向接口编程思想的体现 —— 只依赖抽象的接口,不要依赖具体的实现