G (goroutine)
G是goroutine的頭文字, goroutine可以解釋為受管理的輕量線程, goroutine使用go關(guān)鍵詞創(chuàng)建.
舉例來說, func main() { go other() }, 這段代碼創(chuàng)建了兩個goroutine,
一個是main, 另一個是other, 注意main本身也是一個goroutine.
goroutine的新建, 休眠, 恢復, 停止都受到go運行時的管理.
goroutine執(zhí)行異步操作時會進入休眠狀態(tài), 待操作完成后再恢復, 無需占用系統(tǒng)線程,
goroutine新建或恢復時會添加到運行隊列, 等待M取出并運行.
M (machine)
M是machine的頭文字, 在當前版本的golang中等同于系統(tǒng)線程.
M可以運行兩種代碼:
- go代碼, 即goroutine, M運行g(shù)o代碼需要一個P
- 原生代碼, 例如阻塞的syscall, M運行原生代碼不需要P
M會從運行隊列中取出G, 然后運行G, 如果G運行完畢或者進入休眠狀態(tài), 則從運行隊列中取出下一個G運行, 周而復始.
有時候G需要調(diào)用一些無法避免阻塞的原生代碼, 這時M會釋放持有的P并進入阻塞狀態(tài), 其他M會取得這個P并繼續(xù)運行隊列中的G.
go需要保證有足夠的M可以運行G, 不讓CPU閑著, 也需要保證M的數(shù)量不能過多.
P (process)
P是process的頭文字, 代表M運行G所需要的資源.
一些講解協(xié)程的文章把P理解為cpu核心, 其實這是錯誤的.
雖然P的數(shù)量默認等于cpu核心數(shù), 但可以通過環(huán)境變量GOMAXPROC修改, 在實際運行時P跟cpu核心并無任何關(guān)聯(lián).
P也可以理解為控制go代碼的并行度的機制,
如果P的數(shù)量等于1, 代表當前最多只能有一個線程(M)執(zhí)行g(shù)o代碼,
如果P的數(shù)量等于2, 代表當前最多只能有兩個線程(M)執(zhí)行g(shù)o代碼.
執(zhí)行原生代碼的線程數(shù)量不受P控制.
因為同一時間只有一個線程(M)可以擁有P, P中的數(shù)據(jù)都是鎖自由(lock free)的, 讀寫這些數(shù)據(jù)的效率會非常的高.
數(shù)據(jù)結(jié)構(gòu)
在講解協(xié)程的工作流程之前, 還需要理解一些內(nèi)部的數(shù)據(jù)結(jié)構(gòu).
G的狀態(tài)
- 空閑中(_Gidle): 表示G剛剛新建, 仍未初始化
- 待運行(_Grunnable): 表示G在運行隊列中, 等待M取出并運行
- 運行中(_Grunning): 表示M正在運行這個G, 這時候M會擁有一個P
- 系統(tǒng)調(diào)用中(_Gsyscall): 表示M正在運行這個G發(fā)起的系統(tǒng)調(diào)用, 這時候M并不擁有P
- 等待中(_Gwaiting): 表示G在等待某些條件完成, 這時候G不在運行也不在運行隊列中(可能在channel的等待隊列中)
- 已中止(_Gdead): 表示G未被使用, 可能已執(zhí)行完畢(并在freelist中等待下次復用)
- 棧復制中(_Gcopystack): 表示G正在獲取一個新的??臻g并把原來的內(nèi)容復制過去(用于防止GC掃描)
M的狀態(tài)
M并沒有像G和P一樣的狀態(tài)標記, 但可以認為一個M有以下的狀態(tài):
- 自旋中(spinning): M正在從運行隊列獲取G, 這時候M會擁有一個P
- 執(zhí)行g(shù)o代碼中: M正在執(zhí)行g(shù)o代碼, 這時候M會擁有一個P
- 執(zhí)行原生代碼中: M正在執(zhí)行原生代碼或者阻塞的syscall, 這時M并不擁有P
- 休眠中: M發(fā)現(xiàn)無待運行的G時會進入休眠, 并添加到空閑M鏈表中, 這時M并不擁有P
自旋中(spinning)這個狀態(tài)非常重要, 是否需要喚醒或者創(chuàng)建新的M取決于當前自旋中的M的數(shù)量.
P的狀態(tài)
- 空閑中(_Pidle): 當M發(fā)現(xiàn)無待運行的G時會進入休眠, 這時M擁有的P會變?yōu)榭臻e并加到空閑P鏈表中
- 運行中(_Prunning): 當M擁有了一個P后, 這個P的狀態(tài)就會變?yōu)檫\行中, M運行G會使用這個P中的資源
- 系統(tǒng)調(diào)用中(_Psyscall): 當go調(diào)用原生代碼, 原生代碼又反過來調(diào)用go代碼時, 使用的P會變?yōu)榇藸顟B(tài)
- GC停止中(_Pgcstop): 當gc停止了整個世界(STW)時, P會變?yōu)榇藸顟B(tài)
- 已中止(_Pdead): 當P的數(shù)量在運行時改變, 且數(shù)量減少時多余的P會變?yōu)榇藸顟B(tài)
本地運行隊列
在go中有多個運行隊列可以保存待運行(_Grunnable)的G, 它們分別是各個P中的本地運行隊列和全局運行隊列.
入隊待運行的G時會優(yōu)先加到當前P的本地運行隊列, M獲取待運行的G時也會優(yōu)先從擁有的P的本地運行隊列獲取,
本地運行隊列入隊和出隊不需要使用線程鎖.
本地運行隊列有數(shù)量限制, 當數(shù)量達到256個時會入隊到全局運行隊列.
本地運行隊列的數(shù)據(jù)結(jié)構(gòu)是環(huán)形隊列, 由一個256長度的數(shù)組和兩個序號(head, tail)組成.
當M從P的本地運行隊列獲取G時, 如果發(fā)現(xiàn)本地隊列為空會嘗試從其他P盜取一半的G過來,這個機制叫做Work Stealing, 詳見后面的代碼分析.
全局運行隊列
全局運行隊列保存在全局變量sched中, 全局運行隊列入隊和出隊需要使用線程鎖.
全局運行隊列的數(shù)據(jù)結(jié)構(gòu)是鏈表, 由兩個指針(head, tail)組成.
空閑M鏈表
當M發(fā)現(xiàn)無待運行的G時會進入休眠, 并添加到空閑M鏈表中, 空閑M鏈表保存在全局變量sched.
進入休眠的M會等待一個信號量(m.park), 喚醒休眠的M會使用這個信號量.
go需要保證有足夠的M可以運行G, 是通過這樣的機制實現(xiàn)的:
- 入隊待運行的G后, 如果當前無自旋的M但是有空閑的P, 就喚醒或者新建一個M
- 當M離開自旋狀態(tài)并準備運行出隊的G時, 如果當前無自旋的M但是有空閑的P, 就喚醒或者新建一個M
- 當M離開自旋狀態(tài)并準備休眠時, 會在離開自旋狀態(tài)后再次檢查所有運行隊列, 如果有待運行的G則重新進入自旋狀態(tài)
因為"入隊待運行的G"和"M離開自旋狀態(tài)"會同時進行, go會使用這樣的檢查順序:
入隊待運行的G => 內(nèi)存屏障 => 檢查當前自旋的M數(shù)量 => 喚醒或者新建一個M
減少當前自旋的M數(shù)量 => 內(nèi)存屏障 => 檢查所有運行隊列是否有待運行的G => 休眠
這樣可以保證不會出現(xiàn)待運行的G入隊了, 也有空閑的資源P, 但無M去執(zhí)行的情況.
空閑P鏈表
當P的本地運行隊列中的所有G都運行完畢, 又不能從其他地方拿到G時,
擁有P的M會釋放P并進入休眠狀態(tài), 釋放的P會變?yōu)榭臻e狀態(tài)并加到空閑P鏈表中, 空閑P鏈表保存在全局變量sched
下次待運行的G入隊時如果發(fā)現(xiàn)有空閑的P, 但是又沒有自旋中的M時會喚醒或者新建一個M, M會擁有這個P, P會重新變?yōu)檫\行中的狀態(tài).
工作流程(概覽)
下圖是協(xié)程可能出現(xiàn)的工作狀態(tài), 圖中有4個P, 其中M1~M3正在運行G并且運行后會從擁有的P的運行隊列繼續(xù)獲取G:
只看這張圖可能有點難以想象實際的工作流程, 這里我根據(jù)實際的代碼再講解一遍:
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
}
程序啟動時會先創(chuàng)建一個G, 指向的是main(實際是runtime.main而不是main.main, 后面解釋):
圖中的虛線指的是G待運行或者開始運行的地址, 不是當前運行的地址.
M會取得這個G并運行:
這時main會創(chuàng)建一個新的channel, 并啟動兩個新的G:
接下來G: main會從channel獲取數(shù)據(jù), 因為獲取不到, G會保存狀態(tài)并變?yōu)榈却?_Gwaiting)并添加到channel的隊列:
因為G: main保存了運行狀態(tài), 下次運行時將會從_ = <- c繼續(xù)運行.
接下來M會從運行隊列獲取到G: printNumber并運行:
printNumber會打印數(shù)字, 完成后向channel寫數(shù)據(jù),
寫數(shù)據(jù)時發(fā)現(xiàn)channel中有正在等待的G, 會把數(shù)據(jù)交給這個G, 把G變?yōu)榇\行(_Grunnable)并重新放入運行隊列:
接下來M會運行下一個G: printNumber, 因為創(chuàng)建channel時指定了大小為3的緩沖區(qū), 可以直接把數(shù)據(jù)寫入緩沖區(qū)而無需等待:
然后printNumber運行完畢, 運行隊列中就只剩下G: main了:
最后M把G: main取出來運行, 會從上次中斷的位置_ <- c繼續(xù)運行:
第一個_ <- c的結(jié)果已經(jīng)在前面設(shè)置過了, 這條語句會執(zhí)行成功.
第二個_ <- c在獲取時會發(fā)現(xiàn)channel中有已緩沖的0, 于是結(jié)果就是這個0, 不需要等待.
最后main執(zhí)行完畢, 程序結(jié)束.
有人可能會好奇如果最后再加一個_ <- c會變成什么結(jié)果, 這時因為所有G都進入等待狀態(tài), go會檢測出來并報告死鎖:
fatal error: all goroutines are asleep - deadlock!
開始代碼分析
關(guān)于概念的講解到此結(jié)束, 從這里開始會分析go中的實現(xiàn)代碼, 我們需要先了解一些基礎(chǔ)的內(nèi)容.
匯編代碼
從以下的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, 使用的是默認選項, 即啟用優(yōu)化和內(nèi)聯(lián)):
(lldb) di -n main.mainhello`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.fhello[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.fhello[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 + 38880hello[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.printNumberhello`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:10hello[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.* + 560hello[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:196hello[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 + 38880hello[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
這些匯編代碼現(xiàn)在看不懂也沒關(guān)系, 下面會從這里取出一部分來解釋.
調(diào)用規(guī)范
不同平臺對于函數(shù)有不同的調(diào)用規(guī)范.
例如32位通過棧傳遞參數(shù), 通過eax寄存器傳遞返回值.
64位windows通過rcx, rdx, r8, r9傳遞前4個參數(shù), 通過棧傳遞第5個開始的參數(shù), 通過eax寄存器傳遞返回值.
64位linux, unix通過rdi, rsi, rdx, rcx, r8, r9傳遞前6個參數(shù), 通過棧傳遞第7個開始的參數(shù), 通過eax寄存器傳遞返回值.
go并不使用這些調(diào)用規(guī)范(除非涉及到與原生代碼交互), go有一套獨自的調(diào)用規(guī)范.
go的調(diào)用規(guī)范非常的簡單, 所有參數(shù)都通過棧傳遞, 返回值也通過棧傳遞,
例如這樣的函數(shù):
type MyStruct struct {
X int;
P *int
}
func someFunc(x int, s MyStruct) (int, MyStruct) {
...
}
調(diào)用函數(shù)時的棧的內(nèi)容如下:
可以看得出參數(shù)和返回值都從低位到高位排列, go函數(shù)可以有多個返回值的原因也在于此. 因為返回值都通過棧傳遞了.
需要注意的這里的"返回地址"是x86和x64上的, arm的返回地址會通過LR寄存器保存, 內(nèi)容會和這里的稍微不一樣.
另外注意的是和c不一樣, 傳遞構(gòu)造體時整個構(gòu)造體的內(nèi)容都會復制到棧上, 如果構(gòu)造體很大將會影響性能.
TLS
TLS的全稱是Thread-local storage, 代表每個線程的中的本地數(shù)據(jù).
例如標準c中的errno就是一個典型的TLS變量, 每個線程都有一個獨自的errno, 寫入它不會干擾到其他線程中的值.
go在實現(xiàn)協(xié)程時非常依賴TLS機制, 會用于獲取系統(tǒng)線程中當前的G和G所屬的M的實例.
因為go并不使用glibc, 操作TLS會使用系統(tǒng)原生的接口, 以linux x64為例,
go在新建M時會調(diào)用arch_prctl這個syscall設(shè)置FS寄存器的值為M.tls的地址,
運行中每個M的FS寄存器都會指向它們對應的M實例的tls, linux內(nèi)核調(diào)度線程時FS寄存器會跟著線程一起切換,
這樣go代碼只需要訪問FS寄存器就可以存取線程本地的數(shù)據(jù).
上面的匯編代碼中的
hello[0x401000] <+0>: movq %fs:-0x8, %rcx
會把指向當前的G的指針從TLS移動到rcx寄存器中.
棧擴張
因為go中的協(xié)程是stackful coroutine, 每一個goroutine都需要有自己的??臻g,
棧空間的內(nèi)容在goroutine休眠時需要保留, 待休眠完成后恢復(這時整個調(diào)用樹都是完整的).
這樣就引出了一個問題, goroutine可能會同時存在很多個, 如果每一個goroutine都預先分配一個足夠的??臻g那么go就會使用過多的內(nèi)存.
為了避免這個問題, go在一開始只為goroutine分配一個很小的??臻g, 它的大小在當前版本是2K.
當函數(shù)發(fā)現(xiàn)棧空間不足時, 會申請一塊新的棧空間并把原來的棧內(nèi)容復制過去.
上面的匯編代碼中的
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小, 如果小于等于則需要調(diào)到下面調(diào)用morestack_noctxt函數(shù).
細心的可能會發(fā)現(xiàn)比較的值跟實際減去的值不一致, 這是因為stackguard0下面會預留一小部分空間, 編譯時確定不超過預留的空間可以省略比對.
寫屏障(Write Barrier)
因為go支持并行GC, GC的掃描和go代碼可以同時運行, 這樣帶來的問題是GC掃描的過程中g(shù)o代碼有可能改變了對象的依賴樹,
例如開始掃描時發(fā)現(xiàn)根對象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的標記階段啟用, 平時會直接把值寫入到目標地址:
關(guān)于寫屏障的詳細將在下一篇(GC篇)分析.
值得一提的是CoreCLR的GC也有寫屏障的機制, 作用跟這里說明的一樣.
閉包(Closure)
閉包這個概念本身應該不需要解釋, 我們實際看一看go是如何實現(xiàn)閉包的:
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)
}
這段代碼的輸出結(jié)果是3 2 3, 熟悉go的應該不會感到意外.
main函數(shù)執(zhí)行executeFn函數(shù)的匯編代碼如下:
hello[0x4a096f] <+47>: movq $0x1, 0x40(%rsp) ; 變量a等于1
hello[0x4a0978] <+56>: leaq 0x151(%rip), %rax ; 寄存器rax等于匿名函數(shù)main.main.func1的地址
hello[0x4a097f] <+63>: movq %rax, 0x60(%rsp) ; 變量rsp+0x60等于匿名函數(shù)的地址
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) ; 第一個參數(shù)等于地址rsp+0x60hello[0x4a09a0] <+96>: callq 0x4a08f0 ; 執(zhí)行main.executeFn
hello[0x4a09a5] <+101>: movq 0x8(%rsp), %rax ; 寄存器rax等于返回值
我們可以看到傳給executeFn的是一個指針, 指針指向的內(nèi)容是[匿名函數(shù)的地址, 變量a的地址, 變量b的值].
變量a傳地址的原因是匿名函數(shù)中對a進行了修改, 需要反映到原來的a上.
executeFn函數(shù)執(zhí)行閉包的匯編代碼如下:
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 ; 把第一個參數(shù)(閉包)的指針移到寄存器rdx
hello[0x4a0912] <+34>: movq (%rdx), %rax ; 把閉包中函數(shù)的指針移到寄存器rax
hello[0x4a0915] <+37>: callq *%rax ; 調(diào)用閉包中的函數(shù)hello[0x4a0917] <+39>: movq (%rsp), %rax ; 把返回值移到寄存器rax
hello[0x4a091b] <+43>: movq %rax, 0x20(%rsp) ; 把寄存器rax移到返回值中(參數(shù)后面)
hello[0x4a0920] <+48>: movq 0x8(%rsp), %rbp ; 把變量rsp+0x8的值恢復寄存器rbp(恢復原rbp)
hello[0x4a0925] <+53>: addq $0x10, %rsp ; 釋放??臻ghello[0x4a0929] <+57>: retq ; 從函數(shù)返回
可以看到調(diào)用閉包時參數(shù)并不通過棧傳遞, 而是通過寄存器rdx傳遞, 閉包的匯編代碼如下:
hello[0x455660] <+0>: movq 0x8(%rdx), %rax ; 第一個參數(shù)移到寄存器rax(變量a的指針)
hello[0x455664] <+4>: movq (%rax), %rcx ; 把寄存器rax指向的值移到寄存器rcx(變量a的值)
hello[0x455667] <+7>: addq 0x10(%rdx), %rcx ; 添加第二個參數(shù)到寄存器rcx(變量a的值+變量b的值)
hello[0x45566b] <+11>: movq %rcx, (%rax) ; 把寄存器rcx移到寄存器rax指向的值(相加的結(jié)果保存回變量a)
hello[0x45566e] <+14>: movq %rcx, 0x8(%rsp) ; 把寄存器rcx移到返回結(jié)果
hello[0x455673] <+19>: retq ; 從函數(shù)返回
閉包的傳遞可以總結(jié)如下:
- 閉包的內(nèi)容是[匿名函數(shù)的地址, 傳給匿名函數(shù)的參數(shù)(不定長)...]
- 傳遞閉包給其他函數(shù)時會傳遞指向"閉包的內(nèi)容"的指針
- 調(diào)用閉包時會把指向"閉包的內(nèi)容"的指針放到寄存器rdx(在go內(nèi)部這個指針稱為"上下文")
- 閉包會從寄存器rdx取出參數(shù)
- 如果閉包修改了變量, 閉包中的參數(shù)會是指針而不是值, 修改時會修改到原來的位置上
閉包+goroutine
細心的可能會發(fā)現(xiàn)在上面的例子中, 閉包的內(nèi)容在棧上, 如果不是直接調(diào)用executeFn而是go executeFn呢?
把上面的代碼改為go executeFn(func() ...)可以生成以下的匯編代碼:
hello[0x455611] <+33>: leaq 0xb4a8(%rip), %rax ; 寄存器rax等于類型信息
hello[0x455618] <+40>: movq %rax, (%rsp) ; 第一個參數(shù)等于類型信息
hello[0x45561c] <+44>: callq 0x40d910 ; 調(diào)用runtime.newobjecthello[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) ; 第一個參數(shù)等于類型信息
hello[0x45563d] <+77>: callq 0x40d910 ; 調(diào)用runtime.newobject
hello[0x455642] <+82>: movq 0x8(%rsp), %rax ; 寄存器rax等于返回值(這里稱為新對象fn)
hello[0x455647] <+87>: leaq 0x82(%rip), %rcx ; 寄存器rcx等于匿名函數(shù)main.main.func1的地址
hello[0x45564e] <+94>: movq %rcx, (%rax) ; 新對象fn+0的值等于main.main.func1的地址
hello[0x455651] <+97>: testb (%rax), %al ; 確保新對象fn不等于nilhello[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 ; 啟用寫屏障時調(diào)用后面的邏輯
hello[0x455661] <+113>: movq 0x28(%rsp), %rcx ; 寄存器rcx等于新對象a
hello[0x455666] <+118>: movq %rcx, 0x8(%rax) ; 設(shè)置新對象fn+0x8的值等于新對象a
hello[0x45566a] <+122>: movq $0x2, 0x10(%rax) ; 設(shè)置新對象fn+0x10的值等于2(變量b的值)
hello[0x455672] <+130>: movq %rax, 0x10(%rsp) ; 第三個參數(shù)等于新對象fn(額外參數(shù))
hello[0x455677] <+135>: movl $0x10, (%rsp) ; 第一個參數(shù)等于0x10(函數(shù)+參數(shù)的大小)
hello[0x45567e] <+142>: leaq 0x22fb3(%rip), %rax ; 第二個參數(shù)等于一個常量構(gòu)造體的地址
hello[0x455685] <+149>: movq %rax, 0x8(%rsp) ; 這個構(gòu)造體的類型是funcval, 值是executeFn的地址
hello[0x45568a] <+154>: callq 0x42e690 ; 調(diào)用runtime.newproc創(chuàng)建新的goroutine
我們可以看到goroutine+閉包的情況更復雜, 首先go會通過逃逸分析算出變量a和閉包會逃逸到外面,
這時go會在heap上分配變量a和閉包, 上面調(diào)用的兩次newobject就是分別對變量a和閉包的分配.
在創(chuàng)建goroutine時, 首先會傳入函數(shù)+參數(shù)的大小(上面是8+8=16), 然后傳入函數(shù)+參數(shù), 上面的參數(shù)即閉包的地址.
m0和g0
go中還有特殊的M和G, 它們是m0和g0.
m0是啟動程序后的主線程, 這個m對應的實例會在全局變量m0中, 不需要在heap上分配,
m0負責執(zhí)行初始化操作和啟動第一個g, 在之后m0就和其他的m一樣了.
g0是僅用于負責調(diào)度的G, g0不指向任何可執(zhí)行的函數(shù), 每個m都會有一個自己的g0,
在調(diào)度或系統(tǒng)調(diào)用時會使用g0的棧空間, 全局變量的g0是m0的g0.
如果上面的內(nèi)容都了解, 就可以開始看golang的源代碼了.
程序初始化
go程序的入口點是runtime.rt0_go, 流程是:
分配??臻g, 需要2個本地變量+2個函數(shù)參數(shù), 然后向8對齊
把傳入的argc和argv保存到棧上
更新g0中的stackguard的值, stackguard用于檢測??臻g是否不足, 需要分配新的棧空間
獲取當前cpu的信息并保存到各個全局變量
調(diào)用_cgo_init如果函數(shù)存在
初始化當前線程的TLS, 設(shè)置FS寄存器為m0.tls+8(獲取時會-8)
測試TLS是否工作
設(shè)置g0到TLS中, 表示當前的g是g0
設(shè)置m0.g0 = g0
設(shè)置g0.m = m0
調(diào)用runtime.check做一些檢查
調(diào)用runtime.args保存?zhèn)魅氲腶rgc和argv到全局變量
調(diào)用runtime.osinit根據(jù)系統(tǒng)執(zhí)行不同的初始化
這里(linux x64)設(shè)置了全局變量ncpu等于cpu核心數(shù)量
調(diào)用runtime.schedinit執(zhí)行共同的初始化
這里的處理比較多, 會初始化??臻g分配器, GC, 按cpu核心數(shù)量或GOMAXPROCS的值生成P等
生成P的處理在procresize中
調(diào)用runtime.newproc創(chuàng)建一個新的goroutine, 指向的是runtime.main
runtime.newproc這個函數(shù)在創(chuàng)建普通的goroutine時也會使用, 在下面的"go的實現(xiàn)"中會詳細講解
調(diào)用runtime·mstart啟動m0
啟動后m0會不斷從運行隊列獲取G并運行, runtime.mstart調(diào)用后不會返回
runtime.mstart這個函數(shù)是m的入口點(不僅僅是m0), 在下面的"調(diào)度器的實現(xiàn)"中會詳細講解
第一個被調(diào)度的G會運行runtime.main, 流程是:
- 標記主函數(shù)已調(diào)用, 設(shè)置mainStarted = true
- 啟動一個新的M執(zhí)行sysmon函數(shù), 這個函數(shù)會監(jiān)控全局的狀態(tài)并對運行時間過長的G進行搶占
- 要求G必須在當前M(系統(tǒng)主線程)上執(zhí)行
- 調(diào)用runtime_init函數(shù)
- 調(diào)用gcenable函數(shù)
- 調(diào)用main.init函數(shù), 如果函數(shù)存在
- 不再要求G必須在當前M上運行
- 如果程序是作為c的類庫編譯的, 在這里返回
- 調(diào)用main.main函數(shù)
- 如果當前發(fā)生了panic, 則等待panic處理
- 調(diào)用exit(0)退出程序
G M P的定義
G的定義在這里.
M的定義在這里.
P的定義在這里.
G里面比較重要的成員如下
- stack: 當前g使用的棧空間, 有l(wèi)o和hi兩個成員
- stackguard0: 檢查??臻g是否足夠的值, 低于這個值會擴張棧, 0是go代碼使用的
- stackguard1: 檢查棧空間是否足夠的值, 低于這個值會擴張棧, 1是原生代碼使用的
- m: 當前g對應的m
- sched: g的調(diào)度數(shù)據(jù), 當g中斷時會保存當前的pc和rsp等值到這里, 恢復運行時會使用這里的值
- atomicstatus: g的當前狀態(tài)
- schedlink: 下一個g, 當g在鏈表結(jié)構(gòu)中會使用
- preempt: g是否被搶占中
- lockedm: g是否要求要回到這個M執(zhí)行, 有的時候g中斷了恢復會要求使用原來的M執(zhí)行
M里面比較重要的成員如下
- g0: 用于調(diào)度的特殊g, 調(diào)度和執(zhí)行系統(tǒng)調(diào)用時會切換到這個g
- curg: 當前運行的g
- p: 當前擁有的P
- nextp: 喚醒M時, M會擁有這個P
- park: M休眠時使用的信號量, 喚醒M時會通過它喚醒
- schedlink: 下一個m, 當m在鏈表結(jié)構(gòu)中會使用
- mcache: 分配內(nèi)存時使用的本地分配器, 和p.mcache一樣(擁有P時會復制過來)
- lockedg: lockedm的對應值
P里面比較重要的成員如下
- status: p的當前狀態(tài)
- link: 下一個p, 當p在鏈表結(jié)構(gòu)中會使用
- m: 擁有這個P的M
- mcache: 分配內(nèi)存時使用的本地分配器
- runqhead: 本地運行隊列的出隊序號
- runqtail: 本地運行隊列的入隊序號
- runq: 本地運行隊列的數(shù)組, 可以保存256個G
- gfree: G的自由列表, 保存變?yōu)開Gdead后可以復用的G實例
- gcBgMarkWorker: 后臺GC的worker函數(shù), 如果它存在M會優(yōu)先執(zhí)行它
- gcw: GC的本地工作隊列, 詳細將在下一篇(GC篇)分析
go的實現(xiàn)
使用go命令創(chuàng)建goroutine時, go會把go命令編譯為對runtime.newproc的調(diào)用, 堆棧的結(jié)構(gòu)如下:
第一個參數(shù)是funcval + 額外參數(shù)的長度, 第二個參數(shù)是funcval, 后面的都是傳遞給goroutine中執(zhí)行的函數(shù)的額外參數(shù).
funcval的定義在這里, fn是指向函數(shù)機器代碼的指針.
runtime.newproc的處理如下:
- 計算額外參數(shù)的地址argp
- 獲取調(diào)用端的地址(返回地址)pc
- 使用systemstack調(diào)用newproc1
systemstack會切換當前的g到g0, 并且使用g0的??臻g, 然后調(diào)用傳入的函數(shù), 再切換回原來的g和原來的??臻g.
切換到g0后會假裝返回地址是mstart, 這樣traceback的時候可以在mstart停止.
這里傳給systemstack的是一個閉包, 調(diào)用時會把閉包的地址放到寄存器rdx, 具體可以參考上面對閉包的分析.
runtime.newproc1的處理如下:
調(diào)用getg獲取當前的g, 會編譯為讀取FS寄存器(TLS), 這里會獲取到g0
設(shè)置g對應的m的locks++, 禁止搶占
獲取m擁有的p
新建一個g
首先調(diào)用gfget從p.gfree獲取g, 如果之前有g(shù)被回收在這里就可以復用
獲取不到時調(diào)用malg分配一個g, 初始的棧空間大小是2K
需要先設(shè)置g的狀態(tài)為已中止(_Gdead), 這樣gc不會去掃描這個g的未初始化的棧
把參數(shù)復制到g的棧上
把返回地址復制到g的棧上, 這里的返回地址是goexit, 表示調(diào)用完目標函數(shù)后會調(diào)用goexit
設(shè)置g的調(diào)度數(shù)據(jù)(sched)
設(shè)置sched.sp等于參數(shù)+返回地址后的rsp地址
設(shè)置sched.pc等于目標函數(shù)的地址, 查看gostartcallfn和gostartcall
設(shè)置sched.g等于g
設(shè)置g的狀態(tài)為待運行(_Grunnable)
調(diào)用runqput把g放到運行隊列
runqputslow會把本地運行隊列中一半的g放到全局運行隊列, 這樣下次就可以繼續(xù)用快速的本地運行隊列了
首先隨機把g放到p.runnext, 如果放到runnext則入隊原來在runnext的g
然后嘗試把g放到P的"本地運行隊列"
如果本地運行隊列滿了則調(diào)用runqputslow把g放到"全局運行隊列"
如果當前有空閑的P, 但是無自旋的M(nmspinning等于0), 并且主函數(shù)已執(zhí)行則喚醒或新建一個M
首先交換nmspinning到1, 成功再繼續(xù), 多個線程同時執(zhí)行wakep只有一個會繼續(xù)
調(diào)用startm函數(shù)
newm會新建一個m的實例, m的實例包含一個g0, 然后調(diào)用newosproc動一個系統(tǒng)線程
newosproc會調(diào)用syscall clone創(chuàng)建一個新的線程
線程創(chuàng)建后會設(shè)置TLS, 設(shè)置TLS中當前的g為g0, 然后執(zhí)行mstart
調(diào)用pidleget從"空閑P鏈表"獲取一個空閑的P
調(diào)用mget從"空閑M鏈表"獲取一個空閑的M
如果沒有空閑的M, 則調(diào)用newm新建一個M
調(diào)用notewakeup(&mp.park)喚醒線程
這一步非常重要, 用于保證當前有足夠的M運行G, 具體請查看上面的"空閑M鏈表"
喚醒或新建一個M會通過wakep函數(shù)
創(chuàng)建goroutine的流程就這么多了, 接下來看看M是如何調(diào)度的.
調(diào)度器的實現(xiàn)
M啟動時會調(diào)用mstart函數(shù), m0在初始化后調(diào)用, 其他的的m在線程啟動后調(diào)用.
mstart函數(shù)的處理如下:
調(diào)用getg獲取當前的g, 這里會獲取到g0
如果g未分配棧則從當前的??臻g(系統(tǒng)??臻g)上分配, 也就是說g0會使用系統(tǒng)??臻g
調(diào)用mstart1函數(shù)
調(diào)用gosave函數(shù)保存當前的狀態(tài)到g0的調(diào)度數(shù)據(jù)中, 以后每次調(diào)度都會從這個棧地址開始
調(diào)用asminit函數(shù), 不做任何事情
調(diào)用minit函數(shù), 設(shè)置當前線程可以接收的信號(signal)
調(diào)用schedule函數(shù)
調(diào)用schedule函數(shù)后就進入了調(diào)度循環(huán), 整個流程可以簡單總結(jié)為:
schedule函數(shù)獲取g
=> [必要時休眠]
=> [喚醒后繼續(xù)獲取]
=> execute函數(shù)執(zhí)行g(shù)
=> 執(zhí)行后返回到goexit
=> 重新執(zhí)行schedule函數(shù)
schedule函數(shù)的處理如下:
如果當前GC需要停止整個世界(STW), 則調(diào)用stopm休眠當前的M
如果M擁有的P中指定了需要在安全點運行的函數(shù)(P.runSafePointFn), 則運行它
快速獲取待運行的G, 以下處理如果有一個獲取成功后面就不會繼續(xù)獲取
如果當前GC正在標記階段, 則查找有沒有待運行的GC Worker, GC Worker也是一個G
為了公平起見, 每61次調(diào)度從全局運行隊列獲取一次G, (一直從本地獲取可能導致全局運行隊列中的G不被運行)
從P的本地運行隊列中獲取G, 調(diào)用runqget函數(shù)
快速獲取失敗時, 調(diào)用findrunnable函數(shù)獲取待運行的G, 會阻塞到獲取成功為止
再次檢查當前GC是否在標記階段, 在則查找有沒有待運行的GC Worker, GC Worker也是一個G
再次檢查如果當前GC需要停止整個世界, 或者P指定了需要再安全點運行的函數(shù), 則跳到findrunnable的頂部重試
再次檢查全局運行隊列中是否有G, 有則獲取并返回
釋放M擁有的P, P會變?yōu)榭臻e(_Pidle)狀態(tài)
把P添加到"空閑P鏈表"中
讓M離開自旋狀態(tài), 這里的處理非常重要, 參考上面的"空閑M鏈表"
首先減少表示當前自旋中的M的數(shù)量的全局變量nmspinning
再次檢查所有P的本地運行隊列, 如果不為空則讓M重新進入自旋狀態(tài), 并跳到findrunnable的頂部重試
再次檢查有沒有待運行的GC Worker, 有則讓M重新進入自旋狀態(tài), 并跳到findrunnable的頂部重試
再次檢查網(wǎng)絡(luò)事件反應器是否有待運行的G, 這里對netpoll的調(diào)用會阻塞, 直到某個fd收到了事件
如果最終還是獲取不到G, 調(diào)用stopm休眠當前的M
喚醒后跳到findrunnable的頂部重試
調(diào)用runqsteal嘗試從其他P的本地運行隊列盜取一半的G
如果當前GC需要停止整個世界(STW), 則調(diào)用stopm休眠當前的M
如果M擁有的P中指定了需要在安全點運行的函數(shù)(P.runSafePointFn), 則運行它
如果有析構(gòu)器待運行則使用"運行析構(gòu)器的G"
從P的本地運行隊列中獲取G, 調(diào)用runqget函數(shù)
從全局運行隊列獲取G, 調(diào)用globrunqget函數(shù), 需要上鎖
從網(wǎng)絡(luò)事件反應器獲取G, 函數(shù)netpoll會獲取哪些fd可讀可寫或已關(guān)閉, 然后返回等待fd相關(guān)事件的G
如果獲取不到G, 則執(zhí)行Work Stealing
如果還是獲取不到G, 就需要休眠M了, 接下來是休眠的步驟
成功獲取到一個待運行的G
讓M離開自旋狀態(tài), 調(diào)用resetspinning, 這里的處理和上面的不一樣
如果當前有空閑的P, 但是無自旋的M(nmspinning等于0), 則喚醒或新建一個M
上面離開自旋狀態(tài)是為了休眠M, 所以會再次檢查所有隊列然后休眠
這里離開自選狀態(tài)是為了執(zhí)行G, 所以會檢查是否有空閑的P, 有則表示可以再開新的M執(zhí)行G
如果G要求回到指定的M(例如上面的runtime.main)
調(diào)用startlockedm函數(shù)把G和P交給該M, 自己進入休眠
從休眠喚醒后跳到schedule的頂部重試
調(diào)用execute函數(shù)執(zhí)行G
execute函數(shù)的處理如下:
調(diào)用getg獲取當前的g
把G的狀態(tài)由待運行(_Grunnable)改為運行中(_Grunning)
設(shè)置G的stackguard, 棧空間不足時可以擴張
增加P中記錄的調(diào)度次數(shù)(對應上面的每61次優(yōu)先獲取一次全局運行隊列)
設(shè)置g.m.curg = g
設(shè)置g.m = m
調(diào)用gogo函數(shù)
這個函數(shù)會根據(jù)g.sched中保存的狀態(tài)恢復各個寄存器的值并繼續(xù)運行g(shù)
首先針對g.sched.ctxt調(diào)用寫屏障(GC標記指針存活), ctxt中一般會保存指向[函數(shù)+參數(shù)]的指針
設(shè)置TLS中的g為g.sched.g, 也就是g自身
設(shè)置rsp寄存器為g.sched.rsp
設(shè)置rax寄存器為g.sched.ret
設(shè)置rdx寄存器為g.sched.ctxt (上下文)
設(shè)置rbp寄存器為g.sched.rbp
清空sched中保存的信息
跳轉(zhuǎn)到g.sched.pc
因為前面創(chuàng)建goroutine的newproc1函數(shù)把返回地址設(shè)為了goexit, 函數(shù)運行完畢返回時將會調(diào)用goexit函數(shù)
g.sched.pc在G首次運行時會指向目標函數(shù)的第一條機器指令,
如果G被搶占或者等待資源而進入休眠, 在休眠前會保存狀態(tài)到g.sched,
g.sched.pc會變?yōu)閱拘押笮枰^續(xù)執(zhí)行的地址, "保存狀態(tài)"的實現(xiàn)將在下面講解.
目標函數(shù)執(zhí)行完畢后會調(diào)用goexit函數(shù), goexit函數(shù)會調(diào)用goexit1函數(shù), goexit1函數(shù)會通過mcall調(diào)用goexit0函數(shù).
mcall這個函數(shù)就是用于實現(xiàn)"保存狀態(tài)"的, 處理如下:
- 設(shè)置g.sched.pc等于當前的返回地址
- 設(shè)置g.sched.sp等于寄存器rsp的值
- 設(shè)置g.sched.g等于當前的g
- 設(shè)置g.sched.bp等于寄存器rbp的值
- 切換TLS中當前的g等于m.g0
- 設(shè)置寄存器rsp等于g0.sched.sp, 使用g0的棧空間
- 設(shè)置第一個參數(shù)為原來的g
- 設(shè)置rdx寄存器為指向函數(shù)地址的指針(上下文)
- 調(diào)用指定的函數(shù), 不會返回
mcall這個函數(shù)保存當前的運行狀態(tài)到g.sched, 然后切換到g0和g0的??臻g, 再調(diào)用指定的函數(shù).
回到g0的??臻g這個步驟非常重要, 因為這個時候g已經(jīng)中斷, 繼續(xù)使用g的棧空間且其他M喚醒了這個g將會產(chǎn)生災難性的后果.
G在中斷或者結(jié)束后都會通過mcall回到g0的??臻g繼續(xù)調(diào)度, 從goexit調(diào)用的mcall的保存狀態(tài)其實是多余的, 因為G已經(jīng)結(jié)束了.
goexit1函數(shù)會通過mcall調(diào)用goexit0函數(shù), goexit0函數(shù)調(diào)用時已經(jīng)回到了g0的棧空間, 處理如下:
- 把G的狀態(tài)由運行中(_Grunning)改為已中止(_Gdead)
- 清空G的成員
- 調(diào)用dropg函數(shù)解除M和G之間的關(guān)聯(lián)
- 調(diào)用gfput函數(shù)把G放到P的自由列表中, 下次創(chuàng)建G時可以復用
- 調(diào)用schedule函數(shù)繼續(xù)調(diào)度
G結(jié)束后回到schedule函數(shù), 這樣就結(jié)束了一個調(diào)度循環(huán).
不僅只有G結(jié)束會重新開始調(diào)度, G被搶占或者等待資源也會重新進行調(diào)度, 下面繼續(xù)來看這兩種情況.
搶占的實現(xiàn)
上面我提到了runtime.main會創(chuàng)建一個額外的M運行sysmon函數(shù), 搶占就是在sysmon中實現(xiàn)的.
sysmon會進入一個無限循環(huán), 第一輪回休眠20us, 之后每次休眠時間倍增, 最終每一輪都會休眠10ms.
sysmon中有netpool(獲取fd事件), retake(搶占), forcegc(按時間強制執(zhí)行g(shù)c), scavenge heap(釋放自由列表中多余的項減少內(nèi)存占用)等處理.
retake函數(shù)負責處理搶占, 流程是:
枚舉所有的P
調(diào)用preemptone函數(shù)
設(shè)置g.preempt = true
設(shè)置g.stackguard0 = stackPreempt
調(diào)用handoffp解除M和P之間的關(guān)聯(lián)
如果P在系統(tǒng)調(diào)用中(_Psyscall), 且經(jīng)過了一次sysmon循環(huán)(20us~10ms), 則搶占這個P
如果P在運行中(_Prunning), 且經(jīng)過了一次sysmon循環(huán)并且G運行時間超過forcePreemptNS(10ms), 則搶占這個P
為什么設(shè)置了stackguard就可以實現(xiàn)搶占?
因為這個值用于檢查當前??臻g是否足夠, go函數(shù)的開頭會比對這個值判斷是否需要擴張棧.
stackPreempt是一個特殊的常量, 它的值會比任何的棧地址都要大, 檢查時一定會觸發(fā)棧擴張.
棧擴張調(diào)用的是morestack_noctxt函數(shù), morestack_noctxt函數(shù)清空rdx寄存器并調(diào)用morestack函數(shù).
morestack函數(shù)會保存G的狀態(tài)到g.sched, 切換到g0和g0的??臻g, 然后調(diào)用newstack函數(shù).
newstack函數(shù)判斷g.stackguard0等于stackPreempt, 就知道這是搶占觸發(fā)的, 這時會再檢查一遍是否要搶占:
- 如果M被鎖定(函數(shù)的本地變量中有P), 則跳過這一次的搶占并調(diào)用gogo函數(shù)繼續(xù)運行G
- 如果M正在分配內(nèi)存, 則跳過這一次的搶占并調(diào)用gogo函數(shù)繼續(xù)運行G
- 如果M設(shè)置了當前不能搶占, 則跳過這一次的搶占并調(diào)用gogo函數(shù)繼續(xù)運行G
- 如果M的狀態(tài)不是運行中, 則跳過這一次的搶占并調(diào)用gogo函數(shù)繼續(xù)運行G
即使這一次搶占失敗, 因為g.preempt等于true, runtime中的一些代碼會重新設(shè)置stackPreempt以重試下一次的搶占.
如果判斷可以搶占, 則繼續(xù)判斷是否GC引起的, 如果是則對G的??臻g執(zhí)行標記處理(掃描根對象)然后繼續(xù)運行,
如果不是GC引起的則調(diào)用gopreempt_m函數(shù)完成搶占.
gopreempt_m函數(shù)會調(diào)用goschedImpl函數(shù), goschedImpl函數(shù)的流程是:
- 把G的狀態(tài)由運行中(_Grunnable)改為待運行(_Grunnable)
- 調(diào)用dropg函數(shù)解除M和G之間的關(guān)聯(lián)
- 調(diào)用globrunqput把G放到全局運行隊列
- 調(diào)用schedule函數(shù)繼續(xù)調(diào)度
因為全局運行隊列的優(yōu)先度比較低, 各個M會經(jīng)過一段時間再去重新獲取這個G執(zhí)行,
搶占機制保證了不會有一個G長時間的運行導致其他G無法運行的情況發(fā)生.
channel的實現(xiàn)
在goroutine運行的過程中, 有時候需要對資源進行等待, channel就是最典型的資源.
channel的數(shù)據(jù)定義在這里, 其中關(guān)鍵的成員如下:
- qcount: 當前隊列中的元素數(shù)量
- dataqsiz: 隊列可以容納的元素數(shù)量, 如果為0表示這個channel無緩沖區(qū)
- buf: 隊列的緩沖區(qū), 結(jié)構(gòu)是環(huán)形隊列
- elemsize: 元素的大小
- closed: 是否已關(guān)閉
- elemtype: 元素的類型, 判斷是否調(diào)用寫屏障時使用
- sendx: 發(fā)送元素的序號
- recvx: 接收元素的序號
- recvq: 當前等待從channel接收數(shù)據(jù)的G的鏈表(實際類型是sudog的鏈表)
- sendq: 當前等待發(fā)送數(shù)據(jù)到channel的G的鏈表(實際類型是sudog的鏈表)
- lock: 操作channel時使用的線程鎖
發(fā)送數(shù)據(jù)到channel實際調(diào)用的是runtime.chansend1函數(shù), chansend1函數(shù)調(diào)用了chansend函數(shù), 流程是:
檢查channel.recvq是否有等待中的接收者的G
如果sudog.elem不等于nil, 調(diào)用sendDirect函數(shù)從發(fā)送者直接復制元素
等待接收的sudog.elem是指向接收目標的內(nèi)存的指針, 如果是接收目標是_則elem是nil, 可以省略復制
等待發(fā)送的sudog.elem是指向來源目標的內(nèi)存的指針
復制后調(diào)用goready恢復發(fā)送者的G
把G的狀態(tài)由等待中(_Gwaiting)改為待運行(_Grunnable)
把G放到P的本地運行隊列
如果當前有空閑的P, 但是無自旋的M(nmspinning等于0), 則喚醒或新建一個M
切換到g0調(diào)用ready函數(shù), 調(diào)用完切換回來
如果有, 表示channel無緩沖區(qū)或者緩沖區(qū)為空
調(diào)用send函數(shù)
從發(fā)送者拿到數(shù)據(jù)并喚醒了G后, 就可以從chansend返回了
判斷是否可以把元素放到緩沖區(qū)中
如果緩沖區(qū)有空余的空間, 則把元素放到緩沖區(qū)并從chansend返回
無緩沖區(qū)或緩沖區(qū)已經(jīng)寫滿, 發(fā)送者的G需要等待
調(diào)用gopark函數(shù)
mcall函數(shù)和上面說明的一樣, 會把當前的狀態(tài)保存到g.sched, 然后切換到g0和g0的??臻g并執(zhí)行指定的函數(shù)
park_m函數(shù)首先把G的狀態(tài)從運行中(_Grunning)改為等待中(_Gwaiting)
然后調(diào)用dropg函數(shù)解除M和G之間的關(guān)聯(lián)
再調(diào)用傳入的解鎖函數(shù), 這里的解鎖函數(shù)會對解除channel.lock的鎖定
最后調(diào)用schedule函數(shù)繼續(xù)調(diào)度
通過mcall函數(shù)調(diào)用park_m函數(shù)
獲取當前的g
新建一個sudog
設(shè)置sudog.elem = 指向發(fā)送內(nèi)存的指針
設(shè)置sudog.g = g
設(shè)置sudog.c = channel
設(shè)置g.waiting = sudog
把sudog放入channel.sendq
調(diào)用goparkunlock函數(shù)
從這里恢復表示已經(jīng)成功發(fā)送或者channel已關(guān)閉
檢查sudog.param是否為nil, 如果為nil表示channel已關(guān)閉, 拋出panic
否則釋放sudog然后返回
從channel接收數(shù)據(jù)實際調(diào)用的是runtime.chanrecv1函數(shù), chanrecv1函數(shù)調(diào)用了chanrecv函數(shù), 流程是:
檢查channel.sendq中是否有等待中的發(fā)送者的G
如果無緩沖區(qū), 調(diào)用recvDirect函數(shù)把元素直接復制給接收者
如果有緩沖區(qū)代表緩沖區(qū)已滿
復制后調(diào)用goready恢復接收者的G, 處理同上
把隊列中下一個要出隊的元素直接復制給接收者
把發(fā)送的元素復制到隊列中剛才出隊的位置
這時候緩沖區(qū)仍然是滿的, 但是發(fā)送序號和接收序號都會增加1
如果有, 表示channel無緩沖區(qū)或者緩沖區(qū)已滿, 這兩種情況需要分別處理(為了保證入出隊順序一致)
調(diào)用recv函數(shù)
把數(shù)據(jù)交給接收者并喚醒了G后, 就可以從chanrecv返回了
判斷是否可以從緩沖區(qū)獲取元素
如果緩沖區(qū)有元素, 則直接取出該元素并從chanrecv返回
無緩沖區(qū)或緩沖區(qū)無元素, 接收者的G需要等待
獲取當前的g
新建一個sudog
設(shè)置sudog.elem = 指向接收內(nèi)存的指針
設(shè)置sudog.g = g
設(shè)置sudog.c = channel
設(shè)置g.waiting = sudog
把sudog放入channel.recvq
調(diào)用goparkunlock函數(shù), 處理同上
從這里恢復表示已經(jīng)成功接收或者channel已關(guān)閉
檢查sudog.param是否為nil, 如果為nil表示channel已關(guān)閉
和發(fā)送不一樣的是接收不會拋panic, 會通過返回值通知channel已關(guān)閉
釋放sudog然后返回
關(guān)閉channel實際調(diào)用的是closechan函數(shù), 流程是:
- 設(shè)置channel.closed = 1
- 枚舉channel.recvq, 清零它們sudog.elem, 設(shè)置sudog.param = nil
- 枚舉channel.sendq, 設(shè)置sudog.elem = nil, 設(shè)置sudog.param = nil
- 調(diào)用goready函數(shù)恢復所有接收者和發(fā)送者的G
可以看到如果G需要等待資源時,
會記錄G的運行狀態(tài)到g.sched, 然后把狀態(tài)改為等待中(_Gwaiting), 再讓當前的M繼續(xù)運行其他G.
等待中的G保存在哪里, 什么時候恢復是等待的資源決定的, 上面對channel的等待會讓G放到channel中的鏈表.
對網(wǎng)絡(luò)資源的等待可以看netpoll相關(guān)的處理, netpoll在不同系統(tǒng)中的處理都不一樣, 有興趣的可以自己看看.
參考鏈接
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很早就已經(jīng)開始寫golang內(nèi)部實現(xiàn)相關(guān)的文章了, 他的文章很有參考價值, 建議同時閱讀他寫的內(nèi)容.
morsmachine寫的針對協(xié)程的分析也建議參考.
golang中的協(xié)程實現(xiàn)非常的清晰, 在這里要再次佩服google工程師的功力, 可以寫出這樣簡單易懂的代碼不容易.