golang 在 runtime 中的一些騷東西
最近在研究效能優化的時候,看到了 golang runtime 包下的一個文件HACKING.md
覺得頗有意思,讀完之後覺得對於 runtime 的理解更上一層,於是想著翻譯一下。
本章內容會有一定深度,需要有一定基礎的讀者,限於篇幅在這裡不可能完全展開各個細節。
這一篇文件面向的讀者是 runtime 的開發者,所以有很多內容在我們普通使用中是接觸不到的。
這篇文件是會被經常編輯的,並且隨著時間推移目前的內容可能會過時。這篇文件旨在說明寫 runtime 程式碼和普通的 go 程式碼有什麼不同,所以關注於一些普遍的概念而不是一些細節的實現。
排程器結構
排程器管理三個在 runtime 中十分重要的型別:G
、M
和P
。哪怕你不寫 scheduler 相關程式碼,你也應當要了解這些概念。
G、M 和 P
一個G
就是一個 goroutine,在 runtime 中通過型別g
來表示。當一個 goroutine 退出時,g
物件會被放到一個空閒的g
物件池中以用於後續的 goroutine 的使用(譯者注:減少記憶體分配開銷)。
一個M
就是一個系統的執行緒,系統執行緒可以執行使用者的 go 程式碼、runtime 程式碼、系統呼叫或者空閒等待。在 runtime 中通過型別m
來表示。在同一時間,可能有任意數量的M
,因為任意數量的M
可能會阻塞在系統呼叫中。(譯者注:當一個M
執行阻塞的系統呼叫時,會將M
和P
解綁,並建立出一個新的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
。
所有的g
、m
和p
物件都是分配在堆上且永不釋放的,所以它們的記憶體使用是很穩定的。得益於此,runtime 可以在排程器實現中避免寫屏障(譯者注:垃圾回收時需要的一種屏障,會帶來一些效能開銷)。
使用者棧和系統棧
每個存活著的(non-dead)G
都會有一個相關聯的使用者棧,使用者的程式碼就是在這個使用者棧上執行的。使用者棧一開始很小(比如 2K),並且動態地生長或者收縮。
每一個M
都有一個相關聯的系統棧(也被稱為g0
棧,因為這個棧也是通過g
實現的);如果是在 Unix 平臺上,還會有一個 signal
棧(也被稱為gsignal
棧)。系統棧和signal
棧不能生長,但是足夠大到執行任何 runtime 和 cgo 的程式碼(在純 go 二進位制中為 8K,在 cgo 情況下由系統分配)。
runtime 程式碼經常通過呼叫systemstack
、mcall
或者asmcgocall
臨時性的切換到系統棧去執行一些特殊的任務,比如:不能被搶佔的、不應該擴張使用者棧的和會切換使用者 goroutine 的。在系統棧上執行的程式碼隱含了不可搶佔的含義,同時垃圾回收器不會掃描系統棧。當一個M
在系統棧上執行時,當前的使用者棧是沒有被執行的。
getg()
和getg().m.curg
如果想要獲取當前使用者的g
,需要使用getg().m.curg
。
getg()
雖然會返回當前的g
,但是當正在系統棧或者signal
棧上執行的時候,會返回的是當前M
的g0
或者gsignal
,而這很可能不是你想要的。
如果要判斷當前正在系統棧上執行還是使用者棧上執行,可以使用getg() == getg().m.curg
。
錯誤處理和上報
在使用者程式碼中,有一些可以被合理地(reasonably)恢復的錯誤可以像往常一樣使用panic
,但是有一些情況下,panic
可能導致立即的致命的錯誤,比如在系統棧中呼叫或者當執行mallocgc
時。
大部分的 runtime 的錯誤是不可恢復的,對於這些不可恢復的錯誤應該使用throw
,throw
會列印出traceback
並立即終止程式。throw
應當被傳入一個字串常量以避免在該情況下還需要為 string 分配記憶體。根據約定,更多的資訊應當在throw
之前使用print
或者println
列印出來,並且應當以runtime.
開頭。
為了進行 runtime 的錯誤除錯,有一個很實用的方法是設定GOTRACEBACK=system
或 GOTRACEBACK=crash
。
同步
runtime 中有多種同步機制,這些同步機制不僅是語義上不同,和 go 排程器以及作業系統排程器之間的互動也是不一樣的。
最簡單的就是mutex
,可以使用lock
和unlock
來操作。這種方法主要用來短期(長期的話效能差)地保護一些共享的資料。在mutex
上阻塞會直接阻塞整個M
,而不會和 go 的排程器進行互動。因此,在 runtime 中的最底層使用 mutex
是安全的,因為它還會阻止相關聯的G
和P
被重新排程(M
都阻塞了,無法執行排程了)。rwmutex
也是類似的。
如果是要進行一次性的通知,可以使用note
。note
提供了notesleep
和notewakeup
。不像傳統的 UNIX 的sleep/wakeup
,note
是無競爭的(race-free),所以如果notewakeup
已經發生了,那麼notesleep
將會立即返回。note
可以在使用後通過noteclear
來重置,但是要注意noteclear
和notesleep
、notewakeup
不能發生競爭。類似mutex
,阻塞在note
上會阻塞整個M
。然而,note
提供了不同的方式來呼叫sleep
:notesleep
會阻止相關聯的G
和P
被重新排程;notetsleepg
的表現卻像一個阻塞的系統呼叫一樣,允許P
被重用去執行另一個G
。儘管如此,這仍然比直接阻塞一個G
要低效,因為這需要消耗一個M
。
如果需要直接和 go 排程器互動,可以使用gopark
和goready
。gopark
掛起當前的 goroutine——把它變成waiting
狀態,並從排程器的執行佇列中移除——然後排程另一個 goroutine 到當前的M
或者P
。goready
將一個被掛起的 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 的使用非常謹慎,並且儘可能避免不需要的原子操作。如果對於一個變數的訪問已經被另一種同步機制所保護,那麼這個已經被保護的訪問一般就不需要是原子的。這麼做主要有以下原因:
- 合理地使用非原子和原子操作使得程式碼更加清晰可讀,對於一個變數的原子操作意味著在另一處可能會有併發的對於這個變數的操作。
- 非原子的操作允許自動的競爭檢測。runtime 本身目前並沒有一個競爭檢測器,但是未來可能會有。原子操作會使得競爭檢測器忽視掉這個檢測,但是非原子的操作可以通過競爭檢測器來驗證你的假設(是否會發生競爭)。
- 非原子的操作可以提高效能。
當然,所有對於一個共享變數的非原子的操作都應當在文件中註明該操作是如何被保護的。
有一些比較普遍的將原子操作和非原子操作混合在一起的場景有:
- 大部分操作都是讀,且寫操作被鎖保護的變數。在鎖保護的範圍內,讀操作沒必要是原子的,但是寫操作必須是原子的。在鎖保護的範圍外,讀操作必須是原子的。
- 僅僅在 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
(見後文)。
在堆外記憶體所分配的物件不應該包含堆上的指標物件,除非同時遵守了以下的規則:
- 所有在堆外記憶體指向堆上的指標都必須是垃圾回收的根(garbage collection roots)。也就是說,所有指標必須可以通過一個全域性變數所訪問到,或者顯式地使用
runtime.markroot
來標記。 - 如果記憶體被重用了,堆上的指標在被標記為 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:nowritebarrierrec
。go: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
判斷中失敗。這個型別可能被用於全域性變數、棧上變數,或者堆外記憶體上的物件(比如通過sysAlloc
、persistentalloc
、fixalloc
或者其它手動管理的span
進行分配)。特別的:
-
new(T)
、make([]T)
、append([]T, ...)
和隱式的對於T
的堆上分配是不允許的(儘管隱式的分配在 runtime 中是從來不被允許的)。 - 一個指向普通型別的指標(除了
unsafe.Pointer
)不能被轉換成一個指向go:notinheap
型別的指標,就算它們有相同的底層型別(underlying type)。 - 任何一個包含了
go:notinheap
型別的型別自身也是go:notinheap
的。如果結構體和陣列包含go:notinheap
的元素,那麼它們自身也是go:notinheap
型別。map 和 channel 不允許有go:notinheap
型別。為了使得事情更加清晰,任何隱式的go:notinheap
型別都應該顯式地標明go:notinheap
。 - 指向
go:notinheap
型別的指標的寫屏障可以被忽略。
最後一點是go:notinheap
型別真正的好處。runtime 在底層結構中使用這個來避免排程器和記憶體分配器的記憶體屏障以避免非法檢查或者單純提高效能。這種方法是適度的安全(reasonably safe)的並且不會使得 runtime 的可讀性降低。
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- 近期做的一些東西
- 用Golang做點自動化的東西Golang
- 記錄一些細碎的東西
- Android,你要掌握的一些東西Android
- 《籠中窺夢》:“我們就想做一些與眾不同的東西”
- 從String型別發散想到的一些東西型別
- Linux初學者需要注意的一些東西Linux
- 整個小東西,在IDEA中自動生成PO、DAO、MapperIdeaAPP
- [隨便寫寫] 開始寫一些東西了
- js中的arguments是一個好東西JS
- 《燕雲十六聲》“在玩一種很新的東西”
- 《關於MySQL的一些騷操作》MySql
- 獲騰訊、鷹角投資,這遊戲在嘗試一些熟悉而新穎的東西遊戲
- GoLang中字串的一些使用總結Golang字串
- Activity啟動模式聯想到多程式相關的一些東西模式
- 怪東西
- 如何下載Github程式碼倉中的東西?Github
- 在阿里工作的日子裡,我都學到了哪些東西?阿里
- OkHttp 攔截器的一些騷操作HTTP
- 第 64 期深入淺出 Golang RuntimeGolang
- 每日一個 Golang Packages 06/10 runtimeGolangPackage
- 其實在直播平臺買東西的客戶最愚蠢
- golang中經常會犯的一些錯誤Golang
- Linux Bash 提示符的一些騷操作Linux
- 夢是個神奇的東西
- 筆試不會的東西筆試
- 未來學東西的思路
- Objc Runtime在專案中該怎麼用OBJ
- 在宇宙的眼眸下,如何正確地關心東數西算?
- golang runtime實現多核並行任務Golang並行
- golang-event在以太坊中的使用Golang
- Golang 中 defer Close() 的潛在風險Golang
- [IDE][IDEA]教你一些IDEA比較騷的操作Idea
- PHPSTORM 相關東西PHPORM
- iOS要用但不想記的東西iOS
- Linux 命令列下的好東西Linux命令列
- 計組那些容易搞混的東西
- 我在阿里工作的這段時間裡,都學到了哪些東西阿里