golang(Go語言)內存管理(二):Go 內存管理

介紹

瞭解操作系統對內存的管理機制後,現在可以去看下 Go 語言是如何利用底層的這些特性來優化內存的。Go 的內存管理基本上參考 tcmalloc 來實現的,只是細節上根據自身的需要做了一些小的優化調整。

Go 的內存是自動管理的,我們可以隨意定義變量直接使用,不需要考慮變量背後的內存申請和釋放的問題。本文意在搞清楚 Go 在方面幫我們做了什麼,使我們不用關心那些複雜內存的問題,還依舊能寫出較爲高效的程序。

本篇只介紹 Go 的內存管理模型,與其相關的還有逃逸分析垃圾回收內容,因爲篇幅的關係,打算後面找時間各自整理出一篇。

程序動態申請內存空間,是要使用系統調用的,比如 Linux 系統上是調用 mmap 方法實現的。但對於大型系統服務來說,直接調用 mmap 申請內存,會有一定的代價。比如:

  1. 系統調用會導致程序進入內核態,內核分配完內存後(也就是上篇所講的,對虛擬地址和物理地址進行映射等操作),再返回到用戶態。
  2. 頻繁申請很小的內存空間,容易出現大量內存碎片,增大操作系統整理碎片的壓力。
  3. 爲了保證內存訪問具有良好的局部性,開發者需要投入大量的精力去做優化,這是一個很重的負擔。

如何解決上面的問題呢?有經驗的人,可能很快就想到解決方案,那就是我們常說的對象池(也可以說是緩存)。

假設系統需要頻繁動態申請內存來存放一個數據結構,比如 [10]int。那麼我們完全可以在程序啓動之初,一次性申請幾百甚至上千個 [10]int。這樣完美的解決了上面遇到的問題:

  1. 不需要頻繁申請內存了,而是從對象池裏拿,程序不會頻繁進入內核態
  2. 因爲一次性申請一個連續的大空間,對象池會被重複利用,不會出現碎片。
  3. 程序頻繁訪問的就是對象池背後的同一塊內存空間,局部性良好。

這樣做會造成一定的內存浪費,我們可以定時檢測對象池的大小,保證可用對象的數量在一個合理的範圍,少了就提前申請,多了就自動釋放。

如果某種資源的申請和回收是昂貴的,我們都可以通過建立資源池的方式來解決,其他比如連接池內存池等等,都是一個思路。

Golang 內存管理

Golang 的內存管理本質上就是一個內存池,只不過內部做了很多的優化。比如自動伸縮內存池大小,合理的切割內存塊等等。

內存池 mheap

Golang 的程序在啓動之初,會一次性從操作系統那裏申請一大塊內存作爲內存池。這塊內存空間會放在一個叫 mheapstruct 中管理,mheap 負責將這一整塊內存切割成不同的區域,並將其中一部分的內存切割成合適的大小,分配給用戶使用。

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

  • page: 內存頁,一塊 8K 大小的內存空間。Go 與操作系統之間的內存申請和釋放,都是以 page 爲單位的。
  • span: 內存塊,一個或多個連續的 page 組成一個 span。如果把 page 比喻成工人,span 可看成是小隊,工人被分成若干個隊伍,不同的隊伍幹不同的活。
  • sizeclass: 空間規格,每個 span 都帶有一個 sizeclass,標記着該 span 中的 page 應該如何使用。使用上面的比喻,就是 sizeclass 標誌着 span 是一個什麼樣的隊伍。
  • object: 對象,用來存儲一個變量數據內存空間,一個 span 在初始化時,會被切割成一堆等大object。假設 object 的大小是 16Bspan 大小是 8K,那麼就會把 span 中的 page 就會被初始化 8K / 16B = 512object。所謂內存分配,就是分配一個 object 出去。

示意圖:

上圖中,不同顏色代表不同的 span,不同 spansizeclass 不同,表示裏面的 page 將會按照不同的規格切割成一個個等大的 object 用作分配。

使用 Go1.11.5 版本測試了下初始堆內存應該是 64M 左右,低版本會少點。

測試代碼:

package main
import "runtime"
var stat runtime.MemStats
func main() {
    runtime.ReadMemStats(&stat)
    println(stat.HeapSys)
}

內部的整體內存佈局如下圖所示:

  • mheap.spans:用來存儲 pagespan 信息,比如一個 span 的起始地址是多少,有幾個 page,已使用了多大等等。
  • mheap.bitmap 存儲着各個 span 中對象的標記信息,比如對象是否可回收等等。
  • mheap.arena_start: 將要分配給應用程序使用的空間。

再說明下,圖中的空間大小,是 Go 向操作系統申請的虛擬內存地址空間,操作系統會將該段地址空間預留出來不做它用;而不是真的創建出這麼大的虛擬內存,在頁表中創建出這麼大的映射關係。

mcentral

用途相同span 會以鏈表的形式組織在一起。 這裏的用途用 sizeclass 來表示,就是指該 span 用來存儲哪種大小的對象。比如當分配一塊大小爲 n 的內存時,系統計算 n 應該使用哪種 sizeclass,然後根據 sizeclass 的值去找到一個可用的 span 來用作分配。其中 sizeclass 一共有 67 種(Go1.5 版本,後續版本可能會不會改變不好說),如圖所示:

找到合適的 span 後,會從中取一個 object 返回給上層使用。這些 span 被放在一個叫做 mcentral 的結構中管理。

mheap 將從 OS 那裏申請過來的內存初始化成一個大 span(sizeclass=0)。然後根據需要從這個大 span 中切出小 span,放在 mcentral 中來管理。大 spanmheap.freelargemheap.busylarge 等管理。如果 mcentral 中的 span 不夠用了,會從 mheap.freelarge 上再切一塊,如果 mheap.freelarge 空間不夠,會再次從 OS 那裏申請內存重複上述步驟。下面是 mheap 和 mcentral 的數據結構:

type mheap struct {
    // other fields
    lock      mutex
    free      [_MaxMHeapList]mspan // free lists of given length, 1M 以下
    freelarge mspan                // free lists length >= _MaxMHeapList, >= 1M
    busy      [_MaxMHeapList]mspan // busy lists of large objects of given length
    busylarge mspan                // busy lists of large objects length >= _MaxMHeapList

    central [_NumSizeClasses]struct { // _NumSizeClasses = 67
        mcentral mcentral
        // other fields
    }
    // other fields
}

// Central list of free objects of a given size.
type mcentral struct {
    lock      mutex // 分配時需要加鎖
    sizeclass int32 // 哪種 sizeclass
    nonempty  mspan // 還有可用的空間的 span 鏈表
    empty     mspan // 沒有可用的空間的 span 列表
}

這種方式可以避免出現外部碎片(文章最後面有外部碎片的介紹),因爲同一個 span 是按照固定大小分配和回收的,不會出現不可利用的一小塊內存把內存分割掉。這個設計方式與現代操作系統中的夥伴系統有點類似。

mcache

如果你閱讀的比較仔細,會發現上面的 mcentral 結構中有一個 lock 字段;因爲併發情況下,很有可能多個線程同時從 mcentral 那裏申請內存的,必須要用鎖來避免衝突。

但鎖是低效的,在高併發的服務中,它會使內存申請成爲整個系統的瓶頸;所以在 mcentral 的前面又增加了一層 mcache。

每一個 mcache 和每一個處理器(P) 是一一對應的,也就是說每一個 P 都有一個 mcache 成員。 Goroutine 申請內存時,首先從其所在的 P 的 mcache 中分配,如果 mcache 沒有可用 span,再從 mcentral 中獲取,並填充到 mcache 中。

從 mcache 上分配內存空間是不需要加鎖的,因爲在同一時間裏,一個 P 只有一個線程在其上面運行,不可能出現競爭。沒有了鎖的限制,大大加速了內存分配。

所以整體的內存分配模型大致如下圖所示:

其他優化

zero size

有一些對象所需的內存大小是0,比如 [0]int, struct{},這種類型的數據根本就不需要內存,所以沒必要走上面那麼複雜的邏輯。

系統會直接返回一個固定的內存地址。源碼如下:


func mallocgc(size uintptr, typ *_type, flags uint32) unsafe.Pointer {
    // 申請的 0 大小空間的內存
    if size == 0 {
        return unsafe.Pointer(&zerobase)
    }
    //.....
}

測試代碼:

package main

import (
    "fmt"
)

func main() {
    var (
        a struct{}
        b [0]int
        c [100]struct{}
        d = make([]struct{}, 1024)
    )
    fmt.Printf("%p\n", &a)
    fmt.Printf("%p\n", &b)
    fmt.Printf("%p\n", &c)
    fmt.Printf("%p\n", &(d[0]))
    fmt.Printf("%p\n", &(d[1]))
    fmt.Printf("%p\n", &(d[1000]))
}
// 運行結果,6 個變量的內存地址是相同的:
0x1180f88
0x1180f88
0x1180f88
0x1180f88
0x1180f88
0x1180f88

Tiny對象

上面提到的 sizeclass=1 的 span,用來給 <= 8B 的對象使用,所以像 int32, byte, bool 以及小字符串等常用的微小對象,都會使用 sizeclass=1 的 span,但分配給他們 8B 的空間,大部分是用不上的。並且這些類型使用頻率非常高,就會導致出現大量的內部碎片。

所以 Go 儘量不使用 sizeclass=1 的 span, 而是將 < 16B 的對象爲統一視爲 tiny 對象(tinysize)。分配時,從 sizeclass=2 的 span 中獲取一個 16B 的 object 用以分配。如果存儲的對象小於 16B,這個空間會被暫時保存起來 (mcache.tiny 字段),下次分配時會複用這個空間,直到這個 object 用完爲止。

如圖所示:

以上圖爲例,這樣的方式空間利用率是 (1+2+8) / 16 * 100% = 68.75%,而如果按照原始的管理方式,利用率是 (1+2+8) / (8 * 3) = 45.83%
源碼中註釋描述,說是對 tiny 對象的特殊處理,平均會節省 20% 左右的內存。

如果要存儲的數據裏有指針,即使 <= 8B 也不會作爲 tiny 對象對待,而是正常使用 sizeclass=1span

大對象

如上面所述,最大的 sizeclass 最大隻能存放 32K 的對象。如果一次性申請超過 32K 的內存,系統會直接繞過 mcache 和 mcentral,直接從 mheap 上獲取,mheap 中有一個 freelarge 字段管理着超大 span。

總結

內存的釋放過程,沒什麼特別之處。就是分配的返過程,當 mcache 中存在較多空閒 span 時,會歸還給 mcentral;而 mcentral 中存在較多空閒 span 時,會歸還給 mheap;mheap 再歸還給操作系統。這裏就不詳細介紹了。

總結一下,這種設計之所以快,主要有以下幾個優勢:

  1. 內存分配大多時候都是在用戶態完成的,不需要頻繁進入內核態。
  2. 每個 P 都有獨立的 span cache,多個 CPU 不會併發讀寫同一塊內存,進而減少 CPU L1 cache 的 cacheline 出現 dirty 情況,增大 cpu cache 命中率。
  3. 內存碎片的問題,Go 是自己在用戶態管理的,在 OS 層面看是沒有碎片的,使得操作系統層面對碎片的管理壓力也會降低。
  4. mcache 的存在使得內存分配不需要加鎖。

當然這不是沒有代價的,Go 需要預申請大塊內存,這必然會出現一定的浪費,不過好在現在內存比較廉價,不用太在意。

總體上來看,Go 內存管理也是一個金字塔結構:

這種設計比較通用,比如現在常見的 web 服務設計,爲提升系統性能,一般都會設計成 客戶端 cache -> 服務端 cache -> 服務端 db 這幾層(當然也可能會加入更多層),也是金字塔結構。

將有限的計算資源佈局成金字塔結構,再將數據從熱到冷分爲幾個層級,放置在金字塔結構上。調度器不斷做調整,將熱數據放在金字塔頂層,冷數據放在金字塔底層。

這種設計利用了計算的局部性特徵,認爲冷熱數據的交替是緩慢的。所以最怕的就是,數據訪問出現冷熱驟變。在操作系統上我們稱這種現象爲內存顛簸,系統架構上通常被說成是緩存穿透。其實都是一個意思,就是過度的使用了金字塔低端的資源。

這套內部機制,使得開發高性能服務容易很多,通俗來講就是坑少了。一般情況下你隨便寫寫性能都不會太差。我遇到過的導致內存分配出現壓力的主要有 2 中情況:

  1. 頻繁申請大對象,常見於文本處理,比如寫一個海量日誌分析的服務,很多日誌內容都很長。這種情況建議自己維護一個對象([]byte)池,避免每次都要去 mheap 上分配。
  2. 濫用指針,指針的存在不僅容易造成內存浪費,對 GC 也會造成額外的壓力,所以儘量不要使用指針。

內存碎片

內存碎片是系統在內存管理過程中,會不可避免的出現一塊塊無法被使用的內存空間,這是內存管理的產物。

內部碎片

一般都是因爲字節對齊,如上面介紹 Tiny 對象分配的部分;爲了字節對齊,會導致一部分內存空間直接被放棄掉,不做分配使用。
再比如申請 28B 大小的內存空間,系統會分配 32B 的空間給它,這也導致了其中 4B 空間是被浪費掉的。這就是內部碎片。

外部碎片

一般是因爲內存的不斷分配釋放,導致一些釋放的小內存塊分散在內存各處,無法被用以分配。如圖:

上面的 8B 和 16B 的小空間,很難再被利用起來。不過 Go 的內存管理機制不會引起大量外部碎片。

源代碼調用流程圖

針對 Go1.5 源碼

 

runtime.MemStats 部分註釋

type MemStats struct {
        // heap 分配出去的字節總數,和 HeapAlloc 值相同
        Alloc uint64

        // TotalAlloc 是 heap 累計分配出去字節數,每次分配
        // 都會累加這個值,但是釋放時候不會減少
        TotalAlloc uint64

        // Sys 是指程序從 OS 那裏一共申請了多少內存
        // 因爲除了 heap,程序棧及其他內部結構都使用着從 OS 申請過來的內存
        Sys uint64

        // Mallocs heap 累積分配出去的對象數
        // 活動中的對象總數,即是 Mallocs - Frees
        Mallocs uint64
       
        // Frees 值 heap 累積釋放掉的對象總數
        Frees uint64

        // HeapAlloc 是分配出去的堆對象總和大小,單位字節
        // object 的聲明週期是 待分配 -> 分配使用 -> 待回收 -> 待分配
        // 只要不是待分配的狀態,都會加到 HeapAlloc 中
        // 它和 HeapInuse 不同,HeapInuse 算的是使用中的 span,
        // 使用中的 span 裏面可能還有很多 object 閒置
        HeapAlloc uint64

        // HeapSys 是 heap 從 OS 那裏申請來的堆內存大小,單位字節
        // 指的是虛擬內存的大小,不是物理內存,物理內存大小 Go 語言層面是看不到的。
        // 等於 HeapIdle + HeapInuse
        HeapSys uint64

        // HeapIdle 表示所有 span 中還有多少內存是沒使用的
        // 這些 span 上面沒有 object,也就是完全閒置的,可以隨時歸還給 OS
        // 也可以用於堆棧分配
        HeapIdle uint64

        // HeapInuse 是處在使用中的所有 span 中的總字節數
        // 只要一個 span 中有至少一個對象,那麼就表示它被使用了
        // HeapInuse - HeapAlloc 就表示已經被切割成固定 sizeclass 的 span 裏
        HeapInuse uint64

        // HeapReleased 是返回給操作系統的物理內存總數
        HeapReleased uint64

        // HeapObjects 是分配出去的對象總數
        // 如同 HeapAlloc 一樣,分配時增加,被清理或被釋放時減少
        HeapObjects uint64

        // NextGC is the target heap size of the next GC cycle.
        // NextGC 表示當 HeapAlloc 增長到這個值時,會執行一次 GC
        // 垃圾回收的目標是保持 HeapAlloc ≤ NextGC,每次 GC 結束
        // 下次 GC 的目標,是根據當前可達數據和 GOGC 參數計算得來的
        NextGC uint64

        // LastGC 是最近一次垃圾回收結束的時間 (the UNIX epoch).
        LastGC uint64

        // PauseTotalNs 是自程序啓動起, GC 造成 STW 暫停的累積納秒值
        // STW 期間,所有的 goroutine 都會被暫停,只有 GC 的 goroutine 可以運行
        PauseTotalNs uint64

        // PauseNs 是循環隊列,記錄着 GC 引起的 STW 總時間
        //
        // 一次 GC 循環,可能會出現多次暫停,這裏每項記錄的是一次 GC 循環裏多次暫停的綜合。
        // 最近一次 GC 的數據所在的位置是 PauseNs[NumGC%256]
        PauseNs [256]uint64

        // PauseEnd 是一個循環隊列,記錄着最近 256 次 GC 結束的時間戳,單位是納秒。
        //
        // 它和 PauseNs 的存儲方式一樣。一次 GC 可能會引發多次暫停,這裏只記錄一次 GC 最後一次暫停的時間
        PauseEnd [256]uint64

        // NumGC 指完成 GC 的次數
        NumGC uint32

        // NumForcedGC 是指應用調用了 runtime.GC() 進行強制 GC 的次數
        NumForcedGC uint32

        // BySize 統計各個 sizeclass 分配和釋放的對象的個數
        //
        // BySize[N] 統計的是對象大小 S,滿足 BySize[N-1].Size < S ≤ BySize[N].Size 的對象
        // 這裏不記錄大於 BySize[60].Size 的對象分配
        BySize [61]struct {
                // Size 表示該 sizeclass 的每個對象的空間大小
                // size class.
                Size uint32

                // Mallocs 是該 sizeclass 分配出去的對象的累積總數
                // Size * Mallocs 就是累積分配出去的字節總數
                // Mallocs - Frees 就是當前正在使用中的對象總數
                Mallocs uint64

                // Frees 是該 sizeclass 累積釋放對象總數
                Frees uint64
        }
}


 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章