天天看點

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

Golang最大的特色可以說是協程(goroutine)了, 協程讓本來很複雜的異步程式設計變得簡單, 讓程式員不再需要面對回調地獄,

雖然現在引入了協程的語言越來越多, 但go中的協程仍然是實作的是最徹底的.

這篇文章将通過分析golang的源代碼來講解協程的實作原理.

這個系列分析的golang源代碼是Google官方的實作的1.9.2版本, 不适用于其他版本和gccgo等其他實作,

運作環境是Ubuntu 16.04 LTS 64bit.

要了解協程的實作, 首先需要了解go中的三個非常重要的概念, 它們分别是G, M和P,

沒有看過golang源代碼的可能會對它們感到陌生, 這三項是協程最主要的組成部分, 它們在golang的源代碼中無處不在.

G是goroutine的頭文字, goroutine可以解釋為受管理的輕量線程, goroutine使用<code>go</code>關鍵詞建立.

舉例來說, <code>func main() { go other() }</code>, 這段代碼建立了兩個goroutine,

一個是main, 另一個是other, 注意main本身也是一個goroutine.

goroutine的建立, 休眠, 恢複, 停止都受到go運作時的管理.

goroutine執行異步操作時會進入休眠狀态, 待操作完成後再恢複, 無需占用系統線程,

goroutine建立或恢複時會添加到運作隊列, 等待M取出并運作.

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的頭文字, 代表M運作G所需要的資源.

一些講解協程的文章把P了解為cpu核心, 其實這是錯誤的.

雖然P的數量預設等于cpu核心數, 但可以通過環境變量<code>GOMAXPROC</code>修改, 在實際運作時P跟cpu核心并無任何關聯.

P也可以了解為控制go代碼的并行度的機制,

如果P的數量等于1, 代表目前最多隻能有一個線程(M)執行go代碼,

如果P的數量等于2, 代表目前最多隻能有兩個線程(M)執行go代碼.

執行原生代碼的線程數量不受P控制.

因為同一時間隻有一個線程(M)可以擁有P, P中的資料都是鎖自由(lock free)的, 讀寫這些資料的效率會非常的高.

在講解協程的工作流程之前, 還需要了解一些内部的資料結構.

空閑中(_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并沒有像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的數量.

空閑中(_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個時會入隊到全局運作隊列.

當M從P的本地運作隊列擷取G時, 如果發現本地隊列為空會嘗試從其他P盜取一半的G過來,

全局運作隊列儲存在全局變量<code>sched</code>中, 全局運作隊列入隊和出隊需要使用線程鎖.

全局運作隊列的資料結構是連結清單, 由兩個指針(head, tail)組成.

當M發現無待運作的G時會進入休眠, 并添加到空閑M連結清單中, 空閑M連結清單儲存在全局變量<code>sched</code>.

進入休眠的M會等待一個信号量(m.park), 喚醒休眠的M會使用這個信号量.

go需要保證有足夠的M可以運作G, 是通過這樣的機制實作的:

入隊待運作的G後, 如果目前無自旋的M但是有空閑的P, 就喚醒或者建立一個M

當M離開自旋狀态并準備運作出隊的G時, 如果目前無自旋的M但是有空閑的P, 就喚醒或者建立一個M

當M離開自旋狀态并準備休眠時, 會在離開自旋狀态後再次檢查所有運作隊列, 如果有待運作的G則重新進入自旋狀态

因為"入隊待運作的G"和"M離開自旋狀态"會同時進行, go會使用這樣的檢查順序:

入隊待運作的G =&gt; 記憶體屏障 =&gt; 檢查目前自旋的M數量 =&gt; 喚醒或者建立一個M

減少目前自旋的M數量 =&gt; 記憶體屏障 =&gt; 檢查所有運作隊列是否有待運作的G =&gt; 休眠

這樣可以保證不會出現待運作的G入隊了, 也有空閑的資源P, 但無M去執行的情況.

當P的本地運作隊列中的所有G都運作完畢, 又不能從其他地方拿到G時,

擁有P的M會釋放P并進入休眠狀态, 釋放的P會變為空閑狀态并加到空閑P連結清單中, 空閑P連結清單儲存在全局變量<code>sched</code>

下次待運作的G入隊時如果發現有空閑的P, 但是又沒有自旋中的M時會喚醒或者建立一個M, M會擁有這個P, P會重新變為運作中的狀态.

下圖是協程可能出現的工作狀态, 圖中有4個P, 其中M1~M3正在運作G并且運作後會從擁有的P的運作隊列繼續擷取G:

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

隻看這張圖可能有點難以想象實際的工作流程, 這裡我根據實際的代碼再講解一遍:

程式啟動時會先建立一個G, 指向的是main(實際是runtime.main而不是main.main, 後面解釋):

圖中的虛線指的是G待運作或者開始運作的位址, 不是目前運作的位址.

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

M會取得這個G并運作:

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

這時main會建立一個新的channel, 并啟動兩個新的G:

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

接下來<code>G: main</code>會從channel擷取資料, 因為擷取不到, G會儲存狀态并變為等待中(_Gwaiting)并添加到channel的隊列:

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

因為<code>G: main</code>儲存了運作狀态, 下次運作時将會從<code>_ = &lt;- c</code>繼續運作.

接下來M會從運作隊列擷取到<code>G: printNumber</code>并運作:

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

printNumber會列印數字, 完成後向channel寫資料,

寫資料時發現channel中有正在等待的G, 會把資料交給這個G, 把G變為待運作(_Grunnable)并重新放入運作隊列:

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

接下來M會運作下一個<code>G: printNumber</code>, 因為建立channel時指定了大小為3的緩沖區, 可以直接把資料寫入緩沖區而無需等待:

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

然後printNumber運作完畢, 運作隊列中就隻剩下<code>G: main</code>了:

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

最後M把<code>G: main</code>取出來運作, 會從上次中斷的位置<code>_ &lt;- c</code>繼續運作:

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

第一個<code>_ &lt;- c</code>的結果已經在前面設定過了, 這條語句會執行成功.

第二個<code>_ &lt;- c</code>在擷取時會發現channel中有已緩沖的0, 于是結果就是這個0, 不需要等待.

最後main執行完畢, 程式結束.

有人可能會好奇如果最後再加一個<code>_ &lt;- c</code>會變成什麼結果, 這時因為所有G都進入等待狀态, go會檢測出來并報告死鎖:

關于概念的講解到此結束, 從這裡開始會分析go中的實作代碼, 我們需要先了解一些基礎的内容.

從以下的go代碼:

可以生成以下的彙編代碼(平台是linux x64, 使用的是預設選項, 即啟用優化和内聯):

這些彙編代碼現在看不懂也沒關系, 下面會從這裡取出一部分來解釋.

不同平台對于函數有不同的調用規範.

例如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的調用規範非常的簡單, 所有參數都通過棧傳遞, 傳回值也通過棧傳遞,

例如這樣的函數:

調用函數時的棧的内容如下:

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

可以看得出參數和傳回值都從低位到高位排列, go函數可以有多個傳回值的原因也在于此. 因為傳回值都通過棧傳遞了.

需要注意的這裡的"傳回位址"是x86和x64上的, arm的傳回位址會通過LR寄存器儲存, 内容會和這裡的稍微不一樣.

另外注意的是和c不一樣, 傳遞構造體時整個構造體的内容都會複制到棧上, 如果構造體很大将會影響性能.

例如标準c中的errno就是一個典型的TLS變量, 每個線程都有一個獨自的errno, 寫入它不會幹擾到其他線程中的值.

go在實作協程時非常依賴TLS機制, 會用于擷取系統線程中目前的G和G所屬的M的執行個體.

因為go并不使用glibc, 操作TLS會使用系統原生的接口, 以linux x64為例,

運作中每個M的FS寄存器都會指向它們對應的M執行個體的tls, linux核心排程線程時FS寄存器會跟着線程一起切換,

這樣go代碼隻需要通路FS寄存器就可以存取線程本地的資料.

上面的彙編代碼中的

會把指向目前的G的指針從TLS移動到rcx寄存器中.

棧空間的内容在goroutine休眠時需要保留, 待休眠完成後恢複(這時整個調用樹都是完整的).

這樣就引出了一個問題, goroutine可能會同時存在很多個, 如果每一個goroutine都預先配置設定一個足夠的棧空間那麼go就會使用過多的記憶體.

為了避免這個問題, go在一開始隻為goroutine配置設定一個很小的棧空間, 它的大小在目前版本是2K.

當函數發現棧空間不足時, 會申請一塊新的棧空間并把原來的棧内容複制過去.

會檢查比較rsp減去一定值以後是否比g.stackguard0小, 如果小于等于則需要調到下面調用morestack_noctxt函數.

細心的可能會發現比較的值跟實際減去的值不一緻, 這是因為stackguard0下面會預留一小部分空間, 編譯時确定不超過預留的空間可以省略比對.

因為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也有寫屏障的機制, 但作用跟這裡的不一樣(用于标記跨代引用).

閉包這個概念本身應該不需要解釋, 我們實際看一看go是如何實作閉包的:

這段代碼的輸出結果是<code>3 2 3</code>, 熟悉go的應該不會感到意外.

main函數執行executeFn函數的彙編代碼如下:

我們可以看到傳給executeFn的是一個指針, 指針指向的内容是<code>[匿名函數的位址, 變量a的位址, 變量b的值]</code>.

變量a傳位址的原因是匿名函數中對a進行了修改, 需要反映到原來的a上.

executeFn函數執行閉包的彙編代碼如下:

可以看到調用閉包時參數并不通過棧傳遞, 而是通過寄存器rdx傳遞, 閉包的彙編代碼如下:

閉包的傳遞可以總結如下:

閉包的内容是[匿名函數的位址, 傳給匿名函數的參數(不定長)...]

傳遞閉包給其他函數時會傳遞指向"閉包的内容"的指針

調用閉包時會把指向"閉包的内容"的指針放到寄存器rdx(在go内部這個指針稱為"上下文")

閉包會從寄存器rdx取出參數

如果閉包修改了變量, 閉包中的參數會是指針而不是值, 修改時會修改到原來的位置上

細心的可能會發現在上面的例子中, 閉包的内容在棧上, 如果不是直接調用executeFn而是go executeFn呢?

把上面的代碼改為<code>go executeFn(func() ...)</code>可以生成以下的彙編代碼:

我們可以看到goroutine+閉包的情況更複雜, 首先go會通過逃逸分析算出變量a和閉包會逃逸到外面,

這時go會在heap上配置設定變量a和閉包, 上面調用的兩次newobject就是分别對變量a和閉包的配置設定.

在建立goroutine時, 首先會傳入函數+參數的大小(上面是8+8=16), 然後傳入函數+參數, 上面的參數即閉包的位址.

go中還有特殊的M和G, 它們是m0和g0.

m0是啟動程式後的主線程, 這個m對應的執行個體會在全局變量m0中, 不需要在heap上配置設定,

m0負責執行初始化操作和啟動第一個g, 在之後m0就和其他的m一樣了.

g0是僅用于負責排程的G, g0不指向任何可執行的函數, 每個m都會有一個自己的g0,

在排程或系統調用時會使用g0的棧空間, 全局變量的g0是m0的g0.

如果上面的内容都了解, 就可以開始看golang的源代碼了.

配置設定棧空間, 需要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

這裡(linux x64)設定了全局變量ncpu等于cpu核心數量

這裡的處理比較多, 會初始化棧空間配置設定器, GC, 按cpu核心數量或GOMAXPROCS的值生成P等

runtime.newproc這個函數在建立普通的goroutine時也會使用, 在下面的"go的實作"中會詳細講解

啟動後m0會不斷從運作隊列擷取G并運作, runtime.mstart調用後不會傳回

runtime.mstart這個函數是m的入口點(不僅僅是m0), 在下面的"排程器的實作"中會詳細講解

标記主函數已調用, 設定mainStarted = true

啟動一個新的M執行sysmon函數, 這個函數會監控全局的狀态并對運作時間過長的G進行搶占

要求G必須在目前M(系統主線程)上執行

調用main.init函數, 如果函數存在

不再要求G必須在目前M上運作

如果程式是作為c的類庫編譯的, 在這裡傳回

調用main.main函數

如果目前發生了panic, 則等待panic處理

調用exit(0)退出程式

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指令建立goroutine時, go會把go指令編譯為對runtime.newproc的調用, 堆棧的結構如下:

Golang源碼探索(二) 協程的實作原理核心概念資料結構工作流程(概覽)開始代碼分析程式初始化G M P的定義go的實作排程器的實作搶占的實作channel的實作參考連結

第一個參數是funcval + 額外參數的長度, 第二個參數是funcval, 後面的都是傳遞給goroutine中執行的函數的額外參數.

計算額外參數的位址argp

擷取調用端的位址(傳回位址)pc

切換到g0後會假裝傳回位址是mstart, 這樣traceback的時候可以在mstart停止.

這裡傳給systemstack的是一個閉包, 調用時會把閉包的位址放到寄存器rdx, 具體可以參考上面對閉包的分析.

調用getg擷取目前的g, 會編譯為讀取FS寄存器(TLS), 這裡會擷取到g0

設定g對應的m的locks++, 禁止搶占

擷取m擁有的p

建立一個g

需要先設定g的狀态為已中止(_Gdead), 這樣gc不會去掃描這個g的未初始化的棧

把參數複制到g的棧上

把傳回位址複制到g的棧上, 這裡的傳回位址是goexit, 表示調用完目标函數後會調用goexit

設定g的排程資料(sched)

設定sched.sp等于參數+傳回位址後的rsp位址

設定sched.g等于g

設定g的狀态為待運作(_Grunnable)

首先随機把g放到p.runnext, 如果放到runnext則入隊原來在runnext的g

然後嘗試把g放到P的"本地運作隊列"

runqputslow會把本地運作隊列中一半的g放到全局運作隊列, 這樣下次就可以繼續用快速的本地運作隊列了

如果目前有空閑的P, 但是無自旋的M(nmspinning等于0), 并且主函數已執行則喚醒或建立一個M

這一步非常重要, 用于保證目前有足夠的M運作G, 具體請檢視上面的"空閑M連結清單"

首先交換nmspinning到1, 成功再繼續, 多個線程同時執行wakep隻有一個會繼續

線程建立後會設定TLS, 設定TLS中目前的g為g0, 然後執行mstart

調用notewakeup(&amp;mp.park)喚醒線程

建立goroutine的流程就這麼多了, 接下來看看M是如何排程的.

M啟動時會調用mstart函數, m0在初始化後調用, 其他的的m線上程啟動後調用.

調用getg擷取目前的g, 這裡會擷取到g0

如果g未配置設定棧則從目前的棧空間(系統棧空間)上配置設定, 也就是說g0會使用系統棧空間

調用schedule函數後就進入了排程循環, 整個流程可以簡單總結為:

如果M擁有的P中指定了需要在安全點運作的函數(P.runSafePointFn), 則運作它

快速擷取待運作的G, 以下處理如果有一個擷取成功後面就不會繼續擷取

如果目前GC正在标記階段, 則查找有沒有待運作的GC Worker, GC Worker也是一個G

為了公平起見, 每61次排程從全局運作隊列擷取一次G, (一直從本地擷取可能導緻全局運作隊列中的G不被運作)

如果有析構器待運作則使用"運作析構器的G"

從網絡事件反應器擷取G, 函數netpoll會擷取哪些fd可讀可寫或已關閉, 然後傳回等待fd相關事件的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收到了事件

喚醒後跳到findrunnable的頂部重試

成功擷取到一個待運作的G

如果目前有空閑的P, 但是無自旋的M(nmspinning等于0), 則喚醒或建立一個M

上面離開自旋狀态是為了休眠M, 是以會再次檢查所有隊列然後休眠

這裡離開自選狀态是為了執行G, 是以會檢查是否有空閑的P, 有則表示可以再開新的M執行G

如果G要求回到指定的M(例如上面的runtime.main)

從休眠喚醒後跳到schedule的頂部重試

調用getg擷取目前的g

把G的狀态由待運作(_Grunnable)改為運作中(_Grunning)

設定G的stackguard, 棧空間不足時可以擴張

增加P中記錄的排程次數(對應上面的每61次優先擷取一次全局運作隊列)

設定g.m.curg = g

設定g.m = m

這個函數會根據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會變為喚醒後需要繼續執行的位址, "儲存狀态"的實作将在下面講解.

設定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已經結束了.

把G的狀态由運作中(_Grunning)改為已中止(_Gdead)

清空G的成員

G結束後回到schedule函數, 這樣就結束了一個排程循環.

不僅隻有G結束會重新開始排程, G被搶占或者等待資源也會重新進行排程, 下面繼續來看這兩種情況.

sysmon會進入一個無限循環, 第一輪回休眠20us, 之後每次休眠時間倍增, 最終每一輪都會休眠10ms.

sysmon中有netpool(擷取fd事件), retake(搶占), forcegc(按時間強制執行gc), scavenge heap(釋放自由清單中多餘的項減少記憶體占用)等處理.

枚舉所有的P

如果P在系統調用中(_Psyscall), 且經過了一次sysmon循環(20us~10ms), 則搶占這個P

如果P在運作中(_Prunning), 且經過了一次sysmon循環并且G運作時間超過forcePreemptNS(10ms), 則搶占這個P

設定g.preempt = true

設定g.stackguard0 = stackPreempt

為什麼設定了stackguard就可以實作搶占?

因為這個值用于檢查目前棧空間是否足夠, go函數的開頭會比對這個值判斷是否需要擴張棧.

stackPreempt是一個特殊的常量, 它的值會比任何的棧位址都要大, 檢查時一定會觸發棧擴張.

newstack函數判斷g.stackguard0等于stackPreempt, 就知道這是搶占觸發的, 這時會再檢查一遍是否要搶占:

如果M被鎖定(函數的本地變量中有P), 則跳過這一次的搶占并調用gogo函數繼續運作G

如果M正在配置設定記憶體, 則跳過這一次的搶占并調用gogo函數繼續運作G

如果M設定了目前不能搶占, 則跳過這一次的搶占并調用gogo函數繼續運作G

如果M的狀态不是運作中, 則跳過這一次的搶占并調用gogo函數繼續運作G

即使這一次搶占失敗, 因為g.preempt等于true, runtime中的一些代碼會重新設定stackPreempt以重試下一次的搶占.

如果判斷可以搶占, 則繼續判斷是否GC引起的, 如果是則對G的棧空間執行标記處理(掃描根對象)然後繼續運作,

把G的狀态由運作中(_Grunnable)改為待運作(_Grunnable)

因為全局運作隊列的優先度比較低, 各個M會經過一段時間再去重新擷取這個G執行,

搶占機制保證了不會有一個G長時間的運作導緻其他G無法運作的情況發生.

在goroutine運作的過程中, 有時候需要對資源進行等待, channel就是最典型的資源.

qcount: 目前隊列中的元素數量

dataqsiz: 隊列可以容納的元素數量, 如果為0表示這個channel無緩沖區

buf: 隊列的緩沖區, 結構是環形隊列

elemsize: 元素的大小

closed: 是否已關閉

elemtype: 元素的類型, 判斷是否調用寫屏障時使用

sendx: 發送元素的序号

recvx: 接收元素的序号

recvq: 目前等待從channel接收資料的G的連結清單(實際類型是sudog的連結清單)

sendq: 目前等待發送資料到channel的G的連結清單(實際類型是sudog的連結清單)

lock: 操作channel時使用的線程鎖

檢查channel.recvq是否有等待中的接收者的G

如果有, 表示channel無緩沖區或者緩沖區為空

等待接收的sudog.elem是指向接收目标的記憶體的指針, 如果是接收目标是<code>_</code>則elem是nil, 可以省略複制

等待發送的sudog.elem是指向來源目标的記憶體的指針

把G的狀态由等待中(_Gwaiting)改為待運作(_Grunnable)

把G放到P的本地運作隊列

從發送者拿到資料并喚醒了G後, 就可以從chansend傳回了

判斷是否可以把元素放到緩沖區中

如果緩沖區有空餘的空間, 則把元素放到緩沖區并從chansend傳回

無緩沖區或緩沖區已經寫滿, 發送者的G需要等待

擷取目前的g

建立一個sudog

設定sudog.elem = 指向發送記憶體的指針

設定sudog.g = g

設定sudog.c = channel

設定g.waiting = sudog

把sudog放入channel.sendq

mcall函數和上面說明的一樣, 會把目前的狀态儲存到g.sched, 然後切換到g0和g0的棧空間并執行指定的函數

park_m函數首先把G的狀态從運作中(_Grunning)改為等待中(_Gwaiting)

再調用傳入的解鎖函數, 這裡的解鎖函數會對解除channel.lock的鎖定

從這裡恢複表示已經成功發送或者channel已關閉

檢查sudog.param是否為nil, 如果為nil表示channel已關閉, 抛出panic

否則釋放sudog然後傳回

檢查channel.sendq中是否有等待中的發送者的G

如果有, 表示channel無緩沖區或者緩沖區已滿, 這兩種情況需要分别處理(為了保證入出隊順序一緻)

如果有緩沖區代表緩沖區已滿

把隊列中下一個要出隊的元素直接複制給接收者

把發送的元素複制到隊列中剛才出隊的位置

這時候緩沖區仍然是滿的, 但是發送序号和接收序号都會增加1

把資料交給接收者并喚醒了G後, 就可以從chanrecv傳回了

判斷是否可以從緩沖區擷取元素

如果緩沖區有元素, 則直接取出該元素并從chanrecv傳回

無緩沖區或緩沖區無元素, 接收者的G需要等待

設定sudog.elem = 指向接收記憶體的指針

把sudog放入channel.recvq

從這裡恢複表示已經成功接收或者channel已關閉

檢查sudog.param是否為nil, 如果為nil表示channel已關閉

和發送不一樣的是接收不會抛panic, 會通過傳回值通知channel已關閉

釋放sudog然後傳回

設定channel.closed = 1

枚舉channel.recvq, 清零它們sudog.elem, 設定sudog.param = nil

枚舉channel.sendq, 設定sudog.elem = nil, 設定sudog.param = nil

可以看到如果G需要等待資源時,

會記錄G的運作狀态到g.sched, 然後把狀态改為等待中(_Gwaiting), 再讓目前的M繼續運作其他G.

等待中的G儲存在哪裡, 什麼時候恢複是等待的資源決定的, 上面對channel的等待會讓G放到channel中的連結清單.

對網絡資源的等待可以看netpoll相關的處理, netpoll在不同系統中的處理都不一樣, 有興趣的可以自己看看.

<a href="https://github.com/golang/go">https://github.com/golang/go</a>

<a href="https://golang.org/s/go11sched">https://golang.org/s/go11sched</a>

<a href="http://supertech.csail.mit.edu/papers/steal.pdf">http://supertech.csail.mit.edu/papers/steal.pdf</a>

<a href="https://docs.google.com/document/d/1ETuA2IOmnaQ4j81AtTGT40Y4_Jr6_IDASEKg0t0dBR8/edit#heading=h.x4kziklnb8fr">https://docs.google.com/document/d/1ETuA2IOmnaQ4j81AtTGT40Y4_Jr6_IDASEKg0t0dBR8/edit#heading=h.x4kziklnb8fr</a>

<a href="https://blog.altoros.com/golang-part-1-main-concepts-and-project-structure.html">https://blog.altoros.com/golang-part-1-main-concepts-and-project-structure.html</a>

<a href="https://blog.altoros.com/golang-internals-part-2-diving-into-the-go-compiler.html">https://blog.altoros.com/golang-internals-part-2-diving-into-the-go-compiler.html</a>

<a href="https://blog.altoros.com/golang-internals-part-3-the-linker-and-object-files.html">https://blog.altoros.com/golang-internals-part-3-the-linker-and-object-files.html</a>

<a href="https://blog.altoros.com/golang-part-4-object-files-and-function-metadata.html">https://blog.altoros.com/golang-part-4-object-files-and-function-metadata.html</a>

<a href="https://blog.altoros.com/golang-internals-part-5-runtime-bootstrap-process.html">https://blog.altoros.com/golang-internals-part-5-runtime-bootstrap-process.html</a>

<a href="https://blog.altoros.com/golang-internals-part-6-bootstrapping-and-memory-allocator-initialization.html">https://blog.altoros.com/golang-internals-part-6-bootstrapping-and-memory-allocator-initialization.html</a>

<a href="http://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64">http://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64</a>

<a href="http://legendtkl.com/categories/golang">http://legendtkl.com/categories/golang</a>

<a href="http://www.cnblogs.com/diegodu/p/5803202.html">http://www.cnblogs.com/diegodu/p/5803202.html</a>

<a href="https://www.douban.com/note/300631999/">https://www.douban.com/note/300631999/</a>

<a href="http://morsmachine.dk/go-scheduler">http://morsmachine.dk/go-scheduler</a>

legendtkl很早就已經開始寫golang内部實作相關的文章了, 他的文章很有參考價值, 建議同時閱讀他寫的内容.

morsmachine寫的針對協程的分析也建議參考.

golang中的協程實作非常的清晰, 在這裡要再次佩服google工程師的功力, 可以寫出這樣簡單易懂的代碼不容易.

上一篇: [Lab7]ACL
下一篇: [Lab5]DHCP