1. 程式人生 > >Golang原始碼探索(二) 協程的實現原理

Golang原始碼探索(二) 協程的實現原理

轉自:https://studygolang.com/articles/11627

Golang最大的特色可以說是協程(goroutine)了, 協程讓本來很複雜的非同步程式設計變得簡單, 讓程式設計師不再需要面對回撥地獄,
雖然現在引入了協程的語言越來越多, 但go中的協程仍然是實現的是最徹底的.
這篇文章將通過分析golang的原始碼來講解協程的實現原理.

這個系列分析的golang原始碼是Google官方的實現的1.9.2版本, 不適用於其他版本和gccgo等其他實現,
執行環境是Ubuntu 16.04 LTS 64bit.

核心概念

要理解協程的實現, 首先需要了解go中的三個非常重要的概念, 它們分別是G

MP,
沒有看過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等於目標函式的地址, 檢視gostartcallfngostartcall
    • 設定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
    • 呼叫