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