Golang 最大的特色能够说是协程 (goroutine) 了, 协程让原本很简单的异步编程变得简略, 让程序员不再须要面对回调天堂,
尽管当初引入了协程的语言越来越多, 但 go 中的协程依然是实现的是最彻底的.
这篇文章将通过剖析 golang 的源代码来解说协程的实现原理.
这个系列剖析的 golang 源代码是 Google 官网的实现的 1.9.2 版本, 不适用于其余版本和 gccgo 等其余实现,
运行环境是 Ubuntu 16.04 LTS 64bit.
外围概念
要了解协程的实现, 首先须要理解 go 中的三个十分重要的概念, 它们别离是 G, M 和P,
没有看过 golang 源代码的可能会对它们感到生疏, 这三项是协程最次要的组成部分, 它们在 golang 的源代码中无处不在.
G (goroutine)
G 是 goroutine 的头文字, goroutine 能够解释为受治理的轻量线程, goroutine 应用 go
关键词创立.
举例来说, func main() { go other() }
, 这段代码创立了两个 goroutine,
一个是 main, 另一个是 other, 留神 main 自身也是一个 goroutine.
goroutine 的新建, 休眠, 复原, 进行都受到 go 运行时的治理.
goroutine 执行异步操作时会进入休眠状态, 待操作实现后再复原, 无需占用零碎线程,
goroutine 新建或复原时会增加到运行队列, 期待 M 取出并运行.
M (machine)
M 是 machine 的头文字, 在以后版本的 golang 中 等同于零碎线程.
M 能够运行两种代码:
- go 代码, 即 goroutine, M 运行 go 代码须要一个 P
- 原生代码, 例如阻塞的 syscall, M 运行原生代码不须要 P
M 会从运行队列中取出 G, 而后运行 G, 如果 G 运行结束或者进入休眠状态, 则从运行队列中取出下一个 G 运行, 周而复始.
有时候 G 须要调用一些无奈防止阻塞的原生代码, 这时 M 会开释持有的 P 并进入阻塞状态, 其余 M 会获得这个 P 并持续运行队列中的 G.
go 须要保障有足够的 M 能够运行 G, 不让 CPU 闲着, 也须要保障 M 的数量不能过多.
P (process)
P 是 process 的头文字, 代表 M 运行 G 所须要的资源.
一些解说协程的文章把 P 了解为 cpu 外围, 其实这是谬误的.
尽管 P 的数量默认等于 cpu 外围数, 但能够通过环境变量 GOMAXPROC
批改, 在理论运行时 P 跟 cpu 外围并无任何关联.
P 也能够了解为管制 go 代码的并行度的机制,
如果 P 的数量等于 1, 代表以后最多只能有一个线程 (M) 执行 go 代码,
如果 P 的数量等于 2, 代表以后最多只能有两个线程 (M) 执行 go 代码.
执行原生代码的线程数量不受 P 管制.
因为同一时间只有一个线程 (M) 能够领有 P, P 中的数据都是锁自在 (lock free) 的, 读写这些数据的效率会十分的高.
数据结构
在解说协程的工作流程之前, 还须要了解一些外部的数据结构.
G 的状态
- 闲暇中(_Gidle): 示意 G 刚刚新建, 仍未初始化
- 待运行(_Grunnable): 示意 G 在运行队列中, 期待 M 取出并运行
- 运行中(_Grunning): 示意 M 正在运行这个 G, 这时候 M 会领有一个 P
- 零碎调用中(_Gsyscall): 示意 M 正在运行这个 G 发动的零碎调用, 这时候 M 并不领有 P
- 期待中(_Gwaiting): 示意 G 在期待某些条件实现, 这时候 G 不在运行也不在运行队列中(可能在 channel 的期待队列中)
- 已停止(_Gdead): 示意 G 未被应用, 可能已执行结束(并在 freelist 中期待下次复用)
- 栈复制中(_Gcopystack): 示意 G 正在获取一个新的栈空间并把原来的内容复制过来(用于避免 GC 扫描)
M 的状态
M 并没有像 G 和 P 一样的状态标记, 但能够认为一个 M 有以下的状态:
- 自旋中(spinning): M 正在从运行队列获取 G, 这时候 M 会领有一个 P
- 执行 go 代码中: M 正在执行 go 代码, 这时候 M 会领有一个 P
- 执行原生代码中: M 正在执行原生代码或者阻塞的 syscall, 这时 M 并不领有 P
- 休眠中: M 发现无待运行的 G 时会进入休眠, 并增加到闲暇 M 链表中, 这时 M 并不领有 P
自旋中 (spinning) 这个状态十分重要, 是否须要唤醒或者创立新的 M 取决于以后自旋中的 M 的数量.
P 的状态
- 闲暇中(_Pidle): 当 M 发现无待运行的 G 时会进入休眠, 这时 M 领有的 P 会变为闲暇并加到闲暇 P 链表中
- 运行中(_Prunning): 当 M 领有了一个 P 后, 这个 P 的状态就会变为运行中, M 运行 G 会应用这个 P 中的资源
- 零碎调用中(_Psyscall): 当 go 调用原生代码, 原生代码又反过来调用 go 代码时, 应用的 P 会变为此状态
- GC 进行中 (_Pgcstop): 当 gc 进行了整个世界(STW) 时, P 会变为此状态
- 已停止(_Pdead): 当 P 的数量在运行时扭转, 且数量缩小时多余的 P 会变为此状态
本地运行队列
在 go 中有多个运行队列能够保留待运行 (_Grunnable) 的 G, 它们别离是各个 P 中的本地运行队列和全局运行队列.
入队待运行的 G 时会优先加到以后 P 的本地运行队列, M 获取待运行的 G 时也会优先从领有的 P 的本地运行队列获取,
本地运行队列入队和出队不须要应用线程锁.
本地运行队列有数量限度, 当数量达到 256 个时会入队到全局运行队列.
本地运行队列的数据结构是环形队列, 由一个 256 长度的数组和两个序号 (head, tail) 组成.
当 M 从 P 的本地运行队列获取 G 时, 如果发现本地队列为空会尝试从其余 P 盗取一半的 G 过去,
这个机制叫做 Work Stealing, 详见前面的代码剖析.
全局运行队列
全局运行队列保留在全局变量 sched
中, 全局运行队列入队和出队须要应用线程锁.
全局运行队列的数据结构是链表, 由两个指针 (head, tail) 组成.
闲暇 M 链表
当 M 发现无待运行的 G 时会进入休眠, 并增加到闲暇 M 链表中, 闲暇 M 链表保留在全局变量 sched
.
进入休眠的 M 会期待一个信号量(m.park), 唤醒休眠的 M 会应用这个信号量.
go 须要保障有足够的 M 能够运行 G, 是通过这样的机制实现的:
- 入队待运行的 G 后, 如果以后无自旋的 M 然而有闲暇的 P, 就唤醒或者新建一个 M
- 当 M 来到自旋状态并筹备运行出队的 G 时, 如果以后无自旋的 M 然而有闲暇的 P, 就唤醒或者新建一个 M
- 当 M 来到自旋状态并筹备休眠时, 会在来到自旋状态后再次查看所有运行队列, 如果有待运行的 G 则从新进入自旋状态
因为 ” 入队待运行的 G ” 和 ”M 来到自旋状态 ” 会同时进行, go 会应用这样的查看程序:
入队待运行的 G => 内存屏障 => 查看以后自旋的 M 数量 => 唤醒或者新建一个 M
缩小以后自旋的 M 数量 => 内存屏障 => 查看所有运行队列是否有待运行的 G => 休眠
这样能够保障不会呈现待运行的 G 入队了, 也有闲暇的资源 P, 但无 M 去执行的状况.
闲暇 P 链表
当 P 的本地运行队列中的所有 G 都运行结束, 又不能从其余中央拿到 G 时,
领有 P 的 M 会开释 P 并进入休眠状态, 开释的 P 会变为闲暇状态并加到闲暇 P 链表中, 闲暇 P 链表保留在全局变量 sched
下次待运行的 G 入队时如果发现有闲暇的 P, 然而又没有自旋中的 M 时会唤醒或者新建一个 M, M 会领有这个 P, P 会从新变为运行中的状态.
工作流程(概览)
下图是协程可能呈现的工作状态, 图中有 4 个 P, 其中 M1~M3 正在运行 G 并且运行后会从领有的 P 的运行队列持续获取 G:
只看这张图可能有点难以想象理论的工作流程, 这里我依据理论的代码再解说一遍:
package main
import (
"fmt"
"time"
)
func printNumber(from, to int, c chan int) {
for x := from; x <= to; x++ {fmt.Printf("%d\n", x)
time.Sleep(1 * time.Millisecond)
}
c <- 0
}
func main() {c := make(chan int, 3)
go printNumber(1, 3, c)
go printNumber(4, 6, c)
_ = <- c
_ = <- c
}
程序启动时会先创立一个 G, 指向的是 main(理论是 runtime.main 而不是 main.main, 前面解释):
图中的虚线指的是 G 待运行或者开始运行的地址, 不是以后运行的地址.
M 会获得这个 G 并运行:
这时 main 会创立一个新的 channel, 并启动两个新的 G:
接下来 G: main
会从 channel 获取数据, 因为获取不到, G 会 保留状态 并变为期待中 (_Gwaiting) 并增加到 channel 的队列:
因为 G: main
保留了运行状态, 下次运行时将会从 _ = <- c
持续运行.
接下来 M 会从运行队列获取到 G: printNumber
并运行:
printNumber 会打印数字, 实现后向 channel 写数据,
写数据时发现 channel 中有正在期待的 G, 会把数据交给这个 G, 把 G 变为待运行 (_Grunnable) 并从新放入运行队列:
接下来 M 会运行下一个G: printNumber
, 因为创立 channel 时指定了大小为 3 的缓冲区, 能够间接把数据写入缓冲区而无需期待:
而后 printNumber 运行结束, 运行队列中就只剩下 G: main
了:
最初 M 把 G: main
取出来运行, 会从上次中断的地位 _ <- c
持续运行:
第一个 _ <- c
的后果曾经在后面设置过了, 这条语句会执行胜利.
第二个 _ <- c
在获取时会发现 channel 中有已缓冲的 0, 于是后果就是这个 0, 不须要期待.
最初 main 执行结束, 程序完结.
有人可能会好奇如果最初再加一个 _ <- c
会变成什么后果, 这时因为所有 G 都进入期待状态, go 会检测进去并报告死锁:
fatal error: all goroutines are asleep - deadlock!
开始代码剖析
对于概念的解说到此结束, 从这里开始会剖析 go 中的实现代码, 咱们须要先理解一些根底的内容.
汇编代码
从以下的 go 代码:
package main
import (
"fmt"
"time"
)
func printNumber(from, to int, c chan int) {
for x := from; x <= to; x++ {fmt.Printf("%d\n", x)
time.Sleep(1 * time.Millisecond)
}
c <- 0
}
func main() {c := make(chan int, 3)
go printNumber(1, 3, c)
go printNumber(4, 6, c)
_, _ = <- c, <- c
}
能够生成以下的汇编代码(平台是 linux x64, 应用的是默认选项, 即启用优化和内联):
(lldb) di -n main.main
hello`main.main:
hello[0x401190] <+0>: movq %fs:-0x8, %rcx
hello[0x401199] <+9>: cmpq 0x10(%rcx), %rsp
hello[0x40119d] <+13>: jbe 0x401291 ; <+257> at hello.go:16
hello[0x4011a3] <+19>: subq $0x40, %rsp
hello[0x4011a7] <+23>: leaq 0xb3632(%rip), %rbx ; runtime.rodata + 38880
hello[0x4011ae] <+30>: movq %rbx, (%rsp)
hello[0x4011b2] <+34>: movq $0x3, 0x8(%rsp)
hello[0x4011bb] <+43>: callq 0x4035a0 ; runtime.makechan at chan.go:49
hello[0x4011c0] <+48>: movq 0x10(%rsp), %rax
hello[0x4011c5] <+53>: movq $0x1, 0x10(%rsp)
hello[0x4011ce] <+62>: movq $0x3, 0x18(%rsp)
hello[0x4011d7] <+71>: movq %rax, 0x38(%rsp)
hello[0x4011dc] <+76>: movq %rax, 0x20(%rsp)
hello[0x4011e1] <+81>: movl $0x18, (%rsp)
hello[0x4011e8] <+88>: leaq 0x129c29(%rip), %rax ; main.printNumber.f
hello[0x4011ef] <+95>: movq %rax, 0x8(%rsp)
hello[0x4011f4] <+100>: callq 0x430cd0 ; runtime.newproc at proc.go:2657
hello[0x4011f9] <+105>: movq $0x4, 0x10(%rsp)
hello[0x401202] <+114>: movq $0x6, 0x18(%rsp)
hello[0x40120b] <+123>: movq 0x38(%rsp), %rbx
hello[0x401210] <+128>: movq %rbx, 0x20(%rsp)
hello[0x401215] <+133>: movl $0x18, (%rsp)
hello[0x40121c] <+140>: leaq 0x129bf5(%rip), %rax ; main.printNumber.f
hello[0x401223] <+147>: movq %rax, 0x8(%rsp)
hello[0x401228] <+152>: callq 0x430cd0 ; runtime.newproc at proc.go:2657
hello[0x40122d] <+157>: movq $0x0, 0x30(%rsp)
hello[0x401236] <+166>: leaq 0xb35a3(%rip), %rbx ; runtime.rodata + 38880
hello[0x40123d] <+173>: movq %rbx, (%rsp)
hello[0x401241] <+177>: movq 0x38(%rsp), %rbx
hello[0x401246] <+182>: movq %rbx, 0x8(%rsp)
hello[0x40124b] <+187>: leaq 0x30(%rsp), %rbx
hello[0x401250] <+192>: movq %rbx, 0x10(%rsp)
hello[0x401255] <+197>: callq 0x4043c0 ; runtime.chanrecv1 at chan.go:354
hello[0x40125a] <+202>: movq $0x0, 0x28(%rsp)
hello[0x401263] <+211>: leaq 0xb3576(%rip), %rbx ; runtime.rodata + 38880
hello[0x40126a] <+218>: movq %rbx, (%rsp)
hello[0x40126e] <+222>: movq 0x38(%rsp), %rbx
hello[0x401273] <+227>: movq %rbx, 0x8(%rsp)
hello[0x401278] <+232>: leaq 0x28(%rsp), %rbx
hello[0x40127d] <+237>: movq %rbx, 0x10(%rsp)
hello[0x401282] <+242>: callq 0x4043c0 ; runtime.chanrecv1 at chan.go:354
hello[0x401287] <+247>: movq 0x28(%rsp), %rbx
hello[0x40128c] <+252>: addq $0x40, %rsp
hello[0x401290] <+256>: retq
hello[0x401291] <+257>: callq 0x4538d0 ; runtime.morestack_noctxt at asm_amd64.s:365
hello[0x401296] <+262>: jmp 0x401190 ; <+0> at hello.go:16
hello[0x40129b] <+267>: int3
hello[0x40129c] <+268>: int3
hello[0x40129d] <+269>: int3
hello[0x40129e] <+270>: int3
hello[0x40129f] <+271>: int3
(lldb) di -n main.printNumber
hello`main.printNumber:
hello[0x401000] <+0>: movq %fs:-0x8, %rcx
hello[0x401009] <+9>: leaq -0x8(%rsp), %rax
hello[0x40100e] <+14>: cmpq 0x10(%rcx), %rax
hello[0x401012] <+18>: jbe 0x401185 ; <+389> at hello.go:8
hello[0x401018] <+24>: subq $0x88, %rsp
hello[0x40101f] <+31>: xorps %xmm0, %xmm0
hello[0x401022] <+34>: movups %xmm0, 0x60(%rsp)
hello[0x401027] <+39>: movq 0x90(%rsp), %rax
hello[0x40102f] <+47>: movq 0x98(%rsp), %rbp
hello[0x401037] <+55>: cmpq %rbp, %rax
hello[0x40103a] <+58>: jg 0x40112f ; <+303> at hello.go:13
hello[0x401040] <+64>: movq %rax, 0x40(%rsp)
hello[0x401045] <+69>: movq %rax, 0x48(%rsp)
hello[0x40104a] <+74>: xorl %ebx, %ebx
hello[0x40104c] <+76>: movq %rbx, 0x60(%rsp)
hello[0x401051] <+81>: movq %rbx, 0x68(%rsp)
hello[0x401056] <+86>: leaq 0x60(%rsp), %rbx
hello[0x40105b] <+91>: cmpq $0x0, %rbx
hello[0x40105f] <+95>: je 0x40117e ; <+382> at hello.go:10
hello[0x401065] <+101>: movq $0x1, 0x78(%rsp)
hello[0x40106e] <+110>: movq $0x1, 0x80(%rsp)
hello[0x40107a] <+122>: movq %rbx, 0x70(%rsp)
hello[0x40107f] <+127>: leaq 0xb73fa(%rip), %rbx ; runtime.rodata + 54400
hello[0x401086] <+134>: movq %rbx, (%rsp)
hello[0x40108a] <+138>: leaq 0x48(%rsp), %rbx
hello[0x40108f] <+143>: movq %rbx, 0x8(%rsp)
hello[0x401094] <+148>: movq $0x0, 0x10(%rsp)
hello[0x40109d] <+157>: callq 0x40bb90 ; runtime.convT2E at iface.go:128
hello[0x4010a2] <+162>: movq 0x18(%rsp), %rcx
hello[0x4010a7] <+167>: movq 0x20(%rsp), %rax
hello[0x4010ac] <+172>: movq 0x70(%rsp), %rbx
hello[0x4010b1] <+177>: movq %rcx, 0x50(%rsp)
hello[0x4010b6] <+182>: movq %rcx, (%rbx)
hello[0x4010b9] <+185>: movq %rax, 0x58(%rsp)
hello[0x4010be] <+190>: cmpb $0x0, 0x19ea1b(%rip) ; time.initdone.
hello[0x4010c5] <+197>: jne 0x401167 ; <+359> at hello.go:10
hello[0x4010cb] <+203>: movq %rax, 0x8(%rbx)
hello[0x4010cf] <+207>: leaq 0xfb152(%rip), %rbx ; go.string.* + 560
hello[0x4010d6] <+214>: movq %rbx, (%rsp)
hello[0x4010da] <+218>: movq $0x3, 0x8(%rsp)
hello[0x4010e3] <+227>: movq 0x70(%rsp), %rbx
hello[0x4010e8] <+232>: movq %rbx, 0x10(%rsp)
hello[0x4010ed] <+237>: movq 0x78(%rsp), %rbx
hello[0x4010f2] <+242>: movq %rbx, 0x18(%rsp)
hello[0x4010f7] <+247>: movq 0x80(%rsp), %rbx
hello[0x4010ff] <+255>: movq %rbx, 0x20(%rsp)
hello[0x401104] <+260>: callq 0x45ad70 ; fmt.Printf at print.go:196
hello[0x401109] <+265>: movq $0xf4240, (%rsp) ; imm = 0xF4240
hello[0x401111] <+273>: callq 0x442a50 ; time.Sleep at time.go:48
hello[0x401116] <+278>: movq 0x40(%rsp), %rax
hello[0x40111b] <+283>: incq %rax
hello[0x40111e] <+286>: movq 0x98(%rsp), %rbp
hello[0x401126] <+294>: cmpq %rbp, %rax
hello[0x401129] <+297>: jle 0x401040 ; <+64> at hello.go:10
hello[0x40112f] <+303>: movq $0x0, 0x48(%rsp)
hello[0x401138] <+312>: leaq 0xb36a1(%rip), %rbx ; runtime.rodata + 38880
hello[0x40113f] <+319>: movq %rbx, (%rsp)
hello[0x401143] <+323>: movq 0xa0(%rsp), %rbx
hello[0x40114b] <+331>: movq %rbx, 0x8(%rsp)
hello[0x401150] <+336>: leaq 0x48(%rsp), %rbx
hello[0x401155] <+341>: movq %rbx, 0x10(%rsp)
hello[0x40115a] <+346>: callq 0x403870 ; runtime.chansend1 at chan.go:99
hello[0x40115f] <+351>: addq $0x88, %rsp
hello[0x401166] <+358>: retq
hello[0x401167] <+359>: leaq 0x8(%rbx), %r8
hello[0x40116b] <+363>: movq %r8, (%rsp)
hello[0x40116f] <+367>: movq %rax, 0x8(%rsp)
hello[0x401174] <+372>: callq 0x40f090 ; runtime.writebarrierptr at mbarrier.go:129
hello[0x401179] <+377>: jmp 0x4010cf ; <+207> at hello.go:10
hello[0x40117e] <+382>: movl %eax, (%rbx)
hello[0x401180] <+384>: jmp 0x401065 ; <+101> at hello.go:10
hello[0x401185] <+389>: callq 0x4538d0 ; runtime.morestack_noctxt at asm_amd64.s:365
hello[0x40118a] <+394>: jmp 0x401000 ; <+0> at hello.go:8
hello[0x40118f] <+399>: int3
这些汇编代码当初看不懂也没关系, 上面会从这里取出一部分来解释.
调用标准
不同平台对于函数有不同的调用标准.
例如 32 位通过栈传递参数, 通过 eax 寄存器传递返回值.
64 位 windows 通过 rcx, rdx, r8, r9 传递前 4 个参数, 通过栈传递第 5 个开始的参数, 通过 eax 寄存器传递返回值.
64 位 linux, unix 通过 rdi, rsi, rdx, rcx, r8, r9 传递前 6 个参数, 通过栈传递第 7 个开始的参数, 通过 eax 寄存器传递返回值.
go 并不应用这些调用标准(除非波及到与原生代码交互), go 有一套单独的调用标准.
go 的调用标准十分的简略, 所有参数都通过栈传递, 返回值也通过栈传递,
例如这样的函数:
type MyStruct struct {X int; P *int}
func someFunc(x int, s MyStruct) (int, MyStruct) {...}
调用函数时的栈的内容如下:
能够看得出参数和返回值都从低位到高位排列, go 函数能够有多个返回值的起因也在于此. 因为返回值都通过栈传递了.
须要留神的这里的 ” 返回地址 ” 是 x86 和 x64 上的, arm 的返回地址会通过 LR 寄存器保留, 内容会和这里的略微不一样.
另外留神的是和 c 不一样, 传递结构体时整个结构体的内容都会复制到栈上, 如果结构体很大将会影响性能.
TLS
TLS 的全称是 Thread-local storage, 代表每个线程的中的本地数据.
例如规范 c 中的 errno 就是一个典型的 TLS 变量, 每个线程都有一个单独的 errno, 写入它不会烦扰到其余线程中的值.
go 在实现协程时十分依赖 TLS 机制, 会用于获取零碎线程中以后的 G 和 G 所属的 M 的实例.
因为 go 并不应用 glibc, 操作 TLS 会应用零碎原生的接口, 以 linux x64 为例,
go 在新建 M 时会调用 arch_prctl 这个 syscall 设置 FS 寄存器的值为 M.tls 的地址,
运行中每个 M 的 FS 寄存器都会指向它们对应的 M 实例的 tls, linux 内核调度线程时 FS 寄存器会跟着线程一起切换,
这样 go 代码只须要拜访 FS 寄存器就能够存取线程本地的数据.
下面的汇编代码中的
hello[0x401000] <+0>: movq %fs:-0x8, %rcx
会把指向以后的 G 的指针从 TLS 挪动到 rcx 寄存器中.
栈扩张
因为 go 中的协程是 stackful coroutine, 每一个 goroutine 都须要有本人的栈空间,
栈空间的内容在 goroutine 休眠时须要保留, 待休眠实现后复原 (这时整个调用树都是残缺的).
这样就引出了一个问题, goroutine 可能会同时存在很多个, 如果每一个 goroutine 都事后调配一个足够的栈空间那么 go 就会应用过多的内存.
为了防止这个问题, go 在一开始只为 goroutine 调配一个很小的栈空间, 它的大小在以后版本是 2K.
当函数发现栈空间有余时, 会申请一块新的栈空间并把原来的栈内容复制过来.
下面的汇编代码中的
hello[0x401000] <+0>: movq %fs:-0x8, %rcx
hello[0x401009] <+9>: leaq -0x8(%rsp), %rax
hello[0x40100e] <+14>: cmpq 0x10(%rcx), %rax
hello[0x401012] <+18>: jbe 0x401185 ; <+389> at hello.go:8
会查看比拟 rsp 减去肯定值当前是否比 g.stackguard0 小, 如果小于等于则须要调到上面调用 morestack_noctxt 函数.
仔细的可能会发现比拟的值跟理论减去的值不统一, 这是因为 stackguard0 上面会预留一小部分空间, 编译时确定不超过预留的空间能够省略比对.
写屏障(Write Barrier)
因为 go 反对并行 GC, GC 的扫描和 go 代码能够同时运行, 这样带来的问题是 GC 扫描的过程中 go 代码有可能扭转了对象的依赖树,
例如开始扫描时发现根对象 A 和 B, B 领有 C 的指针, GC 先扫描 A, 而后 B 把 C 的指针交给 A, GC 再扫描 B, 这时 C 就不会被扫描到.
为了防止这个问题, go 在 GC 的标记阶段会启用写屏障(Write Barrier).
启用了写屏障 (Write Barrier) 后, 当 B 把 C 的指针交给 A 时, GC 会认为在这一轮的扫描中 C 的指针是存活的,
即便 A 可能会在稍后丢掉 C, 那么 C 就在下一轮回收.
写屏障只针对指针启用, 而且只在 GC 的标记阶段启用, 平时会间接把值写入到指标地址:
对于写屏障的具体将在下一篇 (GC 篇) 剖析.
值得一提的是 CoreCLR 的 GC 也有写屏障的机制, 但作用跟这里的不一样(用于标记跨代援用).
闭包(Closure)
闭包这个概念自身应该不须要解释, 咱们理论看一看 go 是如何实现闭包的:
package main
import ("fmt")
func executeFn(fn func() int) int {return fn();
}
func main() {
a := 1
b := 2
c := executeFn(func() int {
a += b
return a
})
fmt.Printf("%d %d %d\n", a, b, c)
}
这段代码的输入后果是3 2 3
, 相熟 go 的应该不会感到意外.
main 函数执行 executeFn 函数的汇编代码如下:
hello[0x4a096f] <+47>: movq $0x1, 0x40(%rsp) ; 变量 a 等于 1
hello[0x4a0978] <+56>: leaq 0x151(%rip), %rax ; 寄存器 rax 等于匿名函数 main.main.func1 的地址
hello[0x4a097f] <+63>: movq %rax, 0x60(%rsp) ; 变量 rsp+0x60 等于匿名函数的地址
hello[0x4a0984] <+68>: leaq 0x40(%rsp), %rax ; 寄存器 rax 等于变量 a 的地址
hello[0x4a0989] <+73>: movq %rax, 0x68(%rsp) ; 变量 rsp+0x68 等于变量 a 的地址
hello[0x4a098e] <+78>: movq $0x2, 0x70(%rsp) ; 变量 rsp+0x70 等于 2(变量 b 的值)
hello[0x4a0997] <+87>: leaq 0x60(%rsp), %rax ; 寄存器 rax 等于地址 rsp+0x60
hello[0x4a099c] <+92>: movq %rax, (%rsp) ; 第一个参数等于地址 rsp+0x60
hello[0x4a09a0] <+96>: callq 0x4a08f0 ; 执行 main.executeFn
hello[0x4a09a5] <+101>: movq 0x8(%rsp), %rax ; 寄存器 rax 等于返回值
咱们能够看到传给 executeFn 的是一个指针, 指针指向的内容是 [匿名函数的地址, 变量 a 的地址, 变量 b 的值]
.
变量 a 传地址的起因是匿名函数中对 a 进行了批改, 须要反映到原来的 a 上.
executeFn 函数执行闭包的汇编代码如下:
hello[0x4a08ff] <+15>: subq $0x10, %rsp ; 在栈上调配 0x10 的空间
hello[0x4a0903] <+19>: movq %rbp, 0x8(%rsp) ; 把原来的寄存器 rbp 移到变量 rsp+0x8
hello[0x4a0908] <+24>: leaq 0x8(%rsp), %rbp ; 把变量 rsp+0x8 的地址移到寄存器 rbp
hello[0x4a090d] <+29>: movq 0x18(%rsp), %rdx ; 把第一个参数 (闭包) 的指针移到寄存器 rdx
hello[0x4a0912] <+34>: movq (%rdx), %rax ; 把闭包中函数的指针移到寄存器 rax
hello[0x4a0915] <+37>: callq *%rax ; 调用闭包中的函数
hello[0x4a0917] <+39>: movq (%rsp), %rax ; 把返回值移到寄存器 rax
hello[0x4a091b] <+43>: movq %rax, 0x20(%rsp) ; 把寄存器 rax 移到返回值中(参数前面)
hello[0x4a0920] <+48>: movq 0x8(%rsp), %rbp ; 把变量 rsp+0x8 的值复原寄存器 rbp(恢复原 rbp)
hello[0x4a0925] <+53>: addq $0x10, %rsp ; 开释栈空间
hello[0x4a0929] <+57>: retq ; 从函数返回
能够看到调用闭包时参数并不通过栈传递, 而是通过寄存器 rdx 传递, 闭包的汇编代码如下:
hello[0x455660] <+0>: movq 0x8(%rdx), %rax ; 第一个参数移到寄存器 rax(变量 a 的指针)
hello[0x455664] <+4>: movq (%rax), %rcx ; 把寄存器 rax 指向的值移到寄存器 rcx(变量 a 的值)
hello[0x455667] <+7>: addq 0x10(%rdx), %rcx ; 增加第二个参数到寄存器 rcx(变量 a 的值 + 变量 b 的值)
hello[0x45566b] <+11>: movq %rcx, (%rax) ; 把寄存器 rcx 移到寄存器 rax 指向的值(相加的后果保留回变量 a)
hello[0x45566e] <+14>: movq %rcx, 0x8(%rsp) ; 把寄存器 rcx 移到返回后果
hello[0x455673] <+19>: retq ; 从函数返回
闭包的传递能够总结如下:
- 闭包的内容是[匿名函数的地址, 传给匿名函数的参数(不定长)…]
- 传递闭包给其余函数时会传递指向 ” 闭包的内容 ” 的指针
- 调用闭包时会把指向 ” 闭包的内容 ” 的指针放到寄存器 rdx(在 go 外部这个指针称为 ” 上下文 ”)
- 闭包会从寄存器 rdx 取出参数
- 如果闭包批改了变量, 闭包中的参数会是指针而不是值, 批改时会批改到原来的地位上
闭包 +goroutine
仔细的可能会发现在下面的例子中, 闭包的内容在栈上, 如果不是间接调用 executeFn 而是 go executeFn 呢?
把下面的代码改为 go executeFn(func() ...)
能够生成以下的汇编代码:
hello[0x455611] <+33>: leaq 0xb4a8(%rip), %rax ; 寄存器 rax 等于类型信息
hello[0x455618] <+40>: movq %rax, (%rsp) ; 第一个参数等于类型信息
hello[0x45561c] <+44>: callq 0x40d910 ; 调用 runtime.newobject
hello[0x455621] <+49>: movq 0x8(%rsp), %rax ; 寄存器 rax 等于返回值(这里称为新对象 a)
hello[0x455626] <+54>: movq %rax, 0x28(%rsp) ; 变量 rsp+0x28 等于新对象 a
hello[0x45562b] <+59>: movq $0x1, (%rax) ; 新对象 a 的值等于 1
hello[0x455632] <+66>: leaq 0x136e7(%rip), %rcx ; 寄存器 rcx 等于类型信息
hello[0x455639] <+73>: movq %rcx, (%rsp) ; 第一个参数等于类型信息
hello[0x45563d] <+77>: callq 0x40d910 ; 调用 runtime.newobject
hello[0x455642] <+82>: movq 0x8(%rsp), %rax ; 寄存器 rax 等于返回值(这里称为新对象 fn)
hello[0x455647] <+87>: leaq 0x82(%rip), %rcx ; 寄存器 rcx 等于匿名函数 main.main.func1 的地址
hello[0x45564e] <+94>: movq %rcx, (%rax) ; 新对象 fn+ 0 的值等于 main.main.func1 的地址
hello[0x455651] <+97>: testb (%rax), %al ; 确保新对象 fn 不等于 nil
hello[0x455653] <+99>: movl 0x78397(%rip), %ecx ; 寄存器 ecx 等于以后是否启用写屏障
hello[0x455659] <+105>: leaq 0x8(%rax), %rdx ; 寄存器 rdx 等于新对象 fn+0x8 的地址
hello[0x45565d] <+109>: testl %ecx, %ecx ; 判断以后是否启用写屏障
hello[0x45565f] <+111>: jne 0x455699 ; 启用写屏障时调用前面的逻辑
hello[0x455661] <+113>: movq 0x28(%rsp), %rcx ; 寄存器 rcx 等于新对象 a
hello[0x455666] <+118>: movq %rcx, 0x8(%rax) ; 设置新对象 fn+0x8 的值等于新对象 a
hello[0x45566a] <+122>: movq $0x2, 0x10(%rax) ; 设置新对象 fn+0x10 的值等于 2(变量 b 的值)
hello[0x455672] <+130>: movq %rax, 0x10(%rsp) ; 第三个参数等于新对象 fn(额定参数)
hello[0x455677] <+135>: movl $0x10, (%rsp) ; 第一个参数等于 0x10(函数 + 参数的大小)
hello[0x45567e] <+142>: leaq 0x22fb3(%rip), %rax ; 第二个参数等于一个常量结构体的地址
hello[0x455685] <+149>: movq %rax, 0x8(%rsp) ; 这个结构体的类型是 funcval, 值是 executeFn 的地址
hello[0x45568a] <+154>: callq 0x42e690 ; 调用 runtime.newproc 创立新的 goroutine
咱们能够看到 goroutine+ 闭包的状况更简单, 首先 go 会通过逃逸剖析算出变量 a 和闭包会逃逸到里面,
这时 go 会在 heap 上调配变量 a 和闭包, 下面调用的两次 newobject 就是别离对变量 a 和闭包的调配.
在创立 goroutine 时, 首先会传入函数 + 参数的大小(下面是 8 +8=16), 而后传入函数 + 参数, 下面的参数即闭包的地址.
m0 和 g0
go 中还有非凡的 M 和 G, 它们是 m0 和 g0.
m0 是启动程序后的主线程, 这个 m 对应的实例会在全局变量 m0 中, 不须要在 heap 上调配,
m0 负责执行初始化操作和启动第一个 g, 在之后 m0 就和其余的 m 一样了.
g0 是仅用于负责调度的 G, g0 不指向任何可执行的函数, 每个 m 都会有一个本人的 g0,
在调度或零碎调用时会应用 g0 的栈空间, 全局变量的 g0 是 m0 的 g0.
如果下面的内容都理解, 就能够开始看 golang 的源代码了.
程序初始化
go 程序的入口点是 runtime.rt0_go, 流程是:
- 调配栈空间, 须要 2 个本地变量 + 2 个函数参数, 而后向 8 对齐
- 把传入的 argc 和 argv 保留到栈上
- 更新 g0 中的 stackguard 的值, stackguard 用于检测栈空间是否有余, 须要调配新的栈空间
- 获取以后 cpu 的信息并保留到各个全局变量
- 调用_cgo_init 如果函数存在
- 初始化以后线程的 TLS, 设置 FS 寄存器为 m0.tls+8(获取时会 -8)
- 测试 TLS 是否工作
- 设置 g0 到 TLS 中, 示意以后的 g 是 g0
- 设置 m0.g0 = g0
- 设置 g0.m = m0
- 调用 runtime.check 做一些查看
- 调用 runtime.args 保留传入的 argc 和 argv 到全局变量
-
调用 runtime.osinit 依据零碎执行不同的初始化
- 这里 (linux x64) 设置了全局变量 ncpu 等于 cpu 外围数量
-
调用 runtime.schedinit 执行独特的初始化
- 这里的解决比拟多, 会初始化栈空间分配器, GC, 按 cpu 外围数量或 GOMAXPROCS 的值生成 P 等
- 生成 P 的解决在 procresize 中
-
调用 runtime.newproc 创立一个新的 goroutine, 指向的是
runtime.main
- runtime.newproc 这个函数在创立一般的 goroutine 时也会应用, 在上面的 ”go 的实现 ” 中会具体解说
-
调用 runtime·mstart 启动 m0
- 启动后 m0 会一直从运行队列获取 G 并运行, runtime.mstart 调用后不会返回
- runtime.mstart 这个函数是 m 的入口点(不仅仅是 m0), 在上面的 ” 调度器的实现 ” 中会具体解说
第一个被调度的 G 会运行 runtime.main, 流程是:
- 标记主函数已调用, 设置 mainStarted = true
- 启动一个新的 M 执行 sysmon 函数, 这个函数会监控全局的状态并对运行工夫过长的 G 进行抢占
- 要求 G 必须在以后 M(零碎主线程)上执行
- 调用 runtime_init 函数
- 调用 gcenable 函数
- 调用 main.init 函数, 如果函数存在
- 不再要求 G 必须在以后 M 上运行
- 如果程序是作为 c 的类库编译的, 在这里返回
- 调用 main.main 函数
- 如果以后产生了 panic, 则期待 panic 解决
- 调用 exit(0)退出程序
G M P 的定义
G 的定义在这里.
M 的定义在这里.
P 的定义在这里.
G 外面比拟重要的成员如下
- stack: 以后 g 应用的栈空间, 有 lo 和 hi 两个成员
- stackguard0: 查看栈空间是否足够的值, 低于这个值会扩张栈, 0 是 go 代码应用的
- stackguard1: 查看栈空间是否足够的值, 低于这个值会扩张栈, 1 是原生代码应用的
- m: 以后 g 对应的 m
- sched: g 的调度数据, 当 g 中断时会保留以后的 pc 和 rsp 等值到这里, 复原运行时会应用这里的值
- atomicstatus: g 的以后状态
- schedlink: 下一个 g, 当 g 在链表构造中会应用
- preempt: g 是否被抢占中
- lockedm: g 是否要求要回到这个 M 执行, 有的时候 g 中断了复原会要求应用原来的 M 执行
M 外面比拟重要的成员如下
- g0: 用于调度的非凡 g, 调度和执行零碎调用时会切换到这个 g
- curg: 以后运行的 g
- p: 以后领有的 P
- nextp: 唤醒 M 时, M 会领有这个 P
- park: M 休眠时应用的信号量, 唤醒 M 时会通过它唤醒
- schedlink: 下一个 m, 当 m 在链表构造中会应用
- mcache: 分配内存时应用的本地分配器, 和 p.mcache 一样(领有 P 时会复制过去)
- lockedg: lockedm 的对应值
P 外面比拟重要的成员如下
- status: p 的以后状态
- link: 下一个 p, 当 p 在链表构造中会应用
- m: 领有这个 P 的 M
- mcache: 分配内存时应用的本地分配器
- runqhead: 本地运行队列的出队序号
- runqtail: 本地运行队列的入队序号
- runq: 本地运行队列的数组, 能够保留 256 个 G
- gfree: G 的自在列表, 保留变为_Gdead 后能够复用的 G 实例
- gcBgMarkWorker: 后盾 GC 的 worker 函数, 如果它存在 M 会优先执行它
- gcw: GC 的本地工作队列, 具体将在下一篇 (GC 篇) 剖析
go 的实现
应用 go 命令创立 goroutine 时, go 会把 go 命令编译为对 runtime.newproc 的调用, 堆栈的构造如下:
第一个参数是 funcval + 额定参数的长度, 第二个参数是 funcval, 前面的都是传递给 goroutine 中执行的函数的额定参数.
funcval 的定义在这里, fn 是指向函数机器代码的指针.
runtime.newproc 的解决如下:
- 计算额定参数的地址 argp
- 获取调用端的地址(返回地址)pc
- 应用 systemstack 调用 newproc1
systemstack 会切换以后的 g 到 g0, 并且应用 g0 的栈空间, 而后调用传入的函数, 再切换回原来的 g 和原来的栈空间.
切换到 g0 后会伪装返回地址是 mstart, 这样 traceback 的时候能够在 mstart 进行.
这里传给 systemstack 的是一个闭包, 调用时会把闭包的地址放到寄存器 rdx, 具体能够参考上面对闭包的剖析.
runtime.newproc1 的解决如下:
- 调用 getg 获取以后的 g, 会编译为读取 FS 寄存器(TLS), 这里会获取到 g0
- 设置 g 对应的 m 的 locks++, 禁止抢占
- 获取 m 领有的 p
-
新建一个 g
- 首先调用 gfget 从 p.gfree 获取 g, 如果之前有 g 被回收在这里就能够复用
- 获取不到时调用 malg 调配一个 g, 初始的栈空间大小是 2K
- 须要先设置 g 的状态为已停止(_Gdead), 这样 gc 不会去扫描这个 g 的未初始化的栈
- 把参数复制到 g 的栈上
- 把返回地址复制到 g 的栈上, 这里的返回地址是 goexit, 示意调用完指标函数后会调用 goexit
-
设置 g 的调度数据(sched)
- 设置 sched.sp 等于参数 + 返回地址后的 rsp 地址
- 设置 sched.pc 等于指标函数的地址, 查看 gostartcallfn 和 gostartcall
- 设置 sched.g 等于 g
- 设置 g 的状态为待运行(_Grunnable)
-
调用 runqput 把 g 放到运行队列
- 首先随机把 g 放到 p.runnext, 如果放到 runnext 则入队原来在 runnext 的 g
- 而后尝试把 g 放到 P 的 ” 本地运行队列 ”
-
如果本地运行队列满了则调用 runqputslow 把 g 放到 ” 全局运行队列 ”
- runqputslow 会把本地运行队列中一半的 g 放到全局运行队列, 这样下次就能够持续用疾速的本地运行队列了
-
如果以后有闲暇的 P, 然而无自旋的 M(nmspinning 等于 0), 并且主函数已执行则唤醒或新建一个 M
- 这一步十分重要, 用于保障以后有足够的 M 运行 G, 具体请查看下面的 ” 闲暇 M 链表 ”
-
唤醒或新建一个 M 会通过 wakep 函数
- 首先替换 nmspinning 到 1, 胜利再持续, 多个线程同时执行 wakep 只有一个会持续
-
调用 startm 函数
- 调用 pidleget 从 ” 闲暇 P 链表 ” 获取一个闲暇的 P
- 调用 mget 从 ” 闲暇 M 链表 ” 获取一个闲暇的 M
-
如果没有闲暇的 M, 则调用 newm 新建一个 M
- newm 会新建一个 m 的实例, m 的实例蕴含一个 g0, 而后调用 newosproc 动一个零碎线程
- newosproc 会调用 syscall clone 创立一个新的线程
- 线程创立后会设置 TLS, 设置 TLS 中以后的 g 为 g0, 而后执行 mstart
- 调用 notewakeup(&mp.park)唤醒线程
创立 goroutine 的流程就这么多了, 接下来看看 M 是如何调度的.
调度器的实现
M 启动时会调用 mstart 函数, m0 在初始化后调用, 其余的的 m 在线程启动后调用.
mstart 函数的解决如下:
- 调用 getg 获取以后的 g, 这里会获取到 g0
- 如果 g 未调配栈则从以后的栈空间 (零碎栈空间) 上调配, 也就是说 g0 会应用零碎栈空间
-
调用 mstart1 函数
- 调用 gosave 函数保留以后的状态到 g0 的调度数据中, 当前每次调度都会从这个栈地址开始
- 调用 asminit 函数, 不做任何事件
- 调用 minit 函数, 设置以后线程能够接管的信号(signal)
- 调用 schedule 函数
调用 schedule 函数后就进入了调度循环, 整个流程能够简略总结为:
schedule 函数获取 g => [必要时休眠] => [唤醒后持续获取] => execute 函数执行 g => 执行后返回到 goexit => 从新执行 schedule 函数
schedule 函数的解决如下:
- 如果以后 GC 须要进行整个世界(STW), 则调用 stopm 休眠以后的 M
- 如果 M 领有的 P 中指定了须要在平安点运行的函数(P.runSafePointFn), 则运行它
-
疾速获取待运行的 G, 以下解决如果有一个获取胜利前面就不会持续获取
- 如果以后 GC 正在标记阶段, 则查找有没有待运行的 GC Worker, GC Worker 也是一个 G
- 为了偏心起见, 每 61 次调度从全局运行队列获取一次 G, (始终从本地获取可能导致全局运行队列中的 G 不被运行)
- 从 P 的本地运行队列中获取 G, 调用 runqget 函数
-
疾速获取失败时, 调用 findrunnable 函数获取待运行的 G, 会阻塞到获取胜利为止
- 如果以后 GC 须要进行整个世界(STW), 则调用 stopm 休眠以后的 M
- 如果 M 领有的 P 中指定了须要在平安点运行的函数(P.runSafePointFn), 则运行它
- 如果有析构器待运行则应用 ” 运行析构器的 G ”
- 从 P 的本地运行队列中获取 G, 调用 runqget 函数
- 从全局运行队列获取 G, 调用 globrunqget 函数, 须要上锁
- 从网络事件反应器获取 G, 函数 netpoll 会获取哪些 fd 可读可写或已敞开, 而后返回期待 fd 相干事件的 G
-
如果获取不到 G, 则执行 Work Stealing
- 调用 runqsteal 尝试从其余 P 的本地运行队列盗取一半的 G
-
如果还是获取不到 G, 就须要休眠 M 了, 接下来是休眠的步骤
- 再次查看以后 GC 是否在标记阶段, 在则查找有没有待运行的 GC Worker, GC Worker 也是一个 G
- 再次查看如果以后 GC 须要进行整个世界, 或者 P 指定了须要再平安点运行的函数, 则跳到 findrunnable 的顶部重试
- 再次查看全局运行队列中是否有 G, 有则获取并返回
- 开释 M 领有的 P, P 会变为闲暇 (_Pidle) 状态
- 把 P 增加到 ” 闲暇 P 链表 ” 中
- 让 M 来到自旋状态, 这里的解决十分重要, 参考下面的 ” 闲暇 M 链表 ”
- 首先缩小示意以后自旋中的 M 的数量的全局变量 nmspinning
- 再次查看所有 P 的本地运行队列, 如果不为空则让 M 从新进入自旋状态, 并跳到 findrunnable 的顶部重试
- 再次查看有没有待运行的 GC Worker, 有则让 M 从新进入自旋状态, 并跳到 findrunnable 的顶部重试
- 再次查看网络事件反应器是否有待运行的 G, 这里对 netpoll 的调用会阻塞, 直到某个 fd 收到了事件
- 如果最终还是获取不到 G, 调用 stopm 休眠以后的 M
- 唤醒后跳到 findrunnable 的顶部重试
- 胜利获取到一个待运行的 G
-
让 M 来到自旋状态, 调用 resetspinning, 这里的解决和下面的不一样
- 如果以后有闲暇的 P, 然而无自旋的 M(nmspinning 等于 0), 则唤醒或新建一个 M
- 下面来到自旋状态是为了休眠 M, 所以会再次查看所有队列而后休眠
- 这里来到自选状态是为了执行 G, 所以会查看是否有闲暇的 P, 有则示意能够再开新的 M 执行 G
-
如果 G 要求回到指定的 M(例如下面的 runtime.main)
- 调用 startlockedm 函数把 G 和 P 交给该 M, 本人进入休眠
- 从休眠唤醒后跳到 schedule 的顶部重试
- 调用 execute 函数执行 G
execute 函数的解决如下:
- 调用 getg 获取以后的 g
- 把 G 的状态由待运行 (_Grunnable) 改为运行中(_Grunning)
- 设置 G 的 stackguard, 栈空间有余时能够扩张
- 减少 P 中记录的调度次数(对应下面的每 61 次优先获取一次全局运行队列)
- 设置 g.m.curg = g
- 设置 g.m = m
-
调用 gogo 函数
- 这个函数会依据 g.sched 中保留的状态复原各个寄存器的值并持续运行 g
- 首先针对 g.sched.ctxt 调用写屏障 (GC 标记指针存活), ctxt 中个别会保留指向[函数 + 参数] 的指针
- 设置 TLS 中的 g 为 g.sched.g, 也就是 g 本身
- 设置 rsp 寄存器为 g.sched.rsp
- 设置 rax 寄存器为 g.sched.ret
- 设置 rdx 寄存器为 g.sched.ctxt (上下文)
- 设置 rbp 寄存器为 g.sched.rbp
- 清空 sched 中保留的信息
- 跳转到 g.sched.pc
- 因为后面创立 goroutine 的 newproc1 函数把返回地址设为了 goexit, 函数运行结束返回时将会调用 goexit 函数
g.sched.pc 在 G 首次运行时会指向指标函数的第一条机器指令,
如果 G 被抢占或者期待资源而进入休眠, 在休眠前会保留状态到 g.sched,
g.sched.pc 会变为唤醒后须要继续执行的地址, “ 保留状态 ” 的实现将在上面解说.
指标函数执行结束后会调用 goexit 函数, goexit 函数会调用 goexit1 函数, goexit1 函数会通过 mcall 调用 goexit0 函数.
mcall 这个函数就是用于实现 ” 保留状态 ” 的, 解决如下:
- 设置 g.sched.pc 等于以后的返回地址
- 设置 g.sched.sp 等于寄存器 rsp 的值
- 设置 g.sched.g 等于以后的 g
- 设置 g.sched.bp 等于寄存器 rbp 的值
- 切换 TLS 中以后的 g 等于 m.g0
- 设置寄存器 rsp 等于 g0.sched.sp, 应用 g0 的栈空间
- 设置第一个参数为原来的 g
- 设置 rdx 寄存器为指向函数地址的指针(上下文)
- 调用指定的函数, 不会返回
mcall 这个函数保留以后的运行状态到 g.sched, 而后切换到 g0 和 g0 的栈空间, 再调用指定的函数.
回到 g0 的栈空间这个步骤十分重要, 因为这个时候 g 曾经中断, 持续应用 g 的栈空间且其余 M 唤醒了这个 g 将会产生灾难性的结果.
G 在中断或者完结后都会通过 mcall 回到 g0 的栈空间持续调度, 从 goexit 调用的 mcall 的保留状态其实是多余的, 因为 G 曾经完结了.
goexit1 函数会通过 mcall 调用 goexit0 函数, goexit0 函数调用时曾经回到了 g0 的栈空间, 解决如下:
- 把 G 的状态由运行中 (_Grunning) 改为已停止(_Gdead)
- 清空 G 的成员
- 调用 dropg 函数解除 M 和 G 之间的关联
- 调用 gfput 函数把 G 放到 P 的自在列表中, 下次创立 G 时能够复用
- 调用 schedule 函数持续调度
G 完结后回到 schedule 函数, 这样就完结了一个调度循环.
不仅只有 G 完结会从新开始调度, G 被抢占或者期待资源也会从新进行调度, 上面持续来看这两种状况.
抢占的实现
下面我提到了 runtime.main 会创立一个额定的 M 运行 sysmon 函数, 抢占就是在 sysmon 中实现的.
sysmon 会进入一个有限循环, 第一轮回休眠 20us, 之后每次休眠工夫倍增, 最终每一轮都会休眠 10ms.
sysmon 中有 netpool(获取 fd 事件), retake(抢占), forcegc(按工夫强制执行 gc), scavenge heap(开释自在列表中多余的项缩小内存占用)等解决.
retake 函数负责解决抢占, 流程是:
-
枚举所有的 P
-
如果 P 在零碎调用中(_Psyscall), 且通过了一次 sysmon 循环(20us~10ms), 则抢占这个 P
- 调用 handoffp 解除 M 和 P 之间的关联
-
如果 P 在运行中(_Prunning), 且通过了一次 sysmon 循环并且 G 运行工夫超过 forcePreemptNS(10ms), 则抢占这个 P
-
调用 preemptone 函数
- 设置 g.preempt = true
- 设置 g.stackguard0 = stackPreempt
-
-
为什么设置了 stackguard 就能够实现抢占?
因为这个值用于查看以后栈空间是否足够, go 函数的结尾会比对这个值判断是否须要扩张栈.
stackPreempt 是一个非凡的常量, 它的值会比任何的栈地址都要大, 查看时肯定会触发栈扩张.
栈扩张调用的是 morestack_noctxt 函数, morestack_noctxt 函数清空 rdx 寄存器并调用 morestack 函数.
morestack 函数会保留 G 的状态到 g.sched, 切换到 g0 和 g0 的栈空间, 而后调用 newstack 函数.
newstack 函数判断 g.stackguard0 等于 stackPreempt, 就晓得这是抢占触发的, 这时会再查看一遍是否要抢占:
- 如果 M 被锁定(函数的本地变量中有 P), 则跳过这一次的抢占并调用 gogo 函数持续运行 G
- 如果 M 正在分配内存, 则跳过这一次的抢占并调用 gogo 函数持续运行 G
- 如果 M 设置了以后不能抢占, 则跳过这一次的抢占并调用 gogo 函数持续运行 G
- 如果 M 的状态不是运行中, 则跳过这一次的抢占并调用 gogo 函数持续运行 G
即便这一次抢占失败, 因为 g.preempt 等于 true, runtime 中的一些代码会从新设置 stackPreempt 以重试下一次的抢占.
如果判断能够抢占, 则持续判断是否 GC 引起的, 如果是则对 G 的栈空间执行标记解决 (扫描根对象) 而后持续运行,
如果不是 GC 引起的则调用 gopreempt_m 函数实现抢占.
gopreempt_m 函数会调用 goschedImpl 函数, goschedImpl 函数的流程是:
- 把 G 的状态由运行中 (_Grunnable) 改为待运行(_Grunnable)
- 调用 dropg 函数解除 M 和 G 之间的关联
- 调用 globrunqput 把 G 放到全局运行队列
- 调用 schedule 函数持续调度
因为全局运行队列的优先度比拟低, 各个 M 会通过一段时间再去从新获取这个 G 执行,
抢占机制保障了不会有一个 G 长时间的运行导致其余 G 无奈运行的状况产生.
channel 的实现
在 goroutine 运行的过程中, 有时候须要对资源进行期待, channel 就是最典型的资源.
channel 的数据定义在这里, 其中要害的成员如下:
- qcount: 以后队列中的元素数量
- dataqsiz: 队列能够包容的元素数量, 如果为 0 示意这个 channel 无缓冲区
- buf: 队列的缓冲区, 构造是环形队列
- elemsize: 元素的大小
- closed: 是否已敞开
- elemtype: 元素的类型, 判断是否调用写屏障时应用
- sendx: 发送元素的序号
- recvx: 接管元素的序号
- recvq: 以后期待从 channel 接收数据的 G 的链表(理论类型是 sudog 的链表)
- sendq: 以后期待发送数据到 channel 的 G 的链表(理论类型是 sudog 的链表)
- lock: 操作 channel 时应用的线程锁
发送数据到 channel 理论调用的是 runtime.chansend1 函数, chansend1 函数调用了 chansend 函数, 流程是:
-
查看 channel.recvq 是否有期待中的接收者的 G
- 如果有, 示意 channel 无缓冲区或者缓冲区为空
-
调用 send 函数
- 如果 sudog.elem 不等于 nil, 调用 sendDirect 函数从发送者间接复制元素
- 期待接管的 sudog.elem 是指向接管指标的内存的指针, 如果是接管指标是
_
则 elem 是 nil, 能够省略复制 - 期待发送的 sudog.elem 是指向起源指标的内存的指针
-
复制后调用 goready 复原发送者的 G
-
切换到 g0 调用 ready 函数, 调用完切换回来
- 把 G 的状态由期待中 (_Gwaiting) 改为待运行(_Grunnable)
- 把 G 放到 P 的本地运行队列
- 如果以后有闲暇的 P, 然而无自旋的 M(nmspinning 等于 0), 则唤醒或新建一个 M
-
- 从发送者拿到数据并唤醒了 G 后, 就能够从 chansend 返回了
-
判断是否能够把元素放到缓冲区中
- 如果缓冲区有空余的空间, 则把元素放到缓冲区并从 chansend 返回
-
无缓冲区或缓冲区曾经写满, 发送者的 G 须要期待
- 获取以后的 g
- 新建一个 sudog
- 设置 sudog.elem = 指向发送内存的指针
- 设置 sudog.g = g
- 设置 sudog.c = channel
- 设置 g.waiting = sudog
- 把 sudog 放入 channel.sendq
-
调用 goparkunlock 函数
-
调用 gopark 函数
-
通过 mcall 函数调用 park_m 函数
- mcall 函数和下面阐明的一样, 会把以后的状态保留到 g.sched, 而后切换到 g0 和 g0 的栈空间并执行指定的函数
- park_m 函数首先把 G 的状态从运行中 (_Grunning) 改为期待中(_Gwaiting)
- 而后调用 dropg 函数解除 M 和 G 之间的关联
- 再调用传入的解锁函数, 这里的解锁函数会对解除 channel.lock 的锁定
- 最初调用 schedule 函数持续调度
-
-
-
从这里复原示意曾经胜利发送或者 channel 已敞开
- 查看 sudog.param 是否为 nil, 如果为 nil 示意 channel 已敞开, 抛出 panic
- 否则开释 sudog 而后返回
从 channel 接收数据理论调用的是 runtime.chanrecv1 函数, chanrecv1 函数调用了 chanrecv 函数, 流程是:
-
查看 channel.sendq 中是否有期待中的发送者的 G
- 如果有, 示意 channel 无缓冲区或者缓冲区已满, 这两种状况须要别离解决(为了保障入出队程序统一)
-
调用 recv 函数
- 如果无缓冲区, 调用 recvDirect 函数把元素间接复制给接收者
-
如果有缓冲区代表缓冲区已满
- 把队列中下一个要出队的元素间接复制给接收者
- 把发送的元素复制到队列中方才出队的地位
- 这时候缓冲区依然是满的, 然而发送序号和接管序号都会减少 1
- 复制后调用 goready 复原接收者的 G, 解决同上
- 把数据交给接收者并唤醒了 G 后, 就能够从 chanrecv 返回了
-
判断是否能够从缓冲区获取元素
- 如果缓冲区有元素, 则间接取出该元素并从 chanrecv 返回
-
无缓冲区或缓冲区无元素, 接收者的 G 须要期待
- 获取以后的 g
- 新建一个 sudog
- 设置 sudog.elem = 指向接管内存的指针
- 设置 sudog.g = g
- 设置 sudog.c = channel
- 设置 g.waiting = sudog
- 把 sudog 放入 channel.recvq
- 调用 goparkunlock 函数, 解决同上
-
从这里复原示意曾经胜利接管或者 channel 已敞开
- 查看 sudog.param 是否为 nil, 如果为 nil 示意 channel 已敞开
- 和发送不一样的是接管不会抛 panic, 会通过返回值告诉 channel 已敞开
- 开释 sudog 而后返回
敞开 channel 理论调用的是 closechan 函数, 流程是:
- 设置 channel.closed = 1
- 枚举 channel.recvq, 清零它们 sudog.elem, 设置 sudog.param = nil
- 枚举 channel.sendq, 设置 sudog.elem = nil, 设置 sudog.param = nil
- 调用 goready 函数复原所有接收者和发送者的 G
能够看到如果 G 须要期待资源时,
会记录 G 的运行状态到 g.sched, 而后把状态改为期待中 (_Gwaiting), 再让以后的 M 持续运行其余 G.
期待中的 G 保留在哪里, 什么时候复原是期待的资源决定的, 上面对 channel 的期待会让 G 放到 channel 中的链表.
对网络资源的期待能够看 netpoll 相干的解决, netpoll 在不同零碎中的解决都不一样, 有趣味的能够本人看看.
参考链接
https://github.com/golang/go
https://golang.org/s/go11sched
http://supertech.csail.mit.edu/papers/steal.pdf
https://docs.google.com/document/d/1ETuA2IOmnaQ4j81AtTGT40Y4_Jr6_IDASEKg0t0dBR8/edit#heading=h.x4kziklnb8fr
https://blog.altoros.com/golang-part-1-main-concepts-and-project-structure.html
https://blog.altoros.com/golang-internals-part-2-diving-into-the-go-compiler.html
https://blog.altoros.com/golang-internals-part-3-the-linker-and-object-files.html
https://blog.altoros.com/golang-part-4-object-files-and-function-metadata.html
https://blog.altoros.com/golang-internals-part-5-runtime-bootstrap-process.html
https://blog.altoros.com/golang-internals-part-6-bootstrapping-and-memory-allocator-initialization.html
http://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64
http://legendtkl.com/categories/golang
http://www.cnblogs.com/diegodu/p/5803202.html
https://www.douban.com/note/300631999/
http://morsmachine.dk/go-scheduler
legendtkl 很早就曾经开始写 golang 外部实现相干的文章了, 他的文章很有参考价值, 倡议同时浏览他写的内容.
morsmachine 写的针对协程的剖析也倡议参考.
golang 中的协程实现十分的清晰, 在这里要再次拜服 google 工程师的功力, 能够写出这样简略易懂的代码不容易.