【譯】 Golang 中的垃圾回收(一)

介紹

垃圾回收器負責追蹤堆內存的分配,釋放掉不須要的空間,追蹤那些還在使用的分配空間。不一樣編程語言對這個機制的實現都很複雜,可是開發人員開發軟件時候並不須要瞭解垃圾回收太細節的東西就能進行構建。另外,不一樣發佈版本編程語言的VM和runtime也老是在改變和進化。對於應用開發人員來講,重要的是保持一個良好的work模型,瞭解編程語言裏垃圾回收器的行爲而且它們是怎麼樣支持這種行爲的。html

對於go 1.12版原本說,go語言使用了非分代,併發的三色標記和清掃的回收器。若是想了解如何進行標記和清掃的工做,請參考這篇文章。golang的垃圾回收器的實現每一個版本都在更新和進化。所以一旦下個版本發佈,講任何細節的實現都再也不準確。git

總而言之,這篇文章不會去講實際的實現細節。我會爲你分享回收器的一些行爲而且去解釋怎樣面對這些行爲,不考慮實現細節以及將來的改變。這將會使你成爲一個更好的golang開發者程序員

堆不是一個容器

我不會把堆看作是一個能夠存儲或者是釋放值的容器。理解這件事情很重要,內存裏並無明肯定義了「堆」的一個分界線。任何應用程序預留的內存空間,在堆內存分配上是可用的。給定任何堆內存分配空間,它實際在虛擬內存仍是物理內存上的存儲位置和咱們的模型並無關聯。理解這件事情會幫助你更好的理解垃圾回收模型的工做方式。github

回收器行爲

當回收開始,回收器會完成三個階段的工做。這其中兩個階段會產生Stop The World(STW) 延遲,而且另外一個階段也會產生延遲,而且會致使下降應用程序的吞吐量。這三個階段是:golang

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

下面看各個階段的詳細說明。算法

Mark Setup -STW

當回收開始,首先要作的確定是開啓寫屏障(Write Barrier)。寫屏障的目的是在collector和應用程序goroutines併發時候,容許回收器去維持數據在堆中的完整性。編程

爲了開啓寫屏障,每一個運行中的應用程序goroutine必需要停下來。這個活動一般很是快,平均在10~30微妙之間。前提是在你的應用程序goroutines行爲合理的狀況下。安全

注意:爲了更好的理解下面的調度圖解,最好先看過以前寫的golang調度文章
圖1.1

圖1給出了4個應用程序goroutines,在開始垃圾回收以前它們都在運行中。爲了進行回收,4個goroutines中每個都必須被停下來,這麼作的惟一方式就是讓回收器去檢查並等待goroutine去作方法調用。方法調用確保了goroutines在安全的點停下來。若是其中一個goroutine沒有進行方法調用可是其它的作了方法調用,會發生什麼?bash

圖1.2

圖1.2給出了一個問題的實際案例。若是P4的goroutine不停下來的話,垃圾回收就沒法啓動。可是P4正在執行一個tight loop去作一些math處理,這致使回收根本沒法開始。併發

L1:

01 func add(numbers []int) int {
02     var v int
03     for _, n := range numbers {
04         v += n
05     }
06     return v
07 }
複製代碼

L1給出了P4 goroutine正在執行的代碼。取決於slice的大小,goroutine可能會執行很長很長的時間,致使了根本沒有機會停下來。這種代碼會阻止垃圾回收開始。更糟糕的是,其餘的P沒法爲其餘goroutine服務,由於collector處於等待狀態。因此goroutine在合理的實際範圍內進行方法調用是相當重要的。

注意:這部分是golang團隊將在1.14版本中要改進的內容,經過加入調度器的搶佔式調度技術

Marking -Concurrent

一旦寫屏障開啓,回收器就會開始進入都標記階段。回收器第一件作的事情就是拿走25%的可用CPU給本身使用。collector使用Goroutines去進行回收工做,也就是它會從應用程序搶過來對應數量的P和M。這意味着,4個線程的go程序裏,會有一個P被拿去處理回收工做。

圖1.3

圖1.3給出了collector是怎樣拿走P1的在進行回收工做的時候。如今回收器能夠開始Marking過程了。標記階段就是標記處理堆內存中的in-use的值。它會去檢查棧中全部存在的goroutines,去找到指向堆內存的根指針。以後回收器必須從根指針開始,遍歷堆內存的樹圖。當P1上進行處理Marking工做,應用程序能夠在P二、P3和P4上繼續併發執行。這意味着回收器減小了當前CPU容量的25%。

我但願事情到此就結束了,可是並非。若是P1上GC的goroutine在in-use的堆內存達到上限時候沒完成Marking會怎麼樣?若是其餘3個應用程序的goroutine致使了collector沒法按時完成工做會怎麼樣?若是發生這種狀況,新的內存分配就必須慢下來,尤爲是在對應的goroutine上。

若是回收器肯定它必需要減慢內存分配速度,它就會招募應用程序的goroutines去協助(Assist)進行標記工做。這叫作Mark Assist。任何應用程序goroutine被置入Mark Assist的時間和它將要在堆內存中加的數據量是成比例的。Mark Assist的一個正面功能就是它幫助提升了回收速度。

圖1.4

圖1.4展現了,以前P3上應用程序運行的goroutine,如今正在進行Mark Assist來幫助進行回收工做。但願其餘goroutines不要參與其中。應用程序有分配內存壓力的時候會看到大部分正在運行的goroutines在垃圾回收的時候去處理小量的Mark Assist工做。

Mark Termination -STW

一旦標記工做完成,下一個過程就是Mark Termination。這個時候寫屏障關閉,各類清理工做會進行,而且計算出下一次的回收目標。在標記過程當中那些發現本身處理tight loop的goroutines也會致使Mark Termination STW的延時增長。

圖1.5

圖1.5展現了,Mark Termination階段完成,全部Goroutines都會中止。這個活動一般會在60~90微妙內完成。這個階段完成能夠沒有STW,可是經過STW,會使得代碼更加簡單,而且增長的代碼複雜度並不值得這點小增益。

一旦回收完成了,每一個P能夠再次被應用程序goroutines去使用,程序又回到了全力運行的狀態。

圖1.6

圖1.6展現了回收完成後,所有可用的P如今正在處理應用程序的工做。

Sweeping - Concurrent

在回收完成以後,會有另一個活動,叫作清掃(Sweeping)。Sweeping就是清理內存中有值可是沒有被標記爲in-use的堆內存。這個活動發生在當應用程序goroutines嘗試去在堆中分配新的值的時候。Sweeping延遲增長到了堆內存分配的開銷中,而且和任何垃圾回收的延遲都沒有關聯。

下面是在個人機器上進行trace的樣本,個人機器上有12個hardware thread去執行goroutines。

圖1.7

圖1.7展現了trace的部分快照。你能夠看到在回收中(注意上面藍色GC行),12個P中的3個被拿去處理GC。你能夠看到goroutine 2450,1978和2696在這時間正在進行Mark Assist而不是它本身的程序work。在回收的最後,只有一個P去處理GC而且最終進行STW(Mark Termination)工做.

在回收完成後,程序又回到了全力運行的狀態。你能夠看到在這些goroutine下面的許多玫瑰色的豎線。

圖1.8

圖1.8展現了那些玫瑰色的線表明了goroutine進行Sweeping工做而不是它本身的程序工做的時候。這些時刻goroutine會嘗試在堆中分配新的值。

圖1.9

圖1.9展現了一個goroutine在Sweeping活動最後的追蹤數據。 runtime.mallocgc的調用會去在堆中分配新的值。 runtime.(*mcache).nextFree調用會致使Sweeping。一旦堆中再也不有分配的內存須要回收, nextFree就不會再看見。

上面描述的回收行爲僅僅發生在當回收已經啓動並正在處理的過程當中。在肯定何時開始回收中,GC配置選項扮演了重要的角色。

GC percentage

runtime中有一個配置選項叫作 GC Percentage,默認值是100。這個值表明了下一次回收開始以前,有多少新的堆內存能夠分配。GC Percentage設置爲100意味着,基於回收完成以後被標記爲生存的堆內存數量,下一次回收的開始必須在有100%以上的新內存分配到堆內存時啓動。

做爲例子,想象回收完成了並標記了2MB的in-use堆內存。

注意:圖表中的堆內存不表明實際狀況。go中的堆內存一般都是凌亂的碎片化的,你不會有圖表中那種清晰的區分。這些圖表提供了一個方便的可視化的堆內存模型來方便理解。

圖1.10

圖1.10展現了,在上一次的回收完成後,有2MB的in-use堆內存。因爲GC Percentage設置了100%,下一次回收啓動須要在堆內存增長了2MB或者更多內存時候或者以前啓動。

圖1.11

圖1.11展現了2MB或者更多內存處於in-use。這會觸發回收。一種方式去看到這些行爲的方法,就是爲每次GC生成一個GC trace。

L2

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
複製代碼

L2展現瞭如何使用GODEBUG變量去生成GC trace。下面的L3展現了程序生成的gc traces。

L3

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
複製代碼

L3展現了GC中的實際數值和它的含義。我最後會講到這些值,可是如今注意1405 GC trace的內存片斷。

圖1.12

L4

// 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 trace行給出了以下的信息:Marking Work開始以前堆內存中in-use大小是7MB。當Marking Work完成後,堆內存中in-use的大小是11MB。這意味着回收中額外增長了4MB的內存分配。Marking Work完成以後堆內存中存活空間的大小是6MB。這意味着下次回收開始以前,in-use堆內存能夠增長到12MB(100%*生存堆內存大小=6MB)

你能夠看到回收器超過了它設定的目標1MB,Marking Work完成以後的in-use堆內存是11MB而不是10MB。可是不要緊,由於目標是根據當前in-use的堆內存計算獲得的,也就是堆內存中標記爲生存的空間,當回收進行的時候會有額外隨時間計算增長的內存分配。在這個案例裏,應用程序作了一些事情,致使在Marking以後,須要比預期更多去使用的堆內存。

若是你看下一個GC Trace 行(1406),你開會看到在2ms內事情是如何改變的。

圖1.13

L5

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
複製代碼

L5展現了在以前的回收工做開始以後(6.068s vs 6.070s)這個回收工做開始了2ms的狀態,儘管in-use的堆內存在容許的12MB中僅僅達到8MB。須要注意到,若是回收器決定最好要早一點開始進行回收的話,它就會那麼作。這個案例下,它可能提早開始回收了,由於應用程序的分配壓力很大而且collector想要下降在此次回收工做中Mark Assist的延遲。

還有兩個事情要注意,回收器在它設定的目標內完成了。在Marking 完成以後in-use的堆內存空間是11MB而不是13MB,少了2MB。在Marking完成以後堆內存中標記爲存活的空間一樣是6MB。

另外,你能夠得到更多GC的細節經過增長gcpacertrace=1的標記。這會讓回收器打印concurrent pacer的內部狀態。

L6

$ 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 trace能夠告訴你不少應用程序的健康狀態以及回收器的速度。

Pacing

回收器有一個pacing算法,它會去肯定何時回收去開始。算法依靠一個反饋循環,回收器會使用這種算法收集應用程序運行時候的信息,以及應用程序給堆形成的壓力。壓力能夠定義爲在給定的時間內應用程序在堆上的分配有多快。壓力肯定了回收器運行的速度。

在回收器開始回收以前,它會計算它認爲完成回收所需的時間。 一旦回收運行,會形成正在運行的應用程序上的延遲,這將減慢應用程序的工做。 每次回收都會增長應用程序的總體延遲。

有一個錯誤觀念就是認爲下降回收器的速度是一種提升性能的方式。若是你能夠推遲下一次的回收,你就會推遲它產生的延時。 但其實提高性能並不在於下降回收器的速度。

你能夠決定改變GC Percentage的值,設置大於100。這會增長下次回收開始以前能夠分配的內存大小。這會下降回收器的速度。可是不要考慮這麼作。

圖1.14

圖1.14展現了改變GC percentage並改變了在下次回收以前能夠分配的堆內存。你能夠預想到回收器是怎麼被降速的,由於它要等待堆內存in-use。

嘗試直接影響回收速度並不能提高回收器性能。重要的事情是在於在每次回收的之間或者是回收的時候作更多事情,這個你能夠經過減小work的堆內存的分配量來進行影響。

注意:也能夠用盡小的堆來實現所須要的吞吐量。記住,在雲環境中,最小化堆內存的使用很重要。

圖1.15

圖1.15展現了go程序內部的一些統計。藍色版本的統計展現了沒有任何優化的狀況下,應用程序處理10k請求狀況。綠色版本代表了相同10k請求下,4.48GB的非生產性的內存分配產生而被發現後,從應用程序中移除以後的統計狀況(下降堆內存分配壓力)。

看一下兩個版本的平均回收速度(2.08ms vs 1.96ms)。它們幾乎差不太多,大概是2ms。不一樣的地方在於兩個版本在每一次回收之間的work的量。應用程序每次回收之間,處理requests次數從3.98次變爲 7.13次。能夠看幾乎相同的時間內有79.1%的工做量提高。能夠看到,回收工做沒有隨着分配內存的減小而降速,而是保持了原來速度。成功點在於每次回收之間作了更多的事情。

調整回收器的回收速度,推遲延遲代價不是你提高應用程序的性能的方法,它只是減小了回收器須要運行的時間,這反過來會減小形成的延遲成本。回收器產生的延遲代價已經解釋過了,可是這裏再進行一個簡單的總結說明。

Collector 延遲代價

每次回收工做,會帶來兩種類型的延遲。第一種是竊取CPU,在回收的時候這種竊取CPU的行爲意味着你的應用程序沒有以滿CPU的狀態運行。應用程序goroutines如今和回收器goroutine共享P,或者是進行Mark Assist。

圖1.16

圖1.16代表了只有75%的CPU在進行應用程序工做。由於回收器佔用了一個P。

圖1.17

圖1.17中,只有一半的CPU在處理應用程序work。由於P3正在進行Mark Assist,P1被collector佔用。

注意:Marking一般須要4 CPU-millsecondes/MB 的生存堆(舉例,爲了評估Marking階段運行多少millseconds,這個值會設置爲生存堆MB大小去除以0.25*CPU數目的值)。Marking實際運行大概是1MB/ms,可是隻有1/4的CPU去處理。

第二種類型的延遲就是在回收中產生的STW。STW就是沒有任何goroutine進行工做的狀況。整個應用程序本質上是中止狀態。

圖1.18

圖1.18展現了,STW時候全部goroutine都中止了。每次回收會發生兩次STW。若是你的應用程序處於健康狀態,那麼回收器會讓STW時間保持在100微秒之內。

下降GC延遲

減小GC延遲的方式是識別哪些是應用程序中沒必要要的內存分配並移除它們,這會在幾個方面幫助提升collector。

幫助回收器:

  • 維持了最小堆
  • 找到最優的一致速度
  • 每次回收保持在目標(goal)以內
  • 最小化回收的時間,STW和Mark Assist

這些事情都會幫助減小回收器產生的延遲,從而增長應用程序的吞吐量和性能。改變回收速度並無什麼用。你能夠經過作出正確的工程方面決策,下降堆內存的分配壓力來提高性能。

理解應用程序正在運行的workload

關於性能,還須要清楚你的workload的類型。理解你的workload意味着,肯定你使用合理數量的goroutines來處理你的工做。CPU vs IO bound 的workloads是不一樣的,須要作出不一樣抉擇。相關內容能夠參考這裏

結論

若是你花時間去專一於減小內存分配,你會獲得性能上的提高。可是你不可能寫出0分配的程序來,因此瞭解和確認productive(對程序有幫助的)的內存分配和not productive(損害性能)的分配是很重要的。以後你就能夠信任垃圾回收器幫你維持好內存的健康和穩定,而後讓你的程序持續的運行下去。

垃圾回收器是一個很好的折衷方式。花一點代價去進行垃圾回收,這樣就不須要考慮內存管理的問題。Go 垃圾回收器可以讓程序員更加高效和多產,可讓你寫出足夠快的程序。

原文連接:www.ardanlabs.com/blog/2018/1…
相關文章
相關標籤/搜索