Go中的垃圾收集:第一部分

序幕

這是三部分系列中的第一篇文章,它將提供對Go中垃圾收集器背後的機制和語義的理解。這篇文章重點介紹了收藏家語義的基礎材料。

介紹

垃圾收集器負責跟蹤堆內存分配,釋放不再需要的分配,並保留仍在使用中的分配。語言決定如何實現此行為很複雜,但應該不應該讓應用程序開發人員瞭解細節以構建軟件。此外,對於語言的VM或運行時的不同版本,這些系統的實現總是在變化和發展。對於應用程序開發人員來說,重要的是保持一個良好的工作模型,瞭解垃圾收集器對其語言的行為以及如何在不關心實現的情況下對這種行為表示同情。

從版本1.12開始,Go編程語言使用非代數併發三色標記和掃描收集器。如果你想直觀地看到標記和掃描收集器是如何工作的,Ken Fox寫了這篇偉大的文章並提供動畫。Go的收集器的實現隨著Go的每個版本的變化而發生變化。因此,一旦發佈下一版本的語言,任何談論實現細節的帖子將不再準確。

儘管如此,我將在本文中做的建模不會關注實際的實現細節。建模將關注您將經歷的行為以及您應該在未來幾年看到的行為。在這篇文章中,我將與您分享收集者的行為,並解釋如何對該行為表示同情,無論當前的實施情況如何或未來如何變化。這將使您成為更好的Go開發人員。

堆不是容器

我永遠不會將堆稱為可以存儲或釋放值的容器。重要的是要理解沒有線性遏制內存來定義“堆”。認為為進程空間中的應用程序使用保留的任何內存都可用於堆內存分配。虛擬或物理存儲任何給定的堆內存分配與我們的模型無關。這種理解將幫助您更好地瞭解垃圾收集器的工作原理。

收集器行為

收集開始時,收集器將運行三個階段的工作。其中兩個階段會產生Stop The World(STW)延遲,另一個階段會產生延遲,從而降低應用程序的吞吐量。這三個階段是:

  • Mark Setup - STW
  • Marking - Concurrent
  • Mark Termination - STW

這是每個階段的細分。

Mark Setup - STW

集合開始時,必須執行的第一個活動是打開寫屏障。Write Barrier的目的是允許收集器在集合期間維護堆上的數據完整性,因為收集器和應用程序goroutine將同時運行。

為了打開寫屏障,必須停止每個運行goroutine的應用程序。此活動通常非常快,平均在10到30微秒之內。也就是說,只要應用程序goroutines表現正常。

Go中的垃圾收集:第一部分 - 語義

圖1

圖1顯示了在集合開始之前運行的4個應用程序goroutine。必須停止這4個goroutine中的每一個。唯一的方法是讓收集器觀察並等待每個goroutine進行函數調用。函數調用保證goroutine處於安全點停止。如果其中一個goroutine不進行函數調用而其他函數執行,會發生什麼?

圖2顯示了一個真正的問題。在P4上運行的goroutine停止之前,集合無法啟動,並且這種情況不會發生,因為它處於執行某些數學運算的緊密循環中。

Go中的垃圾收集:第一部分 - 語義

圖2

代碼1:

 func add(numbers []int) int {
var v int
for _, n := range numbers {
v += n
}
return v
}

代碼1顯示了在P4上運行的Goroutine正在執行的代碼。根據切片的大小,Goroutine可能會運行一段不合理的時間而無法停止。這種代碼可以阻止集合啟動。更糟糕的是,當收集器等待時,其他P不能為任何其他goroutine提供服務。goroutines在合理的時間範圍內進行函數調用至關重要

Marking - Concurrent

一旦寫屏障打開,收集器就開始標記階段。收集器所做的第一件事就是佔用自身可用CPU容量的25%。收集器使用Goroutines進行收集工作,並且需要與Goroutines使用的應用程序相同的P和M. 這意味著對於我們的4線程Go程序,一個完整的P將專門用於收集工作。

Go中的垃圾收集:第一部分 - 語義

圖3

圖3顯示了收集器在收集過程中如何為自己收集P1。現在收集器可以開始標記階段。標記階段包括在堆內存中標記仍在使用中的值。這項工作首先檢查所有現有goroutine的堆棧,以找到堆內存的根指針。然後收集器必須從那些根指針遍歷堆內存圖。當標記工作在P1上進行時,應用程序工作可以在P2,P3和P4上同時繼續進行。這意味著收集器的影響已最小化到當前CPU容量的25%。

我希望這是故事的結局,但事實並非如此。如果在收集過程中確定在P1上專用於GC的Goroutine在使用中的堆內存達到極限之前無法完成標記工作,該怎麼辦?如果3個Goroutines中只有一個進行應用工作是收集器無法及時完成的​​原因怎麼辦?在這種情況下,新的分配必須放慢速度,特別是從那個Goroutine。

如果收集器確定它需要減慢分配,它將招募應用程序Goroutines以協助標記工作。這稱為馬克助攻。任何應用程序Goroutine放置在Mark Assist中的時間長度與它添加到堆內存中的數據量成正比。Mark Assist的一個積極的副作用是它有助於更​​快地完成收集。

Go中的垃圾收集:第一部分 - 語義

圖4

圖4顯示了在P3上運行的應用程序Goroutine現在如何執行Mark Assist並幫助進行收集工作。希望其他應用程序Goroutines也不需要參與其中。分配重的應用程序可以看到大多數正在運行的Goroutines在收集期間執行少量Mark Assist。

收藏家的一個目標是消除對Mark Assists的需求。如果任何給定的集合最終需要大量的Mark Assist,則收集器可以更早地開始下一個垃圾收集。這樣做是為了減少下一次收集所需的Mark輔助量。

Mark Termination - STW

標記工作完成後,下一階段是Mark Termination。這是當寫屏障關閉時,執行各種清理任務,並計算下一個收集目標。在標記階段發現自己處於緊密循環中的Goroutines也可能導致Mark Termination STW延遲延長。

Go中的垃圾收集:第一部分 - 語義

圖5

圖5顯示了Mark終止階段完成後所有Goroutines是如何停止的。此活動通常平均在60到90微秒之內。這個階段可以在沒有STW的情況下完成,但是通過使用STW,代碼更簡單,並且增加的複雜性不值得小的增益。

收集完成後,應用程序Goroutines可以再次使用每個P,應用程序將恢復全油門。

Go中的垃圾收集:第一部分 - 語義

圖6

圖6顯示了收集完成後,所有可用的P現在如何處理應用程序的工作。應用程序恢復到收集開始之前的完全控制狀態。

Sweeping - Concurrent

完成一個名為Sweeping的集合後會發生另一個活動。清除是指回收與堆內存中未標記為使用中的值相關聯的內存。當應用程序Goroutines嘗試在堆內存中分配新值時,會發生此活動。Sweeping的延遲被添加到在堆內存中執行分配的成本中,並且不依賴於與垃圾收集相關的任何延遲。

以下是我的機器上的跟蹤示例,其中有12個硬件線程可用於執行Goroutines。

Go中的垃圾收集:第一部分 - 語義

圖7

圖7顯示了跟蹤的部分快照。您可以在此集合中看到如何(將您的視圖保持在頂部的藍色GC行中),十二個P中的三個專用於GC。你可以看到Goroutine 2450,1978和2696在這段時間裡正在表演Mark Assist的工作,而不是它的應用工作。在集合的最後,只有一個P專用於GC並最終執行STW(Mark Termination)工作。

收集完成後,應用程序將恢復全油門運行。除了你看到Goroutines下面有很多玫瑰色的線條。

Go中的垃圾收集:第一部分 - 語義

圖8

圖8顯示了那些玫瑰色線條代表Goroutine執行清掃工作而非其應用工作的時刻。這些是Goroutine試圖在堆內存中分配新值的時刻。

Go中的垃圾收集:第一部分 - 語義

圖9

圖9顯示了Sweep活動中其中一個Goroutines的堆棧跟蹤結束。調用runtime.mallocgc是調用在堆內存中分配新值。調用runtime.(*mcache).nextFree導致Sweep活動。一旦堆內存中沒有更多的分配要回收,nextFree就不會再看到調用了。

剛剛描述的集合行為僅在集合已啟動並正在運行時發生。GC百分比配置選項在確定集合何時開始時起著重要作用。

GC百分比

運行時中有一個名為GC Percentage的配置選項,默認情況下設置為100。此值表示在下一個集合必須啟動之前可以分配多少新堆內存的比率。將GC百分比設置為100意味著,基於在收集完成後標記為活動的堆內存量,下一個集合必須在100%以上的新分配添加到堆內存時啟動。

舉個例子,假設一個集合在使用中有2MB的堆內存。

注意:使用Go時,本文中堆內存的圖表不代表真實的配置文件。Go中的堆內存通常會碎片化並且混亂,並且您沒有圖像所代表的乾淨分離。這些圖提供了一種以更容易理解的方式可視化堆內存的方法,該方式對於您將體驗的行為是準確的。

Go中的垃圾收集:第一部分 - 語義

圖10

圖10顯示了最後一次收集完成後正在使用的2MB堆內存。由於GC百分比設置為100%,因此下一個集合需要在添加2 MB堆內存時或之前啟動。

Go中的垃圾收集:第一部分 - 語義

圖11

圖11顯示現在正在使用2個MB的堆內存。這將觸發一個集合。查看所有這些操作的方法是為每個發生的集合生成GC跟蹤。

GC跟蹤

運行任何Go應用程序時,可以通過在環境變量中GODEBUG包含gctrace=1選項來生成GC跟蹤。每次發生集合時,運行時都會將GC跟蹤信息寫入stderr。

代碼2

GODEBUG=gctrace=1 ./app
gc 1405 @6.068s 11%: 0.058+1.2+0.083 ms clock, 0.70+2.5/1.5/0+0.99 ms cpu, 7->11->6 MB, 10 MB goal, 12 P
gc 1406 @6.070s 11%: 0.051+1.8+0.076 ms clock, 0.61+2.0/2.5/0+0.91 ms cpu, 8->11->6 MB, 13 MB goal, 12 P
gc 1407 @6.073s 11%: 0.052+1.8+0.20 ms clock, 0.62+1.5/2.2/0+2.4 ms cpu, 8->14->8 MB, 13 MB goal, 12 P

代碼2顯示瞭如何使用該GODEBUG變量生成GC跟蹤。該列表還顯示了正在運行的Go應用程序生成的3條跟蹤。

以下是通過查看清單中的第一個GC跟蹤線來細分GC跟蹤中每個值的含義。

代碼3

gc 1405 @6.068s 11%: 0.058+1.2+0.083 ms clock, 0.70+2.5/1.5/0+0.99 ms cpu, 7->11->6 MB, 10 MB goal, 12 P
// General
gc 1404 : The 1404 GC run since the program started
@6.068s : Six seconds since the program started
11% : Eleven percent of the available CPU so far has been spent in GC
// Wall-Clock
0.058ms : STW : Mark Start - Write Barrier on
1.2ms : Concurrent : Marking
0.083ms : STW : Mark Termination - Write Barrier off and clean up
// CPU Time
0.70ms : STW : Mark Start
2.5ms : Concurrent : Mark - Assist Time (GC performed in line with allocation)
1.5ms : Concurrent : Mark - Background GC time
0ms : Concurrent : Mark - Idle GC time
0.99ms : STW : Mark Term
// Memory

7MB : Heap memory in-use before the Marking started
11MB : Heap memory in-use after the Marking finished
6MB : Heap memory marked as live after the Marking finished
10MB : Collection goal for heap memory in-use after Marking finished
// Threads
12P : Number of logical processors or threads used to run Goroutines

代碼3顯示了第一個GC跟蹤線的實際數字,按行值分解。我最終將討論大多數這些值,但現在只關注跟蹤1405的GC跟蹤的內存部分。

圖12

Go中的垃圾收集:第一部分 - 語義

代碼4

// Memory
7MB : Heap memory in-use before the Marking started
11MB : Heap memory in-use after the Marking finished
6MB : Heap memory marked as live after the Marking finished
10MB : Collection goal for heap memory in-use after Marking finished

此GC跟蹤行在代碼4中告訴您的是,在標記工作開始之前,正在使用的堆內存量為7MB。標記工作完成後,正在使用的堆內存量達到11MB。這意味著在收集過程中還有4MB的分配。標記工作完成後標記為活動的堆內存量為6MB。這意味著在下一個集合需要啟動之前,應用程序可以將正在使用的堆內存量增加到12MB(實時堆大小為6MB的100%)。

你可以看到收藏家錯過了1MB的目標。標記工作完成後正在使用的堆內存量為11MB而不是10MB。沒關係,因為目標是根據當前正在使用的堆內存量,標記為實時的堆內存量以及有關在集合運行時將發生的其他分配的計時計算來計算的。在這種情況下,應用程序做了一些事情,需要在Marking之後使用更多堆內存而不是預期。

如果查看下一個GC跟蹤線(1406),您將看到事情在2ms內發生了變化。

圖13

Go中的垃圾收集:第一部分 - 語義

代碼5

gc 1406 @6.070s 11%: 0.051+1.8+0.076 ms clock, 0.61+2.0/2.5/0+0.91 ms cpu, 8->11->6 MB, 13 MB goal, 12 P
// Memory
8MB : Heap memory in-use before the Marking started
11MB : Heap memory in-use after the Marking finished
6MB : Heap memory marked as live after the Marking finished
13MB : Collection goal for heap memory in-use after Marking finished

代碼5顯示了這個集合在上一個集合開始後2ms(6.068s對6.070s)的啟動情況,即使使用中的堆內存僅達到允許的12MB的8MB。重要的是要注意,如果收集者決定更早開始收集它會更好。在這種情況下,它可能更早開始,因為應用程序分配很多,收集器希望減少此集合期間的Mark Assist延遲量。

還有兩點需要注意。這次收藏家保持在其目標之內。標記完成後正在使用的堆內存量為11MB而不是13MB,少了2 MB。標記完成後標記為活動的堆內存量在6MB時相同。

作為旁註。您可以通過添加gcpacertrace=1標誌從GC跟蹤中獲取更多詳細信息。這會導致收集器打印有關併發起搏器內部狀態的信息。

代碼6

$ export GODEBUG=gctrace=1,gcpacertrace=1 ./app
Sample output:
gc 5 @0.071s 0%: 0.018+0.46+0.071 ms clock, 0.14+0/0.38/0.14+0.56 ms cpu, 29->29->29 MB, 30 MB goal, 8 P
pacer: sweep done at heap size 29MB; allocated 0MB of spans; swept 3752 pages at +6.183550e-004 pages/byte
pacer: assist ratio=+1.232155e+000 (scan 1 MB in 70->71 MB) workers=2+0
pacer: H_m_prev=30488736 h_t=+2.334071e-001 H_T=37605024 h_a=+1.409842e+000 H_a=73473040 h_g=+1.000000e+000 H_g=60977472 u_a=+2.500000e-001 u_g=+2.500000e-001 W_a=308200 goalΔ=+7.665929e-001 actualΔ=+1.176435e+000 u_a/u_g=+1.000000e+000

運行GC跟蹤可以告訴您很多關於應用程序的運行狀況和收集器的速度。收集器運行的速度在收集過程中起著重要作用。

Pacing

收集器具有調步算法,該算法用於確定何時開始收集。該算法依賴於收集器用於收集有關正在運行的應用程序的信息以及應用程序放在堆上的壓力的反饋循環。壓力可以定義為應用程序在給定時間內分配堆內存的速度。正是壓力決定了收藏家需要的速度。

在收集器開始收集之前,它會計算它認為完成收集所需的時間。然後,一旦集合運行,將在正在運行的應用程序上造成延遲,這將減慢應用程序的工作。每個集合都會增加應用程序的整體延遲。

一種誤解是認為減慢收集器的速度是提高性能的一種方法。這個想法是,如果你可以推遲下一個集合的開始,那麼你將延遲它將造成的延遲。同情收藏家並不是要放慢步伐。

您可以決定將GC百分比值更改為大於100的值。這將增加在下一個集合必須啟動之前可以分配的堆內存量。這可能導致收集速度減慢。不要考慮這樣做。

圖14

Go中的垃圾收集:第一部分 - 語義

圖14顯示了更改GC百分比如何更改在下一個集合必須啟動之前允許分配的堆內存量。您可以直觀地瞭解收集器在等待更多堆內存使用時如何減慢速度。

試圖直接影響收集的速度與收集者的同情無關。這真的是在每個集合之間或集合期間完成更多的工作。您可以通過減少任何工作添加到堆內存的分配數量或數量來影響它。

注意:這個想法也是為了用盡可能小的堆來實現所需的吞吐量。請記住,在雲環境中運行時,最小化堆內存等資源的使用非常重要。

圖15

Go中的垃圾收集:第一部分 - 語義

清單15顯示了將在本系列的下一部分中使用的正在運行的Go應用程序的一些統計信息。藍色版本顯示應用程序的統計信息,而不通過應用程序處理10k請求時進行任何優化。在發現4.48GB的非生產性內存分配後,綠色版本顯示統計數據,並從應用程序中刪除相同的10k請求。

查看兩個版本的平均收集速度(2.08ms vs 1.96ms)。它們幾乎相同,約為2.0毫秒。這兩個版本之間的根本變化是每個集合之間的工作量。該應用程序從每個集合處理3.98到7.13個請求。這是以同樣的速度完成工作量增加79.1%。正如您所看到的,該集合並沒有隨著這些分配的減少而減慢,但保持不變。獲勝來自於在每個系列之間完成更多工作。

調整集合的速度以延遲延遲成本並不是您提高應用程序性能的方式。它是關於減少收集器運行所需的時間,這反過來將減少造成的延遲成本。已經解釋了收集器造成的延遲成本,但為了清楚起見,讓我再次總結一下。

收集器延遲成本

每個集合在運行的應用程序上有兩種類型的延遲。首先是竊取CPU容量。這種被盜CPU容量的影響意味著您的應用程序在收集過程中沒有全速運行。應用程序Goroutines現在與收集器的Goroutines共享P或幫助收集(Mark Assist)。

圖16

Go中的垃圾收集:第一部分 - 語義

圖16顯示了應用程序如何僅將75%的CPU容量用於應用程序工作。這是因為收集器本身就有專用的P1。這將是大部分收藏品。

圖17

Go中的垃圾收集:第一部分 - 語義

圖17顯示了應用程序在這個時刻(通常只有幾微秒)現在只能將其CPU容量的一半用於應用程序工作。這是因為P3上的goroutine正在執行Mark Assist,並且收集器為自己設置了專用P1。

注意:標記通常需要每MB實時堆4個CPU毫秒(例如,估計標記階段將運行多少毫秒,以MB為單位取實時堆大小除以CPU *的數量)。標記實際上以大約1 MB / ms的速度運行,但只有四分之一的CPU。

造成的第二個延遲是收集期間發生的STW延遲量。STW時間是沒有應用程序Goroutines執行任何應用程序工作的時間。該應用程序基本上已停止。

圖18

Go中的垃圾收集:第一部分 - 語義

圖18顯示了所有Goroutines停止的STW延遲。每次收集都會發生兩次。如果您的應用程序運行正常,則收集器應該能夠將大部分集合的總STW時間保持在100微秒或以下。

您現在知道收集器的不同階段,內存的大小,調整的工作方式以及收集器對正在運行的應用程序造成的不同延遲。有了這些知識,最終可以回答你如何與收藏家同情的問題。

Being Sympathetic

對收集器表示同情是為了減少堆內存的壓力。請記住,壓力可以定義為應用程序在給定時間內分配堆內存的速度。當壓力減小時,收集器造成的潛伏期將會減少。這是GC延遲會降低您的應用程序速度。

減少GC延遲的方法是從應用程序中識別並刪除不必要的分配。這樣做有助於收集器的幾種方式。

幫助收藏家:

  • 儘可能保持最小的堆。
  • 找到最佳的一致步伐。
  • 保持每個集合的目標。
  • 最小化每個集合,STW和Mark Assist的持續時間。

所有這些都有助於減少收集器對正在運行的應用程序造成的延遲。這將提高應用程序的性能和吞吐量。收集的速度與它無關。這些是你可以做的其他事情,以幫助做出更好的工程決策,減少堆上的壓力。

瞭解應用程序執行工作負載的性質

瞭解工作負載意味著確保使用合理數量的goroutine來完成您已完成的工作。CPU與IO綁定的工作負載不同,需要不同的工程決策。

瞭解已定義的數據及其在應用程序中的傳遞方式

瞭解數據意味著瞭解您要解決的問題。數據語義一致性是維護數據完整性的關鍵部分,並允許您在堆棧上選擇堆分配時知道(通過讀取代碼)。

結論

如果你花時間專注於減少分配,那麼你就像Go開發人員一樣,對垃圾收集器表示同情。您不打算編寫零分配應用程序,因此重要的是要認識到有效的分配(幫助應用程序的分配)和那些沒有生產力的分配(那些損害應用程序)之間的差異。然後將您的信任和信任放在垃圾收集器中,以保持堆健康並使您的應用程序始終如一地運行。

擁有垃圾收集器是一個很好的權衡。我將花費垃圾收集的成本,所以我沒有內存管理的負擔。Go是關於允許您作為開發人員提高工作效率,同時仍然編寫足夠快的應用程序。垃圾收集器是實現這一目標的重要組成部分。在下一篇文章中,我將向您展示一個示例Web應用程序以及如何使用該工具查看所有這些操作。


分享到:


相關文章: