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