存儲管理是操作系統非常重要的功能之一,本文主要介紹操作系統存儲管理的基礎知識,包括緩存相關知識、連續內存分配、伙伴系統、非連續內存分配、內存碎片等,並結合linux系統對這些知識進行簡單的驗證。文章內容來自筆者學習清華大學和UCSD的操作系統課程的筆記和總結,以及自己的思考和實踐。
分層的存儲管理:
CPU(Central Processing Unit)是計算機的核心,其主要工作是解釋計算機指令、處理數據。那么這些指令和數據來自哪里呢?和TCP/IP的分層設計思想一樣,數據的存儲管理也分為以下四層:
- 寄存器
- cache
- 內存
- 外存(外設)
四層中,越上層的速度越快,同時造價也更為昂貴,自然空間也更小。寄存器通常只有幾十之多幾百個字節,主要用來存放固定的指針或者計算的中間結果。cache是一個比較通用的術語,在計算機體系機構中,稱之為高速緩沖存儲器,直接由硬件來管理,不同的CPU有不同級別的cache內存由操作系統來管理。內存(一般又稱為主存)在cache失效的時候訪問,速度要比cache慢至少一個數量級,內存管理是操作系統中最復雜的功能之一。外存又稱之為虛擬內存,是將外設的一部分空間用來存儲未能加載到內存中的數據,當內存缺頁(頁的概念后面會介紹)的時候就會用到外存,外存的速度與內存比起來與天壤之別,慢個幾十萬倍都是可能的。
cache:
在計算機系統中,CPU的運行速度與主存(即我們平常所說的內存)的訪問速度極不匹配,導致CPU的利用率比較低。為了提高CPU利用率,現代計算機體系結構中廣泛采用高速緩沖存儲器(Cache)技術。
cache也分為好多級,比如一級緩存(L1 cache)、二級緩存(L2 cache),基本上的CPU都至少有L1 cache與L2 cache,目前稍微好一點的CPU也會有L3 cache,對於性能更好的CPU還會有L4 cache。L1 cache、L2 cache一般都只有幾十K個字節,存儲指令或者數據,L3cache可以有幾百個字節或者幾M,L4 cache可以有幾十、幾百M。同樣的,空間大小與速度、價格相關。
局部性原理:
我們看到,cache的容量(特別是效率最高的L1 cache、L2 cache)都非常小,那怎么保證有比較高的命中率呢,如果命中率比較低,每次還得去內存取數據,還要置換cache中的數據,反而得不償失。幸好,程序的局部性原理保證了cache能有比較高的命中率。什么是局部性原理:程序一般以模塊的形式組織,某一模塊的程序,往往集中在存儲器邏輯地址空間中很小的一塊范圍內,且程序地址分布是連續的。也就是說,CPU在一段較短的時間內,是對連續地址的一段很小的主存空間頻繁地進行訪問,而對此范圍以外地址的訪問甚少,這種現象稱為程序訪問的局部性(principle of locality)。具體來說,包括:
時間局部性(Temporal locality):最近被訪問的數據在很短的時間內還會被訪問;
空間局部性(Spatial locality):當前正在被訪問的內存的臨近的內存區域很快也會被訪問。
置換算法:
有了程序的局部性原理,就能保證當一篇內存區域加載到cache中后,能被多次命中。但由於cache的容量小,所以總有命中失敗的情況,當沒有命中,那么就需要去內存把一段內存區域(以塊為單位,一個塊包括若干個字節)加載到cache,那么如果此時cache已經滿了,那么需要將一些塊交換出去,如何選擇被置換的塊被稱為替換算法,包括:
1. 最不經常使用(LFU)算法
LFU(Least Frequently Used,最不經常使用)算法將一段時間內被訪問次數最少的那個塊替換出去。每塊設置一個計數器,從0開始計數,每訪問一次,被訪塊的計數器就增1。當需要替換時,將計數值最小的塊換出,同時將所有塊的計數器都清零。
這種算法將計數周期限定在對這些特定塊兩次替換之間的間隔時間內,不能嚴格反映近期訪問情況,新調入的塊很容易被替換出去。
2. 近期最少使用(LRU)算法
LRU(Least Recently Used,近期最少使用)算法是把CPU近期最少使用的塊替換出去。這種替換方法需要隨時記錄Cache中各塊的使用情況,以便確定哪個塊是近期最少使用的塊。每塊也設置一個計數器,Cache每命中一次,命中塊計數器清零,其他各塊計數器增1。當需要替換時,將計數值最大的塊換出。
LRU算法相對合理,但實現起來比較復雜,系統開銷較大。這種算法保護了剛調入Cache的新數據塊,具有較高的命中率。LRU算法不能肯定調出去的塊近期不會再被使用,所以這種替換算法不能算作最合理、最優秀的算法。但是研究表明,采用這種算法可使Cache的命中率達到90%左右。
3. 隨機替換
最簡單的替換算法是隨機替換。隨機替換算法完全不管Cache的情況,簡單地根據一個隨機數選擇一塊替換出去。隨機替換算法在硬件上容易實現,且速度也比前兩種算法快。缺點則是降低了命中率和Cache工作效率。
LRU是非常聰明且適用的思想,在寫代碼的過程中,也經常會用到緩存,自然也會用到LRU
寫回策略:
被加載到cache中的數據,除了被讀取,也可能被修改,那么怎么保持cache中的數據與內存中的數據同步,這稱之為寫回策略:
1、寫回法(Write-Back)
當CPU寫Cache命中時,只修改Cache的內容,而不是立即寫入主存;只有當此塊被換出時才寫回主存。
使用這種方法寫Cache和寫主存異步進行,顯著減少了訪問主存的次數,但是存在數據不一致的隱患。實現這種方法時,每個Cache塊必須配置一個修改位,以反映此塊是否被CPU修改過。
2、全寫法(Write-Through)
當寫 Cache命中時,Cache與主存同時發生寫修改。
使用這種方法寫Cache和寫主存同步進行,因而較好地維護了Cache與主存的內容一致性。實現這種方法時,Cache中的每個塊無需設置修改位以及相應的判斷邏輯,但由於Cache對CPU向主存的寫操作沒有高速緩沖功能,從而降低了Cache的功效。
3、寫一次法(Write-Once)
寫一次法是基於寫回法並結合全寫法的寫操作策略,寫命中與寫未命中的處理方法與寫回法基本相同,只是第一次寫命中時要同時寫入主存,以便於維護系統全部Cache的一致性。
當代計算機,一般都有多個CPU,多個CPU可能各自有獨立的cache,也可能共享一部分cache,這使得cache的管理(置換和寫回)更加復雜。特別對於寫回,怎么保證各個獨立cache中的數據一致,計算機體系機構中,用MESI_protocol來解決這個問題,具體可以參加infoq上的《緩存一致性(Cache Coherency)入門》
linux環境下的cache
在linux(debian8)環境下,CPU的信息在/proc/cpuinfo文件里面,我們平常說的多少核CPU其實都是指邏輯CPU,在/proc/cpuinfo里面"processor"的數量就是邏輯CPU數量。邏輯CPU的數目實際上等於 物理CPU數目 * 單個物理CPU的核數 * 2(如果是intel的CPU並且啟用了ht,hyper-threading, 超線程技術)。通過以下指令可以查看詳細信息
- grep "processor" /proc/cpuinfo |wc -l 查看邏輯CPU數目
- grep "physical id" /proc/cpuinfo |sort |uniq |wc -l 查看物理CPU數目
- grep "cores" /proc/cpuinfo|uniq 查看單個物理CPU的CPU cores數目
- grep "flags" /proc/cpuinfo | grep ht |uniq 查看是否開啟了ht
實測如下:
在/sys/devices/system/cpu 目錄下面,有每一個邏輯CPU的信息,每一個邏輯CPU都對應一個CPU*文件夾, for example:
在cpu*文件有一個cache文件,事實上就是該CPU的緩存信息,隨便選擇一個邏輯CPU查看:
可以看到,在筆者的機器上有四級cache,每一級緩存目錄下包含的文件可能是不同的,但至少都包含以下文件:
coherency_line_size
level
number_of_sets
physical_line_partition
shared_cpu_list
shared_cpu_map
size
type
ways_of_associativity
《Linux查看cache信息》這篇文章介紹了每個文件的意義。其中size是緩存的大小;type是緩存的類型(存數據、存指令、未指定);shared_cpu_list和shared_cpu_map是該cache在各個邏輯CPU之間的共享信息;coherency_line_size是每一個cache line包含多少自己,cache line是緩存與內存交換數據的最小單位,coherency_line_size = size / coherency_line_size / ways_of_associativity, 為什么這樣設計,《關於CPU Cache -- 程序員需要知道的那些事》這篇文章有詳細的介紹。
本機上每一級索引的類型
以第1級索引(index0)為例,查看coherency_line_size、 size、 coherency_line_size、 ways_of_associativity的關系:
連續內存分配與內存碎片
連續內存分配是指給進程分配一塊不小於指定大小的物理地址連續的內存區域,不同進程可能需求的內存塊大小是不一樣的,但需要是連續的。對於這一需求,有不同的分配算法,但不管什么分配算法,操作系統都需要至少維護兩部分信息:每個進程已經占用的分區、空閑的分區。
內部碎片與外部碎片:
內存碎片(fragmentation)是指操作系統在內存分配過程中,遺留下來的不能被利用到的內存區域。內存碎片導致部分內存被浪費,大量的內存碎片也會影響到系統的性能。
內部碎片(internal fragmentation),在連續內存分配策略中,操作系統按需分配一塊連續的內存給應用程序。一般來說,實際上給到應用程序的會略大於需求,主要是為了計算的方便,比如應用程序需要23bytes,但事實上可能分配32bytes。這樣多余的部分就成為了內部碎片。在連續內存分配策略中,內部碎片是很難避免的。但是如果使用非連續內存分配策略就能一定程度避免這個問題。
外部碎片(external fragmentation),開始的時候,可用的內存是一大塊,但持續的分配釋放過程中,就會形成一部分互相隔離的空閑區域,但這個部分區域尺寸較小,難以滿足應用程序的需求,這就導致了外部碎片。 不同的策略,可能產生的外部碎片嚴重情況不一樣。外部碎片的情況可以用下面這個公式衡量:
如果fragmentation為0%,那么表示所有的可用內存在一個空閑分區內。這個值越大,表明最大的空閑分區越小,空閑分區越零散。
動態分區分配策略
不同的連續內存分配策略,使用不同的數據結構,因此分配時的開銷和釋放時查找可合並區域並插入和合並后區域的開銷是不同的,最常見的三種策略分別是:
- 最先匹配(first fit)
- 最佳匹配(best fit)
- 最差匹配(worst fit)
下面結合一個實例來形象觀察三種策略的區別,假設現在需要400個字節的分區,目前內存中空閑分區如下圖(黃色為空閑區域)
對於最先匹配,只需要找到第一個滿足條件的空閑塊就行了,於是在這里找到的就是“1K bytes”這個區域。在這種策略下,空閑分區按照地址排序就行了,在釋放分區時,只需要檢查相鄰的分區是否可以合並就行了。優點是簡單,缺點是有外部碎片,並且需要大塊分區時可能比較慢(需要向后遍歷)
對於最佳匹配,需要找到一個剛好比需要的分區稍微大一點的空閑分區,在這個環境中找到的就是“500 bytes”這個區域。在這種策略下,按照空閑分區的大小排序,能夠迅速的找到可行的分配。但是在釋放分區,合並的時候需要遍歷找到臨近的分區,判斷是否合並。優點在於如果大部分需求的分區較小,這種策略能避免大塊空閑區域去被拆分,而且能極大程度減小外部碎片的大小。缺點在於釋放的時候速度較慢,而且可能存在大量較小的外部碎片。
對於最差匹配,直接選擇最大的分區,在這個環境中找到的就是“2K bytes”這個區域。在這種策略下,按照空閑分區的大小逆序排序,能夠O(1)時間復雜度找到可行的分配。但是在釋放分區,合並的時候需要遍歷找到臨近的分區,判斷是否合並。優點在於分配速度快,也能避免大量的小碎片。缺點在於釋放時速度慢,而且因為每次都分配最大的空閑分區,在后期可能滿意滿足較大分區的需求。
伙伴系統(buddy system)
伙伴系統是非常出名的連續內存分配算法,Linux系統就是實用伙伴系統來做內核里的存儲分配,很多語言或者內存數據庫在做緩存的時候也會使用伙伴系統分配空間。
伙伴分配算法將可分配區間划分為2的N次方,初始的時候只有一個大小為2的M次方的最大空閑快。分配的時候,由小到大在空閑塊數組(或者鏈表)中找最小的可用空閑塊,如果空閑塊過大(超過需求的兩倍),則對空閑塊進行二等分,直到得到合適的可用空閑塊,由於伙伴系統一定是找到屬於最合適的空閑塊,那么屬於上面提到的“最佳匹配策略”。釋放的時候講塊放入空閑塊數組(或者鏈表),然后合並滿足合並條件的空閑塊。
wiki上Buddy_memory_allocation的提到,在伙伴分配算法中,最小的塊(即最基本的分配單元)不能太小,不然單純為了記錄哪一部分內存被使用或者空閑就先帶來大量的內存和計算開銷。但如果基本分配單元太大,會導致大量的內部碎片。因此理想的基本單元需要足夠小,以避免內部碎片,同時要足夠大,降低額外開銷。
從上面的描述,伙伴系統比較適合用二叉樹來實現,coolshell上的《伙伴分配器的一個極簡實現》這篇文章給出了一個實現,值得借鑒。
伙伴系統的優點在於原理比較簡單,而且外部碎片比較少,合並空閑分區開銷也比較小。但缺點也很明顯,內部碎片比較嚴重,比如最小單位為64K,如果需要65k的空間,那么不得不分配一塊128K的空間,浪費了接近實際所需的一倍空間。因此在實際的應用中,會對伙伴系統進行一些修改和優化,以便減少內部碎片,提高利內存用率,比如Slab_allocation。
下面是wiki上的一個例子,最小的基本單元為64K,最大可分配空間為1M(64K* 16),本文只簡單描述幾個步驟,如果需要每一步都細看,那么可以參考wiki或者這篇文章,里面有中文翻譯。
step1:這個是初始狀態,所有空閑內存就只有一個1M的分區
step2:請求A需要分配一個34K的連續內存區域,滿足條件的應該是一個64K的分區,但是現在只有一個1M的分區,所以連續4次二分,切出了一個64K區域(上圖中淺藍色部分),分配給請求A。
這一步分配之后,造成的內存碎片是64k - 34k = 30k。當前可分配的分區分別是:1個64K, 1個128K,1個256K,1個512K
step3:請求B需要一個66K的連續內存區域,滿足條件的應該是一個128K的分區,事實上也是有的(上圖中綠色部分),不過導致的內存碎片為128K - 66K = 62K
step4:請求C需要35K,目前有一個空閑的64K(上圖中紅色部分),直接分配。當前可分配的分區分別是:1個256K,1個512K
step5:請求D需要67K,但目前沒有一個128K的空閑分區,於是將256K的二分,給一個128K給請求D(上圖中暗紅色部分)。當前可分配的分區分別是:1個128K,1個512K
step6:應用程序釋放請求B占用的內存區域,雖然當前有兩塊128K的空閑分區,但由於地址不相鄰,沒法合並
step7:應用程序釋放請求D占用的內存區域,這個時候D占用的空閑分區就可以和其右邊的分區合並。
step8:應用程序釋放請求A占用的內存區域,沒有可合並的分區
step9:應用程序釋放請求B占用的內存區域,遞歸合並,最終合成一塊1M的分區。
非連續內存分配:
在上一章,詳細介紹了連續內存分配的相關知識,可以看到連續內存分配雖然比較簡單,但是也存在諸多的問題,比如內部碎片與外部碎片的問題,難以滿足內存大小的動態修改需求,而且因為內存碎片的問題,導致內存利用率比較低。在本章介紹非連續內存分配策略,包括段式、頁式、段頁式。
顧名思義,非連續內存分配允許一個程序使用非連續的物理地址空間,即一個程序的邏輯地址空間被映射到物理地址空間不同的塊(block),每一個塊內部是連續的,但塊與塊之間可以不連續。其核心目標是提高內存利用率和管理靈活性,並允許代碼和數據的共享。
當然,不連續也會引入相應的問題,需要有對應的解決方案或者折中權衡。首先是邏輯地址與物理地址的轉換,對於連續內存分配,只需要起始地址和偏移就行了,對於非連續,需要記錄邏輯地址與所有塊的映射關系。其次,分塊的粒度影響着邏輯地址與物理地址的轉換,操作系統中常用兩種粒度:段式(segmentation)與頁式(paging) ,前者是粗粒度,使用段表做地址轉換,后者是細粒度,用頁表做地址轉換。
段式
一個段表示的是訪問方式和存儲數據等屬性相同的一部分地址空間,對應的是一個連續的內存“塊”,若干個段組成進程的邏輯地址空間,比如堆、棧、數據、代碼段。在段式策略中,邏輯地址由二元組(s, addr)組成,其中s為段號,addr為段內偏移。下面這個圖形象描述了段式訪問流程:
操作系統為應用程序設置段表,段表維護了每一個段的基址和長度,用段號(本質就是段表的索引)就能查找到對應的段基址和長度。內存訪問順序如下:CPU計算的時候取出邏輯地址(段號, 偏移);用段號去查找出對應的段基址和段長度;判斷邏輯地址的偏移和段長度的大小關系,如果偏移大於段長度,那么直接報內存異常;通過段基址加上邏輯地址便宜,就找到了物理內存的實際地址。
頁式
頁式是比段式更細粒度的策略,對於頁式存儲管理,首先需要了解兩個概念:頁幀(Frame)、頁面(Page)
頁幀又稱為物理頁面,是指把物理空間划分為大小相同的基本分配單元, 基本單元的大小為2的N次方。因此內存物理地址可以由二元祖(f, o)表示,其中f為幀號, o為幀內偏移,假設每一幀的大小為2的S次方,那么物理地址 = f *2 S + o.
頁面又稱為邏輯頁面,是指把邏輯地址空間划分為大小相同的基本分配單元。因此邏輯地址可以由二元祖(p, o)表示,其中p為頁號, o為頁內偏移,假設每一頁的大小為2的S次方,那么邏輯地址 = p *2 S + o.在linux環境下,可以用shell命令 getconf PAGESIZE 來查看頁面的大小。
為了方便,幀 與 頁的基本大小相同,因此頁內偏移 等於 幀內偏移; 但頁號不等於幀號。因此只需要維護頁號到幀號的映射關系就行了,這就是頁表的作用。
每個進程都有一個頁表,每個頁面對應一個頁表項,頁表隨進程的運行狀態動態變化,頁表基址寄存器(page table base register)記錄頁表的基址。由頁表基址加上頁號就能得到響應的頁表項,頁表項里面最重要的就是幀號信息,當然還有一些其他字段,如存在位、修改位等。下面看看頁式存儲管理的訪問過程:
CPU計算的時候取出邏輯地址(頁號, 偏移);用頁號加上頁表基址得到頁表項,取出幀號;通過幀號和偏移,就找到了物理內存的實際地址。
從上面的描述不難想到頁式存儲管理的兩個問題:第一,訪問一個單元需要兩次內存訪問(先讀頁表項,再讀數據),這是性能問題;第二,如果地址空間比較大,而頁面的基本單元較小,那么一個頁表的大小會很大,這個是額外空間消耗問題。為了解決這兩個問題,又引入了快表(Translation look-aside buffe)、多級頁面、反質頁表,快表解決性能問題,后面兩個解決頁表大小的問題。現在的計算機都是采用頁機制來進行地址轉換(事實上是虛擬頁式存儲)。
段頁式
段式、頁式各有優劣段式存儲在內存保護方面有優勢,而頁式存儲在內存利用和優化轉移到后備存儲(即虛擬存儲)方面有優勢。段頁式則是二者的集合,即在段式存儲管理基礎上 給每個段加一級頁表。這樣,邏輯地址就變成了三元組(s, p, o), 分別是段號、頁號和頁內偏移。
段頁式存儲管理,進程的段表項實際上存儲的是該段的頁表項,這樣不同的段(屬於不同的進程)可以指向同一個頁表,這就可以共享段。
總結:
本文是操作系統存儲管理的基礎知識,主要介紹了分層的存儲管理;cache(CPU告訴緩存)的工作原理、置換策略、寫回策略;連續內存分配理論及其實例伙伴系統,內存碎片的產生;非連續存儲的概念,段式、頁式、段頁式各自的特點,本文並不涉及虛擬存儲相關知識。對於很多知識點,並沒有深入,如果感興趣,可以結合鏈接進一步學習。