介紹
要搞明白 Go 語言的內存管理,就必須先理解操作系統以及機器硬件是如何管理內存的。因爲 Go 語言的內部機制是建立在這個基礎之上的,它的設計,本質上就是儘可能的會發揮操作系統層面的優勢,而避開導致低效情況。
操作系統內存管理
其實現在計算機內存管理的方式都是一步步演變來的,最開始是非常簡單的,後來爲了滿足各種需求而增加了各種各樣的機制,越來越複雜。這裏我們只介紹和開發者息息相關的幾個機制。
最原始的方式
我們可以把內存看成一個數組,每個數組元素的大小是 1B
,也就是 8 位(bit)。CPU 通過內存地址來獲取內存中的數據,內存地址可以看做成數組的遊標(index)。
CPU 在執行指令的時候,就是通過內存地址,將物理內存上的數據載入到寄存器,然後執行機器指令。但隨着發展,出現了多任務的需求,也就是希望多個任務能同時在系統上運行。這就出現了一些問題:
- 內存訪問衝突:程序很容易出現 bug,就是 2 或更多的程序使用了同一塊內存空間,導致數據讀寫錯亂,程序崩潰。更有一些黑客利用這個缺陷來製作病毒。
- 內存不夠用:因爲每個程序都需要自己單獨使用的一塊內存,內存的大小就成了任務數量的瓶頸。
- 程序開發成本高:你的程序要使用多少內存,內存地址是多少,這些都不能搞錯,對於人來說,開發正確的程序很費腦子。
舉個例子,假設有一個程序,當代碼運行到某處時,需要使用 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 內存訪問的完整過程:
- CPU 使用虛擬地址訪問數據,比如執行了 MOV 指令加載數據到寄存器,把地址傳遞給 MMU。
- MMU 生成 PTE 地址,並從主存(或自己的 Cache)中得到它。
- 如果 MMU 根據 PTE 得到真實的物理地址,正常讀取數據。流程到此結束。
- 如果 PTE 信息表示沒有關聯的物理地址,MMU 則觸發一個缺頁異常。
- 操作系統捕獲到這個異常,開始執行異常處理程序。在物理內存上創建一頁內存,並更新頁表。
- 缺頁處理程序在物理內存中確定一個犧牲頁,如果這個犧牲頁上有數據,則把數據保存到磁盤上。
- 缺頁處理程序更新 PTE。
- 缺頁處理程序結束,再回去執行上一條指令(導致缺頁異常的那個指令,也就是 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 長度爲 10000
, step = 1
和 step = 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 語言中
malloc
和free
操作的內存就在這裏;Go 語言主要靠 GC 自動管理這部分。
其實現在的操作系統,進程內部的內存區域沒這麼簡單,要比這複雜多了,比如內核區域,共享庫區域。因爲我們不是要真的開發一套操作系統,細節可以忽略。這裏只需要記住堆空間和棧空間即可。
- 棧空間是通過壓棧出棧方式自動分配釋放的,由系統管理,使用起來高效無感知。
- 堆空間是用以動態分配的,由程序自己管理分配和釋放。Go 語言雖然可以幫我們自動管理分配和釋放,但是代價也是很高的。
結論
局部性好的程序,可以提高緩存命中率,這對底層系統的內存管理是很友好的,可以提高程序的性能。CPU Cache 層面的低命中率導致的是程序運行緩慢,內存層面的低命中率會出現內存顛簸,出現這種現象時你的服務基本上已經癱瘓了。Go 語言的內存管理是參考 tcmalloc 實現的,它其實就是利用好了 OS 管理內存的這些特點,來最大化內存分配性能的。
參考
- Go Memory Management
- 《深入理解計算機系統》