golang(Go語言)內存管理(一):系統內存管理

介紹

要搞明白 Go 語言的內存管理,就必須先理解操作系統以及機器硬件是如何管理內存的。因爲 Go 語言的內部機制是建立在這個基礎之上的,它的設計,本質上就是儘可能的會發揮操作系統層面的優勢,而避開導致低效情況。

操作系統內存管理

其實現在計算機內存管理的方式都是一步步演變來的,最開始是非常簡單的,後來爲了滿足各種需求而增加了各種各樣的機制,越來越複雜。這裏我們只介紹和開發者息息相關的幾個機制。

最原始的方式

我們可以把內存看成一個數組,每個數組元素的大小是 1B,也就是 8 位(bit)。CPU 通過內存地址來獲取內存中的數據,內存地址可以看做成數組的遊標(index)。

CPU 在執行指令的時候,就是通過內存地址,將物理內存上的數據載入到寄存器,然後執行機器指令。但隨着發展,出現了多任務的需求,也就是希望多個任務能同時在系統上運行。這就出現了一些問題:

  1. 內存訪問衝突:程序很容易出現 bug,就是 2 或更多的程序使用了同一塊內存空間,導致數據讀寫錯亂,程序崩潰。更有一些黑客利用這個缺陷來製作病毒。
  2. 內存不夠用:因爲每個程序都需要自己單獨使用的一塊內存,內存的大小就成了任務數量的瓶頸。
  3. 程序開發成本高:你的程序要使用多少內存,內存地址是多少,這些都不能搞錯,對於人來說,開發正確的程序很費腦子。

舉個例子,假設有一個程序,當代碼運行到某處時,需要使用 100M 內存,其他時候 1M 內存就夠;爲了避免和其他程序衝突,程序初始化時,就必須申請獨立 100M 內存以保證正常運行,這就是一種很大的浪費,因爲這 100M 它大多數時候用不上,其他程序還不能用。

虛擬內存

虛擬內存的出現,很好的爲了解決上述的一些列問題。用戶程序只能使用虛擬的內存地址來獲取數據,系統會將這個虛擬地址翻譯成實際的物理地址。

所有程序統一使用一套連續虛擬地址,比如 0x0000 ~ 0xffff。從程序的角度來看,它覺得自己獨享了一整塊內存。不用考慮訪問衝突的問題。系統會將虛擬地址翻譯成物理地址,從內存上加載數據。

對於內存不夠用的問題,虛擬內存本質上是將磁盤當成最終存儲,而主存作爲了一個 cache。程序可以從虛擬內存上申請很大的空間使用,比如 1G;但操作系統不會真的在物理內存上開闢 1G 的空間,它只是開闢了很小一塊,比如 1M 給程序使用。
這樣程序在訪問內存時,操作系統看訪問的地址是否能轉換成物理內存地址。能則正常訪問,不能則再開闢。這使得內存得到了更高效的利用。

如下圖所示,每個進程所使用的虛擬地址空間都是一樣的,但他們的虛擬地址會被映射到主存上的不同區域,甚至映射到磁盤上(當內存不夠用時)。

其實本質上很簡單,就是操作系統將程序常用的數據放到內存里加速訪問,不常用的數據放在磁盤上。這一切對用戶程序來說完全是透明的,用戶程序可以假裝所有數據都在內存裏,然後通過虛擬內存地址去訪問數據。在這背後,操作系統會自動將數據在主存和磁盤之間進行交換。

虛擬地址翻譯

虛擬內存的實現方式,大多數都是通過頁表來實現的。操作系統虛擬內存空間分成一頁一頁的來管理,每頁的大小爲 4K(當然這是可以配置的,不同操作系統不一樣)。磁盤和主內存之間的置換也是以爲單位來操作的。4K 算是通過實踐折中出來的通用值,太小了會出現頻繁的置換,太大了又浪費內存。

虛擬地址 -> 物理地址 的映射關係由頁表(Page Table)記錄,它其實就是一個數組,數組中每個元素叫做頁表條目(Page Table Entry,簡稱 PTE),PTE 由一個有效位和 n 位地址字段構成,有效位標識這個虛擬地址是否分配了物理內存。

頁表被操作系統放在物理內存的指定位置,CPU 上有個 Memory Management Unit(MMU) 單元,CPU 把虛擬地址給 MMU,MMU 去物理內存中查詢頁表,得到實際的物理地址。當然 MMU 不會每次都去查的,它自己也有一份緩存叫Translation Lookaside Buffer (TLB),是爲了加速地址翻譯。

你慢慢會發現整個計算機體系裏面,緩存是無處不在的,整個計算機體系就是建立在一級級的緩存之上的,無論軟硬件。

讓我們來看一下 CPU 內存訪問的完整過程:

  1. CPU 使用虛擬地址訪問數據,比如執行了 MOV 指令加載數據到寄存器,把地址傳遞給 MMU。
  2. MMU 生成 PTE 地址,並從主存(或自己的 Cache)中得到它。
  3. 如果 MMU 根據 PTE 得到真實的物理地址,正常讀取數據。流程到此結束。
  4. 如果 PTE 信息表示沒有關聯的物理地址,MMU 則觸發一個缺頁異常。
  5. 操作系統捕獲到這個異常,開始執行異常處理程序。在物理內存上創建一頁內存,並更新頁表。
  6. 缺頁處理程序在物理內存中確定一個犧牲頁,如果這個犧牲頁上有數據,則把數據保存到磁盤上。
  7. 缺頁處理程序更新 PTE。
  8. 缺頁處理程序結束,再回去執行上一條指令(導致缺頁異常的那個指令,也就是 MOV 指令)。這次肯定命中了。

內存命中率

你可能已經發現,上述的訪問步驟中,從第 4 步開始都是些很繁瑣的操作,頻繁的執行對性能影響很大。畢竟訪問磁盤是非常慢的,它會引發程序性能的急劇下降。如果內存訪問到第 3 步成功結束了,我們就說頁命中了;反之就是未命中,或者說缺頁,表示它開始執行第 4 步了。

假設在 n 次內存訪問中,出現命中的次數是 m,那麼 m / n * 100% 就表示命中率,這是衡量內存管理程序好壞的一個很重要的指標。

如果物理內存不足了,數據會在主存和磁盤之間頻繁交換,命中率很低,性能出現急劇下降,我們稱這種現象叫內存顛簸。這時你會發現系統的 swap 空間利用率開始增高, CPU 利用率中 iowait 佔比開始增高。

大多數情況下,只要物理內存夠用,頁命中率不會非常低,不會出現內存顛簸的情況。因爲大多數程序都有一個特點,就是局部性

局部性就是說被引用過一次的存儲器位置,很可能在後續再被引用多次;而且在該位置附近的其他位置,也很可能會在後續一段時間內被引用。

前面說過計算機到處使用一級級的緩存來提升性能,歸根結底就是利用了局部性的特徵,如果沒有這個特性,一級級的緩存不會有那麼大的作用。所以一個局部性很好的程序運行速度會更快。

CPU Cache

隨着技術發展,CPU 的運算速度越來越快,但內存訪問的速度卻一直沒什麼突破。最終導致了 CPU 訪問主存就成了整個機器的性能瓶頸。CPU Cache 的出現就是爲了解決這個問題,在 CPU 和 主存之間再加了 Cache,用來緩存一塊內存中的數據,而且還不只一個,現代計算機一般都有 3 級 Cache,其中 L1 Cache 的訪問速度和寄存器差不多。

現在訪問數據的大致的順序是 CPU --> L1 Cache --> L2 Cache --> L3 Cache --> 主存 --> 磁盤。從左到右,訪問速度越來越慢,空間越來越大,單位空間(比如每字節)的價格越來越低。

現在存儲器的整體層次結構大致如下圖:

在這種架構下,緩存的命中率就更加重要了,因爲系統會假定所有程序都是有局部性特徵的。如果某一級出現了未命中,他就會將該級存儲的數據更新成最近使用的數據。

主存與存儲器之間以 page(通常是 4K) 爲單位進行交換,cache 與 主存之間是以 cache line(通常 64 byte) 爲單位交換的。

舉個例子

讓我們通過一個例子來驗證下命中率的問題,下面的函數是循環一個數組爲每個元素賦值。

func Loop(nums []int, step int) {
    l := len(nums)
    for i := 0; i < step; i++ {
        for j := i; j < l; j += step {
            nums[j] = 4
        }
    }
}

參數 step 爲 1 時,和普通一層循環一樣。假設 step 爲 2 ,則效果就是跳躍式遍歷數組,如 1,3,5,7,9,2,4,6,8,10 這樣,step 越大,訪問跨度也就越大,程序的局部性也就越不好。

下面是 nums 長度爲 10000step = 1step = 16 時的壓測結果:

goos: darwin
goarch: amd64
BenchmarkLoopStep1-4              300000              5241 ns/op
BenchmarkLoopStep16-4             100000             22670 ns/op

可以看出,2 種遍歷方式會出現 3 倍的性能差距。這種問題最容易出現在多維數組的處理上,比如遍歷一個二維數組很容易就寫出局部性很差的代碼。

程序的內存佈局

最後看一下程序的內存佈局。現在我們知道了每個程序都有自己一套獨立的地址空間可以使用,比如 0x0000 ~ 0xffff,但我們在用高級語言,無論是 C 還是 Go 寫程序的時候,很少直接使用這些地址。我們都是通過變量名來訪問數據的,編譯器會自動將我們的變量名轉換成真正的虛擬地址。

那最終編譯出來的二進制文件,是如何被操作系統加載到內存中並執行的呢?

其實,操作系統已經將一整塊內存劃分好了區域,每個區域用來做不同的事情。如圖:

  • text 段:存儲程序的二進制指令,及其他的一些靜態內容
  • data 段:用來存儲已被初始化的全局變量。比如常量(const)。
  • bss 段:用來存放未被初始化的全局變量。和 .data 段一樣都屬於靜態分配,在這裏面的變量數據在編譯就確定了大小,不釋放。
  • stack 段:棧空間,主要用於函數調用時存儲臨時變量的。這部分的內存是自動分配自動釋放的。
  • heap 段:堆空間,用於動態分配,C 語言中 mallocfree 操作的內存就在這裏;Go 語言主要靠 GC 自動管理這部分。

其實現在的操作系統,進程內部的內存區域沒這麼簡單,要比這複雜多了,比如內核區域,共享庫區域。因爲我們不是要真的開發一套操作系統,細節可以忽略。這裏只需要記住堆空間棧空間即可。

  • 棧空間是通過壓棧出棧方式自動分配釋放的,由系統管理,使用起來高效無感知。
  • 堆空間是用以動態分配的,由程序自己管理分配和釋放。Go 語言雖然可以幫我們自動管理分配和釋放,但是代價也是很高的。

結論

局部性好的程序,可以提高緩存命中率,這對底層系統的內存管理是很友好的,可以提高程序的性能。CPU Cache 層面的低命中率導致的是程序運行緩慢,內存層面的低命中率會出現內存顛簸,出現這種現象時你的服務基本上已經癱瘓了。Go 語言的內存管理是參考 tcmalloc 實現的,它其實就是利用好了 OS 管理內存的這些特點,來最大化內存分配性能的。

參考

  1. Go Memory Management
  2. 《深入理解計算機系統》
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章