介紹
要搞明白 Go 語言的內(nèi)存管理放航,就必須先理解操作系統(tǒng)以及機(jī)器硬件是如何管理內(nèi)存的。因?yàn)?Go 語言的內(nèi)部機(jī)制是建立在這個(gè)基礎(chǔ)之上的圆裕,它的設(shè)計(jì)三椿,本質(zhì)上就是盡可能的會(huì)發(fā)揮操作系統(tǒng)層面的優(yōu)勢,而避開導(dǎo)致低效情況葫辐。
操作系統(tǒng)內(nèi)存管理
其實(shí)現(xiàn)在計(jì)算機(jī)內(nèi)存管理的方式都是一步步演變來的,最開始是非常簡單的伴郁,后來為了滿足各種需求而增加了各種各樣的機(jī)制耿战,越來越復(fù)雜。這里我們只介紹和開發(fā)者息息相關(guān)的幾個(gè)機(jī)制焊傅。
最原始的方式
我們可以把內(nèi)存看成一個(gè)數(shù)組剂陡,每個(gè)數(shù)組元素的大小是 1B
狈涮,也就是 8 位(bit)。CPU 通過內(nèi)存地址來獲取內(nèi)存中的數(shù)據(jù)鸭栖,內(nèi)存地址可以看做成數(shù)組的游標(biāo)(index)歌馍。
CPU 在執(zhí)行指令的時(shí)候,就是通過內(nèi)存地址界睁,將物理內(nèi)存上的數(shù)據(jù)載入到寄存器剩辟,然后執(zhí)行機(jī)器指令芥映。但隨著發(fā)展,出現(xiàn)了多任務(wù)的需求晓锻,也就是希望多個(gè)任務(wù)能同時(shí)在系統(tǒng)上運(yùn)行。這就出現(xiàn)了一些問題:
- 內(nèi)存訪問沖突:程序很容易出現(xiàn) bug飞几,就是 2 或更多的程序使用了同一塊內(nèi)存空間砚哆,導(dǎo)致數(shù)據(jù)讀寫錯(cuò)亂,程序崩潰屑墨。更有一些黑客利用這個(gè)缺陷來制作病毒躁锁。
- 內(nèi)存不夠用:因?yàn)槊總€(gè)程序都需要自己單獨(dú)使用的一塊內(nèi)存,內(nèi)存的大小就成了任務(wù)數(shù)量的瓶頸卵史。
- 程序開發(fā)成本高:你的程序要使用多少內(nèi)存战转,內(nèi)存地址是多少,這些都不能搞錯(cuò)程腹,對(duì)于人來說匣吊,開發(fā)正確的程序很費(fèi)腦子。
舉個(gè)例子寸潦,假設(shè)有一個(gè)程序色鸳,當(dāng)代碼運(yùn)行到某處時(shí),需要使用 100M
內(nèi)存见转,其他時(shí)候 1M
內(nèi)存就夠命雀;為了避免和其他程序沖突,程序初始化時(shí)斩箫,就必須申請(qǐng)獨(dú)立 100M
內(nèi)存以保證正常運(yùn)行吏砂,這就是一種很大的浪費(fèi),因?yàn)檫@ 100M
它大多數(shù)時(shí)候用不上乘客,其他程序還不能用狐血。
虛擬內(nèi)存
虛擬內(nèi)存的出現(xiàn),很好的為了解決上述的一些列問題易核。用戶程序只能使用虛擬的內(nèi)存地址來獲取數(shù)據(jù)匈织,系統(tǒng)會(huì)將這個(gè)虛擬地址翻譯成實(shí)際的物理地址。
所有程序統(tǒng)一使用一套連續(xù)虛擬地址,比如 0x0000 ~ 0xffff
缀匕。從程序的角度來看纳决,它覺得自己獨(dú)享了一整塊內(nèi)存。不用考慮訪問沖突的問題乡小。系統(tǒng)會(huì)將虛擬地址翻譯成物理地址阔加,從內(nèi)存上加載數(shù)據(jù)。
對(duì)于內(nèi)存不夠用的問題满钟,虛擬內(nèi)存本質(zhì)上是將磁盤當(dāng)成最終存儲(chǔ)胜榔,而主存作為了一個(gè) cache。程序可以從虛擬內(nèi)存上申請(qǐng)很大的空間使用零远,比如 1G
苗分;但操作系統(tǒng)不會(huì)真的在物理內(nèi)存上開辟 1G
的空間,它只是開辟了很小一塊牵辣,比如 1M
給程序使用摔癣。
這樣程序在訪問內(nèi)存時(shí),操作系統(tǒng)看訪問的地址是否能轉(zhuǎn)換成物理內(nèi)存地址纬向。能則正常訪問择浊,不能則再開辟。這使得內(nèi)存得到了更高效的利用逾条。
如下圖所示琢岩,每個(gè)進(jìn)程所使用的虛擬地址空間都是一樣的,但他們的虛擬地址會(huì)被映射到主存上的不同區(qū)域师脂,甚至映射到磁盤上(當(dāng)內(nèi)存不夠用時(shí))担孔。
其實(shí)本質(zhì)上很簡單,就是操作系統(tǒng)將程序常用的數(shù)據(jù)放到內(nèi)存里加速訪問吃警,不常用的數(shù)據(jù)放在磁盤上糕篇。這一切對(duì)用戶程序來說完全是透明的,用戶程序可以假裝所有數(shù)據(jù)都在內(nèi)存里酌心,然后通過虛擬內(nèi)存地址去訪問數(shù)據(jù)拌消。在這背后,操作系統(tǒng)會(huì)自動(dòng)將數(shù)據(jù)在主存和磁盤之間進(jìn)行交換安券。
虛擬地址翻譯
虛擬內(nèi)存的實(shí)現(xiàn)方式墩崩,大多數(shù)都是通過頁表來實(shí)現(xiàn)的。操作系統(tǒng)虛擬內(nèi)存空間分成一頁一頁的來管理侯勉,每頁的大小為 4K
(當(dāng)然這是可以配置的鹦筹,不同操作系統(tǒng)不一樣)。磁盤和主內(nèi)存之間的置換也是以頁為單位來操作的址貌。4K
算是通過實(shí)踐折中出來的通用值盛龄,太小了會(huì)出現(xiàn)頻繁的置換,太大了又浪費(fèi)內(nèi)存。
虛擬地址 -> 物理地址
的映射關(guān)系由頁表(Page Table)記錄余舶,它其實(shí)就是一個(gè)數(shù)組,數(shù)組中每個(gè)元素叫做頁表?xiàng)l目(Page Table Entry锹淌,簡稱 PTE)匿值,PTE 由一個(gè)有效位和 n 位地址字段構(gòu)成,有效位標(biāo)識(shí)這個(gè)虛擬地址是否分配了物理內(nèi)存赂摆。
頁表被操作系統(tǒng)放在物理內(nèi)存的指定位置挟憔,CPU 上有個(gè) Memory Management Unit(MMU) 單元,CPU 把虛擬地址給 MMU烟号,MMU 去物理內(nèi)存中查詢頁表绊谭,得到實(shí)際的物理地址。當(dāng)然 MMU 不會(huì)每次都去查的汪拥,它自己也有一份緩存叫Translation Lookaside Buffer (TLB)达传,是為了加速地址翻譯。
你慢慢會(huì)發(fā)現(xiàn)整個(gè)計(jì)算機(jī)體系里面迫筑,緩存是無處不在的宪赶,整個(gè)計(jì)算機(jī)體系就是建立在一級(jí)級(jí)的緩存之上的,無論軟硬件脯燃。
讓我們來看一下 CPU 內(nèi)存訪問的完整過程:
- CPU 使用虛擬地址訪問數(shù)據(jù)搂妻,比如執(zhí)行了 MOV 指令加載數(shù)據(jù)到寄存器,把地址傳遞給 MMU辕棚。
- MMU 生成 PTE 地址欲主,并從主存(或自己的 Cache)中得到它。
- 如果 MMU 根據(jù) PTE 得到真實(shí)的物理地址逝嚎,正常讀取數(shù)據(jù)扁瓢。流程到此結(jié)束。
- 如果 PTE 信息表示沒有關(guān)聯(lián)的物理地址懈糯,MMU 則觸發(fā)一個(gè)缺頁異常涤妒。
- 操作系統(tǒng)捕獲到這個(gè)異常,開始執(zhí)行異常處理程序赚哗。在物理內(nèi)存上創(chuàng)建一頁內(nèi)存她紫,并更新頁表。
- 缺頁處理程序在物理內(nèi)存中確定一個(gè)犧牲頁屿储,如果這個(gè)犧牲頁上有數(shù)據(jù)贿讹,則把數(shù)據(jù)保存到磁盤上。
- 缺頁處理程序更新 PTE够掠。
- 缺頁處理程序結(jié)束民褂,再回去執(zhí)行上一條指令(導(dǎo)致缺頁異常的那個(gè)指令,也就是 MOV 指令)。這次肯定命中了赊堪。
內(nèi)存命中率
你可能已經(jīng)發(fā)現(xiàn)面殖,上述的訪問步驟中,從第 4 步開始都是些很繁瑣的操作哭廉,頻繁的執(zhí)行對(duì)性能影響很大脊僚。畢竟訪問磁盤是非常慢的,它會(huì)引發(fā)程序性能的急劇下降遵绰。如果內(nèi)存訪問到第 3 步成功結(jié)束了辽幌,我們就說頁命中了;反之就是未命中椿访,或者說缺頁乌企,表示它開始執(zhí)行第 4 步了。
假設(shè)在 n 次內(nèi)存訪問中成玫,出現(xiàn)命中的次數(shù)是 m加酵,那么 m / n * 100%
就表示命中率,這是衡量內(nèi)存管理程序好壞的一個(gè)很重要的指標(biāo)梁剔。
如果物理內(nèi)存不足了虽画,數(shù)據(jù)會(huì)在主存和磁盤之間頻繁交換,命中率很低荣病,性能出現(xiàn)急劇下降码撰,我們稱這種現(xiàn)象叫內(nèi)存顛簸。這時(shí)你會(huì)發(fā)現(xiàn)系統(tǒng)的 swap 空間利用率開始增高个盆, CPU 利用率中 iowait
占比開始增高脖岛。
大多數(shù)情況下,只要物理內(nèi)存夠用颊亮,頁命中率不會(huì)非常低柴梆,不會(huì)出現(xiàn)內(nèi)存顛簸的情況。因?yàn)榇蠖鄶?shù)程序都有一個(gè)特點(diǎn)终惑,就是局部性绍在。
局部性就是說被引用過一次的存儲(chǔ)器位置,很可能在后續(xù)再被引用多次雹有;而且在該位置附近的其他位置偿渡,也很可能會(huì)在后續(xù)一段時(shí)間內(nèi)被引用。
前面說過計(jì)算機(jī)到處使用一級(jí)級(jí)的緩存來提升性能霸奕,歸根結(jié)底就是利用了局部性的特征溜宽,如果沒有這個(gè)特性,一級(jí)級(jí)的緩存不會(huì)有那么大的作用质帅。所以一個(gè)局部性很好的程序運(yùn)行速度會(huì)更快适揉。
CPU Cache
隨著技術(shù)發(fā)展留攒,CPU 的運(yùn)算速度越來越快,但內(nèi)存訪問的速度卻一直沒什么突破嫉嘀。最終導(dǎo)致了 CPU 訪問主存就成了整個(gè)機(jī)器的性能瓶頸炼邀。CPU Cache 的出現(xiàn)就是為了解決這個(gè)問題,在 CPU 和 主存之間再加了 Cache吃沪,用來緩存一塊內(nèi)存中的數(shù)據(jù)汤善,而且還不只一個(gè),現(xiàn)代計(jì)算機(jī)一般都有 3 級(jí) Cache票彪,其中 L1 Cache 的訪問速度和寄存器差不多。
現(xiàn)在訪問數(shù)據(jù)的大致的順序是 CPU --> L1 Cache --> L2 Cache --> L3 Cache --> 主存 --> 磁盤
不狮。從左到右降铸,訪問速度越來越慢,空間越來越大摇零,單位空間(比如每字節(jié))的價(jià)格越來越低推掸。
現(xiàn)在存儲(chǔ)器的整體層次結(jié)構(gòu)大致如下圖:
在這種架構(gòu)下,緩存的命中率就更加重要了驻仅,因?yàn)橄到y(tǒng)會(huì)假定所有程序都是有局部性特征的谅畅。如果某一級(jí)出現(xiàn)了未命中,他就會(huì)將該級(jí)存儲(chǔ)的數(shù)據(jù)更新成最近使用的數(shù)據(jù)噪服。
主存與存儲(chǔ)器之間以 page(通常是 4K) 為單位進(jìn)行交換毡泻,cache 與 主存之間是以 cache line(通常 64 byte) 為單位交換的。
舉個(gè)例子
讓我們通過一個(gè)例子來驗(yàn)證下命中率的問題粘优,下面的函數(shù)是循環(huán)一個(gè)數(shù)組為每個(gè)元素賦值仇味。
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
}
}
}
參數(shù) step 為 1 時(shí),和普通一層循環(huán)一樣雹顺。假設(shè) step 為 2 丹墨,則效果就是跳躍式遍歷數(shù)組,如 1,3,5,7,9,2,4,6,8,10
這樣嬉愧,step 越大贩挣,訪問跨度也就越大,程序的局部性也就越不好没酣。
下面是 nums 長度為 10000
王财, step = 1
和 step = 16
時(shí)的壓測結(jié)果:
goos: darwin
goarch: amd64
BenchmarkLoopStep1-4 300000 5241 ns/op
BenchmarkLoopStep16-4 100000 22670 ns/op
可以看出,2 種遍歷方式會(huì)出現(xiàn) 3 倍的性能差距四康。這種問題最容易出現(xiàn)在多維數(shù)組的處理上搪搏,比如遍歷一個(gè)二維數(shù)組很容易就寫出局部性很差的代碼。
程序的內(nèi)存布局
最后看一下程序的內(nèi)存布局∩两穑現(xiàn)在我們知道了每個(gè)程序都有自己一套獨(dú)立的地址空間可以使用疯溺,比如 0x0000 ~ 0xffff
论颅,但我們?cè)谟酶呒?jí)語言,無論是 C 還是 Go 寫程序的時(shí)候囱嫩,很少直接使用這些地址恃疯。我們都是通過變量名來訪問數(shù)據(jù)的,編譯器會(huì)自動(dòng)將我們的變量名轉(zhuǎn)換成真正的虛擬地址墨闲。
那最終編譯出來的二進(jìn)制文件今妄,是如何被操作系統(tǒng)加載到內(nèi)存中并執(zhí)行的呢?
其實(shí)鸳碧,操作系統(tǒng)已經(jīng)將一整塊內(nèi)存劃分好了區(qū)域盾鳞,每個(gè)區(qū)域用來做不同的事情。如圖:
- text 段:存儲(chǔ)程序的二進(jìn)制指令瞻离,及其他的一些靜態(tài)內(nèi)容
-
data 段:用來存儲(chǔ)已被初始化的全局變量腾仅。比如常量(
const
)。 - bss 段:用來存放未被初始化的全局變量套利。和 .data 段一樣都屬于靜態(tài)分配推励,在這里面的變量數(shù)據(jù)在編譯就確定了大小,不釋放肉迫。
- stack 段:椦榇牵空間,主要用于函數(shù)調(diào)用時(shí)存儲(chǔ)臨時(shí)變量的喊衫。這部分的內(nèi)存是自動(dòng)分配自動(dòng)釋放的跌造。
-
heap 段:堆空間,用于動(dòng)態(tài)分配格侯,C 語言中
malloc
和free
操作的內(nèi)存就在這里鼻听;Go 語言主要靠 GC 自動(dòng)管理這部分。
其實(shí)現(xiàn)在的操作系統(tǒng)联四,進(jìn)程內(nèi)部的內(nèi)存區(qū)域沒這么簡單撑碴,要比這復(fù)雜多了,比如內(nèi)核區(qū)域朝墩,共享庫區(qū)域醉拓。因?yàn)槲覀儾皇且娴拈_發(fā)一套操作系統(tǒng),細(xì)節(jié)可以忽略收苏。這里只需要記住堆空間和椧诼保空間即可。
- 椔拱裕空間是通過壓棧出棧方式自動(dòng)分配釋放的排吴,由系統(tǒng)管理,使用起來高效無感知懦鼠。
- 堆空間是用以動(dòng)態(tài)分配的钻哩,由程序自己管理分配和釋放屹堰。Go 語言雖然可以幫我們自動(dòng)管理分配和釋放,但是代價(jià)也是很高的街氢。
結(jié)論
局部性好的程序扯键,可以提高緩存命中率,這對(duì)底層系統(tǒng)的內(nèi)存管理是很友好的珊肃,可以提高程序的性能荣刑。CPU Cache 層面的低命中率導(dǎo)致的是程序運(yùn)行緩慢,內(nèi)存層面的低命中率會(huì)出現(xiàn)內(nèi)存顛簸伦乔,出現(xiàn)這種現(xiàn)象時(shí)你的服務(wù)基本上已經(jīng)癱瘓了厉亏。Go 語言的內(nèi)存管理是參考 tcmalloc 實(shí)現(xiàn)的,它其實(shí)就是利用好了 OS 管理內(nèi)存的這些特點(diǎn)烈和,來最大化內(nèi)存分配性能的叶堆。
參考
- Go Memory Management
- 《深入理解計(jì)算機(jī)系統(tǒng)》