抓主線,三個點:
- 虛擬內存組織
- 虛擬內存和物理內存的轉換
- 物理內存組織
虛擬內存組織
平時在進程中,所謂的內存地址,其實都是虛擬地址(VA),而不是實際的物理內存地址(PA)。這樣做的好處在于:
- 進程間隔離
- 方便共享資源
- 可以按需分配物理內存
注:在32位機器中,進程可用的地址空間是2^32, 即4GB。64位機器是2^48
在Linux內核中,每一個進程,就是一個task_struct結構。在task_struct中,有一個mm_struct結構指針,這個結構是用來管理進程的內存的。其中,又有一個mmap字段,存儲了一堆vm_area_struct,vm_area_struct中的vm_start和vm_end就標識一段虛擬內存區域。由于平常進程會有這樣的操作:給一個虛擬地址,然后找出這個地址所屬的區間,及對應的vm_area_struct,所以使用紅黑樹去組織這堆vm_area_struct。當訪問一個所有vm_area_struct都不存在的地址時,就會發生段錯誤。
注意:mm是指針,因此可以多個進程指向同一個mm_struct,多個進程使用同一塊內存空間。
動態內存管理
了解進程地址空間的都知道,一個進程的地址空間通常都分成離散的若干塊,每一個塊都有自用的特殊用處。
其中,堆我們應該最熟悉了,在堆的vm_start和vm_end之間的存儲區域,就是我們平時動態分配內存的區域。由于是動態分配的,就涉及快速分配、快速釋放、內存碎片等問題。動態內存分配大致上可以分為幾種:
- 隱式鏈表+邊界標記
- 顯式鏈表+邊界標記
- 分離鏈表
隱式鏈表
已分配的內存塊中有頭部會記錄這個內存塊的使用情況,例如塊大小和這塊內存是否被使用等信息。
利用這個塊大小和是否使用這兩個信息,我們就可以遍歷空閑鏈表,已達到分配內存的目的。
釋放內存時,我們就使用邊界標記法來快速合并空閑內存塊。
顯示鏈表
我們可以發現,在隱式鏈表方案中,分配內存的時間復雜度和內存塊是線性關系,如果內存塊很多時,那么分配內存就很慢,為了提高性能,我們可以顯式的維護一條空閑鏈表,這樣分配內存就快多了。是否時,可以LIFO,或者按地址順序去插入到鏈表中,LIFO好處在于釋放是常數級別的復雜度,按地址順序插入的好處是使用首次適配時能更好的利用存儲器。同樣,合并的時候,使用邊界標記法來快速合并。
分離鏈表
進一步,我們可以把不同大小的內存塊,分別用不同的鏈表的組織,那么查空閑塊的時候,就直接到相關大小的鏈表中找,沒有就往更大的鏈表中找,可以避免在很多小空閑小內存塊時,突然分配一塊大內存要找半天。分配時會分割內存塊,然后把多余的放到對應的鏈表中。釋放時會合并,然后放到一個相應的塊(lazy操作)。目前所有高性能的內存分配器都使用分離鏈表的模型。伙伴系統其實就是2的k次冪的分離鏈表。
之前提到兩次邊界標記法,下面簡單說一下
對于隱式鏈表和顯式鏈表,合并時,不知道相鄰的上一塊內存是否被使用,除非從頭開始遍歷。為了解決這個性能問題,我們除了在頭部記錄當前塊大小,當前塊是否被使用外,再加一個標志位,記錄上一個相鄰的內存塊是否被使用;對于空閑的內存塊,增加和空閑內存塊頭部一樣的腳部(在空閑塊加額外信息,而不是在已分配塊中加,可以提高內存使用率)。這樣合并空閑內存塊時,無論上一塊內存塊,還是下一塊內存塊,都能很快速的合并了。
番外1:C++ STL內存池
申請一大塊內存,然后分割成一個個固定塊,組織成不同的空閑內存塊鏈表。小內存從內存池中分配,大內存直接向系統申請。小內存釋放是給回鏈表,大內存的釋放直接給回系統。所以頻繁new小于128字節的對象,看起來會像有內存泄露。
空閑內存鏈表分為8、16、24、32、...、128這幾個固定大小的內存塊鏈表
實現的trick:
使用union去指向一個內存塊節點
union _Obj {
union _Obj* _M_free_list_link;
char _M_client_data[1];
}
這個結果用來指向一個內存塊,當是空閑內存塊時:
使用_M_free_list_link變量,即next指針,指向下一塊空閑內存塊
當分配出去后,使用_M_client_data,就表示這個內存塊的分配出去的內存塊的地址
就是說,把next指針作為內存塊大小的一部分,因為只有內存塊空閑時,next才有意義,分配出去時next就沒意義了,這樣就省了一個專門的指針空間,省內存。
番外2:malloc與free
128kb是堆,超過128kb是mmap
虛擬地址(VA)和物理地址(PA)的轉換
總所周知,x86系統的MMU是段頁結構,由分段部件和分頁部件組成。在x86系統中,有三種地址空間:
邏輯地址、線性地址、物理地址
邏輯地址就相當于段地址,線性地址就相當于頁地址。
為了簡化內存管理,Linux不是一個段頁式結合的系統,而是一個分頁的。但是由于硬件限制,在x86系統上,Linux依然會裝模作樣的做一些段地址的初始化,實際上把邏輯地址直接映射成線性地址,即線性地址=邏輯地址。
然后我們再說一下Linux的分頁系統
分頁系統
mm_struct中,有一個PGD字段,這個字段指向的是頁表的基地址。這個頁表,就是用來把虛擬地址,轉換成物理地址的一個表。切換進程的地址空間,其實就是把CR3寄存器的值置為該進程的頁表基地址。
為了節省內存,操作系統一般會用多級頁表,根據硬件的不同來確定用幾級。
- 64位系統:使用四級分頁或三級分頁,跟硬件有關。
- 未開啟PAE(物理地址擴展)的32位系統:只使用二級分頁,頁上級目錄和頁中間目錄里的值全為0。
- 開啟PAE的32位系統:使用三級分頁,這種情況下被排除在外的是頁上級目錄,也就是頁上級目錄中所有值都為0。
一圖勝千言,以3級頁表為例:
一個VA分成4部分,VPN1 VPN2 VPN3 VPO。翻譯的過程是這樣的:
- 根據一級頁表基地址(PGD)和 VPN1 ,找到對應的頁表目錄項(PTE),獲取二級頁表基地址
- 根據二級頁表基地址 和 VPN2 ,找到對應的頁表目錄項(PTE),獲取三級頁表基地址
- 根據三級頁表基地址 和 VPN3 ,找到對應的頁表目錄項(PTE),獲取物理頁的地址(PPN)
- 根據PPN 和 VPO ,合成物理地址(PA)
由于PTE的值也是一個存在物理內存中的數據,因此也能CPU緩存也能緩存它,因此我們的翻譯過程是:
傳輸后備緩沖器(TLB)
一個虛擬地址,要經過頁表一級一級的查找,就算有CPU緩存,這樣速度太慢,而且CPU緩存是通用的,很容易被其他數據淘汰掉,命中率還進一步降低。為了速度,我們希望直接能通過VPN來獲取到PTE。
所以CPU中有TLB的存在,TLB相當于頁表專用的高速緩存。把VA中的VPO去掉,然后去TLB那里查。
以一級頁表為例,當TLB沒命中時的翻譯過程:
TLB命中的翻譯過程:
完整的過程如下:
TLB以VPN作key來緩存,所以是和每個進程相關聯的,因而,切換進程后,我們要避免上一個進程的TLB對當前進程的TLB有影響,不然就去到找到的就是錯誤的地址了。因此當頁表樹變化時,我們要刷新TLB。
刷新TLB的成本非常大,因此有很多優化策略,例如lazy刷新、盡量只刷新用戶空間的TLB、系統建立軟TLB等等。這里不展開。
物理內存組織
架構
一般來說,CPU的內存控制架構分兩種,一致性內存訪問(uniform memory access)和非一致性內存訪問(non uniform memory access)
UMA將可用內存以連續方式組織起來,系統中的每個處理器訪問各個內存都是同樣的塊。 NUMA的各個CPU都有本地內存,可支持特別快的訪問,各個處理器之間通過總線連接起來。
Linux為了統一情況,使用NUMA模型,當計算機是UMA時,就用一個NUMA節點來管理整個系統的內存。
在i386機器中,頁式存儲管理器的硬件在CPU內部實現的,而不是使用獨立的MMU,而我們知道,DMA是不經過CPU的,因此也沒有經過MMU地址映射,換句話說,就是需要直接訪問物理地址。DMA是外設,還有其他的各種外設,有些外設的尋址空間有限,這樣就出現了分區。對于物理內存,一般來說會分成三個區域:
- · ZONE_DMA(0-16 MB):包含 ISA/PCI 設備需要的低端物理內存區域中的內存范圍。
- ·ZONE_NORMAL(16-896 MB):由內核直接映射到高端范圍的物理內存的內存范圍。所有的內核操作都只能使用這個內存區域來進行,因此這是對性能至關重要的區域。
- ZONE_HIGHMEM(896 MB 以及更高的內存):系統中內核不能映像到的其他可用內存。
注:大小根據cpu不同會有所不同,另外64位系統的是DMA、DMA32、NORMAL三個區
對于某個用戶頁面的請求可以首先從“普通”區域中來滿足(ZONE_NORMAL);
如果失敗,就從 ZONE_HIGHMEM 開始嘗試; 如果這也失敗了,就從 ZONE_DMA 開始嘗試。
NUMA的系統架構詳細點,就變成這樣:
Linux將NUMA中內存訪問速度一致的(按內存通道劃分),稱為一個節點(Node),用struct pglist_data結構表示,通常使用時,用它的typedef定義的pg_data_t。系統中的每個節點都通過pgdat_list鏈表pg_data_t->node_next連接起來,以NULL為接受標志。每個節點有進一步分成多個區(zone),用struct zone_struct表示,它的typedef定義為zone_t。每個區又有多個頁面(page)。
節點、區、頁三者的關系如下:
物理內存的分配和釋放
在zone_struct中,free_area類型數組的成員free_area,用于實現伙伴系統。下標是0,表示2的0次方個連續頁面,下標是n,表示2的n次方個連續頁面
之前說過伙伴系統是分離鏈表的一個特例而已,這里就不展開討論細節了。
從結構中我們可以看出,伙伴系統最多只能細化到一個頁上,很多時候,我們用不到一頁,所以在伙伴系統的基礎上,有SLUB分配器(以前是SLAB,因為巨復雜,并且內存使用率不高,被替換掉了),專門用于分配小內存。
SLUB
Linux 內核經常使用許多特定的數據結構(例如task_struct),這些結構在使用前需要調用“構造函數”進行初始化。所以在 Linux 內核中,我們把用來存儲數據結構的內存單元叫做對象。如果我們能在使用前就把對象初始化好,會 大大提高內存分配的速度。 1) 內核中許多對象的使用非常頻繁,而且使用的數量隨著系統的運行動態變化,不可預料。 2) 對象的大小一般都比較小,比如幾十 Byte,如果按照對待應用程序的內存分配方式,最少一次分配 4KB,會造成內存的浪費。 3) 內核初始化對象的時間花費,甚至超過分配和釋放內存本身的時間。如果我們不銷毀回收的對象,而建立某種緩存機制,不必每次使用前都初始化,會大大提高內存分配的速度。 Linux 內核中用的數據結構繁多,所以內核中對象的種類也很多。每種對象的大小,使用的頻度都不一樣,也無法預測。內核的內存管理要動態的適應每種對象的特點,高速高效的管理內存。 Slub 分配機制的基本思想:當內核申請使用對象時,一次初始化一組對象,供內核使用;當內核釋放對象時,不釋放所占用內存,將該內存保持為可直接使用的初始化狀態,供下次使用。Slub 機制使用了這種基本思想和其他一些思想來構建一個在空間和時間上都具有高效性的內存分配機制。
我們先說剛開始的SLUB版本,再說3.20中的SLUB版本
先說slub的基本結構,每一個kmem_cache管理某種特定大小的對象池,在kmen_cache中,有兩種重要的結構
- kmem_cache_cpu 代表一個cpu
- kmem_cache_node 代表一個NUMA節點
kmem_cachec_cpu中,有個freelist,記錄了當前這個cpu使用的slab中的空閑對象池。
kmem_cache_node中,有個partial,記錄當前這個節點中部分滿的page,page中也有個freelist,記錄了這個page中的空閑對象池。
注:這里的page,指的不是一個頁面,而是2^order個連續頁面,order值由kmem_cache里面的oo或min來確定
分配的時候,優先從kmem_cache_cpu中分配,如果不行,就從kmem_cache_node中的partial中拿一個page來分配。分配時具體由以下幾種情況:
1、kmem_cache_cpu的freelist不為空
直接返回freelist的第一個節點
2、kmem_cache_cpu的freelist為空,kmem_cache_node的partial不為空
選取一個page,把page中的freelist賦予kmem_cache_cpu中的freelist,再把page中的freelist置NULL,并從partial中去掉。
3、kmem_cache_cpu的freelist為空,keme_cache_node的partial為空
從伙伴系統中獲取一批頁面,放到kmem_cache_node的partial中,然后同2
釋放的時候,直接把對象釋放到對應的page的freelist中,然后按照page的狀態執行相應的操作,例如page原本是full,變成partial了,就放到kmem_cache_node的partial中去,變成empty了,就回收到伙伴系統中。
這里面有幾個值得思考的地方
- 為什么引入kmem_cache_cpu
- 為什么直接把page的freelist給kmem_cache_cpu,然后page的freelist置NULL
我們先設想完全沒有kmem_cache_cpu時,分配的動作就變成:
- 從對應的kmem_cache_node的partial鏈表里面選擇一個page;
- 從選中的page的freelist鏈表里面選擇一個對象;
但是這個過程有個不好的地方,kmem_cache_node的partial鏈表是全局的、page的freelist鏈表也是全局的:
- 第一步訪問partial鏈表的時候,由于分配后的page可能變成full,所以訪問partial需要上鎖;
- 第二步訪問page->freelist鏈表的時候,需要取出對象,所以也需要上鎖;
每次分配對象都有兩個鎖,效率低。
想一下解決方案,我們把分配的page從partial中拿出來,那么就可以避免lock kmem_cache_node了。問題在于我們釋放對象時,這個page可能重新回到partial中,其他cpu可能會操作這個page,所以還是會lock page->freelist。因此我們引入kmem_cache_cpu及cpu級別的freelist。分配時,kmem_cache_cpu直接接管了page->freelist,就只和cpu級別的freelist打交道,之后的分配操作就和page->freelist沒有關系了。那么就算page上有對象釋放,重新回到partial,然后被其他處理器cache,cpu間的分配動作也是井水不犯河水,不用加鎖了。
所以有了kmem_cache_cpu和cpu級別的freelist,kmem_cache_cpu的freelist不為空時,分配就完全不用加鎖。kmem_cache_cpu的freelist為空時,只加一次鎖。
當然,這樣設計也提高了cpu緩存的命中率。
性能更進一步
我們可以發現,在分配的時候,大部分時間都不用加鎖,可是釋放的時候,既要操作partial,也要操作page->freelist,那么就要加兩次鎖了,所以釋放的時候會有性能問題。
因此,在Linux kernel 3.20版本中,SLUB引入了per cpu cache for partial pages
使得SLUB的性能提高了20%,主要在這三方面的改善:
- 釋放時不用lock kmem_cache_node
- 批量從partial中獲取page,把分配時lock kmem_cache_node的成本分攤了
- 更多的從cpu->partial中獲取page,減少lock kmem_cache_node
(圖有點錯,cpu0和cpu1才對)
加入了這個特性后,分配的變化就是在cpu->freelist和page中間,多了一個cpu->partial,若cpu->freelist為空,就從cpu->partial中獲取page,若cpu->partial為空,就從node->partial中批量獲取page,加到cpu->partial中。這個批量的值是保持cpu->partial中的空閑對象池多于kmem_cache結構中的cpu_partial的1/2。而這個cpu_partial的值是根據對象的大小決定的,Linux中的源碼:
if (!kmem_cache_has_cpu_partial(s))
s->cpu_partial = 0;
else if (s->size >= PAGE_SIZE)
s->cpu_partial = 2;
else if (s->size >= 1024)
s->cpu_partial = 6;
else if (s->size >= 256)
s->cpu_partial = 13;
else
s->cpu_partial = 30;
釋放的變化在于釋放時,如果page從full變成partial,就放到cpu->partial中。