字節大規模微服務語言發展之路

演講嘉賓 | 馬春輝

整理|敘緣

編輯|王一鵬

在 DIVE 全球基礎軟件創新大會 2022 上,阿里雲程序語言與編譯器團隊負責人李三紅出品了《DIVE 編程語言新風向專場》。本文整理自字節跳動高級工程師馬春輝在 DIVE 全球基礎軟件創新大會 2022 的演講分享,主題爲 “字節大規模微服務語言發展之路”。

以下爲演講整理內容。

Golang 現狀

Golang(Go 語言)從 09 年開源到現在,短短十多年時間,已經快速成爲編程領域非常熱門的一門語言,根據 2019 年的 JetBrains 的統計,現在全球有兩百多萬名開發者,並且還在持續增長。國外很多公司,比如 Google、Uber,國內也有像字節、騰訊等等,都在比較大規模使用 Go 語言,以至於很多人把 Go 稱爲雲原生最佳語言。

在字節內部,微服務使用最多的語言就是 Golang。但字節也不是一開始就使用 Golang。最早期用的是 Python,在 2014 年,我們經歷了一場大規模的微服務開發的過程,從 Python 轉向 Golang。據說一個最主要的原因是當時 Python 對 CPU 資源利用率不高,當時負責語言選型的同學經過調研,最終選擇了 Golang。現在看來,這位同學眼光非常超前。

當時爲什麼沒有選擇 Java?Java 有非常多優點,一直到現在都具有統治力。但是站在微服務角度,它有一些固有缺點,比如說資源開銷。並行時資源開銷越低,意味着部署密度越高,計算成本越低。而 Java 在運行過程中,要花費較多資源進行 JIT 編譯。另外,JVM 本身要佔用大概五六十兆左右的內存,而在微服務中,內存不能超賣超售,所以相對來說,JVM 本身佔用的內存比較多。另外,JVM 還要佔用大概一兩百兆左右的磁盤,對於分佈式架構的微服務來說,會影響分發部署速度。此外,Java 的啓動速度也一直比較令人詬病,對於需要快速迭代和回滾的微服務來說,啓動速度慢會影響交付效率和快速回滾,也有可能讓用戶感受到訪問延遲。當然,Java 也一直在優化。比如說 CDS,還有這幾年開始興起的靜態編譯。但是很遺憾,字節進行語言選型時,這些項目還不存在。另外可能還有一個沒有選擇 Java 的原因,就是當時負責語言選型的同學不是特別喜歡 Java。

Golang 優勢

中科院的崔慧敏教授說過這麼一句話:“設計編程語言一直有兩個目標,一個是讓編程越來越容易,另外一個就是在新的硬件架構出現以後,可以充分利用硬件特質,發揮更高性能。”

Golang 就是讓編程越來越容易的一種語言,它在開發效率和性能之間取得了比較好的平衡。

Golang 有很多優點。首先,它從語言層面上支持高併發。它自帶了 Goroutine、也就是協程,可以比較充分地利用多核的性能,讓程序員更容易使用併發。其次,它非常簡單易學,並且開發效率非常高。Go 的關鍵字只有 25 個,對比一下 C11,大概有 40 多個關鍵字。雖然 Go 的關鍵字數量更少,但是表達能力很強大,幾乎支持大多數其他語言裏一些比較好用的特性。它的編譯速度也非常快。

Golang 存在的問題

Golang 作爲一個開源語言,而且 Go team 的核心成員也曾公開表示 Go 完全開源,並且也積極擁抱社區,但是,社區內一直有這樣一個說法:“Go 是 Google 的 Go,而不是社區的 Go”。比較典型的一個故事就是 Go 的 module 的發展歷史,或者說它的上位史。一般來說,Go 的發展一直被 Google 的 Go Team 核心團隊牢牢把控,外界的聲音、社區的聲音,對 Go 語言的發展來說似乎沒那麼重要,也就是說,外界很難主導設計一個完整的特性。當然,關於社區的事情,目前我們也在積極籌備,希望能夠得到社區的一些良好反饋。

另外一個問題是,隨着微服務越來越龐大,包括單個微服務越來越大,以及部署微服務的容器數量也越來越大,達到一定的程度之後,會遇到越來越多性能方面的問題,我們在後續會重點介紹。

此外還有一個問題,微服務數量上來之後,會遇到一些觀測問題。

性能問題

前面提到,隨着單個微服務本身大小的增加,以及部署微服務的機器數量越來越多,我們遇到越來越多的性能問題。這些性能問題,可以分爲以下兩個方面,一個是 GC,這是屬於內存管理的一個問題;另外一個是編譯生成代碼的質量問題。另外在調度(Scheduling)這塊,我們也有同事在進行一些優化分析工作。

 性能問題之 GC

首先我們談一下 GC 的問題,或者說內存管理的問題。

內存管理包括了內存分配和垃圾回收兩個方面,對於 Go 來說,GC 是一個併發 - 標記 - 清除(CMS)算法收集器。但是需要注意一點,Go 在實現 GC 的過程當中,過多地把重心放在了暫停時間——也就是 Stop the World(STW)的時間方面,但是代價是犧牲了 GC 中的其他特性。

我們知道,GC 有很多需要關注的方面,比如吞吐量——GC 肯定會減慢程序,那麼它對吞吐量有多大的影響;還有,在一段固定的 CPU 時間裏可以回收多少垃圾;另外還有 Stop the World 的時間和頻率;以及新申請內存的分配速度;還有在分配內存時,空間的浪費情況;以及在多核機器下,GC 能否充分利用多核等很多方面問題。非常遺憾的是,Golang 在設計和實現時,過度強調了暫停時間有限。但這帶來了其他影響:比如在執行的過程當中,堆是不能壓縮的,也就是說,對象也是不能移動的;還有它也是一個不分代的 GC。所以體現在性能上,就是內存分配和 GC 通常會佔用比較多 CPU 資源。

我們有同事進行過一些統計,很多微服務在晚高峯期,內存分配和 GC 時間甚至會佔用超過 30% 的 CPU 資源。佔用這麼高資源的原因大概有兩點,一個是 Go 裏面比較頻繁地進行內存分配操作;另一個是 Go 在分配堆內存時,實現相對比較重,消耗了比較多 CPU 資源。比如它中間有 acquired M 和 GC 互相搶佔的鎖;它的代碼路徑也比較長;指令數也比較多;內存分配的局部性也不是特別好。因此我們有同學做優化的第一件事就是嘗試降低內存管理,特別是內存分配帶來的開銷,進而降低 GC 開銷。

我們這邊同學經過調研發現,很多微服務進行內存分配時,分配的對象大部分都是比較小的對象。基於這個觀測,我們設計了 GAB(Goroutine allocation buffer)機制,用來優化小對象內存分配。Go 的內存分配用的是 tcmalloc 算法,傳統的 tcmalloc,會爲每個分配請求執行一個比較完整的 malloc GC 方法,而我們的 Gab 爲每個 Goroutine 預先分配一個比較大的 buffer,然後使用 bump-pointer 的方式,爲適合放進 Gab 裏的小對象來進行快速分配。我們算法和 tcmalloc 算法完全兼容,而且它的分配操作可以隨意被 Stop the world 打斷。雖然我們的 Gab 優化可能會造成一些空間浪費,但是在很多微服務上測試後,發現 CPU 性能大概節省了 5% 到 12%。

 性能問題之生成代碼

另外一個問題是 Golang 生成代碼的質量問題。Go 的編譯器相比傳統編譯器來說,可以說實現得比較簡陋,優化的數量比較少。Go 在編譯階段總共只有 40 多個 Pass,而作爲對比,LLVM 在 O2 的時候就有兩百多個優化的 Pass。Go 在編譯優化時,優化算法的實現也大多選擇那些計算精度不高,但是速度比較快的算法。也就是說,Go 非常注重編譯時間,導致生成代碼的效率不高。

對於我們微服務的一些場景來說,可以不用那麼在意編譯速度。我們很多微服務,編譯一次後會部署到幾萬個,甚至幾十萬個核上運行,而且通常會運行比較久。在這種情況下,如果增加一點點編譯時間卻能夠節省 CPU 資源,那麼這個開銷是可以接受的。

我們在 Golang 編譯器的基礎上,以編譯速度和 binary size 爲代價進行了一些優化。當然,我們還是控制了編譯速度和 binary size 的代價。比如說我們 binary size 通常的增長大概在 5% 到 15% 之內,而編譯速度也沒有降低特別多,大概 50% 到 100% 左右。

目前我們在編譯器上大概有五個優化,我挑兩三個重點介紹一下。

第一個優化就是內聯優化。內聯優化是其他優化的基礎,它的作用就是在編譯時,把函數的定義替換到調用的位置。函數調用本身是有開銷的,在 Go1.17 之前,Go 的傳參是棧上傳參,函數入棧出棧是有開銷的,做函數調用實際上是執行一次跳轉,可能也會有指令 cache 缺失的開銷。

Golang 原生的內聯優化受到比較多限制。比如一些語言特性會阻止內聯,比如說如果一個函數內部含有 defer,如果把這個函數內聯到調用的地方,可能會導致 defer 函數執行的時機和原有語義不一致。所以這種情況下,Go 沒有辦法做內聯。此外,如果一個函數是 interface 類型的函數調用,那麼這個函數也不會被內聯。

另外,Go 的編譯器從 1.9 纔開始支持非葉子節點的內聯,雖然非葉子節點的內聯默認是打開的,但是策略卻非常保守。舉個例子,如果在非葉子節點的函數中存在兩個函數調用,那麼這個函數在內聯評估時就不會被內聯。另外,從實現的角度上,內聯的策略也做得非常保守。我們在字節的 go 編譯器中修改了內聯策略,讓更多函數可以被內聯,這樣帶來的最直接收益就是可以減少很多函數調用開銷。雖然單次函數調用的開銷可能並不是特別大,但是積少成多,總量也不少。

另外更重要的是,內聯之後增加了其他優化的機會,比如說逃逸分析、公共子表達式刪除等等。因爲編譯器優化大多數都是函數內的局部優化,內聯相當於擴大了這些優化的分析範圍,可以讓後面的分析和優化效果更加明顯。

當然,內聯雖然好,也不能無限制內聯,因爲內聯也是有開銷的。比如我們發現,經過內聯優化後,binary size 體積大概增加了 5% 到 10%,編譯時間也有所增加。同時,它還有另外一個更重要的運行時開銷。也就是說,內聯增加後會導致棧的長度有所增加,進而導致運行時擴棧會增加不小的開銷。爲了降低擴棧的開銷,我們也針對性地調整了一下 Golang 的初始棧大小。

這裏再簡單介紹一下棧調整的背景。Golang 通過 goroutine 支持高併發,用戶可以創建非常多的 goroutine。爲了降低對內存的要求,每個 goroutine 的棧就不能像其他語言的線程的棧那樣,設置成兩兆到八兆這麼大的空間,要不然很容易 OOM。在 Linux 上,Golang 的起始棧大小是 2K。Go 會在函數開頭時檢查一下當前棧的剩餘空間,看看是否滿足當前函數正常運行的需求,所以會在開頭插入一個棧檢查的指令,如果發現不能滿足,就會觸發擴棧操作:先申請一塊內存,把當前棧複製過去,最後再遍歷一下棧,逐幀地修改棧上的指針,避免出現指針指向老的棧的情況。這個開銷是很大的,內聯策略的調整會讓更多數據分配到棧上,加劇這種現象出現,所以我們調整了 GO 的起始棧大小。

我們收益最大的一個優化應該就是內聯策略的優化調整上。另外我們還進行了一些其他優化,比如說前面提的 Gab 優化,我們會在編譯期把 Gab 的快速分配路徑直接生成到編譯器的代碼中,這樣可以加快分配到 Gab 上的對象的內存分配速度。

因爲 Go 的內存分配的優化開銷還是比較大的,所以我們一個優化重點就是想辦法降低在堆上的分配。而 Golang 分配對象到堆上還是棧上,這個過程由逃逸分析控制,所以我們也進行了一些逃逸分析的優化。

大家可以看到,我們目前在編譯器上實現的優化,大多都是通用優化。理論上,所有微服務都可能享受到這些優化的收益,目前我們實際上線的微服務也證明了這點。

我們看一下這些優化的收益。可以看到,在 Microbenchmark,也就是 Go 自帶的那個 Go1 的 benchmark 上,多的有接近 20% 的性能提升,少的也有百分之十幾。

我們發現,基本上線上所有微服務或多或少都會節省一些 CPU 資源。除此之外,延遲也有不同程度的降低,以及內存使用也有不同程度的下降。我們在目前上線的一些微服務上,目前對於高峯期的 CPU,已經節約了大概有十幾萬核了。

正因爲我們在編譯器上經過了幾個簡單的優化,都能得到這麼明顯的優化效果,所以目前我們有兩個策略在走,第一個是繼續嘗試在 Go 原生編譯器裏引入更多編譯器優化,希望進一步提升 Go 的原生編譯器性能;另一個,我們也考慮藉助 LLVM 強大的優化能力,把 Go 的源代碼編譯成 LLVM IR,然後生成可執行代碼來進行性能上的優化。

現在社區上已經有這麼一個項目,就是 Gollvm,基本可用,但是不支持很多重要的特性。比如說它不支持彙編語言,如果微服務當中或者引用的第三方庫裏含有 Plan9 的彙編,Gollvm 現在是不支持的。另外,它的 GC 暫時不支持精確棧掃描,採用的是保守棧掃描策略。另外,Gollvm 現在的性能相比 GO 原生編譯器還有不小差距。但是我們現在也在調研和研究。

 性能問題之觀測

另外,在 Go 上線的過程中,我們還發現了一個比較明顯的問題,就是性能觀測問題,具體來說就是測不準。

它自帶的 pprof 工具,結果不是太準確。這在 Go 社區內部也有一些討論,大概原理是 Go 的 pprof 工具使用 itimer 來發生信號,觸發 pprof 採樣,但是在 Linux 上,特別是某些版本的 Linux 上,這些信號量可能不是那麼準確。根據我們 pprof 的結果來統計,一些容器上大概有 20% 甚至 50% 的結果被丟掉了。它還有一個問題,在一個線程上觸發的信號可能會採樣到另外一個 M 上,一個 M 上觸發的這個採用信號可能會採到另外一個 M 上的數據。

而 perf 呢,很遺憾,我們很多線上容器內部不支持 perf。出於一些安全策略的考慮,也不允許在線上安裝 perf 這樣的工具。

可能大家都聽說過,Uber 在 Go 上開發了一個 pprof++ 的工具,類似於 pprof,也是調用 pprof 的一些接口,使用硬件的 PMU 來觸發採樣。但是 Uber 的 pprof++ 的一個問題是性能損耗非常大。我們經過一些驗證,發現在一些小例子上,在打上 Uber 的 pprof++ 的 patch 之後,僅僅是打上這個 patch 而不是打開這個 pprof,就有大概 3% 左右的性能損耗。我們前面做編譯器優化、做內存分配優化,性能提升很多也就提升 5% 到 10%,只把這 patch 打上去,性能就損耗了 3%。所以我們不能接受這種性能損耗。

比較幸運的是,Go1.18 之後,它提出了 per-M 這個 pprof,對每個 M 來進行採樣,結果相對比較準確。

我們在 Go1.6、1.17 上,也仿照 Uber,採用了 PMU 的 pprof 形式。這種方式實際驗證下來,對性能的損耗比較小,我們想辦法避免了 Uber 的性能損耗大的問題。此外它還能夠提供一些比如 branchmiss/icache 等等更強大的 CPU 採樣分析能力。

總結與展望

Golang 還是比較有前景的,而且目前還在迅速發展。

從長期趨勢來看,基於更高級編程語言的軟件系統會逐步取得競爭優勢。因爲隨着 CPU 等硬件資源的價格進一步下降,而開發成本,開發人力成本,還有項目研發風險以及系統的穩定性、安全性方面,可能會成爲更重要的決策和考量。目前來說,Go 不僅擁有非常好的開發效率,也有着可以說媲美 C 的性能,而且它還很好地提供了互聯網環境下服務端開發的各種好用特性。所以很多人將 Go 語言稱爲雲原生最佳語言。

當然,未來可能會有一種新語言,有更好的效率、更高的性能,也可能會有更開放的設計開發環境,最終取代 Go。我也非常希望能有這樣的新語言出現。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/0X4lasAf5Sbt_tromlqwIQ