淺析linux內存管理

物理內存和虛擬內存

現代的操作系統中進程訪問的都是虛擬內存,而虛擬內存到物理內存的轉換是由系統默默完成的。首先來扒一扒它的歷史,直接使用物理內存效率豈不是更高,何必加一箇中間層?
在計算機早期,物理內存的容量是K級別的,計算機中只運行了一個程序,OS就是一個簡單的庫,編寫語言還是彙編,盡一切可能縮小程序的規模來節省內存。這時候一個程序獨佔整個計算機,圖1.
後來有了多道程序,在計算機中運行多個程序,這樣cpu可以輪換着服務多個程序的執行,計算機仍然非常昂貴,這樣一個計算機服務多個程序,相當於成本降低了。此時每個程序劃分出來一塊內存,所有的程序全部在內存中等待cpu分配給它運行,圖2
程序多了就存在一些問題:
1.程序A能訪問程序B的數據,可能是無意間錯誤訪問了,需要提供程序間數據的隔離
2.程序增多,需要手工分配運行空間,每次更換程序都需要重新分配,重新給鏈接地址,能不能自動分配地址
之後就發明了虛擬內存地址空間的概念,每個程序運行時都有自己的虛擬地址空間,並且獨享全部的地址空間;程序之間不能直接互相訪問數據,進程間通信需要通過專門的方式:套接字,信號量,共享內存等等,這些都需要OS內核的參與;內核負責維護虛擬地址空間並且在切換程序運行的時候切換到對應進程的地址空間,內核可以訪問進程的數據,後來爲了安全性加了一些限制條件,也不能隨意訪問進程的地址空間。
內存管理的演進虛擬地址空間到地址空間的轉換是通過MMU(Memory Management Unit)來完成的,CPU訪問內存的地址請求發送給MMU,是虛擬地址,CPU是看不到物理內存的,MMU將地址轉換成物理地址,也就是VA->PA的轉換。

其中MMU轉換依賴頁表,頁表是一系列頁表項的數組,每個頁表項保存着物理頁的地址,頁表項在頁表中的索引表示虛擬地址,頁表項存在於物理內存中,MMU可以看到物理內存,切換進程的時候從物理內存中加載頁表,這時候是頁表的物理地址,這樣不會存在雞和蛋誰先誰後的問題。而MMU每次做地址轉換不能總是去訪問物理內存拿到頁的對應關係,訪問RAM的速度和CPU速度相差太大,所以MMU中做了一個頁表的緩存TLB(Translation Lookaside Buffer),不過它的存儲大小是有限的,只能存儲一部分頁表項,需要相應的算法例如LRU來決定哪部分頁表項能夠緩存在TLB中。
VA->PAMMU除了完成VA->PA的轉換之外,還負責一些權限的檢查。現在典型的頁劃分爲4K,在32bit系統上我們表示頁的地址的時候後12bit是空的,只需要前面20bit就可以表示頁幀的地址,MMU在取任意的32bit地址的時候分成兩個步驟:1.將虛擬地址分爲虛擬頁地址和業內地址;2.虛擬頁地址就是在頁表中的索引,獲得頁表項中物理頁的地址;3.將物理頁地址和頁內偏移合成真正的物理地址

虛擬地址 轉換過程 物理地址
0x12345678 虛擬頁地址:0x12345000 + 頁內偏移:0x678
頁表page table[0x12345] = 0xabcde000 ->頁幀地址
物理地址=0xabcde000+0x12345
0xabcde678

而每一個頁表項是佔據4個字節,物理頁的地址也是隻佔據20bit,剩餘的12bit可以用來做其他用途,其中有兩個關於權限控制的:_PAGE_BIT_RW和_PAGE_BIT_USER,分別控制讀寫權限和內核是否有讀寫權限。
當權限錯誤或者虛擬地址沒有對應的物理頁時會出發異常,異常觸發後內核可以嘗試修復異常,也就是缺頁中斷,藉助這個時機實現了COW,頁的按需分配,swap in等功能。

物理內存

物理內存通常是按照zone來管理的,通常有DMA,DMA32,NORMAL,HIGHMEM。其中DMA zone的範圍一般是0-32M,因爲早期的DMA引擎的尋址範圍是16位,它看不到32M以上的物理地址,0-32M的地址就是爲了舊的DMA設備預留的物理空間,平時儘量不要使用這一部分地址空間以免DMA設備訪問的時候沒有物理內存空間給它用,不過現在的DMA設備基本上都已經很少只支持16bit的空間的,一般都是32bit的,DMA32位的就是給這一部分設備用的,DMA zone的範圍是由DMA設備的尋址範圍決定的,DMA設備如果都支持64bit尋址,也沒必要保留DMA/32 zone了。DMA引擎一般訪問連續的物理內存時是最高效的,CPU只需要一次請求就能完成連續的一片內存的讀寫操作,不需要頻繁的中斷要求CPU參與DMA活動,所以一般DMA需要分配連續的內存。不過目前高級的DMA設備可能配備了IOMMU,和MMU一樣能夠完成VA->PA的轉換,IOMMU也需要頁表一樣的東西來保存虛擬和物理地址的對應關係,減少了對連續物理內存的依賴。
而HIGHMEM目前基本上只在32bit系統上纔有的,經典的虛擬地址空間劃分爲1:3,即內核空間在3-4G範圍,用戶空間在0-3G範圍,這樣內核最多隻能線性映射1G的內存,當訪問多於1G內存的時候它已經沒有虛擬地址進行映射了,所以爲了在內核中能夠訪問更多的內存,它預留出了128M的虛擬地址空間段來進行動態映射,所以物理內存高於896M的部分稱之爲HIGHMEM,這部分純粹是因爲虛擬地址空間不足纔有的概念。如果虛擬地址空間劃分爲2:2,那麼1G+896M以上的物理內存才稱爲HIGHMEM。目前64bit系統上內核能夠使用的虛擬地址空間完全充足,不再有HIGHMEM的概念了。
物理內存中的ZONE32bit系統中,內存小於1G內核可以直接線性映射,不過還是需要留出來一部分虛擬地址空間來做io map,所以典型的是隻線性映射0-896M.
vmalloc區域預留出一部分虛擬地址空間,因爲這部分是頁表來維護的,和用戶空間的地址空間映射相同,不要求物理內存連續.除了物理內存它還可以做io映射,動態訪問io寄存器等.
在最高端的部分預留出來一小塊地址空間來做固定映射,顧名思義,這部分的虛擬地址空間的用途是不變的,不能用來做其他用途,有點類似於線性映射,不同的是在內核啓動的時候動態將物理內存綁定映射,之後還可以解除映射,重新映射等.例如FIX_TEXT_POKE1用來在修改text段內容指令的時候使用,修改指令時映射到FIX_TEXT_POKE1地址,修改完成後解除映射.
當物理內存>4G時,32bit系統上已經無法訪問>4G的內存,x86發明了一種方法PAE(Physical Address Extension),通過動態窗口的方式能夠訪問更高地址的物理內存,它最多可以尋址64Gb RAM .這允許進程A使用第一個4G的內存,而進程B使用下一個4G的內存。總共使用了超過4G的物理內存,但是單個進程使用的內存總量仍然限制爲4G.

剛開始看內存管理的時候,我就擔憂,如果物理內存總共512M,內核全部線性映射了,用戶程序豈不是沒的用了?
內核線性映射只是說它可以不創建頁表就可以直接使用,但是不代表它已經全部使用了,用戶程序需要內存的時候仍然可以從這裏拿.根據內存的相對稀有程度,一般HIGHMEM<NORMAL<DMA.
用戶地址空間全部是靠頁表來維護的,它對於物理內存是否連續完全不敏感,所以一般優先從HIGHMEM給他分配內存,相對來說資源比較豐富.
如果HIGHMEM不能夠滿足的話,那就需要從NORMAL zone中給他分配,最後實在沒辦法了再從DMA zone中給他分配.

內核線性映射訪問不代表已經使用了,只是可以直接通過線性偏移進行VA->PA轉換

而在64bit位系統上,首先HIGHMEM區域肯定是不需要存在了,目前內核實際使用了48bit的地址空間,其中內核空間0xffff8000 00000000->0xFFFFFFFF FFFFFFFF共計128T,可以線性映射所有的物理內存.其他的和32bit系統上基本上保持相同.

上面這些ZONE是地址空間的原因和限制存在的,但是他們並不是直接的物理內存分配管理器,而是buddy系統.
zone buddy

buddy系統

buddy系統最大的特點是簡單,數據結構簡單,管理對象簡單,其次它能較好的進行連續頁管理.Buddy算法主要是解決外部碎片問題,每個zone都有buddy管理器來管理着可用的頁面,按照2的冪級(order)大小排成鏈表隊列,存放在free_area數組。
zone到buddy的管理:
buddy

  • buddy拆分:
    申請多個物理頁的時候,要求頁數是2^order.首先會遍歷zone->free_area[order]嘗試找到空閒的頁.存在空閒頁的話直接從鏈表上移除,更新統計數據.如果沒有空閒頁了,就繼續嘗試從zone->free_area[order+1]中找空閒頁,如果空閒的話則拆分,一部分返回給申請者,另外一部分掛到zone->free_area[order]上.

  • buddy合併:
    釋放2^order個頁時,會嘗試和相鄰的頁合併,合併的策略是找相同order的連續頁:page ^ (1 << order),如果當前page=8,order=1,則尋找8^(1<<1)也就是page=10. 而如果page=10,order=1,它需要和10^(1<<1)也就是page=8合併,說明在order=2層次上page=8和page=10是buddy,組成一個新的2^(order +1)的連續區域.
    爲什麼不和前面的page=6合併呢?如果page=8和它合併,原本page=6和page=4是一對buddy,現在page=6和page=8組合成一個新的區域,前面6個頁就無法組合成2^order形式,後面的頁也無法組成2^order中形式,最終拆亂之後就無法組成最完美的狀態.
    合併完之後還會嘗試向上繼續合併2^(order +2),上一級page的頁幀號計算方式:Parent page = page & ~(1 << order),直至最終到達2^max_order的區域管理。

Buddy的2的冪級管理方式目前是最簡單的,管理頁的申請釋放狀態,還提供連續頁管理的能力。

slab分配器

內核的很多操作通常都是小對象的分配,大小通常遠小於1頁,它們會在系統生命週期內進行無數次分配。直接使用buddy時,最小的單元是頁,會造成很大的頁內浪費。slab 緩存分配器通過對類似大小(遠小於1page)的對象進行緩存而提供小內存的申請釋放管理,從而避免了常見的內部碎片問題。slab機制是基於buddy算法的,前者是對後者的細化,解決頁內部碎片問題。
常見的slab分配器有三種:slab,slub,slob。slab是最早發明的,也是其他兩種分配器的代名詞,但是它太複雜了,很難維護,管理數據佔用的空間頁比較大,所以後來發明瞭簡化版的slub分配器,這是目前PC上默認的分配器。而嵌入式系統上內存有限,內存對象使用有限,有了進一步簡化的slob分配器,代碼總共只有600多行,不提供本地CPU高速緩存和本地節點管理。
slab使用方式通常有兩種:kmalloc和kmem_cache,kmalloc提供了一些常用的內置大小的對象的管理,對象大小範圍也非常廣:8,16,32...8192,這種方式簡化了api,降低了使用者的難度,不過因爲很多地方同時從一個對象池中獲取/釋放對象,必然會增加管理鎖的衝突概率。而kmem_cache這種方式提供了專有對象的管理,它和kmalloc的對象管理方式是相同的,都是slab分配器來管理,只不過存儲的對象用途專一,申請和釋放中鎖衝突概率較小,但是它需要使用者主動創建和銷燬kmem_cache。kmem_cache的一個使用技巧是能夠探測module中的內存泄露,module創建kmem_cache並且所有的對象都存儲在其中,當卸載的時候銷燬kmem_cache,如果存在內存泄露,kmem_cache會銷燬失敗。
下面簡單看一下slub分配器的管理:
一個slub通常是是由一個或多個頁組成,也就是我們常說的slub對象,它是頁的組合。
kmem_cache的主要管理數據結構:
slub1.每個cpu上都有對應的slub緩存kmem_cache_cpu,這樣能夠避免申請資源的時候競爭,提高本地的緩存命中率
其中的page指向當前正在使用的slub對象,能夠快速的從當前slub中申請到對象,如果當前的slub爲空的時候,先從partial中獲取一個slub對象。
partial中存放着一些部分空的slub對象
freelist指向當前可用的對象
2.在NUMA系統中,每個節點有自己的slub對象管理,兩個鏈表:partial中掛着部分滿的slub對象
slub中對於小內存對象的管理:
一部分用來存放使用的對象,另外在它的尾部還有一個空閒指針,下圖是一個slub中對象多次申請釋放之後freelist之間的關係。其中object_size是對象真實大小,但是之後是一個ptr,是一個指針,需要進行對齊8字節,即offset=align(object_size, sizeof(void *))。
slab內部對象管理1.對象的申請
申請的位置優先級:cpu_slab->page —> cpu_slab->partial ----> node->list_partial -------> alloc slub from buddy
1.直接在cpu_slab->page中申請,更新freelist指向下一個空閒對象
2.當前slub已經用完了,即cpu_slab->freelist爲空,那從cpu_slab->partial中取下一個部分滿的slub到cpu_slab->page中,當前全滿的slub就不維護了,和buddy只維護可用內存一樣
3.cpu_slab->partial中也沒有可用的slub了,則開始從node->list_partial中獲取,一次獲取的slub中空閒對象要超過cpu_partial/2
4.如果node->list_partial中也爲空則從buddy中獲取頁組成slub放到node->list_partial中,首先嚐試獲取oo->order個頁,如果沒有那麼多空閒內存,則少申請一點min->order個頁
通過kmem_cache_create初始化之後,它的cpu_slab->page,cpu_slab->partial和node->list_partial全部爲空,即上面第4種情況。

2.對象的釋放
1.如果對象所在的slab不是全滿狀態,則直接釋放,更新freelist。
2.如果對象所在的slub是全滿狀態,釋放後slub就變成了部分滿狀態,將該slab掛到cpu_slab->partial中。
3.如果對象所在的slub在釋放該slub後變成全空,系統首先會檢查node部分空鏈表中slub緩衝區的個數,如果node部分空鏈表中slub緩衝區數量小於kmem_cache中的min_partial,則將這個空閒slab緩衝區放入node部分空鏈表中。否則釋放此空閒slab,將其佔用頁框返回夥伴系統中。

內存碎片化

當機器長時間運行後,就會出現內存碎片問題,雖然有內存可用但是不符合要求,例如現在有16個頁,但是沒有連續的2個頁,申請連續的2個頁時就沒有滿足條件的內存.

物理內存碎片化問題是無法避免的,在理論上是無法徹底解決的,只能進行規避延緩,通常是通過預防碎片化的方式來規避這個問題.
將物理內存通過zone管理,將buddy進一步劃分成幾種類型:

  1. 不可移動頁面 UNMOVABLE:在內存中位置必須固定,無法移動到其他地方,核心內核分配的大部分頁面都屬於這一類。
  2. 可回收頁面 RECLAIMABLE:不能直接移動,但是可以回收,因爲還可以從某些源重建頁面,比如文件映射的數據屬於這種類別,kswapd會按照一定的規則,週期性的回收這類頁面。
  3. 可移動頁面 MOVABLE:可以隨意的移動。屬於用戶空間應用程序的頁屬於此類頁面,它們是通過頁表映射的,因此我們只需要更新頁表項,並把數據複製到新位置就可以了,當然要注意,一個頁面可能被多個進程共享,對應着多個頁表項。

防止碎片化的方法就是把這三種類型的頁幀放在不同的鏈表上,避免不同類型頁面相互干擾。
考慮這樣的情形,一個不可移動的頁面位於可移動頁面中間,那麼我們移動或者回收這些頁面後,這個不可移動的頁幀阻礙着我們獲得更大的連續物理空閒空間。
在分配物理內存時秉承的原則是如果有頁面則儘量提供,進行劃分只是儘量避免干擾,但是如果一個類型的頁面已經耗盡的情況下,則會選擇在其他類型的頁面中嘗試分配,目標就是使系統儘量正常運行。下面的fallbacks定義了適用於大部分情況下的不同類型頁之間的備用關係,例如我們要申請UNMOVABLE的頁面,如果沒有這種類型的頁面則依次從RECLAIMABLE–>MOVABLE–>RESERVE中獲取.

static int fallbacks[MIGRATE_TYPES][4] = {                                                                                                     
    [MIGRATE_UNMOVABLE]   = { MIGRATE_RECLAIMABLE, MIGRATE_MOVABLE,     MIGRATE_RESERVE },
    [MIGRATE_RECLAIMABLE] = { MIGRATE_UNMOVABLE,   MIGRATE_MOVABLE,     MIGRATE_RESERVE },
#ifdef CONFIG_CMA                                                                   
    [MIGRATE_MOVABLE]     = { MIGRATE_CMA,         MIGRATE_RECLAIMABLE, MIGRATE_UNMOVABLE, MIGRATE_RESERVE },
    [MIGRATE_CMA]         = { MIGRATE_RESERVE }, /* Never used */                   
#else                                                                               
    [MIGRATE_MOVABLE]     = { MIGRATE_RECLAIMABLE, MIGRATE_UNMOVABLE,   MIGRATE_RESERVE },
#endif                                                                              
    [MIGRATE_RESERVE]     = { MIGRATE_RESERVE }, /* Never used */                   
#ifdef CONFIG_MEMORY_ISOLATION                                                      
    [MIGRATE_ISOLATE]     = { MIGRATE_RESERVE }, /* Never used */                   
#endif                                                                              
};

在系統啓動的時候,將所有的頁面都是MOVABLE類型的,當初次使用時該類型的zone不存在,從MOVABLE類型中獲取到儘可能多的頁面並轉換成該類型的頁面,這樣可以防止該類型的頁面申請污染了MOVABLE的頁面.

小結

zone分類主要是各種物理內存的稀有程度不同,而zone內的MIGRATE類型主要是爲了防止內存碎片,buddy纔是直接的頁管理,直接使用頁會有很多頁內剩餘空間閒置,發明了slab/slub,這就是內核中基礎的物理內存管理.
應用層一般使用malloc系列的api來申請內存,其實glibc通過brk/mmap創建虛擬內存映射空間,實際訪問的時候發生缺頁中斷真正分配物理內存,這時候是直接從buddy中獲取空閒頁來映射到進程中,之後才能操作的.應用程序對於物理內存無感,它只能看得到虛擬地址空間,這是由虛擬內存塊管理的.

虛擬內存

啓用MMU之後,每個進程都有自己獨立的地址空間,但是空間那麼大,它是用不完的,大部分只是使用一小部分的地址空間.前面我們說頁表項記錄映射關係,一方面頁幀不能過大,這樣很容易產生許多的頁內空間浪費,另一方面是頁不能太小,我們知道每一個頁都有一個page的管理對象,這樣會使page對象佔據的內存激增,所以目前選取了4k作爲一個比較通用的頁大小.如果是一維頁表項存儲所有的對應關係,即有4G/4k=1M個頁表項,每個進程的頁表都需要佔據1M * sizeof(void *)空間,系統啓動1k個進程系統的內存就消耗光了. 開發人員通過多級頁表的方式來減少頁表實際佔用的內存(多級頁表的劃分也是一個折中問題),這樣就需要另外的數據來管理真實的虛擬內存地址空間,也就是下面的主題.
32bit地址空間
64bit地址空間一個進程的虛擬地址空間由mm_struct管理,主要管理的資源有頁表pgd和虛擬內存區域vm_area_struct,頁表就是一大堆頁表項的數組,而vm_area_struct代表一塊具有相同權限的區間,通過/proc/pid/maps看到的內容就是需要維護的屬性:

head /proc/self/maps
55d4c0e27000-55d4c0e2f000 r-xp 00000000 08:07 787059                     /bin/cat
55d4c102e000-55d4c102f000 r--p 00007000 08:07 787059                     /bin/cat
地址區間:`55d4c0e27000-55d4c0e2f000`開始地址->結束地址,邊界是頁對齊的
權限:`rwxp `,總共有讀,寫,執行,是否共享
地址偏移:`00007000`,如果當前區間映射的文件,這裏標明是區間開始位置對應的文件偏移,以頁對齊的
設備號:`08:07`,如果當前區間映射的文件,所在存儲設備的設備號
inode:`787059`,如果當前文件不屬於匿名內存,那就是相關文件的inode號,也有可能是僞文件系統中的inode號
文件路徑:`/bin/cat`,如果當前區間映射的文件,文件的路徑名

其中地址區間和權限是vm_area_struct基本的管理數據,此外還需要記錄它的數據從哪裏來的:磁盤上的文件,還是只是在內存中的堆數據.
mm_struct管理vm_area_struct使用了紅黑樹,鏈表兩種方式管理,紅黑樹結構是查找比較快,鏈表適宜順序遍歷,紅黑樹結構中以起始地址作爲key管理,而鏈表也是按照地址順序存儲的.
單純的虛擬地址空間管理很簡單,但是一扯上物理內存問題就多了,尤其是在用戶地址空間中的故事,也就是下面缺頁異常中處理的主要場景。

  1. 面向應用程序的lazy內存模式,用戶程序分配程序使用malloc api來申請內存,它是glibc的封裝,通過sbrk/mmap向內核申請,起始就只是創建對應的vm_area_struct插入到mm_struct中,而在實際使用的時候不存在對應的頁表項,發生缺頁異常,這時候又來查詢vm_area_struct的範圍和權限,符合要求的地址纔會真正分配物理內存和設置頁表項.這樣一種按需分配的方式欺騙了應用程序,程序看到申請內存成功了,其實只是給了一個白條,沒準實際使用的時候物理內存消耗光了兌換不了白條.
  2. 將進程中不常用的物理內存swap到硬盤,折騰出來更多的物理內存給系統使用,而使用這部分swap出去的數據時候再重新分配內存,將swap區域弄回來,重建頁表.普通進程的私有數據還好,只需要修改它的頁表項,但是如果是多個進程間的共享匿名數據區域要swap區域,就需要找到引用當前內存的所有進程,通過anon_vma來找到引用該物理內存的所有虛擬地址區域
  3. 內核vmalloc區域的管理,通過vmalloc創建的內存是通過vm_area_struct管理的,分配一塊可用的vm_area_struct,之後申請N個頁幀,之後做虛實映射,其中做映射的時候只將對應的頁表項更新到了init_mm中對應的pgd中.當實際使用的時候發現當前進程的頁表中沒有該地址,發生缺頁異常,之後查詢是否是vmalloc區域,如果是,然後再將init_mm中的頁表更新到當前進程的頁表中.

而我們注意到一個進程有兩個mm_struct:active_mm和mm,每個進程都有自己的內存描述符,地址空間又分爲用戶地址空間和內核地址空間,當處於用戶狀態時沒有權限訪問內核地址空間,在內核狀態中只能通過copy_from_user/copy_to_user標準的api訪問用戶空間地址。所有進程共享同一份內核地址空間也就是她們內核部分的頁表是相同的,另外一個特殊之處是內核線程,它通常只能訪問內核地址空間。不同的進程有不同的地址空間,在進程切換的時候需要切換頁表來切換到正確的地址空間上,但是對內核線程來說是沒有意義的,而且所有進程共享同一份內核空間,所以催生出了上面兩個mm_struct,前者代表當前正在使用的,而mm是普通應用進程特有的,內核線程的mm=NULL,當從普通進程切換到內核線程時,使用當前進程的active_mm,並且enter_lazy_tlb免除flush TLB的抖動。

小結:mm_struct是進程地址空間管理結構,vm_area_struct對應着一段一段的虛擬內存區域,而在某個地址上進行讀寫執行操作是否非法,vm_area_struct中記錄這這個信息,圍繞着缺頁異常和vm_area_struct才能完成一些複雜功能.

進程的虛擬內存管理

用戶空間程序一般使用malloc/free來申請和釋放內存,但是當申請成功的時候說的是成功申請到了一塊虛擬內存.此時分配的虛擬內存還沒有對應的物理內存,當第一次訪問虛擬內存的時候會發生缺頁異常,從buddy系統中實際分配物理頁並重新映射到虛擬內存.
malloc並不是一個系統調用,它的後端主要是brk和sbrk,mmap.當申請小於128KB時使用sbrk/brk分配內存,當申請大魚128KB時使用mmap來申請內存.
brk/sbrk/mmap申請的內存區域最小單元也是物理頁幀的大小,用戶程序申請的內存對象一般比較小,一方面會有大量的虛擬地址浪費(雖然它比較浪費),實際上使用的時候分配物理內存,此時就會造成真正的內存頁內空間浪費,另一方面是經常的系統調用開銷比較大.所以目前glibc採用了類似於slab的方式,一次性從內核中申請大塊內存,然後將內存分成不同大小的內存塊,用戶申請時,從內存中選擇一塊相近的內存塊分配出去,只不過glibc不知道自己管理的是虛擬內存.

因爲內核對應用存在一定的欺騙性,所以進程的內存有些一些不同的數據來進行統計它的消耗:

參考鏈接:https://lwn.net/Articles/230975/

指標 全稱 含義 等價
USS Unique Set Size 物理內存 進程獨佔的內存
PSS Proportional Set Size 物理內存 PSS= USS+ 按比例包含共享庫和可執行文件
RSS Resident Set Size 物理內存 RSS= USS+ 包含共享庫和可執行文件
VSS Virtual Set Size 虛擬內存 VSS= RSS+ 未分配實際物理內存

VSS,RSS,USS,PSS
圖中展示了幾個進程的信息和資源統計
1./bin/bash執行了兩次,啓動了兩個進程1044,1045,兩個進程中共享libc和bash的文本段,進程有私有的heap段
2./bin/cat執行了一次,啓動了進程1054,進程中的文本段映射了cat,私有的heap段,共享了libc庫
3.libc在進程1044,1045,1054中共享同一份物理內存.
4.bash在進程1044,1045中共享

VSS是單個進程全部可訪問的地址空間,其大小可能包括還尚未在內存中駐留的部分和沒有進行任何內存頁和文件映射部分。對於確定單個進程實際內存使用大小,VSS用處不大,但是一般來說VSS的增長趨勢和其他幾個RSS的增長趨勢基本相同。
RSS是單個進程實際佔用的內存大小,RSS不太準確的地方在於它包括該進程所使用共享庫全部內存大小。對於一個共享庫,可能被多個進程使用,實際該共享庫只會被裝入內存一次。
PSS相對於RSS計算共享庫內存大小是按比例的。3個進程共享,該庫對PSS大小的貢獻只有1/3。
USS是單個進程私有的內存大小,即該進程獨佔的內存部分。USS揭示了運行一個特定進程在的真實內存增量大小。如果進程終止,USS就是實際被返還給系統的內存大小。

他們幾個之間的關係:VSS>RSS>PSS>USS,一般來說任何一個指標過高都應該值得注意,其中USS尤其需要注意,它的不正常增高往往代表內存泄露.

缺頁異常管理

缺頁異常是mmu發生異常,有兩種情況,一種是找不到虛擬地址對應的物理地址,一種是權限有問題的。MMU發生異常之後,1.會設置錯誤信息,2.將發生異常的地址放入cr2寄存器中,之後會上報給內核,允許軟件來檢查異常並嘗試修復這個異常,如果異常不能修復最後只能oops了.

軟件來修復異常的時候也是檢查兩方面:

1. 地址合法嗎,即頁表項中沒有合法的物理地址,但是VMA中包括了改地址 
2. 權限正確嗎,即頁表項中有對應的物理地址,但是pte中權限和操作權限不符,但是符合VMA中的權限

按照觸發異常的地址來劃分內核空間地址異常和用戶空間地址異常:

異常地址所在地址空間 合法異常種類 異常處理
用戶空間地址 1.地址異常:棧的自然增長,brk/mmap(malloc),匿名頁被swap出去了,缺頁
2.權限錯誤:fork的COW
向進程發異常信號
內核地址空間 1.vmalloc區域頁表沒有同步 oops

用戶空間地址:
1.棧的缺頁異常:目前大部分系統棧空間默認都是配置成向下自然增長,隨着棧幀的疊加,不停的sub $0xN,%rsp操作,當棧底超過了目前棧空間後,並沒有顯示的申請虛擬地址空間,這時候就會觸發地址異常。此時棧所在的VMA中和addr的關係是vm_start > addr && vm_end > addr,而不是vm_start < addr && vm_end > addr,下面就是擴充棧所在的VMA,轉化爲vm_start < addr && vm_end > addr正常情況,之後會實際分配內存。
2.缺頁地址發生在堆區且是第一次使用,即使用mmap/sbrk申請內存,只是插入了對應的VMA,當第一次讀該區域時,觸發mmu異常,此時也並沒有爲其分配物理內存而是將它映射到一個全局可見的全是0的頁。只有寫的時候才真正爲其分配物理頁並重建頁表
3.缺頁地址對應的是文件或者設備文件時,他們的處理時一樣的,在缺頁異常中調用VMA->vm_operation_struct->fault,普通文件默認是:,設備是驅動提供的:,他們會返回創建頁表映射。

4.缺頁地址對應的是堆區,但是因爲長時間不使用被swap out出去了
進程的用戶地址空間通過頁表進行映射的,它並不知道後端的物理內存是否連續。當內存緊張時系統會選擇將進程長時間不使用的物理內存進行回收,映射普通文件的區域可以將該區域對應的髒頁進行回寫,使用時可以再次分配內存,加載磁盤上文件內容重建頁表;映射設備的區域不佔據物理內存所以不對其進行回收;而進程中的堆棧是匿名的,沒有對應的後備存儲,此時可以將一個分區或者文件掛載成swap分區,爲匿名內存創建後備存儲,和普通文件的page cache對應的swap cache,核心就是沒有條件創造條件將物理內存騰出來,將匿名內存寫到swap中並且設置頁表項swap信息。當再次使用已經swap out的數據時就會觸發缺頁異常,重新將swap out的數據從後備存儲中加載進來。

內核空間地址:
使用vmalloc可以使用不連續的物理內存但是通過動態創建頁表映射來獲得連續的虛擬內存,vmalloc區域和內核中線性映射區域是不同的,前者需要動態創建頁表,而後者在系統初始化完成後就不再改變。此時存在一個問題,所有進程看到的應該是相同的內核地址空間,所以理論上在vmalloc時需要修改所有進程頁表中對應的內核部分,這種方式代價太大,採用了按需同步頁表的方式,當新創建的vmalloc區域時,只修改了init_mm中的頁表映射,進程沒有對應的頁表項,之後第一次vmalloc區域時,訪問觸發MMU異常,在handle_mm_fault中校驗屬於有效的vmalloc區域,此時從init_mm中同步相應的頁表。

訪問權限異常只有一種情形:進程fork時的COW機制

進程fork創建一個新的進程,同時子進程也有獨立的地址空間。早期會複製父進程中所有內存相關的:mm_struct,VMA和頁表項,但是大部分子進程不再執行exec加載新的程序,父子進程共享大部分地址空間和數據,而且複製過程的代價相當昂貴,所以發明了COW機制,fork的時候只複製mm_struct和vma,公用一份頁表,同時將所有頁表項都修改成只讀屬性。當父子進程中任意一個開始寫數據的時候,因爲權限異常觸發MMU異常。在缺頁異常處理中對比VMA中的讀寫屬性爲RW,此時就認爲是COW,1.重新分配物理頁並拷貝原始頁內容到新的物理頁中,2,修正新的頁表項屬性 3,修正舊的頁表項屬性

缺頁異常中能夠修復的情況如上所述,當無法修復異常的時候,對用戶空間地址的訪問造成的錯誤向該進程投遞SIGSEGV信號,對內核空間地址訪問造成的異常觸發oops。

而內核開發人員從缺頁異常的修復代價角度又劃分了major和minor兩種。
minor指的是隻分配物理內存而不發生IO,而major指的是既分配物理內存又發生IO操作,而訪問內存和磁盤的速度差了近千倍,所以major類異常耗時會更長。

內存回收

kernel在分配內存時,可能會涉及到多個zone,分配會嘗試從zonelist第一個zone分配,如果失敗就會嘗試下一個低級的zone(這裏的低級僅僅指zone內存的位置,實際上低地址zone是更稀缺的資源)。我們可以想像應用進程通過內存映射申請Highmem 並且加mlock分配,如果此時Highmem zone無法滿足分配,則會嘗試從Normal進行分配。這就有一個問題,來自Highmem的請求可能會耗盡Normal zone的內存,而且由於mlock又無法回收,最終的結果就是Normal zone無內存提供給kernel的正常分配,而Highmem有大把的可回收內存無法有效利用。

因此針對這個case,使得Normal zone在碰到來自Highmem的分配請求時,可以通過lowmem_reserve聲明:可以使用我的內存,但是必須要保留lowmem_reserve[NORMAL]給我自己使用。

同樣當從Normal失敗後,會嘗試從zonelist中的DMA申請分配,通過lowmem_reserve[DMA],限制來自HIGHMEM和Normal的分配請求。

內存api

alloc_page 從buddy中申請物理頁
kmalloc 從內置固定size的slab中申請對象
vmalloc 申請非連續頁,並返回虛擬地址
kmap/kunmap 根據page臨時映射虛擬地址
sys_sbrk/sys_mmap 用戶程序申請虛擬空間
iomap 爲IO設備映射vmalloc區域地址

內存調試

內存踐踏:這種情況下經常發生在寫時越界問題,可能是在棧上越界,也有可能是堆上數據越界,也有可能是野指針導致的數據錯亂。
這種情況一般可以在對象上下邊界加上特定的magic數字來判定是否發生連續寫導致的越界問題,例如內核slab的red zone,當錯誤發生後檢查magic有沒有被破壞可以探測這種行爲。
從現場的特徵上來分辨,棧上越界寫導致的錯誤,當使用backtrace查看堆棧時可能會發現堆棧不符合代碼邏輯,無法理解.這種是棧上存放返回地址的數據被寫壞了;
發生在堆區域的寫越界問題,當通過gdb或者crash讀內存信息時會看到對象的數據不合理,但是堆棧顯示是正常的。

對象重用:內核中的這種問題更加常見,特別是引用計數異常引起的對象重用問題。假設CPU0上routine 0中正在使用obj1並且對其增加引用計數,CPU1上routine 1中正在使用obj1但是並未增加對其引用,之後CPU0上routine0不再使用obj1並決定對其進行銷燬返回給slab中.之後CPU2上routine 2從slab中申請一個對象得到了obj1的地址並對其進行修改操作,接下來CPU1上routine 1就會發現obj1的數據很奇怪。內核中很多的BUG_ON,WARNING_ON設計時和用戶空間的assert一樣,斷言錯誤的時候主動引起系統崩潰來使開發者注意,而斷言錯誤發生很多都是由對象引用計數問題引起的對象重用.
這種沒有好的解決方法,只能對修改對象引用的位置進行統計和日誌記錄來排查問題.

內存泄露:使用C/C++編程的痼疾,稍有不注意就會發生內存泄漏問題,相對應的也有很多工具來解決這個問題:valgrind --tool=memcheck --leak-check=full,address santinizer,內核的SLAB_DEBUG,KASAN等.除了內核支持的工具之外,還可以自己封裝調試接口,例如對自己代碼中的kmalloc/kfree統計.

內存泄漏和對象重用在內核中僅一線之差,對象一直不釋放就是內存泄漏,正在使用中就提前釋放並且被重用就是對象重用問題。

重複釋放:問題一般發生在用戶空間中,在內核中對象的釋放一般是通過引用計數來表示當前引用的,最後一個引用釋放時進行對象釋放,接下來對這個對象釋放一般都是有BUG_ON的檢查,重複釋放當場就會觸發BUG_ON,WARNING_ON;而用戶空間重複釋放問題比較容易查找,一般有glibc自帶的擴展功能mcheck,它目前基本是默認打開的;另外還可以使用其他的tcmalloc,dmalloc等第三方調試庫

memory overview

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