golang 在 runtime 中的一些騷東西

PureWhiteWu發表於2020-02-11

最近在研究效能優化的時候,看到了 golang runtime 包下的一個文件HACKING.md覺得頗有意思,讀完之後覺得對於 runtime 的理解更上一層,於是想著翻譯一下。

本章內容會有一定深度,需要有一定基礎的讀者,限於篇幅在這裡不可能完全展開各個細節。

這一篇文件面向的讀者是 runtime 的開發者,所以有很多內容在我們普通使用中是接觸不到的。

這篇文件是會被經常編輯的,並且隨著時間推移目前的內容可能會過時。這篇文件旨在說明寫 runtime 程式碼和普通的 go 程式碼有什麼不同,所以關注於一些普遍的概念而不是一些細節的實現。

排程器結構

排程器管理三個在 runtime 中十分重要的型別:GMP。哪怕你不寫 scheduler 相關程式碼,你也應當要了解這些概念。

G、M 和 P

一個G就是一個 goroutine,在 runtime 中通過型別g來表示。當一個 goroutine 退出時,g物件會被放到一個空閒的g物件池中以用於後續的 goroutine 的使用(譯者注:減少記憶體分配開銷)。

一個M就是一個系統的執行緒,系統執行緒可以執行使用者的 go 程式碼、runtime 程式碼、系統呼叫或者空閒等待。在 runtime 中通過型別m來表示。在同一時間,可能有任意數量的M,因為任意數量的M可能會阻塞在系統呼叫中。(譯者注:當一個M執行阻塞的系統呼叫時,會將MP解綁,並建立出一個新的M來執行P上的其它G。)

最後,一個P代表了執行使用者 go 程式碼所需要的資源,比如排程器狀態、記憶體分配器狀態等。在 runtime 中通過型別p來表示。P的數量精確地(exactly)等於GOMAXPROCS。一個P可以被理解為是作業系統排程器中的 CPU,p型別可以被理解為是每個 CPU 的狀態。在這裡可以放一些需要高效共享但並不是針對每個P(Per P)或者每個M(Per M)的狀態(譯者注:意思是,可以放一些以P級別共享的資料)。

排程器的工作是將一個G(需要執行的程式碼)、一個M(程式碼執行的地方)和一個P(程式碼執行所需要的許可權和資源)結合起來。當一個M停止執行使用者程式碼的時候(比如進入阻塞的系統呼叫的時候),就需要把它的P歸還到空閒的P池中;為了繼續執行使用者的 go 程式碼(比如從阻塞的系統呼叫退出的時候),就需要從空閒的P池中獲取一個P

所有的gmp物件都是分配在堆上且永不釋放的,所以它們的記憶體使用是很穩定的。得益於此,runtime 可以在排程器實現中避免寫屏障(譯者注:垃圾回收時需要的一種屏障,會帶來一些效能開銷)。

使用者棧和系統棧

每個存活著的(non-dead)G都會有一個相關聯的使用者棧,使用者的程式碼就是在這個使用者棧上執行的。使用者棧一開始很小(比如 2K),並且動態地生長或者收縮。

每一個M都有一個相關聯的系統棧(也被稱為g0棧,因為這個棧也是通過g實現的);如果是在 Unix 平臺上,還會有一個 signal棧(也被稱為gsignal棧)。系統棧和signal棧不能生長,但是足夠大到執行任何 runtime 和 cgo 的程式碼(在純 go 二進位制中為 8K,在 cgo 情況下由系統分配)。

runtime 程式碼經常通過呼叫systemstackmcall或者asmcgocall臨時性的切換到系統棧去執行一些特殊的任務,比如:不能被搶佔的、不應該擴張使用者棧的和會切換使用者 goroutine 的。在系統棧上執行的程式碼隱含了不可搶佔的含義,同時垃圾回收器不會掃描系統棧。當一個M在系統棧上執行時,當前的使用者棧是沒有被執行的。

getg()getg().m.curg

如果想要獲取當前使用者的g,需要使用getg().m.curg

getg()雖然會返回當前的g,但是當正在系統棧或者signal棧上執行的時候,會返回的是當前Mg0或者gsignal,而這很可能不是你想要的。

如果要判斷當前正在系統棧上執行還是使用者棧上執行,可以使用getg() == getg().m.curg

錯誤處理和上報

在使用者程式碼中,有一些可以被合理地(reasonably)恢復的錯誤可以像往常一樣使用panic,但是有一些情況下,panic可能導致立即的致命的錯誤,比如在系統棧中呼叫或者當執行mallocgc時。

大部分的 runtime 的錯誤是不可恢復的,對於這些不可恢復的錯誤應該使用throwthrow會列印出traceback並立即終止程式。throw應當被傳入一個字串常量以避免在該情況下還需要為 string 分配記憶體。根據約定,更多的資訊應當在throw之前使用print或者println列印出來,並且應當以runtime.開頭。

為了進行 runtime 的錯誤除錯,有一個很實用的方法是設定GOTRACEBACK=systemGOTRACEBACK=crash

同步

runtime 中有多種同步機制,這些同步機制不僅是語義上不同,和 go 排程器以及作業系統排程器之間的互動也是不一樣的。

最簡單的就是mutex,可以使用lockunlock來操作。這種方法主要用來短期(長期的話效能差)地保護一些共享的資料。在mutex上阻塞會直接阻塞整個M,而不會和 go 的排程器進行互動。因此,在 runtime 中的最底層使用 mutex是安全的,因為它還會阻止相關聯的GP被重新排程(M都阻塞了,無法執行排程了)。rwmutex也是類似的。

如果是要進行一次性的通知,可以使用notenote提供了notesleepnotewakeup。不像傳統的 UNIX 的sleep/wakeupnote是無競爭的(race-free),所以如果notewakeup已經發生了,那麼notesleep將會立即返回。note可以在使用後通過noteclear來重置,但是要注意noteclearnotesleepnotewakeup不能發生競爭。類似mutex,阻塞在note上會阻塞整個M。然而,note提供了不同的方式來呼叫sleepnotesleep會阻止相關聯的GP被重新排程;notetsleepg的表現卻像一個阻塞的系統呼叫一樣,允許P被重用去執行另一個G。儘管如此,這仍然比直接阻塞一個G要低效,因為這需要消耗一個M

如果需要直接和 go 排程器互動,可以使用goparkgoreadygopark掛起當前的 goroutine——把它變成waiting狀態,並從排程器的執行佇列中移除——然後排程另一個 goroutine 到當前的M或者Pgoready將一個被掛起的 goroutine 恢復到runnable狀態並將它放到執行佇列中。

總結起來如下表:

Blocks
Interface G M P
(rw) mutex Y Y Y
note Y Y Y/N
park Y N N

原子性

runtime 使用runtime/internal/atomic中自有的一些原子操作。這個和sync/atomic是對應的,除了方法名由於歷史原因有一些區別,並且有一些額外的 runtime 需要的方法。

總的來說,我們對於 runtime 中 atomic 的使用非常謹慎,並且儘可能避免不需要的原子操作。如果對於一個變數的訪問已經被另一種同步機制所保護,那麼這個已經被保護的訪問一般就不需要是原子的。這麼做主要有以下原因:

  1. 合理地使用非原子和原子操作使得程式碼更加清晰可讀,對於一個變數的原子操作意味著在另一處可能會有併發的對於這個變數的操作。
  2. 非原子的操作允許自動的競爭檢測。runtime 本身目前並沒有一個競爭檢測器,但是未來可能會有。原子操作會使得競爭檢測器忽視掉這個檢測,但是非原子的操作可以通過競爭檢測器來驗證你的假設(是否會發生競爭)。
  3. 非原子的操作可以提高效能。

當然,所有對於一個共享變數的非原子的操作都應當在文件中註明該操作是如何被保護的。

有一些比較普遍的將原子操作和非原子操作混合在一起的場景有:

  • 大部分操作都是讀,且寫操作被鎖保護的變數。在鎖保護的範圍內,讀操作沒必要是原子的,但是寫操作必須是原子的。在鎖保護的範圍外,讀操作必須是原子的。
  • 僅僅在 STW 期間發生的讀操作,且 STW 期間不會有寫操作。那麼這個時候,讀操作不需要是原子的。

話雖如此,Go Memory Model給出的建議仍然成立Don't be [too] clever。runtime 的效能固然重要,但是魯棒性(robustness)卻更加重要。

堆外記憶體(Unmanaged memory)

一般情況下,runtime 會嘗試使用普通的方法來申請記憶體(堆上記憶體,gc 管理的),然而在某些情況 runtime 必須申請一些不被 gc 所管理的堆外記憶體(unmanaged memory)。這是很必要的,因為有可能該片記憶體就是記憶體管理器自身,或者說呼叫者沒有一個P(譯者注:比如在排程器初始化之前,是不存在P的)。

有三種方式可以申請堆外記憶體:

  • sysAlloc直接從作業系統獲取記憶體,申請的記憶體必須是系統頁表長度的整數倍。可以通過sysFree來釋放。
  • persistentalloc將多個小的記憶體申請合併在一起為一個大的sysAlloc以避免記憶體碎片(fragmentation)。然而,顧名思義,通過persistentalloc申請的記憶體是無法被釋放的。
  • fixalloc是一個SLAB風格的記憶體分配器,分配固定大小的記憶體。通過fixalloc分配的物件可以被釋放,但是記憶體僅可以被相同的fixalloc池所重用。所以fixalloc適合用於相同型別的物件。

普遍來說,使用以上三種方法分配記憶體的型別都應該被標記為//go:notinheap(見後文)。

在堆外記憶體所分配的物件不應該包含堆上的指標物件,除非同時遵守了以下的規則:

  1. 所有在堆外記憶體指向堆上的指標都必須是垃圾回收的根(garbage collection roots)。也就是說,所有指標必須可以通過一個全域性變數所訪問到,或者顯式地使用runtime.markroot來標記。
  2. 如果記憶體被重用了,堆上的指標在被標記為 GC 根並且對 GC 可見前必須 以 0 初始化(zero-initialized,見後文)。不然的話,GC 可能會觀察到過期的(stale)堆指標。可以參見下文Zero-initialization versus zeroing.

Zero-initialization versus zeroing

在 runtime 中有兩種型別的零初始化,取決於記憶體是否已經初始化為了一個型別安全的狀態。

如果記憶體不在一個型別安全的狀態,意思是可能由於剛被分配,並且第一次初始化使用,會含有一些垃圾值(譯者注:這個概念在日常的 Go 程式碼中是遇不到的,如果學過 C 語言的同學應該能理解什麼意思),那麼這片記憶體必須使用memclrNoHeapPointers進行zero-initialized或者無指標的寫。這不會觸發寫屏障(譯者注:寫屏障是 GC 中的一個概念)。

記憶體可以通過typedmemclr或者memclrHasPointers來寫入零值,設定為型別安全的狀態。這會觸發寫屏障。

Runtime-only 編譯指令(compiler directives)

除了go doc compile中註明的//go:編譯指令外,編譯器在 runtime 包中支援了額外的一些指令。

go:systemstack

go:systemstack表明一個函式必須在系統棧上執行,這個會通過一個特殊的函式前引(prologue)動態地驗證。

go:nowritebarrier

go:nowritebarrier告知編譯器如果以下函式包含了寫屏障,觸發一個錯誤(這不會阻止寫屏障的生成,只是單純一個假設)。

一般情況下你應該使用go:nowritebarrierrecgo:nowritebarrier當且僅當 “最好不要” 寫屏障,但是非正確性必須的情況下使用。

go:nowritebarrierrec 與 go:yeswritebarrierrec

go:nowritebarrierrec告知編譯器如果以下函式以及它呼叫的函式(遞迴下去),直到一個go:yeswritebarrierrec為止,包含了一個寫屏障的話,觸發一個錯誤。

邏輯上,編譯器會在生成的呼叫圖上從每個go:nowritebarrierrec函式出發,直到遇到了go:yeswritebarrierrec的函式(或者結束)為止。如果其中遇到一個函式包含寫屏障,那麼就會產生一個錯誤。

go:nowritebarrierrec主要用來實現寫屏障自身,用來避免死迴圈。

這兩種編譯指令都在排程器中所使用。寫屏障需要一個活躍的P(getg().m.p != nil),然而排程器相關程式碼有可能在沒有一個活躍的P的情況下執行。在這種情況下,go:nowritebarrierrec會用在一些釋放P或者沒有P的函式上執行,go:yeswritebarrierrec會用在重新獲取到了P的程式碼上。因為這些都是函式級別的註釋,所以釋放P和獲取P的程式碼必須被拆分成兩個函式。

go:notinheap

go:notinheap適用於型別宣告,表明了一個型別必須不被分配在 GC 堆上。特別的,指向該型別的指標總是應當在runtime.inheap判斷中失敗。這個型別可能被用於全域性變數、棧上變數,或者堆外記憶體上的物件(比如通過sysAllocpersistentallocfixalloc或者其它手動管理的span進行分配)。特別的:

  1. new(T)make([]T)append([]T, ...)和隱式的對於T的堆上分配是不允許的(儘管隱式的分配在 runtime 中是從來不被允許的)。
  2. 一個指向普通型別的指標(除了unsafe.Pointer)不能被轉換成一個指向go:notinheap型別的指標,就算它們有相同的底層型別(underlying type)。
  3. 任何一個包含了go:notinheap型別的型別自身也是go:notinheap的。如果結構體和陣列包含go:notinheap的元素,那麼它們自身也是go:notinheap型別。map 和 channel 不允許有go:notinheap型別。為了使得事情更加清晰,任何隱式的go:notinheap型別都應該顯式地標明go:notinheap
  4. 指向go:notinheap型別的指標的寫屏障可以被忽略。

最後一點是go:notinheap型別真正的好處。runtime 在底層結構中使用這個來避免排程器和記憶體分配器的記憶體屏障以避免非法檢查或者單純提高效能。這種方法是適度的安全(reasonably safe)的並且不會使得 runtime 的可讀性降低。

更多原創文章乾貨分享,請關注公眾號
  • golang 在 runtime 中的一些騷東西
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章