linux頁面回收淺析

關於頁面的使用
在之前的一些文章中,我們瞭解到linux內核會在很多情況下分配頁面。
1、內核代碼可能調用alloc_pages之類的函數,從管理物理頁面的夥伴系統(管理區zone上的free_area空閒鏈表)上直接分配頁面(見《linux內核內存管理淺析》)。比如:驅動程序可能用這種方式來分配緩存;創建進程時,內核也是通過這種方式分配連續的兩個頁面,作爲進程的thread_info結構和內核棧;等等。從夥伴系統分配頁面是最基本的頁面分配方式,其他的內存分配都是基於這種方式的;
2、內核中的很多對象都是用slab機制來管理的(見《linux slub分配器淺析》)。slab就相當於對象池,它將頁面“格式化”成“對象”,存放在池中供人使用。當slab中的對象不足時,slab機制會自動從夥伴系統中分配頁面,並“格式化”成新的對象;
3、磁盤高速緩存(見《linux內核文件讀寫淺析》)。讀寫文件時,頁面被從夥伴系統分配並用於磁盤高速緩存,然後磁盤上的文件數據被載入到對應的磁盤高速緩存頁面中;
4、內存映射。這裏所謂的內存映射實際上是指將內存頁面映射到用戶空間,供用戶進程使用。進程的task_struct->mm結構中的每一個vma就代表着一個映射,而映射的真正實現則是在用戶程序訪問到對應的內存地址之後,由缺頁異常引起的頁面被分配和頁表被更新(見《linux內核內存管理淺析》);

頁面回收簡述
有頁面分配,就會有頁面回收。頁面回收的方法大體上可分爲兩種:
一是主動釋放。就像用戶程序通過free函數釋放曾經通過malloc函數分配的內存一樣,頁面的使用者明確知道頁面什麼時候要被使用,什麼時候又不再需要了。
上面提到的前兩種分配方式,一般都是由內核程序主動釋放的。對於直接從夥伴系統分配的頁面,這是由使用者使用free_pages之類的函數主動釋放的,頁面釋放後被直接放歸夥伴系統;從slab中分配的對象(使用kmem_cache_alloc函數),也是由使用者主動釋放的(使用kmem_cache_free函數)。

另一種頁面回收方式是通過linux內核提供的頁框回收算法(PFRA)進行回收。頁面的使用者一般將頁面當作某種緩存,以提高系統的運行效率。緩存一直存在固然好,但是如果緩存沒有了也不會造成什麼錯誤,僅僅是效率受影響而已。頁面的使用者不明確知道這些緩存頁面什麼時候最好被保留,什麼時候最好被回收,這些都交由PFRA來關心。
簡單來說,PFRA要做的事就是回收這些可以被回收的頁面。爲了避免系統陷入頁面緊缺的困境,PFRA會在內核線程中週期性地被調用運行。或者由於系統已經頁面緊缺,試圖分配頁面的內核執行流程因爲得不到需要的頁面,而同步地調用PFRA。
上面提到的後兩種分配方式,一般是由PFRA來進行回收的(或者由類似刪除文件、進程退出、這樣的過程來同步回收)。

PFRA回收一般頁面
而對於上面提到的前兩種頁面分配方式(直接分配頁面和通過slab分配對象),也有可能需要通過PFRA來回收。
頁面的使用者可以向PFRA註冊回調函數(使用register_shrink函數)。然後由PFRA在適當的時機來調用這些回調函數,以觸發對相應頁面或對象的回收。
其中較爲典型的是對dentry的回收。dentry是由slab分配的,用於表示虛擬文件系統目錄結構的對象。在dentry的引用記數被減爲0的時候,dentry並不是直接被釋放,而是被放到一個LRU鏈表中緩存起來,便於後續的使用。(見《linux內核虛擬文件系統淺析》。)
而這個LRU鏈表中的dentry最終是需要被回收的,於是虛擬文件系統在初始化時,調用register_shrinker註冊了回收函數shrink_dcache_memory。
系統中所有文件系統的超級塊對象被存放在一個鏈表中,shrink_dcache_memory函數掃描這個鏈表,獲取每個超級塊的未被使用dentry的LRU,然後從中回收一些最老的dentry。隨着dentry的釋放,對應的inode將被減引用,也可能引起inode被釋放。
inode被釋放後也是放在一個未使用鏈表中,虛擬文件系統在初始化時還調用register_shrinker註冊了回調函數shrink_icache_memory,用來回收這些未使用的inode,從而inode中關聯的磁盤高速緩存也將被釋放。

另外,隨着系統的運行,slab中可能會存在很多的空閒對象(比如在對某一對象的使用高峯過後)。PFRA中的cache_reap函數就用於回收這些多餘的空閒對象,如果某些空閒的對象正好能夠還原成一個頁面,則這個頁面可以被釋放回夥伴系統;
cache_reap函數要做的事情說起來很簡單。系統中所有存放對象池的kmem_cache結構連成一個鏈表,cache_reap函數掃描其中的每一個對象池,然後尋找可以回收的頁面,並將其回收。(當然,實際的過程要更復雜一點。)

關於內存映射
前面說到,磁盤高速緩存和內存映射一般由PFRA來進行回收。PFRA對這兩者的回收是很類似的,實際上,磁盤高速緩存很可能就被映射到了用戶空間。下面簡單對內存映射做一些介紹:

內存映射分爲文件映射和匿名映射。
文件映射是指代表這個映射的vma對應到一個文件中的某個區域。這種映射方式相對較少被用戶態程序顯式地使用,用戶態程序一般習慣於open一個文件、然後read/write去讀寫文件。
而實際上,用戶程序也可以使用mmap系統調用將一個文件的某個部分映射到內存上(對應到一個vma),然後以訪存的方式去讀寫文件。儘管用戶程序較少這樣使用,但是用戶進程中卻充斥着這樣的映射:進程正在執行的可執行代碼(包括可執行文件、lib庫文件)就是以這樣的方式被映射的。
在《linux內核文件讀寫淺析》一文中,我們並沒有討論關於文件映射的實現。實際上,文件映射是將文件的磁盤高速緩存中的頁面直接映射到了用戶空間(可見,文件映射的頁面是磁盤高速緩存頁面的子集),用戶可以0拷貝地對其進行讀寫。而使用read/write的話,則會在用戶空間的內存和磁盤高速緩存間發生一次拷貝。
匿名映射相對於文件映射,代表這個映射的vma沒有對應到文件。對於用戶空間普通的內存分配(堆空間、棧空間),都屬於匿名映射。
顯然,多個進程可能通過各自的文件映射來映射到同一個文件上(比如大多數進程都映射了libc庫的so文件);那匿名映射呢?實際上,多個進程也可能通過各自的匿名映射來映射到同一段物理內存上,這就是共享內存的實現。

內存映射(包括文件映射和匿名映射)又分爲共享映射和私有映射。私有映射時,如果進程對映射的地址空間進行寫操作,則映射對應的磁盤高速緩存(文件映射)或物理內存(匿名映射),並不會直接被寫。而是將原有內容複製一份,然後再寫這個複製品,並且當前進程的對應頁面映射將切換到這個複製品上去(寫時複製)。也就是說,寫操作是隻有自己可見的。而對於共享映射,寫操作則是大家都可見的。

哪些頁面該回收
至於回收,磁盤高速緩存的頁面(包括文件映射的頁面)都是可以被丟棄並回收的。但是如果頁面是髒頁面,則丟棄之前必須將其寫回磁盤。
而匿名映射的頁面則都是不可以丟棄的,因爲頁面裏面存有用戶程序正在使用的數據,丟棄之後數據就沒法還原了。相比之下,磁盤高速緩存頁面中的數據本身是保存在磁盤上的,可以復現。
於是,要想回收匿名映射的頁面,只好先把頁面上的數據轉儲到磁盤,這就是頁面交換(swap)。顯然,頁面交換的代價相對更高一些。
匿名映射的頁面可以被交換到磁盤上的交換文件或交換分區上(分區即是設備,設備即也是文件。所以下文統稱爲交換文件)。

於是,除非頁面被保留或被上鎖(頁面標記PG_reserved/PG_locked被置位。某些情況下,內核需要暫時性地將頁面保留,避免被回收),所有的磁盤高速緩存頁面都可回收,所有的匿名映射頁面都可交換。

儘管可以回收的頁面很多,但是顯然PFRA應當儘可能少地去回收/交換(因爲這些頁面要從磁盤恢復,需要很大的代價)。所以,PFRA僅當必要時纔回收/交換一部分很少被使用的頁面,每次回收的頁面數是一個經驗值:32。

於是,所有這些磁盤高速緩存頁面和匿名映射頁面都被放到了一組LRU裏面。(實際上,每個zone就有一組這樣的LRU,頁面都被放到自己對應的zone的LRU中。)
一組LRU由幾對鏈表組成,有磁盤高速緩存頁面(包括文件映射頁面)的鏈表、匿名映射頁面的鏈表、等。一對鏈表實際上是active和inactive兩個鏈表,前者是最近使用過的頁面、後者是最近未使用的頁面。
進行頁面回收的時候,PFRA要做兩件事情,一是將active鏈表中最近最少使用的頁面移動到inactive鏈表、二是嘗試將inactive鏈表中最近最少使用的頁面回收。

確定最近最少使用
現在就有一個問題了,怎麼確定active/inactive鏈表中哪些頁面是最近最少使用的呢?
一種方法是排序,當頁面被訪問時,將其移動到鏈表的尾部(假設回收從頭部開始)。但是這就意味着頁面在鏈表中的位置可能頻繁移動,並且移動之前還必須先上鎖(可能有多個CPU在同時訪問),這樣做對效率影響很大。
linux內核採用的是標記加順序的辦法。當頁面在active和inactive兩個鏈表之間移動時,總是將其放到鏈表的尾部(同上,假設回收從頭部開始)。
頁面沒有在鏈表間移動時,並不會調整它們的順序。而是通過訪問標記來表示頁面是否剛被訪問過。如果inactive鏈表中已設置訪問標記的頁面再被訪問,則將其移動到active鏈表中,並且清除訪問標記。(實際上,爲了避免訪問衝突,頁面並不會直接從inactive鏈表移動到active鏈表,而是有一個pagevec中間結構用作緩衝,以避免鎖鏈表。)

頁面的訪問標記有兩種情況,一是放在page->flags中的PG_referenced標記,在頁面被訪問時該標記置位。對於磁盤高速緩存中(未被映射)的頁面,用戶進程通過read、write之類的系統調用去訪問它們,系統調用代碼中會將對應頁面的PG_referenced標記置位。
而對於內存映射的頁面,用戶進程可以直接訪問它們(不經過內核),所以這種情況下的訪問標記不是由內核來設置的,而是由mmu。在將虛擬地址映射成物理地址後,mmu會在對應的頁表項上置一個accessed標誌位,表示頁面被訪問。(同樣的道理,mmu會在被寫的頁面所對應的頁表項上置一個dirty標誌,表示頁面是髒頁面。)
頁面的訪問標記(包括上面兩種標記)將在PFRA處理頁面回收的過程中被清除,因爲訪問標記顯然是應該有有效期的,而PFRA的運行週期就代表這個有效期。page->flags中的PG_referenced標記可以直接清除,而頁表項中的accessed位則需要通過頁面找到其對應的頁表項後才能清除(見下文的“反向映射”)。

那麼,回收過程又是怎樣掃描LRU鏈表的呢?
由於存在多組LRU(系統中有多個zone,每個zone又有多組LRU),如果PFRA每次回收都掃描所有的LRU找出其中最值得回收的若干個頁面的話,回收算法的效率顯然不夠理想。
linux內核PFRA使用的掃描方法是:定義一個掃描優先級,通過這個優先級換算出在每個LRU上應該掃描的頁面數。整個回收算法以最低的優先級開始,先掃描每個LRU中最近最少使用的幾個頁面,然後試圖回收它們。如果一遍掃描下來,已經回收了足夠數量的頁面,則本次回收過程結束。否則,增大優先級,再重新掃描,直到足夠數量的頁面被回收。而如果始終不能回收足夠數量的頁面,則優先級將增加到最大,也就是所有頁面將被掃描。這時,就算回收的頁面數量還是不足,回收過程都會結束。

每次掃描一個LRU時,都從active鏈表和inactive鏈表獲取當前優先級對應數目的頁面,然後再對這些頁面做處理:如果頁面不能被回收(如被保留或被上鎖),則放回對應鏈表頭部(同上,假設回收從頭部開始);否則如果頁面的訪問標記置位,則清除該標記,並將頁面放回對應鏈表尾部(同上,假設回收從頭部開始);否則頁面將從active鏈表被移動到inactive鏈表、或從inactive鏈表被回收。
PFRA不傾向於從active鏈表回收匿名映射的頁面,因爲用戶進程使用的內存一般相對較少,且回收的話需要進行交換,代價較大。所以在內存剩餘較多、匿名映射所佔比例較少的情況下,都不會去回收匿名映射對應的active鏈表中的頁面。(而如果頁面已經被放到inactive鏈表中,就不再去管那麼多了。)

反向映射
像這樣,在PFRA處理頁面回收的過程中,LRU的inactive鏈表中的某些頁面可能就要被回收了。
如果頁面沒有被映射,直接回收到夥伴系統即可(對於髒頁,先寫回、再回收)。否則,還有一件麻煩的事情要處理。因爲用戶進程的某個頁表項正引用着這個頁面呢,在回收頁面之前,還必須給引用它的頁表項一個交待。
於是,問題就來了,內核怎麼知道這個頁面被哪些頁表項所引用呢?爲了做到這一點,內核建立了從頁面到頁表項的反向映射。
通過反向映射可以找到一個被映射的頁面對應的vma,通過vma->vm_mm->pgd就能找到對應的頁表。然後通過page->index得到頁面的虛擬地址。再通過虛擬地址從頁表中找到對應的頁表項。(前面說到的獲取頁表項中的accessed標記,就是通過反向映射實現的。)

頁面對應的page結構中,page->mapping如果最低位置位,則這是一個匿名映射頁面,page->mapping指向一個anon_vma結構;否則是文件映射頁面,page->mapping文件對應的address_space結構。(顯然,anon_vma結構和address_space結構在分配時,地址必須要對齊,至少保證最低位爲0。)
對於匿名映射的頁面,anon_vma結構作爲一個鏈表頭,將映射這個頁面的所有vma通過vma->anon_vma_node鏈表指針連接起來。每當一個頁面被(匿名)映射到一個用戶空間時,對應的vma就被加入這個鏈表。
對於文件映射的頁面,address_space結構除了維護了一棵用於存放磁盤高速緩存頁面的radix樹,還爲該文件映射到的所有vma維護了一棵優先搜索樹。因爲這些被文件映射到的vma並不一定都是映射整個文件,很可能只映射了文件的一部分。所以,這棵優先搜索樹除了索引到所有被映射的vma,還要能知道文件的哪些區域是映射到哪些vma上的。每當一個頁面被(文件)映射到一個用戶空間時,對應的vma就被加入這個優先搜索樹。於是,給定磁盤高速緩存上的一個頁面,就能通過page->index得到頁面在文件中的位置,就能通過優先搜索樹找出這個頁面映射到的所有vma。

上面兩步中,神奇的page->index做了兩件事,得到頁面的虛擬地址、得到頁面在文件磁盤高速緩存中的位置。
vma->vm_start記錄了vma的首虛擬地址,vma->vm_pgoff記錄了該vma在對應的映射文件(或共享內存)中的偏移,而page->index記錄了頁面在文件(或共享內存)中的偏移。
通過vma->vm_pgoff和page->index能得到頁面在vma中的偏移,加上vma->vm_start就能得到頁面的虛擬地址;而通過page->index就能得到頁面在文件磁盤高速緩存中的位置。

頁面換入換出
在找到了引用待回收頁面的頁表項後,對於文件映射,可以直接把引用該頁面的頁表項清空。等用戶再訪問這個地址的時候觸發缺頁異常,異常處理代碼再重新分配一個頁面,並去磁盤裏面把對應的數據讀出來就行了(說不定,頁面在對應的磁盤高速緩存裏面已經有了,因爲其他進程先訪問過)。這就跟頁面映射以後,第一次被訪問的情形一樣;
對於匿名映射,先將頁面寫回到交換文件,然後還得在頁表項中記錄該頁面在交換文件中的index。
頁表項中有一個present位,如果該位被清除,則mmu認爲頁表項無效。在頁表項無效的情況下,其他位不被mmu關心,可以用來存儲其他信息。這裏就用它們來存儲頁面在交換文件中的index了(實際上是交換文件號+交換文件內的索引號)。

將匿名映射的頁面交換到交換文件的過程(換出過程)與將磁盤高速緩存中的髒頁寫回文件的過程很相似。
交換文件也有其對應的address_space結構,匿名映射的頁面在換出時先被放到這個address_space對應磁盤高速緩存中,然後跟髒頁寫回一樣,被寫回到交換文件中。寫回完成後,這個頁面才被釋放(記住,我們的目的是要釋放這個頁面)。
那麼爲什麼不直接把頁面寫回到交換文件,而要經過磁盤高速緩存呢?因爲,這個頁面可能被映射了多次,不可能一次性把所有用戶進程的頁表中對應的頁表項都修改好(修改成頁面在交換文件中的索引),所以在頁面被釋放的過程中,頁面被暫時放在磁盤高速緩存上。
而並不是所有頁表項的修改過程都是能成功的(比如在修改之前頁面又被訪問了,於是現在又不需要回收這個頁面了),所以頁面放到磁盤高速緩存的時間也可能會很長。

同樣,將匿名映射的頁面從交換文件讀出的過程(換入過程)也與將文件數據讀出的過程很相似。
先去對應的磁盤高速緩存上看看頁面在不在,不在的話再去交換文件裏面讀。文件裏的數據也是被讀到磁盤高速緩存中的,然後用戶進程的頁表中對應的頁表項將被改寫,直接指向這個頁面。
這個頁面可能不會馬上從磁盤高速緩存中拿下來,因爲如果還有其他用戶進程也映射到這個頁面(它們的對應頁表項已經被修改成了交換文件的索引),他們也可以引用到這裏。直到沒有其他的頁表項再引用這個交換文件索引時,頁面纔可以從磁盤高速緩存中被取下來。

最後的必殺
前面說到,PFRA可能掃描了所有的LRU還沒辦法回收需要的頁面。同樣,在slab、dentry cache、inode cache、等地方,可能也無法回收到頁面。
這時,如果某段內核代碼一定要獲得頁面呢(沒有頁面,系統可能就要崩潰了)?PFRA只好使出最後的必殺技——OOM(out of memory)。所謂的OOM就是尋找一個最不重要的進程,然後將其殺死。通過釋放這個進程所佔有的內存頁面,以緩解系統壓力。

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