Runtime

Goroutine 原理

Goroutine

“Goroutine 是一個與其他 goroutines 並行運行在同一地址空間的 Go 函數或方法。 一個運行的程序由一個或更多個 goroutine 組成。 它與線程、協程、進程等不同。 它是一個 goroutine” —— Rob Pike

Goroutines 在同一個用戶地址空間裡並行獨立執行 functions, channels 則用於 goroutines 間的通信和同步訪問控制。

goroutine 和 thread 的區別?

  • 內存佔用 創建一個 goroutine 的棧內存(stack)消耗為 2 KB(Linux AMD64 Go v1.4後),運行過程中,如果棧(stack)空間不夠用,會自動進行擴容。 創建一個 thread 為了盡量避免極端情況下操作系統線程棧的溢出,默認會為其分配一個較大的棧內存( 1 - 8 MB 棧內存,POSIX Thread),而且還需要一個被稱為 “guard page” 的區域用於和其他 thread 的棧空間進行隔離。而棧內存空間一旦創建和初始化完成之後其大小就不能再有變化,這決定了在某些特殊場景下系統線程棧還是有溢出的風險。

  • 創建/銷毀 線程(Thread)創建和銷毀都會有巨大的消耗,是內核級的交互(trap)。 而進入內核所消耗的性能代價比較高,開銷較大。 goroutine 是用戶態線程,是由 go runtime 管理,創建和銷毀的消耗非常小。

  • 調度切換 (context switch) 拋開陷入內核,線程切換會消耗 1000-1500 奈秒(ns)(上下文保存成本高,較多寄存器,公平性,複雜時間計算統計), 一個奈秒(ns)平均可以執行 12-18 條指令。 所以由於線程切換,執行指令的條數會減少 12000-18000。 goroutine 的切換約為 200 ns(用戶態、3個寄存器),相當於 2400-3600 條指令。因此,goroutines 切換成本比 threads 要小得多。

  • 複雜性 線程的創建和退出複雜,多個thread間通訊複雜(share memory)。 不能大量創建線程(參考早期的 httpd),成本高,使用網絡多路復用,存在大量callback(參考twemproxy、nginx 的代碼)。 對於應用服務線程門檻高,例如需要做第三方庫隔離,需要考慮引入線程池等。

Goroutine
  • Goroutine is a lightweight thread managed by the Go runtime
  • Stack size (2kb~1gb)
  • Easy to create grouting
  • No context switch
Thread
  • Threads are smallest unit of execution that CPU accepts
  • Process has at least one thread(main thread)
  • Process can have multiple threads.
  • Threads share same address space.
  • Threads run independent of each other.
  • OS scheduler makes scheduling decisions at thread level, not process level.
  • Threads can run concurrently or in parallel
  • Threads are allocated fixed stack size (2mb)
  • Context switch

Do not communicate by sharing memory; instead, share memory by communicating

M:N 模型

Go 創建 M 個線程(CPU執行調度的單元,內核的task_struct),之後創建的 N 個 goroutine 都會依附在這 M 個線程上執行,即 M:N 模型。

同一個時刻,一個線程只能跑一個 goroutine。 當 goroutine 發生阻塞(chan阻塞、mutext、syscall 等等)時,Go 會把當前的 goroutine 調度走, 讓其他 goroutine 來繼續執行,而不是讓線程阻塞休眠,盡可能多的分發任務出去,讓 CPU 忙。

GMP 調度模型

GMP 概念

  • G (Goroutine)
    • goroutine 的縮寫,每次 go func() 都代表一個 G,無限制。
    • 使用 struct runtime.g,包含了當前 goroutine 的狀態、堆棧、上下文。
    • 是對 Go語言中代碼片段的封裝, 其實是一種輕量級的用戶Tread
  • M (Machine 或 worker thread)
    • 工作線程(OS thread)也被稱為 Machine,使用 struct runtime.m,所有 M 是有線程棧的。
    • 如果不對該線程棧(Thread Stack)提供內存的話,系統會給該線程棧提供內存(不同操作系統提供的線程棧大小不同)。當指定了線程棧,則 M.stack→G.stack,M 的 PC 寄存器指向 G 提供的函數,然後去執行。
    • 一個 machine 對應一個內核Thread 相當於內核Thread在Go語言進程(Process)中的映射
  • P (Processor)

    • 只有當M與一個P關聯後才能執行Go程式碼
    • 一個 processor 表示執行 go 代碼片段所必須的上下文環境, 可以理解為用戶代碼邏輯的處理器
  • 每一個 M 都會與一個內核Thread綁定

  • 在運行過程中 M 和內核Thread 之間對應關係部會變化, 在M的生命週期內, 他只會與一個內核Thread綁定
  • M 和 P 以及 P 和 G 之間的關係都是動態可變的 concurrency/03_Scheduler/01_MN_Scheduler

M和P共同構成了一個基本的運行環境, 此時 G0中的代碼片段處於正在運行的狀態, 而其他G隊列處於待執行狀態 當沒有足夠的M來和P組合為G提供運行環境時, Go會創建新的M. 在很多時候M的數量可能會比P要多. 在單個Go進程中, P的最大數量決定了程式的併發規模, 且P的最大數量是由程式決定的. 可以通過修改環境變數GOMAXPROCS和調用runtime.GOMAXPROCS 來設定P的最大值

當 M對應的內核Thread被喚醒時, M將會嘗試為G0捕獲一個P上下文, 可能是從調度器的空閒P列表中獲取, 如果獲取不成功, M 會把G0放到調度器可執行G隊列中, 等待其他P的查找

GM 調度器

Go 1.2前的調度器實現,限制了 Go 並發程序的伸縮性, 尤其是對那些有高吞吐或併行計算需求的服務程序。 在這個調度器中,每個 goroutine 對應於runtime 中的一個抽象結構:G, 而 OS thread 作為“物理 CPU”的存在而被抽象為一個結構:M(machine)。

GM 調度模型的問題

  • 單一全局互斥鎖(Sched.Lock)和集中狀態存儲
    • 導致所有 goroutine 相關操作,比如:創建、重新調度等都要上鎖。
  • Goroutine 傳遞問題
    • M 經常在 M 之間傳遞”可運行”的 goroutine,這導致調度延遲增大以及額外的性能損耗。
    • 譬如一個goroutine 要從另一個channel拿東西時, 他會把goroutine 放到 global queue, 此goroutine 要被執行時, 可能又被換到另外一個 M 去執行, 導致在兩個 M 切換來切換去
  • Per-M 持有內存緩存 (M.mcache)
    • 類似 TCMalloc 的結構,每個M持有 mcache 和 stack alloc,然而只有在 M 運行 Go代碼時才需要使用 cache(每個 mcache 可以高達2mb),當 M 在處於 syscall 時並不需要。運行 Go 代碼和阻塞在 syscall 的 M 的比例高達1:100,造成了很大的浪費。同時內存親和性也較差(proc a new proc b)。
  • 嚴重的線程阻塞/解鎖
    • 在系統調用的情況下,工作線程經常被阻塞和取消阻塞,這增加了很多開銷。 by Dmitry Vyukov “Scalable Go Scheduler Design Doc

GMP 概念

  • P
    • Processor 是一個抽象的概念,並不是真正的物理 CPU。
    • 現在mcache, timer, g, g0是跟著p走, 而不是跟著m

它代表了 M 所需的上下文環境,也是處理用戶級代碼邏輯的處理器。 它負責銜接 M 和 G 的調度上下文,將等待執行的 G 與 M 對接。 當 P 有任務時需要創建或者喚醒一個 M 來執行它隊列裡的任務。 所以 P/M 需要進行綁定,構成一個執行單元。 M 數量可能會多於 P

P 決定了並行任務的數量,可通過 runtime.GOMAXPROCS 來設定。在 Go1.5 之後GOMAXPROCS 被默認設置可用的核數,而之前則默認為1。

在 Docker 上會取得物理機上面的核心數, 導致創建好幾個P, 可以導入"https://github.com/uber-go/automaxprocs" 來解決 Tips: https://github.com/uber-go/automaxprocs Automatically set GOMAXPROCS to match Linux container CPU quota. mcache, timer, g, g0, etc...

GMP 調度器

引入了 local queue,因為 Processor 的存在,runtime 並不需要做一個集中式的 Goroutine 調度, 每一個 Machine 都會在 P's local queue、global queue 或者其他 P 隊列中找 goroutine 執行, 減少全局鎖對性能的影響

這也是 GMP Work-stealing 調度算法的核心。 注意 P 的本地 G 隊列還是可能面臨一個並發訪問的場景,為了避免加鎖,這裡 P 的本地隊列是一個 LockFree的隊列,竊取 G 時使用 CAS 原子操作來完成。關於LockFree 和 CAS 的知識參見 Lock-Free

GMP 問題總結

  • 單一全局互斥鎖(Sched.Lock)和集中狀態存儲 G 被分成全局隊列和 P 的本地隊列,全局隊列依舊是全局鎖,但是使用場景明顯很少,P 本地隊列使用無鎖隊列,使用原子操作來面對可能的並發場景。
  • Goroutine 傳遞問題 G 創建時就在 P 的本地隊列,可以避免在 G 之間傳遞(竊取除外),G 對 P 的數據局部性好; 當 G 開始執行了,系統調用返回後 M 會嘗試獲取可用 P,獲取到了的話可以避免在 M 之間傳遞。而且優先獲取調用阻塞前的 P,所以 G 對 M 數據局部性好,G 對 P 的數據局部性也好。
  • Per-M 持有內存緩存 (M.mcache) 內存 mcache 只存在 P 結構中,P 最多只有 GOMAXPROCS 個,遠小於 M 的個數,所以內存沒有過多的消耗。
  • 嚴重的線程阻塞/解鎖 通過引入自旋,保證任何時候都有處於等待狀態的自旋 M,避免在等待可用的 P 和 G 時頻繁的阻塞和喚醒。 by Dmitry Vyukov “Scalable Go Scheduler Design Doc

    Work-stealing 調度算法

    Goroutine 創建

    P 的初始化:首先會創建邏輯 CPU 核數個 P ,存儲在 sched 的 空閒鍊錶(pidle)。

OS thread 創建

準備運行的新 goroutine 將喚醒 P 以更好地分發工作。 這個 P 將創建一個與之關聯的 M 綁定到一個OS thread。

go func() 中 觸發 Wakeup 喚醒機制: 有空閒的 Processor 而沒有在 spinning (自旋) 狀態的 Machine 時候, 需要去喚醒一個空閒(睡眠)的 M 或者新建一個。

M0 main

程序啟動後,Go 已經將主線程和 M 綁定(rt0_go)。 當 goroutine 創建完後,

Q: 它是放在當前 P 的 local queue 還是 global queue? A: runtime.runqput 這個函數會嘗試把 newg 放到本地隊列上, 如果本地隊列滿了,它會將本地隊列的前半部分和 newg 遷移到全局隊列中。 剩下的事情就等待 M 自己去拿任務了。 Tips: 特殊的g0

Work-stealing

M 綁定的 P 沒有可執行的 goroutine 時,它會去按照優先級去搶占任務:

切換到 g0 然後執行 runtime.schedule, 只有 1/61的機會, 去 global runnable queue 找可不可執行的, 沒有的話再去找local queue, 在沒有的話再去竊取其他的P

找到任何一個任務,切換調用棧執行任務。再循環不斷的獲取任務,直到進入休眠。

為了保證公平性,從隨機位置上的 P 開始,而且遍歷的順序也隨機化了(選擇一個小於 GOMAXPROCS,且和它互為質數的步長),保證遍歷的順序也隨機化了。

陣列步長(stride of an array,也稱increment, pitch或step size)是程式設計時,
相鄰陣列元素在記憶體中的開始位址的距離,度量單位可以是位元組或者陣列元素個數。步長不可小於陣列元素的尺寸,但可以大於,表示有填充的位元組。
陣列步長如果等於陣列元素的尺寸,則陣列在記憶體中是連續的。這可稱為單位步長(unit stride)。非單位步長適用於二維陣列或多維陣列,

Spining thread

線程自旋是相對於線程阻塞而言的,表象就是循環執行一個指定邏輯(就是上面提到的調度邏輯,目的是不停地尋找 G)。 這樣做的問題顯而易見,如果 G 遲遲不來,CPU 會白白浪費在這無意義的計算上。 但好處也很明顯,降低了 M 的上下文切換成本,提高了性能。在兩個地方引入自旋:

  1. 類型1:M 不帶 P 的找 P 掛載(一有 P 釋放就結合)
  2. 類型2:M 帶 P 的找 G 運行(一有 runable 的 G 就執行)

  3. M 帶 P 的找 G 運行

  4. M 不帶 P 的找 P 掛載
  5. G 創建, 然後現在又沒一個 spining M, 則喚醒一個 M
  6. 至少保證一個M在自旋 -->不確定, 待確認

Go 的設計者傾向於高性能的並發表現,選擇了後者。 當然前面也提到過,

為了避免過多浪費 CPU 資源,自旋的 M 最多只允許 GOMAXPROCS (Busy P)。同時當有類型1的自旋 M 存在時,類型2的自旋 M 就不阻塞,阻塞會釋放 P,一釋放 P 就馬上被類型1的自旋 M 搶走了,沒必要。

為了避免過多浪費 CPU 資源,自旋的線程數不會超過 GOMAXPROCS (Busy P), 這是因為一個 P 在同一個時刻只能綁定一個 M, P 的數量不會超過 GOMAXPROCS,自然被綁定的 M 的數量也不會超過。 對於未被綁定的“游離態”的 M,會進入休眠阻塞態。

在新 G 被創建、M 進入系統調用、M 從空閒被激活這三種狀態變化前,調度器會確保至少有一個自旋 M 存在(喚醒或者創建一個 M),除非沒有空閒的 P。

  • 當新 G 創建,如果有可用 P,就意味著新 G 可以被立即執行,即便不在同一個 P 也無妨,所以我們保留一個自旋的 M(這時應該不存在類型1的自旋只有類型2的自旋)就可以保證新 G 很快被運行。
  • 當 M 進入系統調用,意味著 M 不知道何時可以醒來,那麼 M 對應的 P 中剩下的 G 就得有新的 M 來執行,所以我們保留一個自旋的 M 來執行剩下的 G(這時應該不存在類型2的自旋只有類型1的自旋)。
  • 如果 M 從空閒變成活躍,意味著可能一個處於自旋狀態的 M 進入工作狀態了,這時要檢查並確保還有一個自旋 M 存在,以防還有 G 或者還有 P 空著的。

Syscall

Go 有自己封裝的 syscall,也就是進入和退出 syscall 的時候執行 entersyscall/exitsyscall, 也只有被封裝了系統調用才有可能觸發重新調度, 它將改變 P 的狀態為 syscall。 系統監視器 (system monitor),稱為 sysmon,會定時掃描。在執行系統調用時, 如果某個 P 的 G 執行超過一個 sysmon tick,脫離 M。

P1 和 M 脫離後 目前在 idle list 中等待被綁定。 而 syscall 結束後 M 按照如下規則執行直到滿足其中一個條件:

  • 嘗試獲取同一個 P(P1),恢復執行 G
  • 嘗試獲取 idle list 中的空閒 P
  • 把 G 放回 global queue,M 放回到 idle list

sysmon

sysmon 也叫監控線程,它無需 P 也可以運行,他是一個死循環,每20us~10ms循環一次,循環完一次就 sleep 一會,為什麼會是一個變動的周期呢,主要是避免空轉,如果每次循環都沒什麼需要做的事,那麼 sleep 的時間就會加大。

  • 釋放閒置超過5分鐘的 span 物理內存;
  • 如果超過2分鐘沒有垃圾回收,強制執行;
  • 將長時間未處理的 netpoll 添加到全局隊列;
  • 向長時間運行的 G 任務發出搶占調度;
  • 收回因 syscall 長時間阻塞的 P;

協作式搶占, 當 P 在 M 上執行時間超過10ms,sysmon 調用 preemptone 將 G 標記為 stackPreempt 。 因此需要在某個地方觸發檢測邏輯, Go 當前是在檢查棧是否溢出的地方判定(morestack()), M 會保存當前 G 的上下文,重新進入調度邏輯。

Tips:

異步搶占,註冊 sigurg 信號,通過sysmon 檢測,對 M 對應的線程發送信號, 觸發註冊的 handler, 它往當前 G 的 PC 中插入一條指令(調用某個方法), 在處理完 handler,G 恢復後,自己把自己推到了 global queue 中。

Tips: 發生程序 hang 死情況時,通常使用什麼工具診斷?

  • go tool pprof
  • perf top

Network poller

Go 所有的 I/O 都是阻塞的。然後通過 goroutine + channel 來處理並發。 因此所有的 IO 邏輯都是直來直去的,你不再需要回調,不再需要 future,要的僅僅是 step by step。這對於代碼的可讀性是很有幫助的。

G 發起網絡 I/O 操作也不會導致 M 被阻塞(僅阻塞G),從而不會導致大量 M 被創建出來。將異步 I/O 轉換為阻塞 I/O 的部分稱為 netpoller。打開或接受連接都被設置為非阻塞模式。如果你試圖對其進行 I/O 操作,並且文件描述符數據還沒有準備好,G 會進入 gopark 函數,將當前正在執行的 G 狀態保存起來,然後切換到新的堆棧上執行新的 G。

那什麼時候 G 被調度回來呢?

  • sysmon
  • schedule():M 找 G 的調度函數
  • GC:start the world

調用 netpoll() 在某一次調度 G 的過程中,處於就緒狀態的 fd 對應的 G 就會被調度回來。 G 的 gopark 狀態:G 置為 waiting 狀態,等待顯示goready 喚醒,在 poller 中用得較多,還有鎖、chan 等。

OS thread

當使用了 Syscall,Go 無法限制 Blocked OS threads 的數量: The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit. This package’s GOMAXPROCS function queries and changes the limit.

Tips: 使用 syscall 寫程序要認真考慮 pthread exhaust 問題。

Scheduler Affinity

GM 調度器時代的,chan 操作導致的切換代價。

  • Goroutine#7 正在等待消息,阻塞在 chan。一旦收到消息,這個 goroutine 就被推到全局隊列。
  • 然後,chan 推送消息,goroutine#X 將在可用線程上運行,而 goroutine#8 將阻塞在 chan。
  • goroutine#7 現在在可用線程上運行。

在 chan 來回通信的 goroutine 會導致頻繁的 blocks,即頻繁地在本地隊列中重新排隊。然而,由於本地隊列是 FIFO 實現,如果另一個 goroutine 佔用線程,unblock goroutine 不能保證盡快運行。

同時 Go 親緣性調度的一些限制:

  • Work-stealing
    • 當 P 的 local queue 任務不夠,同時 global queue、network poller 也會空,這時從其他 P 竊取 任務運行,然後任務就運行到了其他線程。
  • 系統調用
    • 當 syscall 產生,Go 把當前線程置為 blocking mode,讓一個新的線程接管了這個 P (過一個 sysmon tick 才會交給其他 M,大多數syscall都是很快的)。

goroutine #9 在 chan 被阻塞後恢復。但是,它必須等待#2、#5和#4之後才能運行。 goroutine #5將阻塞其線程,從而延遲goroutine #9,並使其面臨被另一個 P 竊取的風險。

針對 communicate-and-wait 模式,進行了親緣性調度的優化。 Go 1.5 在 P 中引入了 runnext 特殊的一個字段,可以高優先級執行 unblock G。

goroutine #9現在被標記為下一個可運行的。這種新的優先級排序允許 goroutine 在再次被阻塞之前快速運行。這一變化對運行中的標準庫產生了總體上的積極影響,提高了一些包的性能。

Goroutine Lifecycle

Go 程序啟動

整個程序始於一段彙編, 而在隨後的 runtime·rt0_go(也是彙編程序)中,會執行很多初始化工作。

  • 綁定 m0 和 g0,m0就是程序的主線程,程序啟動必然會擁有一個主線程,這個就是 m0。 g0 負責調度,即 shedule() 函數。
  • 創建 P,綁定 m0 和 p0,首先會創建 GOMAXPROCS 個 P ,存儲在 sched 的 空閒鍊錶(pidle)。
  • 新建任務 g 到 p0 本地隊列,m0 的 g0 會創建一個 指向 runtime.main() 的 g ,並放到 p0 的本地隊列。

runtime.main(): 啟動 sysmon 線程;啟動 GC 協程;執行 init,即代碼中的各種 init 函數;執行 main.main 函數。

OS thread 創建

準備運行的新 goroutine 將喚醒 P 以更好地分發工作。這個 P 將創建一個與之關聯的 M 綁定到一個 OS thread。 go func() 中 觸發 Wakeup 喚醒機制:

有空閒的 P 而沒有在 spinning 狀態的 M 時候, 需要去喚醒一個空閒(睡眠)的 M 或者新建一個。 當線程首次創建時,會執行一個特殊的 G,即 g0,它負責管理和調度 G。

特殊的 g0

Go 基於兩種斷點將 G 調度到線程上:

  • 當 G 阻塞時:系統調用、互斥鎖或 chan。阻塞的 G 進入睡眠模式/進入隊列,並允許Go 安排和運行等待其他的 G。
  • 在函數調用期間,如果 G 必須擴展其堆棧。這個斷點允許 Go 調度另一個 G 並避免運行 G 佔用CPU。

在這兩種情況下,運行調度程序的 g0 將當前G 替換為另一個 G,即 ready to run。然後,選擇的 G 替換 g0 並在線程上運行。與常規 G 相反,g0 有一個固定和更大的棧。

  • Defer 函數的分配
  • GC 收集,比如 STW、掃描 G 的堆棧和標記、清楚操作
  • 棧擴容,當需要的時候,由 g0 進行擴棧操作

Schedule

在 Go 中,G 的切換相當輕便,其中需要保存的狀態僅僅涉及以下兩個: Goroutine 在停止運行前執行的指令,程序當前要運行的指令是記錄在程序計數器(PC)中的, G 稍後將在同一指令處恢復運行; G 的堆棧,以便在再次運行時還原局部變量; 在切換之前,堆棧將被保存,以便在 G 再次運行時進行恢復:

從 g 到 g0 或從 g0 到 g 的切換是相當迅速的,它們只包含少量固定的指令。相反,對於調度階段,調度程序需要檢查許多資源以便確定下一個要運行的 G。 當前 g 阻塞在 chan 上並切換到 g0:1、PC 和堆棧指針一起保存在內部結構中;2、將 g0 設置為正在運行的 goroutine;3、g0 的堆棧替換當前堆棧; g0 尋找新的 Goroutine 來運行 g0 使用所選的 Goroutine 進行切換: 1、PC 和堆棧指針是從其內部結構中獲取的;2、程序跳轉到對應的 PC 地址;

Goroutine Recycle

G 很容易創建,棧很小以及快速的上下文切換。 基於這些原因,開發人員非常喜歡並使用它們。 然而,一個產生許多 shortlive 的 G 的程序將花費相當長的時間來創建和銷毀它們。

每個 P 維護一個 freelist G,保持這個列表是本地的, 這樣做的好處是不使用任何鎖來 push/get 一個空閒的 G。 當 G 退出當前工作時,它將被 push 到這個空閒列表中。

為了更好地分發空閒的 G ,調度器也有自己的列表。 它實際上有兩個列表:一個包含已分配棧的 G,另一個包含釋放過堆棧的 G(無棧)。

鎖保護 central list,因為任何 M 都可以訪問它。當本地列表長度超過64時,調度程序持有的列表從 P 獲取 G。然後一半的 G 將移動到中心列表。需求回收 G 是一種節省分配成本的好方法。但是,由於堆棧是動態增長的,現有的G 最終可能會有一個大棧。因此,當堆棧增長(即超過2K)時,Go 不會保留這些棧。

內存分配原理

堆棧 & 逃逸分析

heap和stack的定義

heap: 大陸翻 stack: 大陸翻

Go 有兩個地方可以分配內存:

  1. 一個全局堆空間用來動態分配內存
  2. 另一個是每個 goroutine 都有的自身棧空間。

Stack

棧區的內存一般由編譯器自動進行分配和釋放,其中存儲著函數的入參以及局部變量,這些參數會隨著函數的創建而創建,函數的返回而銷毀。 (通過 CPU push & release)。

A function has direct access to the memory inside its frame, through the frame pointer, but access to memory outside its frame requires indirect access.

Heap

堆區的內存一般由編譯器和工程師自己共同進行管理分配,交給 Runtime GC 來釋放。 堆上分配必須找到一塊足夠大的內存來存放新的變量數據。 後續釋放時,垃圾回收器掃描堆空間尋找不再被使用的對象。

Anytime a value is shared outside the scope of a function’s stack frame, it will be placed (or allocated) on the heap.

棧(Stack)分配廉價,堆(Heap)分配昂貴 stack allocation is cheap and heap allocation is expensive.

變量是在Heap還是Stack上?

寫過其他語言,比如 C 的同學都知道,有明確的棧和堆的相關概念。 而 Go 聲明語法並沒有提到棧和堆,而是交給 Go 編譯器決定在哪分配內存,保證程序的正確性,

Go FAQ 裡面提到這麼一段解釋:

從正確的角度來看,你不需要知道。 Go 中的每個變量只要有引用就會一直存在。

  • 變量的存儲位置(堆還是棧)和語言的語義無關。
  • 存儲位置對於寫出高性能的程序確實有影響。
  • 如果可能,Go 編譯器將為該函數的堆棧偵(stack frame)中的函數分配本地變量。
  • 但是如果編譯器在函數返回後無法證明變量未被引用,則編譯器必須在會被垃圾回收的堆(heap)上分配變量以避免懸空指針錯誤。
  • 此外,如果局部變量非常大,將它存儲在堆(heap)而不是棧(stack)上可能更有意義。
  • 在當前編譯器中,如果變量存在取址,則該變量是堆(heap)上分配的候選變量。
  • 但是基礎的逃逸分析可以將那些生存不超過函數返回值的變量識別出來,並且因此可以分配在棧(stack)上。

Interview/02_Backend.md-什麼是棧stack什麼是堆heap

逃逸分析

"通過檢查變量的作用域是否超出了它所在的棧(Stack)來決定是否將它分配在堆(heap)上"的技術, 其中"變量的作用域超出了它所在的棧"這種行為即被稱為逃逸。 逃逸分析在大多數語言裡屬於靜態分析:在編譯期由靜態代碼分析來決定一個值是否能被分配在棧幀(Stack frame)上,還是需要“逃逸”到堆(heap)上。

  • 減少 GC 壓力,棧上的變量,隨著函數退出後系統直接回收,不需要標記後再清除
  • 減少內存碎片的產生
  • 減輕分配堆內存的開銷,提高程序的運行速度

編譯時可以藉助選項 -gcflags 'm',查看變量逃逸的情況 https://geektutu.com/post/hpg-escape-analysis.html

> go build -gcflags '-m'
# MyGoNote/Golang/Runtime
./escapeAnalysis.go:12:18: inlining call to rand.Intn
./escapeAnalysis.go:5:6: can inline main
./escapeAnalysis.go:12:2: moved to heap: tmp
package main

import "math/rand"

func main() {
    num := getRandom()
    println(*num)
}

//go:noinline
func getRandom() *int {
    tmp := rand.Intn(100) // 局部變數 tmp 逃逸到 heap
    return &tmp
}

超過棧幀(stack frame)

以上的圖會出現問題:

當一個函數被調用時,會在兩個相關的幀邊界間進行上下文切換。 從調用函數切換到被調用函數,如果函數調用時需要傳遞參數,那麼這些參數值也要傳遞到被調用函數的幀邊界中。 Go 語言中幀邊界間的數據傳遞是按值傳遞的 (Pass by value)。 任何在函數 getRandom 中的變量在函數返回時,都將不能訪問。 Go 查找所有變量超過當前函數棧偵的,把它們分配到堆上,避免 outlive 變量。

上述情況中,num 變量不能指向之前的棧(Stack)。 Go 查找所有變量超過當前函數棧偵的,把它們分配到堆(Heap)上,避免 outlive 變量。 變量 tmp 在棧(Stack)上分配,但是它包含了指向堆內存的地址, 所以可以安全的從一個函數的棧偵複製到另外一個函數的棧幀。

逃逸案例

還存在大量其他的 case 會出現逃逸,比較典型的就是 “多級間接賦值容易導致逃逸”, 這裡的多級間接指的是,對某個引用類對像中的引用類成員進行賦值。 (記住公式 Data.Field = Value, 如果 Data, Field 都是引用類型的數據類型, 則會導致 Value逃逸. 這裡的等號 = 不單單只賦值, 也表示參數傳遞)

Go 語言中的引用類數據類型有 func, interface, slice, map, chan, *Type

  • 一個值被分享到函數棧幀範圍之外
  • 在 for 循環外申明,在 for 循環內分配,同理閉包
  • 發送指針或者帶有指針的值到 channel 中
  • 在一個切片上存儲指針或帶指針的值
  • slice 的背後數組被重新分配了
  • 在 interface 類型上調用方法
  • .... go build -gcflags '-m'
逃逸

函數中申請一個新的對象:

如果分配在Stack中,則函數執行結束可自動將內存回收; 如果分配在Heap中,則函數執行結束可交給GC(垃圾回收)處理;

逃逸分析的好處應該是減少了gc 的壓力,Stack的分配比堆快,性能好, 如果變量都分配到Stack上,可以避免Go 頻繁地進行垃圾回收,而垃圾回收會佔用比較大的系統開銷。

逃逸分析基本原則 編譯器會根據變量是否被外部引用來決定是否逃逸:

如果函數外部沒有引用,則優先放到棧(Stack)中; 如果函數外部存在引用,則必定放到堆(Heap)中; 如果棧(Stack)上放不開,則必定放到堆(Heap)上;

  • Stack上分配內存比在Heap中分配內存效率更高
  • Stack上分配的內存不需要 GC 處理,而Heap需要GC
  • 逃逸分析目的是決定內分配地址是Stack還是Heap
  • 逃逸分析在編譯階段完成
  • 傳值VS 傳指針
    • 「函數傳遞指針真的比傳值效率高嗎?如果拷貝的數據量小,由於指針傳遞會產生逃逸,可能會使用Heap,增加垃圾回收(GC)的負擔,所以傳遞指針不一定是高效的。

來源: 通过实例理解Go逃逸分析

1. 指針逃逸

在函數中創建了一個對象,返回了這個對象的指針(引用類數據類)。這種情況下,函數雖然退出了,但是因為指針的存在,對象的內存不能隨著函數結束而回收,因此只能分配在heap上。

函數 createDemo 的局部變量 d 發生了逃逸。 d 作為返回值,在 main 函數中繼續使用, 因此 d 指向的內存不能夠分配在棧上,隨著函數結束而回收,只能分配在heapnew(Demo) escapes to heap 即表示 new(Demo) 逃逸到堆上了

go build -gcflags '-m' Golang/Runtime/escapeAnalysis-2.go
# command-line-arguments
Golang/Runtime/escapeAnalysis-2.go:9:6: can inline createDemo
Golang/Runtime/escapeAnalysis-2.go:16:20: inlining call to createDemo
Golang/Runtime/escapeAnalysis-2.go:17:13: inlining call to fmt.Println
Golang/Runtime/escapeAnalysis-2.go:9:17: leaking param: name
Golang/Runtime/escapeAnalysis-2.go:10:10: new(Demo) escapes to heap
Golang/Runtime/escapeAnalysis-2.go:16:20: new(Demo) escapes to heap
Golang/Runtime/escapeAnalysis-2.go:17:13: ... argument does not escape
// main_pointer.go
package main

import "fmt"

type Demo struct {
    name string
}

func createDemo(name string) *Demo {
    d := new(Demo) // 局部變數 d 逃逸到 heap
    d.name = name
    return d
}

func main() {
    demo := createDemo("demo")
    fmt.Println(demo)
}
2. interface{} 動態類型逃逸

在 Go 語言中,空接口即 interface{} 可以表示任意的類型,如果函數參數為 interface{},編譯期間很難確定其參數的具體類型,也會發生逃逸 demo escapes to heap

Go 1.19 沒看到@@

demo 是 main 函數中的一個局部變量,該變量作為實參傳遞給 fmt.Println(),但是因為 fmt.Println() 的參數類型定義為 interface{},因此也發生了逃逸。

package main
import "fmt"

func main() {
 s := "wekenw"
 fmt.Println(s)
}
$ go build -gcflags=-m
# ceshi
.\main.go:6:13: inlining call to fmt.Println
.\main.go:6:13: s escapes to heap
.\main.go:6:13: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape
<autogenerated>:1: .this does not escape
3. Stack 空間不足

操作系統對內核線程使用的棧空間是有大小限制的,64 位系統上通常是 8 MB。可以使用 ulimit -a 命令查看機器上棧允許佔用的內存的大小。 因為棧空間通常比較小,因此遞歸函數實現不當時,容易導致棧溢出。

對於 Go 語言來說,運行時(runtime) 嘗試在 goroutine 需要的時候動態地分配棧空間,goroutine 的初始棧大小為 2 KB。 當 goroutine 被調度時,會綁定內核線程執行,棧空間大小也不會超過操作系統的限制。

$ ulimit -a
-t: cpu time (seconds)              unlimited
-f: file size (blocks)              unlimited
-d: data seg size (kbytes)          unlimited
-s: stack size (kbytes)             8192
-c: core file size (blocks)         0
-v: address space (kbytes)          unlimited
-l: locked-in-memory size (kbytes)  unlimited
-u: processes                       2784
-n: file descriptors                1048575
$ go build -gcflags=-m Golang/Runtime/escapeAnalysis-3.go 
# command-line-arguments
Golang/Runtime/escapeAnalysis-3.go:6:14: make([]int, 8191) does not escape
Golang/Runtime/escapeAnalysis-3.go:13:14: make([]int, 8192) does not escape
Golang/Runtime/escapeAnalysis-3.go:20:14: make([]int, n) escapes to heap
package main

import "math/rand"

func generate8191() {
    nums := make([]int, 8191) // < 64KB
    for i := 0; i < 8191; i++ {
        nums[i] = rand.Int()
    }
}

func generate8192() {
    nums := make([]int, 8192) // = 64KB
    for i := 0; i < 8192; i++ {
        nums[i] = rand.Int()
    }
}

func generate(n int) {
    nums := make([]int, n) // 不確定大小
    for i := 0; i < n; i++ {
        nums[i] = rand.Int()
    }
}

func main() {
    generate8191()
    generate8192()
    generate(1)
}
  • generate8191() 創建了大小為 8191 的 int 型切片,恰好小於 64 KB(64位機器上,int 佔 8 字節),不包含切片內部字段佔用的內存大小。
  • generate8192() 創建了大小為 8192 的 int 型切片,恰好佔用 64 KB。
  • generate(n),切片大小不確定,調用時傳入。
4. 閉包

一個函數和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函數被引用包圍), 這樣的組合就是閉包(closure)。 也就是說,閉包讓你可以在一個內層函數中訪問到其外層函數的作用域。 https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Closures

https://go.dev/play/p/COC_rAVYYBW

func Increase() func() int {
    n := 0
    return func() int {
        n++
        return n
    }
}

func main() {
    in := Increase()
    fmt.Println(in()) // 1
    fmt.Println(in()) // 2

    in2 := Increase()
    fmt.Println(in2()) // 1
    fmt.Println(in2()) // 2
}

Increase() 返回值是一個閉包函數,該閉包函數訪問了外部變量 n,那變量 n 將會一直存在,直到 in 被銷毀。 很顯然,變量 n 佔用的內存不能隨著函數 Increase() 的退出而回收,因此將會逃逸到堆上

go build -gcflags=-m Golang/Runtime/escapeAnalysis-4.go
# command-line-arguments
Golang/Runtime/escapeAnalysis-4.go:5:6: can inline Increase
Golang/Runtime/escapeAnalysis-4.go:7:9: can inline Increase.func1
Golang/Runtime/escapeAnalysis-4.go:14:16: inlining call to Increase
Golang/Runtime/escapeAnalysis-4.go:7:9: can inline main.func1
Golang/Runtime/escapeAnalysis-4.go:15:16: inlining call to main.func1
Golang/Runtime/escapeAnalysis-4.go:15:13: inlining call to fmt.Println
Golang/Runtime/escapeAnalysis-4.go:16:16: inlining call to main.func1
Golang/Runtime/escapeAnalysis-4.go:16:13: inlining call to fmt.Println
Golang/Runtime/escapeAnalysis-4.go:18:17: inlining call to Increase
Golang/Runtime/escapeAnalysis-4.go:7:9: can inline main.func2
Golang/Runtime/escapeAnalysis-4.go:19:17: inlining call to main.func2
Golang/Runtime/escapeAnalysis-4.go:19:13: inlining call to fmt.Println
Golang/Runtime/escapeAnalysis-4.go:20:17: inlining call to main.func2
Golang/Runtime/escapeAnalysis-4.go:20:13: inlining call to fmt.Println
Golang/Runtime/escapeAnalysis-4.go:6:2: moved to heap: n
Golang/Runtime/escapeAnalysis-4.go:7:9: func literal escapes to heap
Golang/Runtime/escapeAnalysis-4.go:14:16: func literal does not escape
Golang/Runtime/escapeAnalysis-4.go:15:13: ... argument does not escape
Golang/Runtime/escapeAnalysis-4.go:15:16: ~R0 escapes to heap
Golang/Runtime/escapeAnalysis-4.go:16:13: ... argument does not escape
Golang/Runtime/escapeAnalysis-4.go:16:16: ~R0 escapes to heap
Golang/Runtime/escapeAnalysis-4.go:18:17: func literal does not escape
Golang/Runtime/escapeAnalysis-4.go:19:13: ... argument does not escape
Golang/Runtime/escapeAnalysis-4.go:19:17: ~R0 escapes to heap
Golang/Runtime/escapeAnalysis-4.go:20:13: ... argument does not escape
Golang/Runtime/escapeAnalysis-4.go:20:17: ~R0 escapes to heap
傳值 VS 傳指針

傳值會拷貝整個對象,而傳指針只會拷貝指針地址,指向的對像是同一個。 傳指針可以減少值的拷貝,但是會導致內存分配逃逸到堆中,增加垃圾回收(GC)的負擔。 在對象頻繁創建和刪除的場景下,傳遞指針導致的 GC 開銷可能會嚴重影響性能。

一般情況下,對於需要修改原對象值,或占用內存比較大的結構體,選擇傳指針。 對於只讀的佔用內存較小的結構體,直接傳值能夠獲得更好的性能。

連續棧 (Contiguous stack)

分段棧(Segmented stacks)

Go 應用程序運行時,每個 goroutine 都維護著一個自己的棧區,這個棧區只能自己使用不能被其他 goroutine 使用。 棧區的初始大小是2KB(比 x86_64 架構下線程的默認棧2M要小很多), 在 goroutine 運行的時候棧區會按照需要增長和收縮, 佔用的內存最大限制的默認值在64位系統上是1GB。

  • v1.0 ~ v1.1 — 最小棧內存空間為 4KB
  • v1.2 — 將最小棧內存提升到了 8KB
  • v1.3 — 使用連續棧替換之前版本的分段棧
  • v1.4 — 將最小棧內存降低到了 2KB

Note Process vs Threads

Hot split 問題

當前的分段棧的實現方式存在 “hot split” 問題, 如果棧快滿了,那麼下一次的函數調用會強制觸發棧擴容。 當函數返回時,新分配的 “stack chunk” 會被清理掉。 如果這個函數調用產生的範圍是在一個循環中,會導致嚴重的性能問題,頻繁的 alloc/free。

Go 不得不在1.2版本把棧默認大小改為8KB,降低觸發熱分裂的問題,但是每個 goroutine 內存開銷就比較大了。 直到實現了連續棧(contiguous stack),棧大小才改為2KB。

連續棧(Contiguous stacks)

採用複制棧的實現方式,在熱分裂場景中不會頻發釋放內存,即不像分配一個新的內存塊並鏈接到老的棧內存塊, 而是會分配一個兩倍大的內存塊並把老的內存塊內容複製到新的內存塊裡,當棧縮減回之前大小時,我們不需要做任何事情。

  • runtime.newstack 分配更大的棧內存空間
  • runtime.copystack 將舊棧中的內容複製到新棧中
  • 將指向舊棧對應變量的指針重新指向新棧
  • runtime.stackfree 銷毀並回收舊棧的內存空間

如果棧區的空間使用率不超過1/4,那麼在垃圾回收的時候使用 runtime.shrinkstack 進行棧縮容,同樣使用 copystack

棧擴容 (Stack expansion)

Go 運行時判斷棧空間是否足夠,所以在 call function 中會插入 runtime.morestack, 但每個函數調用都判定的話,成本比較高。 在編譯期間通過計算 sp、func stack framesize 確定需要哪個函數調用中插入 runtime.morestack。

  • 當函數式葉子節點, 且棧幀(frame)小於等於 112不插入指令
  • 當葉子函數棧幀大小為 120-128或者非葉子函數棧幀大小為0-128
    • SP < stackguard0
  • 當函數棧幀大小為 128-4096
    • SP - framesize < stackguard0 - StackSmall
  • 大於 StackBig
    • sp-stackguard+StackGuard <= framesize + (StackGuard-StackSmall)

針函數 framesize 的判定:

  • 小於 StackSmall SP < stackguard0,執行棧擴容
  • 大於 StackSmall SP - framesize < stackguard0 - StackSmall
  • 大於 StackBig
    • sp-stackguard+StackGuard <= framesize + (StackGuard-StackSmall)

非葉子函數 : 代表這個函數會調用其他函數

內存結構

內存優化

type  A struct{
  b b // 是放一個結構體, 不是指針
}
type  B struct{

}

new(A) // 只會創建一個對象

Q : 為什麼小對象多了會造成GC壓力? A : 在 Go 語言中,小對象指的是分配在堆上的小記憶體塊,這些對象的大小通常不超過 32 字節。在 Go 中,當使用 new() 或 make() 分配記憶體時,會在堆上分配一個記憶體塊來存儲這個對象。 在 Go 中,垃圾回收(GC)是自動進行的,當堆中的對象不再被引用時,垃圾回收器會回收這些對象所佔用的記憶體空間。然而,當存在大量小對象時,這些對象的數量很容易增加,這會增加垃圾回收的壓力。 這是因為,當垃圾回收器需要回收一些記憶體時,它會遍歷整個堆來查找未被引用的對象。如果堆中有大量的小對象,垃圾回收器需要遍歷的對象數量就會非常大,這會增加垃圾回收器的負擔,導致垃圾回收時間變長,進而影響程式的效能。 因此,在 Go 中,如果需要大量創建小對象,建議使用對象池(Object Pool)技術,將這些對象預先分配好,減少垃圾回收的壓力,提高程式效能。

  1. byte.Buffer
  2. slice, map 預創建
    • 預先創建好大小
  3. 長調用stack
  4. 避免頻繁創建臨時對象
    • 用sync.Pool
    • 用局部對象
  5. 字符串併接 string.Builder . go1.10導入的

https://go.dev/play/p/HqzhRfwKYA5

func main() {
    // strings.Builder 的0值可以直接使用
    var builder strings.Builder

    // 向builder中寫入字符/字符串
    builder.Write([]byte("Hello"))
    builder.WriteByte(' ')
    builder.WriteString("World")

    // String() 方法獲得得拼接的字符串
    fmt.Println(builder.String()) // "Hello World"
}
  1. 不必要的 memory copy
  func main() {
    reader := strings.NewReader("Coding is interesting!")
    writer := os.Stdout
    n, err := io.Copy(writer, reader) // Coding is interesting!
    if err != nil {
      fmt.Println(err)
    }
    fmt.Println()
    fmt.Println(n) // 22
  }
  1. 分析內存逃逸
  2. 系統調用 Readv, Writev
  3. 用Struct 少用Map
  4. Map vs Struct in GOlang (When to use)
  5. 空間使用較小:使用 struct 而非 map 可以減少對內存的使用。因為 map 是一個動態數據結構,需要維護數據的鏈接以及哈希表等結構,佔用較多的內存空間。
  6. 訪問速度更快:由於 struct 是一個靜態數據結構,不需要像 map 那樣遍歷數據以及維護哈希表等操作,所以訪問速度更快。
  7. 編譯時檢查錯誤:使用 struct 可以在編譯時檢查錯誤,因為編譯器可以確定 struct 中每個欄位的類型和名稱。而在使用 map 時,由於 key 和 value 的類型不固定,可能會發生類型錯誤或 key 不存在等錯誤。

    內存管理

TCMalloc 是 Thread Cache Malloc 的簡稱,是Go 內存管理的起源, Go的內存管理是藉鑑了TCMalloc:

  • 內存碎片
    • 隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。
  • 大鎖
    • 同一進程下的所有線程共享相同的內存空間,它們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

小於 32kb 內存分配

當程序裡發生了 32kb 以下的小塊內存申請時, Go 會從一個叫做的 mcache 的本地緩存給程序分配內存。 這樣的一個內存塊裡叫做 mspan,它是要給程序分配內存時的分配單元。 在 Go 的調度器模型裡,每個線程 M 會綁定給一個處理器 P, 在單一粒度的時間裡只能做多處理運行一個 goroutine, 每個 P 都會綁定一個上面說的本地緩存 mcache。 當需要進行內存分配時,當前運行的 goroutine 會從 mcache 中查找可用的 mspan。 從本地 mcache 里分配內存時不需要加鎖,這種分配策略效率更高。

我們需要先知道幾個重要的概念:

  • page: 內存頁,一塊 8K 大小的內存空間。 Go 與操作系統之間的內存申請和釋放,都是以 page 為單位的。
  • span: 內存塊,一個或多個連續的 page 組成一個 span。
  • sizeclass: 空間規格,每個 span 都帶有一個 sizeclass,標記著該 span 中的 page 應該如何使用。
  • object: 對象,用來存儲一個變量數據內存空間,一個 span 在初始化時,會被切割成一堆等大的 object。假設 object 的大小是 16B,span 大小是 8K,那麼就會把 span 中的 page 就會被初始化 8K / 16B = 512 個 object。

申請內存時都分給他們一個 mspan 這樣的單元會不會產生浪費。 其實 mcache 持有的這一系列的 mspan 並不都是統一大小的,而是按照大小, 從 8kb 到 32kb 分了大概 67*2 類的 mspan 。 每個內存頁分為多級固定大小的“空閒列表”,這有助於減少碎片。類似的思路在 Linux Kernel、Memcache都可以見到 Slab-Allactor。

如果分配內存時 mcachce 裡沒有空閒的對口 sizeclass 的 mspan 了, Go 裡還為每種類別的 mspan 維護著一個 mcentral

如果 p的mache用完了, p會去跟 mcentral去要. mcahce像是 CPU 的L0 cache (不加鎖); mcentral 像是 CPU 的 L1 cache (加鎖); mheap 像是 CPU 的 L2 cache

mcentral 的作用是為所有 mcache 提供切分好的 mspan 資源。每個 central 會持有一種特定大小的全局 mspan 列表,包括已分配出去的和未分配出去的。每個 mcentral 對應一種 mspan,當工作線程的 mcache 中沒有合適(也就是特定大小的)的mspan 時就會從 mcentral 去獲取。

mcentral 被所有的工作線程共同享有,存在多個 goroutine 競爭的情況,因此從 mcentral 獲取資源時需要加鎖。 mcentral 里維護著兩個雙向鍊錶,nonempty 表示鍊錶裡還有空閒的 mspan 待分配。 empty表示這條鍊錶裡的 mspan 都被分配了object 或緩存 mcache中。

程序申請內存的時候,mcache 裡已經沒有合適的空閒 mspan了,那麼工作線程就會像下圖這樣去 mcentral 裡去申請。 mcache 從 mcentral 獲取和歸還 mspan 的流程:

  1. 獲取 加鎖;從 nonempty 鍊錶找到一個可用的mspan;並將其從 nonempty 鍊錶刪除;將取出的 mspan 加入到 empty 鍊錶;將 mspan 返回給工作線程;解鎖。

  2. 歸還 加鎖;將 mspan 從 empty 鍊錶刪除;將mspan 加入到 nonempty 鍊錶;解鎖。

mcentral 是 sizeclass 相同的 span 會以鍊錶的形式組織在一起, 就是指該 span 用來存儲哪種大小的對象。

當 mcentral 沒有空閒的 mspan 時,會向 mheap 申請。 而 mheap 沒有資源時,會向操作系統申請新內存。 mheap 主要用於大對象的內存分配,以及管理未切割的 mspan,用於給 mcentral 切割成小對象。

mheap 中含有所有規格的 mcentral,所以當一個 mcache 從 mcentral 申請 mspan 時,只需要在獨立的 mcentral 中使用鎖,並不會影響申請其他規格的 mspan。

所有 mcentral 的集合則是存放於 mheap 中的。 mheap 裡的 arena 區域是真正的堆區,運行時會將 8KB 看做一頁,這些內存頁中存儲了所有在堆上初始化的對象。 運行時使用二維的 runtime.heapArena 數組管理所有的內存, 每個 runtime.heapArena 都會管理 64MB 的內存。

如果 arena 區域沒有足夠的空間,會調用 runtime.mheap.sysAlloc 從操作系統中申請更多的內存。 如下圖 (G1.11前的內存佈局)

bitmap 用來gc判定用, 是不是個指針, 有沒有被掃過 spans == mspan

小於 16b 內存分配

對於小於16字節的對象(且無指針),Go 語言將其劃分為了 tiny 對象。 劃分 tiny 對象的主要目的是為了處理極小的字符串和獨立的轉義變量。 對 json 的基準測試表明,使用 tiny 對象減少了12%的分配次數和20%的堆大小。

  • 首先查看之前分配的元素中是否有空餘的空間
  • 如果當前要分配的大小不夠,例如要分配16字節的大小,這時就需要找到下一個空閒的元素

tiny 分配的第一步是嘗試利用分配過的前一個元素的空間,達到節約內存的目的。

大於 32kb 內存分配

Go 沒法使用工作線程的本地緩存 mcache 和全局中心緩存 mcentral 上管理超過32KB的內存分配, 所以對於那些超過32KB的內存申請,會直接從堆上(mheap)上分配對應的數量的內存頁(每頁大小是8KB)給程序。

  • freelist
  • treap
  • radix tree

內存分配

一般小對象通過 mspan 分配內存;大對象則直接由 mheap 分配內存。

  • Go 在程序啟動時,會向操作系統申請一大塊內存,由 mheap 結構全局管理(現在 Go 版本 不需要連續地址了,所以不會申請一大堆地址)
  • Go 內存管理的基本單元是 mspan,每種 mspan 可以分配特定大小的 object
  • mcache, mcentral, mheap 是 Go 內存管理的三大組件,mcache 管理線程在本地緩存的 mspan;mcentral 管理全局的 mspan 供所有線程

  • What to expect when monitoring memory usage for modern Go applications

EDIT (2020.12.13): From Go 1.16, Go on Linux moves back to using MADV_DONTNEED when releasing memory. However, this blog post still applies in terms of how to monitor memory consumption, although we should see less memory cached by Go runtime. See this issue.

GC 原理

GC 觸發時機

在 Go 中主要會在三個地方觸發 GC:

  1. 監控線程 runtime.sysmon 定時調用;
  2. 手動調用 runtime.GC 函數進行垃圾收集;
  3. 申請內存時 runtime.mallocgc 會根據堆大小判斷是否調用;

  4. 聊聊兩個 Go 即將過時的 GC 優化策略

  5. Go语言GC实现原理及源码分析

Mark & Sweep

Garbage Collection

現代高級編程語言管理內存的方式分為兩種:自動和手動,像 C、C++ 等編程語言使用手動管理內存的方式,工程師編寫代碼過程中需要主動申請或者釋放內存;而 PHP、Java 和 Go 等語言使用自動的內存管理系統,有內存分配器和垃圾收集器來代為分配和回收內存,其中垃圾收集器就是我們常說的 GC。 主流的垃圾回收算法:

  • 引用計數
  • 追蹤式垃圾回收 Go 現在用的三色標記法就屬於追蹤式垃圾回收算法的一種。

Mark & Sweep

STW

stop the world, GC 的一些階段需要停止所有的 mutator 以確定當前的引用關係。這便是很多人對 GC 擔心的來源,這也是 GC 算法優化的重點。

Root

根對像是 mutator 不需要通過其他對象就可以直接訪問到的對象。比如全局對象,棧對像中的數據等。通過Root對象。可以追踪到其他存活的對象。

Mark Sweep 兩個階段:標記(Mark)和 清除(Sweep)兩個階段,所以也叫

這個算法就是嚴格按照追踪式算法的思路來實現的:

  • Stop the World
  • Mark:通過 Root 和 Root 直接間接訪問到的對象, 來尋找所有可達的對象,並進行標記。
  • Sweep:對堆對象迭代,已標記的對象置位標記。所有未標記的對象加入freelist, 可用於再分配。
  • Start the Wrold

這個算法最大的問題是 GC 執行期間需要把整個程序完全暫停, 樸素的 Mark Sweep 是整體 STW,並且分配速度慢,內存碎片率高。

Go v1.1, STW 可能秒級

標記過程需的要 STW,因為對象引用關係如果在標記階段做了修改,會影響標記結果的正確性。 並發 GC 分為兩層含義:

  • 每個 mark 或 sweep 本身是多個線程(協程)執行的(concurrent)
  • mutator 和 collector 同時運行(background)

concurrent 這一層是比較好實現的, GC 時整體進行STW,那麼對象引用關係不會再改變,對 mark 或者sweep 任務進行分塊,就能多個線程(協程) conncurrent 執行任務 mark 或 sweep。

Go v1.3,標記 STW,並發 Sweep

而對於 backgroud 這一層, 也就是說 mutator 和 mark,sweep 同時運行,則相對複雜。

  • 1.3以前的版本使用標記-清掃的方式,整個過程都需要 STW。
  • 1.3版本分離了標記和清掃的操作,標記過程STW,清掃過程並發執行。

backgroup sweep 是比較容易實現的,因為 mark 後,哪些對像是存活,哪些是要被 sweep 是已知的,sweep 的是不再引用的對象。 sweep 結束前,這些對像不會再被分配到,所以 sweep 和 mutator 運行共存。無論全局還是棧不可能能訪問的到這些對象,可以安全清理。

1.5版本在標記過程中使用三色標記法。標記和清掃都並發執行的,但標記階段的前後需要 STW 一定時間來做 GC 的準備工作和棧的re-scan。

Tri-color Mark & Sweep

Go v1.5,並行 Mark & Sweep

三色標記是對標記清楚法的改進,標記清楚法在整個執行時要求長時間 STW, Go 從1.5版本開始改為三色標記法, 初始將所有內存標記為白色, 然後將 roots 加入待掃描隊列(進入隊列即被視為變成灰色), 然後使用並發 goroutine 掃描隊列中的指針, 如果指針還引用了其他指針,那麼被引用的也進入隊列,被掃描的對象視為黑色。

  • 白色對象:潛在的垃圾,其內存可能會被垃圾收集器回收。
  • 黑色對象:活躍的對象,包括不存在任何引用外部指針的對像以及從根對象可達的對象,垃圾回收器不會掃描這些對象的子對象
  • 灰色對象 :活躍的對象,因為存在指向白色對象的外部指針,垃圾收集器會掃描這些對象的子對象

Tri-color Marking

垃圾收集器從 root 開始然後跟隨指針遞歸整個內存空間。 分配於 noscan 的 span 的對象, 不會進行掃描。 然而,此過程不是由同一個 goroutine 完成的,每個指針都排隊在工作池中 然後,先看到的被標記為工作協程的後台協程從該池中出隊,掃描對象,然後將在其中找到的指針排入隊列。

Tri-color Coloring

染色流程:

  • 一開始所有對像被認為是白色
  • 根節點(stacks,heap,global variables)被染色為灰色
    • 如果對象位於標為noscan,則可以將其塗成黑色,並不需要對其進行掃描。

一旦主流程走完,gc會:

  • 選一個灰色對象,標記為黑色
  • 遍歷這個對象的所有指針,標記所有其引用的對象為灰色 最終直到所有對象需要被染色。

標記結束後, 黑色對像是內存中正在使用的對象, 而白色對像是要收集的對象。 由於 struct2的實例是在匿名函數中創建的,並且 無法從堆棧訪問,因此它保持為白色,可以清除。

顏色在內部實現原理: 每個 span 中有一個名為gcmarkBits 的位圖屬性, 該屬性跟踪掃描,並將相應的位設置為1。

Write Barrier

1.5版本在標記過程中使用三色標記法。 回收過程主要有四個階段,其中,標記和清掃都並發執行的, 但標記階段的前後需要 STW 一定時間來做GC 的準備工作和棧的 re-scan。

使用並發的垃圾回收,也就是多個 Mutator 與 Mark 並發執行,想要在並發或者增量的標記算法中保證正確性, 我們需要達成以下兩種三色不變性(Tri-color invariant)中的任意一種:

  • 強三色不變性:黑色對像不會指向白色對象,只會指向灰色對像或者黑色對象。
  • 弱三色不變性 :黑色對象指向的白色對象必須包含一條從灰色對象經由多個白色對象的可達路徑。

可以看出,一個白色對像被黑色對象引用,是注定無法通過這個黑色對象來保證自身存活的, 與此同時,如果所有能到達它的灰色對象與它之間的可達關係全部遭到破壞,那麼這個白色對象必然會被視為垃圾清除掉。 故當上述兩個條件同時滿足時,就會出現對象丟失的問題。 如果這個白色對像下遊還引用了其他對象,並且這條路徑是指向下游對象的唯一路徑,那麼他們也是必死無疑的。

為了防止這種現象的發生,最簡單的方式就是 STW,直接禁止掉其他用戶程序對對象引用關係的干擾, 但是 STW 的過程有明顯的資源浪費,對所有的用戶程序都有很大影響,如何能在保證對像不丟失的情況下合理的盡可能的提高 GC 效率,減少 STW 時間呢?

標記過程需的要 STW,因為對象引用關係如果在標記階段做了修改,會影響標記結果的正確性。灰色對象 B 中包含指向白色對象 C 的指針 e,對象 C 尚未被掃描,此時,如有其他程序,將 e 指針從 B 對像中刪除,並將指向對象 C 的新指針 f插入到黑色對象 A 中,由於對象 A 早已完成掃描,對象 C 就會一直保持白色狀態直到被回收。

Write Barrier - Dijkstra 寫屏障

插入屏障攔截將白色指針插入黑色對象的操作,標記其對應對象為灰色狀態,這樣就不存在黑色對象引用白色對象的情況了,滿足強三色不變式,在插入指針 f 時將 C 對象標記為灰色。 如果對棧上的寫做攔截,那麼流程代碼會非常複雜,並且性能下降會非常大,得不償失。根據局部性的原理來說,其實我們程序跑起來,大部分的其實都是操作在棧上,函數參數啊、函數調用導致的壓棧出棧、局部變量啊,協程棧,這些如果也弄起寫屏障,那麼可想而知了,根本就不現實,複雜度和性能就是越不過去的坎。

Go1.5版本使用的 Dijkstra 寫屏障就是這個原理

1、內存屏障只是對應一段特殊的代碼;2、內存屏障這段代碼在編譯期間生成;3、內存屏障本質上在運行期間攔截內存寫操作,相當於一個 hook 調用 Go 團隊在實現上選擇了在標記階段完成時暫停程序、將所有棧對象標記為灰色並重新掃描,在活躍 goroutine 非常多的程序中,重新掃描的過程需要佔用 10 ~ 100ms 的時間。

初始化 GC 任務,包括開啟寫屏障(write barrier)和開啟輔助 GC(mutator assist),統計 root 對象的任務數量等,這個過程需要STW。

掃描所有 root 對象,包括全局指針和 goroutine(G) 棧上的指針(掃描對應 G 棧時需停止該 G),將其加入標記隊列(灰色隊列),並循環處理灰色隊列的對象,直到灰色隊列為空,該過程後台並行執行。

完成標記工作,重新掃描(re-scan)全局指針和棧。因為 Mark 和 mutator 是並行的,所以在 Mark 過程中可能會有新的對象分配和指針賦值,這個時候就需要通過寫屏障(write barrier)記錄下來,re-scan 再檢查一下,這個過程也是會 STW 的。

按照標記結果回收所有的白色對象,該過程後台並行執行。

對未清掃的span進行清掃, 只有上一輪的GC的清掃工作完成才可以開始新一輪的GC。 如果發現掃描後回收的速度跟不上分配的速度它依然會把⽤戶邏輯暫停,⽤戶邏輯暫停了以後也就意味著不會有新的對像出現,同時會把⽤戶線程搶過來加⼊到垃圾回收⾥⾯加快垃圾回收的速度。這樣⼀來原來的並發還是變成了STW,還是得把⽤戶線程暫停掉,要不然掃描和回收沒完沒了了停不下來,因為新分配對象⽐回收快,所以這種東⻄叫做輔助回收

Write Barrier - Yuasa 刪屏障

刪除屏障也是攔截寫操作的,但是是通過保護灰色對像到白色對象的路徑不會斷來實現的。如上圖例中,在刪除指針 e 時將對象 C 標記為灰色,這樣 C 下游的所有白色對象,即使會被黑色對象引用,最終也還是會被掃描標記的,滿足了弱三色不變式。這種方式的回收精度低,一個對象即使被刪除了最後一個指向它的指針也依舊可以活過這一輪,在下一輪 GC 中被清理掉。

Write Barrier - 混合屏障

插入屏障和刪除屏障各有優缺點, Dijkstra 的插入寫屏障在標記開始時無需 STW,可直接開始,並發進行, 但結束時需要 STW 來重新掃描棧,標記棧上引用的白色對象的存活;Yuasa 的刪除寫屏障則需要在 GC 開始時 STW 掃描堆棧來記錄初始快照,這個過程會保護開始時刻的所有存活對象,但結束時無需 STW。 Golang 中的混合寫屏障滿足的是變形的弱三色不變式,同樣允許黑色對象引用白色對象,白色對象處於灰色保護狀態,但是只由堆上的灰色對象保護。

Go1.8 混合寫屏障結合了Yuasa的刪除寫屏障和Dijkstra的插入寫屏障

shade(*slot)是刪除寫屏障的變形,例如,一個堆上的灰色對象B,引用白色對象C,在GC並發運行的過程中,如果棧已掃描置黑,而賦值器將指向C的唯一指針從B中刪除,並讓棧上其他對象引用它,這時,寫屏障會在刪除指向白色對象C的指針的時候就將C對象置灰,就可以保護下來了,且它下游的所有對像都處於被保護狀態。 如果對象B在棧上,引用堆上的白色對象C,將其引用關係刪除,且新增一個黑色對像到對象C的引用,那麼就需要通過shade(ptr)來保護了,在指針插入黑色對象時會觸發對對象C的置灰操作。如果棧已經被掃描過了,那麼棧上引用的對像都是灰色或受灰色保護的白色對象了,所以就沒有必要再進行這步操作。

由於結合了 Yuasa 的刪除寫屏障和 Dijkstra 的插入寫屏障的優點,只需要在開始時並發掃描各個goroutine 的棧,使其變黑並一直保持,這個過程不需要 STW,而標記結束後,因為棧在掃描後始終是黑色的,也無需再進行 re-scan 操作了,減少了 STW 的時間。

為了移除棧的重掃描過程,除了引入混合寫屏障之外,在垃圾收集的標記階段,我們還需要將創建的所有新對像都標記成黑色,防止新分配的棧內存和堆內存中的對像被錯誤地回收,因為棧內存在標記階段最終都會變為黑色,所以不再需要重新掃描棧空間。

Sweep

Sweep 讓 Go 知道哪些內存可以重新分配使用, 然而,Sweep 過程並不會處理釋放的對象內存置為0(zeroing the memory)。 而是在分配重新使用的時候,重新 reset bit。

每個 span 內有一個 bitmap allocBits, 他表示上一次 GC 之後每一個 object 的分配情況,1:表示已分配,0:表示未使用或釋放。

內部還使用了 uint64 allocCache(deBruijn),加速尋找 freeobject。

GC 將會啟動去釋放不再被使用的內存。 在標記期間,GC 會用一個位圖 gcmarkBits 來跟踪在使用中的內存。

正在被使用的內存被標記為黑色,然而當前執行並不能夠到達的那些內存會保持為白色。 現在,我們可以使用 gcmarkBits 精確查看可用於分配的內存。 Go 使用 gcmarkBits 賦值了 allocBits,這個操作就是內存清理。 然而必須每個 span 都來一次類似的處理,需要耗費大量時間。 Go 的目標是在清理內存時不阻礙執行,並為此提供了兩種策略。

Go 提供兩種方式來清理內存:

  1. 在後台啟動一個 worker 等待清理內存,一個一個 mspan 處理

    • 當開始運行程序時,Go 將設置一個後台運行的 Worker(唯一的任務就是去清理內存),它將進入睡眠狀態並等待內存段掃描。
  2. 當申請分配內存時候 lazy 觸發

    • 當應用程序 goroutine 嘗試在堆內存中分配新內存時,會觸發該操作。清理導致的延遲和吞吐量降低被分散到每次內存分配時。

清理內存段的第二種方式是即時執行。 但是,由於這些內存段已經被分發到每一個處理器 P 的本地緩存 mcache 中,因此很難追踪首先清理哪些內存。 這就是為什麼 Go 首先將所有內存段移動到 mcentral 的原因。 然後,它將會讓本地緩存 mcache 再次請求它們,去即時清理。

即時掃描確保所有內存段在保存資源的過程中都會得到清理,同時會保存資源以及不會阻塞程序執行。

由於後台只有一個 worker 在清理內存塊,清理過程可能會花費一些時間。 但是,我們可能想知道如果另一個 GC 週期在一次清理過程中啟動會發生什麼。 在這種情況下,這個運行 GC 的 Goroutine 就會在開始標記階段前去協助完成剩餘的清理工作。

Stop The World

STW

在垃圾回收機制 (GC) 中,"Stop the World" (STW) 是一個重要階段。顧名思義, 在 "Stop the World" 階段, 當前運行的所有程序將被暫停, 掃描內存的 root 節點和添加寫屏障 (write barrier) 。 這個階段的第一步, 是搶占所有正在運行的 goroutine,被搶占之後, 這些 goroutine 會被懸停在一個相對安全的狀態。

處理器 P (無論是正在運行代碼的處理器還是已在 idle 列表中的處理器), 都會被被標記成停止狀態 (stopped), 不再運行任何代碼。調度器把每個處理器的 M 從各自對應的處理器 P 分離出來, 放到 idle 列表中去。 對於 Goroutine 本身, 他們會被放到一個全局隊列中等待。

1、函數調用觸發;2、信號量搶占

Pacing

運行時中有 GC Percentage 的配置選項,默認情況下為100。 此值表示在下一次垃圾收集必須啟動之前可以分配多少新內存的比率。將 GC 百分比設置為100意味著,基於在垃圾收集完成後標記為活動的堆內存量,下次垃圾收集前,堆內存使用可以增加100%。 如果超過2分鐘沒有觸發,會強制觸發 GC。

使用環境變量 GODEBUG 和 gctrace = 1選項生成GC trace GODEBUG=gctrace=1 ./app

上圖中P1專門用於垃圾收集。現在垃圾收集器可以開始標記階段。應用程序可以在P2,P3和P4上繼續進行。這意味著垃圾收集器的影響已最小化到當前CPU的25%。如果在垃圾收集過程中,P1在堆內存達到極限之前無法完成標記工作(因為應用程序可能在大量分配內存),應用程序Goroutine成為Mark Assist(協助標記)中的時間長度與它申請的堆內存成正比。 Mark Assist有助於更快地完成垃圾收集。

Channel 原理

This browser does not support PDFs.
Please download the PDF to view it: Download PDF.

Reference

© Kimi Tsai all right reserved.            Updated : 2023-07-12 09:04:53

results matching ""

    No results matching ""

    results matching ""

      No results matching ""