萬字長文深入淺出 Golang Runtime

介紹

本文主要內容如下:

  1. Golang Runtime 是個什麼? Golang Runtime 的發展歷程, 每個版本的改進
  2. Go 調度: 協程結構體, 上下文切換, 調度隊列, 大致調度流程, 同步執行流又不阻塞線程的網絡實現等
  3. Go 內存: 內存結構, mspan 結構, 全景圖及分配策略等
  4. Go GC: Golang GC 停頓大致的一個發展歷程, 三色標記實現的一些細節, 寫屏障, 三色狀態, 掃描及元信息, 1.12 版本相對 1.5 版本的改進點, GC Pacer 等
  5. 實踐: 觀察調度, GC 信息, 一些優化的方式, 幾點問題排查的思路, 幾個有意思的問題排查
  6. 總結: 貫穿 Runtime 的思想總結

本文完整版 PPT 可在文末獲取。

爲什麼去了解 runtime 呢?

  1. 可以解決一些棘手的問題: 在寫這個 PPT 的時候, 就有一位朋友在羣裏發了個 pprof 圖, 說同事寫的代碼有問題, CPU 利用率很高., 找不出來問題在哪, 我看了下 pprof 圖, 說讓他找找是不是有這樣用 select 的, 一查的確是的. 平時也幫同事解決了一些和併發, 調度, GC 有關的問題
  2. 好奇心: 大家寫久了 go, 驚歎於它的簡潔, 高性能外, 必然對它是怎麼實現的有很多好奇. 協程怎麼實現, GC 怎麼能併發, 對象在內存裏是怎麼存在的? 等等
  3. 技術深度的一種

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 的時候:

  1. 調度隊列 schedt 是全局的, 對該隊列的操作均需要競爭同一把鎖, 導致伸縮性不好.
  2. 新生成的協程也會放入全局的隊列, 大概率是被其他 m(可以理解爲底層線程的一個表示) 運行了, 內存親和性不好. 當前協程 A 新生成了協程 B, 然後協程 A 比較大概率會結束或者阻塞, 這樣 m 直接去執行協程 B, 內存的親和性也會好很多.
  3. 因爲 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, 緩存, 緩衝, 均衡.

參考文檔

參考鏈接在 PPT 文件中。

本文完整 PPT 可點擊下方圖片獲得。

https://www.lanzous.com/i7lj0he

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://zhuanlan.zhihu.com/p/95056679?utm_id=0