深入浅出Go Runtime
以下內容轉載自?https://mp.weixin.qq.com/s/ivO-USpxiyrL-9BzgE8Vcg
介紹
基于2019.02發布的go 1.12 linux amd64版本, 主要介紹了Runtime一些原理和實現的一些細節, 對大家容易不容易理解或者網絡上很多錯誤的地方做一些梳理:
Golang Runtime是個什么? Golang Runtime的發展歷程, 每個版本的改進
Go調度: 協程結構體, 上下文切換, 調度隊列, 大致調度流程, 同步執行流又不阻塞線程的網絡實現等
Go內存: 內存結構, mspan結構, 全景圖及分配策略等
Go GC: Golang GC停頓大致的一個發展歷程, 三色標記實現的一些細節, 寫屏障, 三色狀態, 掃描及元信息, 1.12版本相對1.5版本的改進點, GC Pacer等
實踐: 觀察調度, GC信息, 一些優化的方式, 幾點問題排查的思路, 幾個有意思的問題排查
總結: 貫穿Runtime的思想總結
這個是我今年8月份在深圳Gopher Meetup做的一個關于runtime的分享, 10月份在Go夜讀(項目地址 https://reading.developerlearning.cn) 里也分享了一次
視頻地址: https://www.bilibili.com/video/av73297683?seid=596154768710832594
一些問題解答: https://github.com/developer-learning/night-reading-go/issues/492
兩個分享都是用的精簡版, 這里的把完整版的講一下, 加一些注語, 注語位于PPT頁的下面. (ppt地址 https://github.com/yifhao/share).
序
為什么去了解runtime呢?
可以解決一些棘手的問題: 在寫這個PPT的時候, 就有一位朋友在群里發了個pprof圖, 說同事寫的代碼有問題, CPU利用率很高., 找不出來問題在哪, 我看了下pprof圖, 說讓他找找是不是有這樣用select的, 一查的確是的. 平時也幫同事解決了一些和并發, 調度, GC有關的問題
好奇心: 大家寫久了go, 驚嘆于它的簡潔, 高性能外, 必然對它是怎么實現的有很多好奇. 協程怎么實現, GC怎么能并發, 對象在內存里是怎么存在的? 等等
技術深度的一種?
本次分享基于2019.02發布的go 1.12 linux amd64版本, 主要介紹了Runtime實現的一點細節. 水平和精力有限, 必然有問題存在, 有問題歡迎大家給我留言.
Runtime簡介及發展
Runtime簡介
go的runtime代碼在go sdk的runtime目錄下.?
主要有所述的4塊功能. 提到runtime, 大家可能會想起java, python的runtime.?
不過go和這兩者不太一樣, java, python的runtime是虛擬機, 而go的runtime和用戶代碼一起編譯到一個可執行文件中.?
用戶代碼和runtime代碼除了代碼組織上有界限外, 運行的時候并沒有明顯的界限.?
如上所示, 一些常用的關鍵字被編譯成runtime包下的一些函數調用.
Runtime版本歷史
左邊標粗的是一些更新比較大的版本. 右邊的GC STW僅供參考.
調度
調度簡述
goroutine實現
我們去看調度的一個進化, 從進程到線程再到協程, 其實是一個不斷共享, 不斷減少切換成本的過程. go實現的協程為有棧協程, go協程的用法和線程的用法基本類似. 很多人會疑問, 協程到底是個什么東西? 用戶態的調度感覺很陌生, 很抽象, 到底是個什么東西?
?
我覺得要理解調度, 要理解兩個概念: 運行和阻塞. 特別是在協程中, 這兩個概念不容易被正確理解. 我們理解概念時往往會代入自身感受, 覺得線程或協程運行就是像我們吭哧吭哧的處理事情, 線程或協程阻塞就是做事情時我們需要等待其他人, 然后就在這等著了. 要是其他人搞好了, 那我們就繼續做當前的事. 其實主體對象搞錯了. 正確的理解應該是我們處理事情時就像CPU, 而不是像線程或者協程. 假如我當前在寫某個服務, 發現依賴別人的函數還沒有ready, 那就把寫服務這件事放一邊. 點開企業微信, 我去和產品溝通一些問題了. 我和產品溝通了一會后, 檢查一下, 發現別人已經把依賴的函數提交了, 然后我就最小化企業微信, 切到IDE, 繼續寫服務A了.
對操作系統有過一些了解, 知道linux下的線程其實是task_struct結構, 線程其實并不是真正運行的實體, 線程只是代表一個執行流和其狀態. 真正運行驅動流程往前的其實是CPU. CPU在時鐘的驅動下, 根據PC寄存器從程序中取指令和操作數, 從RAM中取數據, 進行計算, 處理, 跳轉, 驅動執行流往前. CPU并不關注處理的是線程還是協程, 只需要設置PC寄存器, 設置棧指針等(這些稱為上下文), 那么CPU就可以歡快的運行這個線程或者這個協程了.
線程的運行, 其實是被運行. 其阻塞, 其實是切換出調度隊列, 不再去調度執行這個執行流. 其他執行流滿足其條件, 便會把被移出調度隊列的執行流重新放回調度隊列.
協程同理, 協程其實也是一個數據結構, 記錄了要運行什么函數, 運行到哪里了. go在用戶態實現調度, 所以go要有代表協程這種執行流的結構體, 也要有保存和恢復上下文的函數, 運行隊列. 理解了阻塞的真正含義, 也就知道能夠比較容易理解, 為什么go的鎖, channel這些不阻塞線程. 對于實現的同步執行流效果, 又不阻塞線程的網絡, 接下來也會介紹.
協程結構體和切換函數
?
我們go一個func時一般這樣寫
go func1(arg1 type1,arg2 type2){....}(a1,a2)
一個協程代表了一個執行流, 執行流有需要執行的函數(對應上面的func1), 有函數的入參(a1, a2), 有當前執行流的狀態和進度(對應CPU的PC寄存器和SP寄存器), 當然也需要有保存狀態的地方, 用于執行流恢復. 真正代表協程的是runtime.g結構體. 每個go func都會編譯成runtime.newproc函數, 最終有一個runtime.g對象放入調度隊列. 上面的func1函數的指針設置在runtime.g的startfunc字段, 參數會在newproc函數里拷貝到stack中, sched用于保存協程切換時的pc位置和棧位置. 協程切換出去和恢復回來需要保存上下文, 恢復上下文, 這些由以下兩個匯編函數實現. 以上就能實現協程這種執行流, 并能進行切換和恢復. (下圖中的struct和函數都做了精簡)
GM模型及GPM模型
有了協程的這種執行流形式, 那待運行的協程放在哪呢? 在Go1.0的時候:
?
調度隊列schedt是全局的, 對該隊列的操作均需要競爭同一把鎖, 導致伸縮性不好.
新生成的協程也會放入全局的隊列, 大概率是被其他m(可以理解為底層線程的一個表示)運行了, 內存親和性不好. 當前協程A新生成了協程B, 然后協程A比較大概率會結束或者阻塞, 這樣m直接去執行協程B, 內存的親和性也會好很多.
因為mcache與m綁定, 在一些應用中(比如文件操作或其他可能會阻塞線程的系統調用比較多), m的個數可能會遠超過活躍的m個數, 導致比較大的內存浪費..
那是不是可以給m分配一個隊列, 把阻塞的m的mcache給執行go代碼的m使用? Go 1.1及以后就是這樣做的.
?
在1.1中調度模型更改為GPM模型, 引入邏輯Process的概念, 表示執行Go代碼所需要的資源, 同時也是執行Go代碼的最大的并行度. 這個概念可能很多人不知道怎么理解. P涉及到幾點, 隊列和mcache, 還有P的個數的選取. 首先為什么把全局隊列打散, 以及mcache為什么跟隨P, 這個在GM模型那一頁就講的比較清楚了. 然后為什么P的個數默認是CPU核數: Go盡量提升性能, 那么在一個n核機器上, 如何能夠最大利用CPU性能呢? 當然是同時有n個線程在并行運行中, 把CPU喂飽, 即所有核上一直都有代碼在運行. 在go里面, 一個協程運行到阻塞系統調用, 那么這個協程和運行它的線程m, 自然是不再需要CPU的, 也不需要分配go層面的內存. 只有一直在并行運行的go代碼才需要這些資源, 即同時有n個go協程在并行執行, 那么就能最大的利用CPU, 這個時候需要的P的個數就是CPU核數. (注意并行和并發的區別)
協程狀態及流轉
協程的狀態其實和線程狀態類似,狀態轉換和發生狀態轉換的時機如圖所示. 還是需要注意: 協程只是一個執行流, 并不是運行實體.
調度
并沒有一個一直在運行調度的調度器實體. 當一個協程切換出去或新生成的m, go的運行時從stw中恢復等情況時, 那么接下來就需要發生調度. go的調度是通過線程(m)執行runtime.schedule函數來完成的.
sysmon協程
在linux內核中有一些執行定時任務的線程, 比如定時寫回臟頁的pdflush, 定期回收內存的kswapd0, 以及每個cpu上都有一個負責負載均衡的migration線程等.?
?
在go運行時中也有類似的協程, sysmon. 功能比較多: 定時從netpoll中獲取ready的協程, 進行搶占, 定時GC,打印調度信息,歸還內存等定時任務
?
協作式搶占
go目前(1.12)還沒有實現非協作的搶占. 基本流程是sysmon協程標記某個協程運行過久, 需要切換出去, 該協程在運行函數時會檢查棧標記, 然后進行切換.
同步執行流不阻塞線程的網絡的實現
go寫后臺最舒服的就是能夠以同步寫代碼的方式操作網絡, 但是網絡操作不阻塞線程.?
主要是結合了非阻塞的fd, epoll以及協程的切換和恢復.?
?
linux提供了網絡fd的非阻塞模式, 對于沒有ready的非阻塞fd執行網絡操作時, linux內核不阻塞線程, 會直接返回EAGAIN, 這個時候將協程狀態設置為wait, 然后m去調度其他協程.?
?
go在初始化一個網絡fd的時候, 就會把這個fd使用epollctl加入到全局的epoll節點中. 同時放入epoll中的還有polldesc的指針.
?
func netpollopen(fd uintptr, pd *pollDesc) int32 {
var ev epollevent
ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
return-epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}
在sysmon中, schedule函數中, start the world中等情況下, 會執行netpoll 調用epollwait系統調用.
把ready的網絡事件從epoll中取出來, 每個網絡事件可以通過前面傳入的polldesc獲取到阻塞在其上的協程, 以此恢復協程為runnable.
調度相關結構體
調度綜述
內存分配
內存分配簡介
Go的分配采用了類似tcmalloc的結構.?
?
特點: 使用一小塊一小塊的連續內存頁, 進行分配某個范圍大小的內存需求.?
比如某個連續8KB專門用于分配17-24字節,以此減少內存碎片. 線程擁有一定的cache, 可用于無鎖分配.
同時Go對于GC后回收的內存頁, 并不是馬上歸還給操作系統, 而是會延遲歸還, 用于滿足未來的內存需求.
內存空間結構
在1.10以前go的堆地址空間是線性連續擴展的, 比如在1.10(linux amd64)中, 最大可擴展到512GB.?
?
因為go在gc的時候會根據拿到的指針地址來判斷是否位于go的heap的, 以及找到其對應的span, 其判斷機制需要gc heap是連續的.?
但是連續擴展有個問題, cgo中的代碼(尤其是32位系統上)可能會占用未來會用于go heap的內存. 這樣在擴展go heap時, mmap出現不連續的地址, 導致運行時throw.?
?
在1.11中, 改用了稀疏索引的方式來管理整體的內存. 可以超過512G內存, 也可以允許內存空間擴展時不連續.?
在全局的mheap struct中有個arenas二階數組, 在linux amd64上,一階只有一個slot, 二階有4M個slot, 每個slot指向一個heapArena結構, 每個heapArena結構可以管理64M內存, 所以在新的版本中, go可以管理4M*64M=256TB內存, 即目前64位機器中48bit的尋址總線全部256TB內存.
span機制
?
前面提到了go的內存分配類似于tcmalloc, 采用了span機制來減少內存碎片.?
每個span管理8KB整數倍的內存, 用于分配一定范圍的內存需求.
內存分配全景
?
多層次的分配Cache, 每個P上有一個mcache, mcache會為每個size最多緩存一個span, 用于無鎖分配.?
?
全局每個size的span都有一個mcentral, 鎖的粒度相對于全局的heap小很多, 每個mcentral可以看成是每個size的span的一個全局后備cache.?
在gc完成后, 會把P中的span都flush到mcentral中, 用于清掃后再分配. P有需要span時, 從對應size的mcentral獲取. 獲取不到再上升到全局的heap.
幾種特殊的分配器
對于很小的對象分配, go做了個優化, 把小對象合并, 以移動指針的方式分配.?
?
對于棧內存有stackcache分配, 也有多個層次的分配, 同時stack也有多個不同size.?
用于分配stack的內存也是位于go gc heap, 用mspan管理, 不過這個span的狀態和用于分配對象的mspan狀態不太一樣, 為mSpanManual.?
?
我們可以思考一個問題, go的對象是分配在go gc heap中, 并由mcache, mspan, mcentral這些結構管理, 那么mcache, mspan, mcentral這些結構又是哪里管理和分配的呢??
肯定不是自己管理自己. 這些都是由特殊的分配fixalloc分配的, 每種類型有一個fixalloc, 大致原理就是通過mmap從進程空間獲取一小塊內存(百KB的樣子), 然后用來分配這個固定大小的結構.
內存分配綜合
GC
Golang GC簡述
GC簡介
GC并不是個新事物, 使得GC大放光彩的是Java語言.
Golang GC發展
上面是幾個比較重要的版本. 左圖是根據twitter工程師的數據繪制的(堆比較大), 從1.4的百ms級別的停頓到1.8以后的小于1ms.?
右圖是我對線上服務(Go 1.11編譯)測試的一個結果, 是一個批量拉取數據的服務, 大概3000qps, 服務中發起的rpc調用大概在2w/s. 可以看到大部分情況下GC停頓小于1ms, 偶爾超過一點點.?
整體來說golang gc用起來是很舒心的, 幾乎不用你關心.
三色標記
go采用的是并發三色標記清除法.?
圖展示的是一個簡單的原理.
有幾個問題可以思考一下:?
并發情況下, 會不會漏標記對象??
對象的三色狀態存放在哪??
如何根據一個對象來找到它引用的對象?
寫屏障
GC最基本的就是正確性: 不漏標記對象, 程序還在用的對象都被清除了, 那程序就錯誤了. 有一點浮動垃圾是允許的.?
在并發情況下, 如果沒有一些措施來保障, 那可能會有什么問題呢??
看左邊的代碼和圖示, 第2步標記完A對象, A又沒有引用對象, 那A變成黑色對象.?
在第3步的時候, muator(程序)運行, 把對象C從B轉到了A,?
第4步, GC繼續標記, 掃描B, 此時B沒有引用對象, 變成了黑色對象. 我們會發現C對象被漏標記了.
如何解決這個問題? go使用了寫屏障, 這里的寫屏障是指由編譯器生成的一小段代碼. 在gc時對指針操作前執行的一小段代碼, 和CPU中維護內存一致性的寫屏障不太一樣哈. 所以有了寫屏障后, 第3步, A.obj=C時, 會把C加入寫屏障buf. 最終還是會被掃描的.
?
這里感受一下寫屏障具體生成的代碼.?
我們可以看到在寫入指針slot時, 對寫屏障是否開啟做了判斷, 如果開啟了, 會跳轉到寫屏障函數, 執行加入寫屏障buf的邏輯.?
1.8中寫屏障由Dijkstra寫屏障改成了混合式寫屏障, 使得GC停頓達到了1ms以下.
?
三色狀態
并沒有這樣一個集合把不同狀態對象放到對應集合中. 只是一個邏輯上的意義.
掃描和元信息
gc拿到一個指針, 如何把這個指針指向的對象其引用的子對象都加到掃描隊列呢? 而且go還允許內部指針, 似乎更麻煩了.?
我們分析一下, 要知道對象引用的子對象, 從對象開始到對象結尾, 把對象那一塊內存上是指針的放到掃描隊列就好了.?
那我們是不是得知道對象有多大, 從哪開始到哪結束, 同時要知道內存上的8個字節, 哪里是指針, 哪里是普通的數據.?
?
首先go的對象是mspan管理的, 我們如果能知道對象屬于哪個mspan, 就知道對象多大, 從哪開始, 到哪結束了.?
前面我們講到了areans結構, 可以通過指針加上一定的偏移量, 就知道屬于哪個heap arean 64M塊. 再通過對64M求余, 結合spans數組, 即可知道屬于哪個mspan了.
結合heapArean的bitmap和每8個字節在heapArean中的偏移, 就可知道對象每8個字節是指針還是普通數據(這里的bitmap是在分配對象時根據type信息就設置了, type信息來源于編譯器生成)
GC流程
1.5和1.12的GC大致流程相同.?
上圖是golang官方的ppt里的圖, 下圖是我根據1.12源碼繪制的.?
從最壞可能會有百ms的gc停頓到能夠穩定在1ms以下, 這之間GC做了很多改進.?
右邊是我根據官方issues整理的一些比較重要的改進. 1.6的分布式檢測, 1.7將棧收縮放到了并發掃描階段, 1.8的混合寫屏障, 1.12更改了mark termination檢測算法, mcache flush移除出mark termination等等...
Golang GC Pacer
大家對并發GC除了怎么保證不漏指針有疑問外, 可能還會疑問, 并發GC如何保證能夠跟得上應用程序的分配速度? 會不會分配太快了, GC完全跟不上, 然后OOM?
這個就是Golang GC Pacer的作用.?
Go的GC是一種比例GC, 下一次GC結束時的堆大小和上一次GC存活堆大小成比例. 由GOGC控制, 默認100, 即2倍的關系, 200就是3倍, 以此類推.?
假如上一次GC完成時, 存活對象1000M, 默認GOGC 100, 那么下次GC會在比較接近但小于2000M的時候(比如1900M)開始, 爭取在堆大小達到2000M的時候結束.?
這之間留有一定的裕度, 會計算待掃描對象大小(根據歷史數據計算)與可分配的裕度的比例, 應用程序分配內存根據該比例進行輔助GC, 如果應用程序分配太快了, 導致credit不夠, 那么會被阻塞, 直到后臺的mark跟上來了,該比例會隨著GC進行不斷調整.?
GC結束后, 會根據這一次GC的情況來進行負反饋計算, 計算下一次GC開始的閾值. 如何保證按時完成GC呢??
GC完了后, 所有的mspan都需要sweep, 類似于GC的比例, 從GC結束到下一次GC開始之間有一定的堆分配裕度, 會根據還有多少的內存需要清掃, 來計算分配內存時需要清掃的span數這樣的一個比例.
實踐與總結
觀察調度
觀察一下調度, 加一些請求.?
我們可以看到雖然有1000個連接, 但是go只用了幾個線程就能處理了, 表明go的網絡的確是由epoll管理的.?
runqueue表示的是全局隊列待運行協程數量, 后面的數字表示每個P上的待運行協程數.?
可以看到待處理的任務并沒有增加, 表示雖然請求很多, 但完全能hold住.?
同時可以看到, 不同P上有的時候可能任務不均衡, 但是一會后, 任務又均衡了, 表示go的work stealing是有效的.
觀察GC
其中一些數據的含義, 在分享的時候沒有怎么解釋, 不過網上的解釋幾乎沒有能完全解釋正確.?
我這里敲一下. 其實一般關注堆大小和兩個stw的wall time即可.?
?
gc 8913(第8913次gc) @2163.341s(在程序運行的第2163s) 1%(gc所有work消耗的歷史累計CPU比例, 所以其實這個數據沒太大意義) 0.13(第一個stw的wall time)+14(并發mark的wall time)+0.20(第二個stw的wall time) ms clock, 1.1(第一個stw消耗的CPU時間)+21(用戶程序輔助掃描消耗的cpu時間)/22(分配用于mark的P消耗的cpu時間)/0(空閑的P用于mark的cpu時間)+1.6ms(第2個stw的cpu時間) cpu, 147(gc開始時的堆大小)->149(gc結束的堆大小)->75MB(gc結束時的存活堆大小), 151 MB goal(本次gc預計結束的堆大小), 8P(8個P)
優化
個人建議, 沒事不要總想著優化, 好好curd就好.
?
當然還是有一些優化方法的..
一點實踐
我們將pprof的開啟集成到模板中, 并自動選擇端口, 并集成了gops工具, 方便查詢runtime信息, 同時在瀏覽器上可直接點擊生成火焰圖, pprof圖, 非常的方便, 也不需要使用者關心.
問題排查的一點思路
一次有意思的問題排查
負載, 依賴服務都很正常, CPU利用率也不高, 請求也不多, 就是有很多超時.
?
該服務在線上打印了debug日志, 因為早期的服務模板開啟了gctrace, 框架把stdout重定向到一個文件了. 而輸出gctrace時本來是到console的, 輸出到文件了, 而磁盤跟不上, 導致gctrace日志被阻塞了.
這里更正一下ppt中的內容, 并不是因為gc沒完成而導致其他協程不能運行, 而是后續gc無法開啟, 導致實質上的stw. 打印gc trace日志時, 已經start the world了, 其他協程可以開始運行了. 但是在打印gctrace日志時, 還保持著開啟gc需要的鎖, 所以, 打印gc trace日志一直沒完成, 而gc又比較頻繁, 比如0.1s一次, 這樣會導致下一次gc開始時無法獲取鎖, 每一個進入gc檢查的p阻塞, 實際上就造成了stw.
Runtime的一點個人總結
并行, 縱向多層次, 橫向多個class, 緩存, 緩沖, 均衡.
參考文檔
總結
以上是生活随笔為你收集整理的深入浅出Go Runtime的全部內容,希望文章能夠幫你解決所遇到的問題。