爲Go語言GC正名-20秒到100微妙的演變史

英文原文鏈接:https://blog.twitch.tv/gos-march-to-low-latency-gc-a6fa96f06eb7#.lrmfby2xs


2016-12-1日最新更新:Go1.8已經將Gc的最差情況優化到了100微妙左右,正常的服務gc一般都在10微妙!

下面我們會介紹https://www.twitch.tv視頻直播網站在使用Go過程中的GC耗時演變史。

  我們是視頻直播系統且擁有數百萬的在線用戶,消息和聊天系統全部是用Go寫的,該服務單臺機器同時連接了50萬左右的用戶。在Go1.4到1.5的版本迭代中,GC得到了20倍的提升,在1.6版本得到了10倍的提升,然後跟Go的Runtime開發組進行交流後,在1.7版本又得到了10倍的提升(在1.7之前,我們進行了大量的GC參數調優,在1.7中這些調優都不需要了,原生的runtime就可以支持),總共是2000倍!!!具體的GC停止時間從2秒到了1毫秒!!而且不需要任何GC調優!!

那麼我們開始GC大冒險吧

在2013年的時候,我們用Go重寫了基於IRC的聊天系統,之前是用Python寫的。當時使用的Go版本是1.1,重構後,可以在不進行特殊調優的情況下,達到單臺50萬用戶在線。每個用戶使用了3個goroutine,因此係統中有整整150萬goroutine在運行,但是神奇的是,系統完全沒有任何性能問題,除了GC--基本上每分鐘都會運行幾次GC,每次GC耗時幾秒至10幾秒不等,對於我們的交互性服務來說,這個絕對是不可容忍的。

後面我們對系統進行了大量的優化,包括了減少對象分配、控制對象數量等等,這個時候GC的運行頻率和STW(Stop The World)時間都得到了改進。基本上系統每2分鐘自動GC一次就可以了,雖然GC次數少了,但是每次暫停的時間依然是毀滅性的。

隨着Go1.2的發佈,GC STW時間縮短爲幾秒左右,然後我們對服務進行了切分,這樣也讓GC降低到稍微可以接受的水平。但是這種切分服務的工作隊我們來說也是巨大的負擔,同時和GO的版本也是息息相關的。

在2015年8月開始使用Go1.5後,Go採用了並行和增值GC,這意味着系統不需要在忍受一個超級久的STW時間了。升級到1.5給我們帶來了10倍的GC提升,從2秒到200毫秒。

Go1.5-GC新紀元

雖然Go1.5的GC改進非常棒,但是更棒的是爲未來的持續改進搭好了舞臺!

Go1.5的GC仍然分爲兩個主要階段-markl階段:GC對對象和不再使用的內存進行標記;sweep階段,準備進行回收。這中間還分爲兩個子階段,第一階段,暫停應用,結束上一次sweep,接着進入併發mark階段:找到正在使用的內存;第二階段,mark結束階段,這期間應用再一次暫停。最後,未使用的內存會被逐步回收,這個階段是異步的,不會STW。

gctrace可以用來跟蹤GC週期,包括了每個階段的耗時。對於我們的服務來說,它表明了大部分時間是耗費在mark結束階段,所以我們的GC分析也會集中在mark結束階段這塊兒。

這裏我們需要對GC進行跟蹤,Go原生就自帶一個pprof,但是我們決定使用linux perf工具。使用perf可以採集更高頻率的樣本,也可以觀察os kernel的時間消耗。對kernel進行監控,可以幫我們debug慢系統調用等工作。

下面是我們的profile圖表,使用的Go1.5.1,這是一個Flame Graph,使用了Brendan Gregg的工具獲取,並進行了剪裁,去除了不重要的部分,留下了runtime.gcMark部分,這個函數耗費的時間可以認爲是mark階段的STW時間。


這張圖是依次向上的方式來展示棧調用的,每一塊的寬度代表了CPU時間,顏色和同一行的順序不重要。在圖表的最左邊我們可以找到runtime.gcMark函數,它調用了runtime.parfordo函數。再往上,我們發現了大部分時間都花費在了runtime.markroot上,它調用了runtime.scang, runtime.scanobject, runtime.shrinkstack。

runtime.scang函數是在mark結束階段時進行重新掃描,這個是必須的函數,無法優化。我們再來看看另外兩個函數。

下一個是runtime.scanobject函數,該函數做了幾件事情,但是在mark階段運行的原因是實現finalizers。可能你會想:爲什麼程序要使用這麼多finalizer,給GC帶來這麼大的壓力呢?因爲我們的應用是消息和聊天服務,因此會處理幾十萬的連接。Go的核心net包會爲每個TCP連接分配一個finalizer來幫助控制文件描述符泄漏。

就這個問題我們跟Go runtime組進行了多次溝通,他們給我們提供了一些診斷辦法。在Go1.6中,finalizer的掃描被移到了併發階段中,對於大量連接的應用來說,GC的性能得到了顯著提升。因此在1.6下,STW時間是1.5的2倍,200ms -> 100ms!

棧收縮

Go的gourtine在初始化時有2KB的棧大小,會隨着需要增長。Go的函數在調用前都會假定棧大小是足夠的,如果不夠,那麼舊的gourtine棧會被移動到新的內存區域,同時根據需要重寫指針等。

因此,在程序運行時,goroutine的stack就會自動增長以滿足函數調用需求。GC的一個目標就是回收這些不在需要的棧空間。將goroutine棧移動到一個合適大小的內存空間,這個工作是通過runtime.shrinkstack工作完成的,這個工作在1.5和1.6中是在mark STW階段完成的。


上圖紀錄了1.6的gc圖,runtime.shrinkstack佔據了3/4的時間。如果這個函數能在app運行時異步完成,那對於我們的服務來說,可以得到極大的提升。

Go runtime包的文檔描述了怎麼禁用棧收縮。對於我們的服務,浪費一些內存來換取GC的提升。因此我們決定禁用stack sthrinking,這時GC又得到了2x的提升,STW時間來到了30-70ms。

還有辦法繼續優化嗎?再來另一個profile吧!


缺頁(page faults)?!

細心的讀者應該發現了,上面的GC時間的範圍還是挺大的:30-70ms。這裏的flame graph顯示了較長時間的STW情況:

當GC調用runtime.gcRemoveStackBarriers時,系統產生了一次page fault,導致了一次系統函數調用:page_fault。Page Fault 是kernel把虛擬內存映射到物理內存的方式,進程常常被允許分配大量的虛擬內存,在程序訪問page fault時,會進行映射後去訪問物理內存。

runtime.gcRemoveStackBarriers函數會修正剛被程序訪問的棧內存,事實上,這個函數的目的是移除stack barriers(在GC開始插入),在這個期間系統有大量可用的內存,所以問題來了:爲什麼這次內存訪問會導致page faults

這個時候,一些計算機硬件的背景知識可能會幫上我們。我們用的服務器是現代化的dual-socket機器(應該是主板上有兩個CPU插槽的機器)。每個CPU插槽都有自己的內存條,這種就是NUMA,Non-Uniform Memory Access架構,當線程跑在socket 0上時,那該線程訪問socket 0的內存就會很快,訪問其它內存就會變慢。linux kernel嘗試降低這種延遲:讓線程在它們使用的內存旁運行,並且將物理內存分頁移到了線程運行附近。

有了這些基本知識後,再來看看kernel的page_fault函數。繼續往上看flame graph的調用棧,可以看到kernel調用了do_numa_page和migrate_misplaced_page函數,這兩個函數將程序內存在各個socket的內存之間移動。

在這裏,kernel的這種內存訪問模式是基本上沒有任何意義的,而且爲了匹配這種模式而遷移內存分頁也是代價高昂的。

還好我們有perf,靠它我們跟蹤到了kernel的行爲,這些僅僅依賴Go內部的pprof是不行的-你只能看到程序神祕的慢了,但是慢在哪裏?sorry,我們不知道。但是使用perf是相對較爲複雜的,需要root權限去訪問kernel棧,同時要求Go1.5和1.6使用非標準的構建版本(通過GOEXPERIMENT=framepointer ./make.bash來編譯),不過好消息是GO 1.7版本原生支持這種debug,不需要做任何額外的工作。但是不管如何麻煩,對於我們的服務來說,這種測試是非常必須的。

控制內存遷移

如果使用兩個CPU socket和兩個內存槽太複雜,那我們就只使用一個CPU socket。可以通過linux的tastkset命令來將進程綁定到某個CPU上。這種場景下,程序的線程就只訪問鄰近的內存,kernel會講內存移動到對應的socket內存中。

進行了上面的改造後(除了綁定CPU外,還可以通過設置set_mempolicy(2)函數或者mbind(2)函數將內存策略設置爲MPOL_BIND來實現),STW時間縮減到了10-15ms。這張圖是在pre-1.6版本下獲取的。注意這裏的runtime.freeStackSpans,這個函數在後面已經被移到了併發GC階段,所以不用再關注。到了這裏,對於STW來說,已經沒有多少可以優化了。

GO 1.7

到1.6爲止,我們通過禁用棧收縮等辦法來優化GC。雖然這些辦法都有一定的副作用,比如增加內存消耗等,而且大大增加了操作複雜度。對於一些程序而言,棧收縮是非常重要的,因此只在部分應用上使用了這些優化。還好Go1.7要來了,這個號稱史上改進最多的版本,在Gc上的改進也很顯著:併發的進行棧收縮,這樣我們既實現了低延遲,又避免了對runtime進行調優,只要使用標準的runtime就可以。

自從GO1.5引入併發GC後,runtime會對一個goroutine在上次掃描過stack後是否執行過,進行了跟蹤。STW階段會檢查每個goroutine是否執行過,然後會重新掃描那些執行過的。在GO1.7開始,runtime會維護一個獨立的短list,這樣就不需要在STW期間再遍歷一次所有的goroutine,同時極大的減少了那些會觸發kernel的NUMA遷移的內存訪問。

最後,1.7中,amd64的編譯器會默認維護frame pointers,這樣標準的debug和性能測試工具,例如perf,就可以debug當前的Go函數調用堆棧 了。這樣使用標準構建的程序就可以選擇更多的高級工具,不再需要重新使用不標準的方式來構建Go的工具鏈。這個改進對於系統整體性能測試來說,是非常棒的!

使用2016年6月發佈的pre-1.7版本,GC的STW時間達到了驚人的1ms,而且是在沒有進行任何調優的情況下!!對比Go1.6又是10倍的提升!!

跟Go開發組分享我們的經驗,幫助他們找到了在GC方面一些問題的解決方案。總得來說,從最開始到Go1.7,GC的性能得到了20 * 10 * 10 = 2000x的提升!!!!向Go開發組脫帽致敬!

下一步呢?

所有的分析都聚焦在了GC的STW階段,但是對於GC來說,這個只是調優的一個維度。下一步Go runtime開發的重心將在吞吐方面。

他們近期的提議Transaction Oriented Collector描述了一種方法:對於那些沒有被goroutines共享的內存(goroutine的私有堆棧),提供代價低廉的分配和回收。這樣可以減少full GC的次數,減少整個GC過程的CPU時鐘耗費。



總結:

在現在的Go版本中,還咬着GO GC不行的陳舊觀念不放已經沒有意義了,除非是對延遲要求非常苛刻的應用,比如不允許暫停超過1ms。

現在泛型已經提上了Go開發組的議程了,只不過他們還在討論那種解決方案更完美,等實現,可能要明年了。

祝願Go語言的明天越來越好!



廣告時間

歡迎大家加入Golang隱修會,QQ羣894864歡迎加入這個大

家庭,這裏有所有你想要的,而且熱心大神很多哦!







發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章