優化 Go 的內存使用,避免用 Rust 重寫
關注「Rust 編程指北」,一起學習 Rust,給未來投資
大家好,我是胖蟹哥。
今天分享一篇文章,更多是和 Go 相關。不過從標題可以看到,某些時候,Go 需要較好的優化,才能避免需要使用 Rust 重寫。當然,有些場景,可能會更適合 Rust。這也是有些公司採用 Rust 而不是 Go 的原因。
注意,文章較長!
幾個月前,我們遇到了許多年輕創業公司面臨的問題。我們應該用 Rust 重寫我們的系統嗎?
我們正在構建的工具是通過分析 API 流量被動地監視 API 流量,以提供 “一鍵式”、以 API 爲中心的可見性。我們的用戶會運行一個代理,將 API 流量數據發送到我們的雲進行分析。我們的用戶使用我們來觀察臨時和使用中越來越多的流量——於是他們開始抱怨內存使用情況。
這讓我在絕望的深處和 Go 內存管理的細節中度過了 25 天,試圖讓我們的內存佔用達到可接受的水平。這不是一件容易的事,因爲 Go 是一種內存自動管理語言,其調整垃圾收集的能力有限。
劇透:最終我取得了勝利,我們的團隊仍在使用這個方法。我們設法搞定了 Go 的內存管理並達到了可接受的內存使用水平。
尤其是因爲我在這個過程中沒有找到太多的博客文章來指導我,因此我想寫一些關鍵步驟和經驗教訓。我希望這篇博文對試圖減少 Go 內存佔用的人有所幫助!
如何開始
Akita command-line agent[1] 被動的觀測 API 流量。它以 Akita 的自定義 protobuf 格式創建混淆跟蹤,以發送到 Akita 雲進行進一步分析,或捕獲 HAR 文件以供本地使用。CLI 的初始版本早於我在 Akita 的時間,但我負責確保流量收集滿足我們用戶的需求。使用 Go 的決定使得使用 GoPacket 成爲可能,如 Akita 之前的博客文章 Programmatically Analyze Packet Captures with GoPacket 中所述 [2]。這比嘗試編寫或改編其他 TCP 重組代碼要容易得多。但是,一旦我們開始從臨時和生產環境中捕獲流量 [3],而不僅僅是手動測試和持續集成運行,collection agent 的足跡變得更加重要。
去年夏天的一天,我們注意到 Akita CLI 在收集數據包跟蹤時通常表現良好,但根據容器的常駐集大小來衡量,有時會膨脹到千兆字節的內存。
我們的記憶力在這一努力開始時會達到峯值
不久之後,我們收到了用戶的反饋,手頭的任務變得清晰起來:將內存佔用量減少到可預測的穩定數量。我們的目標是與其他收集代理(例如 DataDog)類似,我們也在境中運行它並可以用於比較。
在 Go 的限制下工作時,這是具有挑戰性的。Go 運行時使用非分代、非壓縮、併發標記和清除垃圾收集器。這種風格的 GC 避免了 “停頓” 和引入長時間的停頓!Go 社區爲他們實現了一系列良好的設計權衡而感到自豪 [4]。然而,Go 對簡單性的關注意味着只有一個參數 SetGCPercent,它控制堆中的活動對象佔比。這可用於以更高的 CPU 使用率爲代價來減少內存開銷,反之亦然。Go 特性(如切片和映射)的慣用用法也 “默認” 引入了大量內存壓力,因爲它們很容易創建。
當我用 C++ 編程時,內存峯值也是一個潛在的問題,但也有很多慣用的方法來處理它們。例如,我們可以專門分配內存或限制特定的調用。我們可以對不同的分配器進行基準測試,或者將一種數據結構替換爲具有更好內存屬性的另一種數據結構。我們甚至可以改變我們的行爲(比如丟棄更多數據包)以應對內存壓力。
我還幫助調試了 Java 中類似的內存問題,這些問題在存儲控制器的受限環境中運行。Java 提供了豐富的工具生態系統,用於分析正在運行的程序上的堆使用和分配行爲。它還提供了一組更大的 knobs 來控制垃圾收集器的行爲。對於我們的應用程序,當內存使用量太大時簡單地退出是可以接受的,而不是通過要求啓動容器限制來危及生產系統的穩定性。
但是對於我當前的問題,我無法向垃圾收集器提供有關何時或如何運行的提示。我也不能將所有內存分配引導到集中控制點。有兩種技術是,但在過程中很難執行:
-
減少活動對象的內存佔用。 正在使用的對象不能被垃圾回收,因此減少內存使用的首要方法是減少它們的大小。
-
減少執行的分配總數。 當程序運行以回收未使用的內存時,Go 會同時進行垃圾收集。但是,Go 的設計目標是儘可能少地影響延遲。如果分配率暫時增加,Go 不僅需要一段時間才能趕上,而且 Go 會故意讓堆大小增加,以便沒有大的延遲等待內存可用。這意味着分配大量對象,即使它們不是同時處於活動狀態,也會導致內存使用量激增,直到垃圾收集器可以完成其工作。
作爲案例研究,我將介紹 Akita CLI 可以應用這些想法的領域。
減少分配給持久對象的內存
我們的第一個配置文件,使用 Go 堆分析器,似乎指向一個明顯的罪魁禍首:重組(reassembly)緩衝區。
顯示重組瓶頸的配置文件
正如之前的一篇博文所述,我們使用 gopacket 來捕獲和解釋網絡流量 [5]。Gopacket 通常非常擅長避免過度分配,但是當 TCP 數據包無序到達時,它會將它們排入重組緩衝區。重組代碼最初從 “頁面緩存” 爲這個緩衝區分配內存,並在那裏維護一個指向它的指針,從不將內存返回給垃圾收集器。
我們的第一個理論是,主機收到丟棄的數據包可能會導致內存使用量出現巨大的、持續的峯值。Gopacket 分配內存來存放亂序接收的數據;也就是說,序列號在下一個數據包應該在的位置之前。HTTP 可以使用持久連接,因此當 gopacket 耐心等待永遠不會發生的重傳時,我們可能會看到兆字節甚至千兆字節的流量。這會導致立即(因爲大量緩衝數據)和持久(因爲永遠不會釋放頁面緩存)的高使用率。
gopacket 數據包分配剖析
我們確實有一個超時,最終迫使 gopacket 交付不完整的數據。但是這被設置爲一個相當長的值,比任何合理的往返時間在繁忙的連接上進行實際數據包重傳都要長得多。我們也沒有使用 gopacket 中可用的設置來限制每個流中的最大重組緩衝區,或用於重組的最大 “頁面緩存”。這意味着可以分配的內存量沒有合理的上限;我們受制於在超時之前到達的數據包有多快。
爲了找到一個合理的值來限制內存使用,我查看了我們系統中的一些數據,以嘗試估計每個流的限制,該限制雖然很小但仍然足夠大以處理真正的重傳。我們發生的一起內存飆升事件表明,在 40 秒內內存使用量增長了 3GB,或者數據速率約爲 75MByte / 秒。這表明,在該數據速率下,我們甚至可以容忍 100 毫秒的往返時間,每個連接只有 7.5 MB 的重組緩衝區。我們將 gopacket 重新配置爲每個連接最多使用 4,000 個 “頁面”(每個 1900 字節,原因我不明白),以及 150,000 個總頁面的共享限制——大約 200MB。
不幸的是,我們不能僅使用 200MB 作爲單一的全侷限制。Akita CLI 爲每個網絡接口設置不同的 gopacket 重組流。這允許它並行處理不同的接口,但我們的內存使用預算必須拆分爲每個接口的單獨限制。Gopacket 沒有任何方法可以在不同的彙編程序之間指定頁面限制。(而且,我們希望大多數流量僅通過單個接口到達的希望很快就被否定了。)因此,這意味着與其有 200MB 的預算來處理實際的數據包丟失,可用於重組緩衝區的實際內存可能低至 20MB——足夠幾個連接,但不是很多。我們最終沒有解決這個問題;我們動態地將 200MB 平均分配給我們正在偵聽的多個網絡接口。
我們還升級到了最新版本的 gopacket,它從一個 sync.Pool 分配了重組緩衝區。Go 標準庫中的這個類就像一個空閒列表,但它的內容最終可以被垃圾收集器回收。這意味着即使我們確實遇到了峯值,內存最終也會減少。但這隻會提高平均值,而不是最壞的情況。
減少這些最大值使我們遠離了那些可怕的 5 GiB 內存峯值,但我們有時仍然會超過 1GiB。還是太大了。
更新了內存使用情況
在 DataDog 中觀察了一段時間後,我確信這些峯值與傳入 API 流量的爆發有關。
額外知識:祕密內幕
幫助用戶控制代理內存佔用,我們網絡參數可通過命令行參數,不列入我們的主要幫助輸出。你可以使用 --gopacket-pages
控制的最大大小 gopacket “頁面緩存”,同時,go-packet-per-conn 控制頁面一個 TCP 連接的最大數量。
我們也暴露了數據包捕獲 “流超時” --stream-timeout-seconds
,控制我們會等多久,就像 --go-packet-per-conn
控制多少數據積累。
最後,--max-http-length
控制着最大數量,這個數我們將試圖捕捉從 HTTP 請求負載或響應體獲得。它默認爲 10 MB。
減少分配給臨時對象的內存
由於修復緩衝區情況並沒有完全解決內存問題,我不得不繼續尋找可以改善內存佔用的地方。沒有其他單一位置能夠保留大量內存。
事實上,即使我們的代理使用了多達 GB 的內存,每當我們查看 Go 的堆配置文件時,我們從未發現它 “在運行中” 有超過幾百 MB 的活動對象。Go 的垃圾回收策略確保總的常駐內存大約是所有存活對象佔用量的兩倍——因此選擇 Go 有效地使我們的成本翻了一番。但是我們的配置文件從來沒有向我們顯示過 500MB 的實時數據,在最壞的情況下只是略高於 200MB。這向我表明,我們已經用存活對象做了我們所能做的一切。
是時候轉移焦點並查看總分配額了。幸運的是,Go 的堆分析器會自動將其收集爲同一個轉儲的一部分,因此我們可以深入瞭解我們在何處分配了大量內存,從而爲垃圾收集器創建 backlog。這是一個示例,顯示了一些明顯的地方(也可在此 Gist 中找到 [6]):
分析堆以減少總分配
重複正則表達式編譯
一份堆配置文件顯示 30% 的分配在 regexp.compile 下。我們使用正則表達式來識別一些數據格式。每次要求執行此工作時,執行此推理的模塊都會重新編譯這些正則表達式:
發現正則表達式處理中的內存瓶頸
將正則表達式移動到模塊級變量中很簡單,只會編譯一次。這意味着我們不再每次都爲正則表達式分配新對象,從而減少了臨時分配的數量。
這部分工作感覺有些令人沮喪,因爲儘管節點從分配樹中刪除,但很難觀察到端到端內存使用情況的變化。因爲我們正在尋找內存使用量的峯值,所以它們不能可靠地按需發生,我們不得不使用像本地負載測試這樣的代理。
訪客上下文
我們用於請求和響應內容的中間表示 (IR) 有一個訪問者框架。內存分配的最高來源是在訪問者中分配上下文對象,它跟蹤代碼當前正在訪問的中間表示位置。因爲訪問者使用遞歸,我們能夠使用一個簡單的預分配堆棧來替換它們。當我們訪問 IR 中更深的一層時,我們通過將索引增加到上下文對象的預分配範圍(並在必要時擴展它)來分配一個新條目。這會將數十甚至數百個分配轉換爲一兩個。
更改之前的配置文件顯示 27.1% 的分配來自 appendPath。更改後立即顯示只有 4.36%。但是,雖然變化很大,但並沒有我想象的那麼大。一些內存分配似乎 “轉移” 到了一個以前不是主要貢獻者的函數!
// before
flat flat% sum% cum cum%
7562.56MB 27.14% 27.14% 7562.56MB 27.14% github.com/akitasoftware/akita-libs/visitors/http_rest.stackVisitorContext.appendPath
// after
flat flat% sum% cum cum%
1225.56MB 5.99% 23.87% 2439.59MB 11.93% github.com/akitasoftware/akita-libs/visitors/http_rest.stackVisitorContext.EnterStruct
892.03MB 4.36% 33.36% 892.03MB 4.36% github.com/akitasoftware/akita-libs/visitors/http_rest.stackVisitorContext.appendPath
將 go tool pprof 切換到 granularity=lines 導致它顯示逐行分配計數而不是函數級總數。這有助於識別之前隱藏在 appendPath 中的幾個分配源,例如創建一個包含返回根的整個路徑的切片。即使多個切片可以重用相同的底層數組,如果共享對象中有可用容量,按需延遲構建這些切片,而不是每次我們切換上下文時,這是一個很大的勝利。
雖然這些預分配和延遲分配對分配的內存量有很大影響,正如分析報告的那樣,但它似乎對我們觀察到的峯值大小沒有太大影響。這表明垃圾收集器在及時回收這些臨時對象方面做得很好。但是讓垃圾收集器的工作不那麼辛苦仍然是理解剩餘問題和 CPU 開銷的勝利。
散列
我們使用 deepmind/objecthash-proto[7] 來散列我們的中間表示。這些散列用於刪除重複對象和索引無序集合,例如響應字段。我們之前已將其確定爲大量 CPU 時間的來源,但它也顯示爲大量內存分配器。我們已經採取了一些措施來避免多次重新散列相同的對象,但它仍然是內存和 CPU 的主要用戶。如果不對我們的中間表示和在線協議進行重大重新設計,我們將無法避免散列。
散列庫中有幾個主要的分配來源。objecthash-proto 使用反射來訪問 protobufs 中的字段,一些反射方法分配內存,如上面配置文件中的 reflect.packEface。另一個問題是,爲了一致地散列結構,objecthash-proto 創建了一個 (key hash, value hash) 對的臨時集合,然後按 key hash 對其進行排序。這在配置文件中顯示爲 bytes.makeSlice。而且我們有很多結構!最後一個煩惱是 objecthash-proto 在散列之前封送每個 protobuf,只是爲了檢查它是否有效。所以分配了相當數量的內存,然後立即扔掉。
在解決了這個問題邊緣之後,我決定生成只對我們的結構進行散列的函數。objecthash-proto 的一大優點是它適用於任何 protobuf!但是我們不需要那個,我們只需要我們的中間表示來工作。一個快速原型表明,編寫一個生成相同哈希值的代碼生成器是可行的,但以更有效的方式這樣做:
-
預先計算所有的鍵哈希值並通過索引引用它們。(protobuf 結構中的鍵只是小整數。)
-
按照鍵哈希的排序順序訪問結構中的字段,這樣就不需要緩衝和排序。
-
直接訪問結構中的所有字段,而不是通過反射。
所有這些都將內存使用量減少到了 objecthash-proto 使用的 OneOfOne/xxhash[8] 庫中單個哈希計算所需的內存。對於 map,我們不得不退回到對哈希進行排序的原始策略,但幸運的是,我們的 IR 由相對較少的 map 組成。
這項工作最終對代理在負載下的行爲產生了明顯的影響。
我做到了!這是散列
現在,分配配置文件主要顯示了我們無法避免的 “有用” 工作:爲進來的數據包分配空間。
解壓數據的臨時存儲
我們還沒有完成。在整個過程中,我真正希望堆配置文件告訴我的是 “在 Go 增加堆大小_之前_分配_了_什麼?” 然後我會更好地瞭解是什麼導致了額外的內存被使用,而不僅僅是之後哪些對象處於活動狀態。大多數時候,導致增加的不是新的 “永久” 對象,而是臨時對象的分配。爲了幫助回答這個問題並識別那些瞬時分配,我每 90 秒從我們生產環境中的一個代理收集堆配置文件,使用 Go 分析器的 HTTP 接口。
我可以看一下配置 90 秒內完成,看看不同的穩定狀態。pprof 工具允許你跟蹤和另一個之間的區別,簡化分析。發現了一個地方,需要在其內存使用是有限的:
Showing nodes accounting for 419.70MB, 87.98% of 477.03MB total
Dropped 129 nodes (cum <= 2.39MB)
Showing top 10 nodes out of 114
flat flat% sum% cum cum%
231.14MB 48.45% 48.45% 234.14MB 49.08% io.ReadAll
52.93MB 11.10% 59.55% 53.43MB 11.20% github.com/google/gopacket/pcap.(*Handle).ReadPacketData
51.45MB 10.79% 70.33% 123.88MB 25.97% github.com/google/gopacket.(*PacketSource).NextPacket
42.42MB 8.89% 79.23% 42.42MB 8.89% bytes.makeSlice
這表明在短短 90 秒內分配了 200 MB(與我們的整個最大重組緩衝區一樣大)!我查看了 io.ReadAll 的回溯,發現分配的原因是緩衝區保存解壓縮數據,然後將其提供給解析器。這有點令人驚訝,因爲我已經將 HTTP 請求或響應的最大限制爲 10MB。但該限制計算的是壓縮大小,而不是未壓縮大小。我們臨時爲 HTTP 響應的未壓縮版本分配了大量內存。
這促使了兩組不同的改進:
-
對於我們關心的數據,使用 Reader 而不是 []byte 來移動數據。JSON 和 YAML 解析器都接受 Reader,因此解壓的輸出可以直接輸入解析器,無需任何額外的緩衝區。
-
對於無論如何我們都無法完全解析的數據,我們對解壓縮大小施加了限制。(Akita 嘗試確定是否可以將文本有效負載解析爲可識別的格式,但我們需要這樣做的數據量很小。)
我們應該改用 Rust 嗎?
雖然這些改進事後看來似乎很明顯,但在大內存減少期間,我和團隊確實有幾次考慮用 Rust 重寫系統,Rust 是一種可以讓你完全控制內存的語言。
我們對 Rust 重寫的立場如下:
-
🦀 支持重寫:Rust 能手動管理內存,因此我們將避免不得不與垃圾收集器搏鬥的問題,因爲我們只會自己釋放未使用的內存,或者更仔細地能夠設計對增加的負載的響應。
-
🦀 支持重寫:Rust 在時髦的程序員中非常流行,似乎許多有創業傾向的開發人員都想加入基於 Rust 的初創公司。
-
🦀 支持重寫:Rust 是一種精心設計的語言,具有很好的特性和很好的錯誤消息。與 Go 的人體工程學相比,人們似乎對 Rust 的人體工程學抱怨更少。
-
🛑 反對重寫:Rust 有手動內存管理,這意味着每當我們編寫代碼時,我們都必須花時間自己管理內存。
-
🛑 反對重寫:我們的代碼庫已經用 Go 編寫了!重寫會使我們退回幾個人周,甚至幾個人月的工程時間。
-
🛑 反對重寫:Rust 比 Go 具有更高的學習曲線,因此團隊(以及可能的團隊新成員)需要更多時間來跟上進度。
對於那些認爲我是在開玩笑說初創公司通常面臨用 Rust 重寫的決定的人來說,Rust 重寫現象是非常真實的。見這裏 [9]:
Jean 講述了我們在團隊中進行的實際對話,並證明了 Rust 的受歡迎程度。
我不會說出名字,但如果你仔細查看初創公司的職位發佈甚至博客文章,你會看到 “Rust 重寫” 的帖子。
歸根結底,我很高興能夠將 Go 中的內存佔用降低到一個合理的水平,這樣我們就可以專注於構建新功能,而不是花大量時間學習新語言和移植現有功能。如果我們的代理最初是用 Python 而不是 Go 編寫的,這可能是一個不同的故事,但 Go 的級別足夠低,我不認爲繼續開發它會出現重大問題。
事情進展如何?
今天,我們能夠從我們自己經常忙碌的生產環境中提取數據,同時保持 Akita CLI 內存使用率較低。在我們的生產環境中,99% 的內存佔用低於 200MB,99.9% 的內存佔用低於 280MB。我們避免了用 Rust 重寫我們的系統。我們已經一個多月沒有被投訴了。
我們今天的內存使用情況
雖然這些改進是專門針對 Akita CLI 代理的,但吸取的教訓不止是針對它:
-
** 減少固定開銷。**Go 的垃圾收集確保你用另一個字節的系統內存爲每個活動字節付費。保持較低的固定開銷將減少駐留集的大小。
-
** 配置文件分配,而不僅僅是實時數據。** 這揭示了是什麼讓 Go 垃圾收集器執行工作,並且內存使用量的峯值通常是由於這些站點的活動增加。
-
流,不要緩衝。在繼續下一個階段之前收集一個處理階段的輸出是一個常見的錯誤。但這可能導致分配與你必須爲完成的結果所做的內存重複分配,並且可能在整個管道完成之前無法釋放。
-
用覆蓋整個工作流程的壽命更長的分配替換頻繁的小分配。結果不是非常慣用的 Go-like,但可以產生巨大的影響。
-
避免使用具有不可預測內存成本的通用庫。Go 的反射能力很棒,可以讓你構建強大的工具。但是,使用它們通常會導致難以確定或控制的成本。像傳入切片而不是固定大小的數組這樣簡單的習慣用法可能會降低性能和內存成本。幸運的是,使用標準庫的 go/ast 和 go/format 包很容易生成 Go 代碼。
雖然我們取得的結果不如用一種讓我們考慮每個字節的語言完全重寫,但它們比以前的行爲有了巨大的改進。我們認爲仔細關注內存使用是系統編程的一項重要技能,即使在有垃圾收集語言中也是如此。
作者:Mark Gritter,原文鏈接:https://www.akitasoftware.com/blog-posts/taming-gos-memory-usage-or-how-we-avoided-rewriting-our-client-in-rust
參考資料
[1]
Akita command-line agent: https://github.com/akitasoftware/akita-cli
[2]
Programmatically Analyze Packet Captures with GoPacket 中所述: https://www.akitasoftware.com/blog-posts/programmatically-analyze-packet-captures-with-gopacket
[3]
從臨時和生產環境中捕獲流量: https://www.akitasoftware.com/blog-posts/monitoring-akitas-services-with-akita
[4]
而感到自豪: https://go.dev/blog/ismmkeynote
[5]
我們使用 gopacket 來捕獲和解釋網絡流量: https://www.akitasoftware.com/blog-posts/programmatically-analyze-packet-captures-with-gopacket
[6]
此 Gist 中找到: https://gist.github.com/jeanqasaur/44e692cd204be9bc097f560238a053ad
[7]
deepmind/objecthash-proto: https://github.com/deepmind/objecthash-proto
[8]
OneOfOne/xxhash: https://github.com/OneOfOne/xxhash
[9]
見這裏: https://twitter.com/jeanqasaur/status/1422621052845232131
覺得不錯,點個贊吧
掃碼關注「Rust 編程指北」
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/X6ezLHAiBoQTq61MWhSILg