1. 程式人生 > >linux中的頁快取和檔案IO

linux中的頁快取和檔案IO

一篇比較好的關於頁快取的描述文章一篇比較好的關於頁快取的描述文章

雖然仔細看過《linux核心設計與實現》,也參考了很多的部落格,並且做了linux程序空間、address_space和檔案的關係圖(設為圖1,參考部落格),但是對於頁快取和檔案IO之間關係的細節一直不是特別明朗。趁著元旦假期看的部落格中思路還算清晰,寫下當前對於頁快取的理解及其和檔案IO之間的關係。

        首先明確的一點是,本文所述的是針對linux引入了虛擬記憶體管理機制以後所涉及的知識點。linux中頁快取的本質就是對於磁碟中的部分資料在記憶體中保留一定的副本,使得應用程式能夠快速的讀取到磁碟中相應的資料,並實現不同程序之間的資料共享。因此,linux中頁快取的引入主要是為了解決兩類重要的問題:

        1.磁碟讀寫速度較慢(ms 級別);

        2.實現不同程序之間或者同一程序的前後不同部分之間對於資料的共享;

        如果沒有程序之間的共享機制,那麼對於系統中所啟動的所有程序在開啟檔案的時候都要將需要的資料從磁碟載入進實體記憶體空間,這樣不僅造成了載入速度變慢(每次都從磁碟中讀取資料),而且造成了實體記憶體的浪費。為了解決以上問題,linux作業系統使用了快取機制。在虛擬記憶體機制出現以前,作業系統使用塊快取機制,但是在虛擬記憶體出現以後作業系統管理IO的粒度更大,因此採用了頁快取機制。此後,和後備儲存的資料互動普遍以頁為單位。頁快取是基於頁的、面向檔案的一種快取機制。

        說到這裡,我們只是對於頁快取的重要性做了介紹。但是,還有三個問題(當然也是本文的重點)還沒有解釋,分別如下。

        1.頁快取究竟是如何實現,其和檔案系統是如何關聯的?

        2.頁快取、記憶體以及檔案IO之間的關係是怎樣的?

        3.頁快取中的資料如何實現和後備儲存之間的同步?

        接下來我們將對這三個問題進行詳細的解釋。

頁快取的實現:
        既然頁快取是以頁為單位進行資料管理的,那麼必須在核心中標識該物理頁。其實每個真正存放資料的物理頁幀都對應一個管理結構體,稱之為struct page,其結構體如下。

struct page  {
    unsigned long    flags;
    atomic_t    _count;
    atomic_t    _mapcount;
    unsigned long    private;
    struct address_space    *mapping;
    pgoff_t    index;
    struct list_head    lru;
    void*    virtual;
};
        下面詳細介紹一下物理頁結構體中各個成員的含義:

        flags:  描述page當前的狀態和其他資訊,如當前的page是否是髒頁PG_dirty;是否是最新的已經同步到後備儲存的頁PG_uptodate; 是否處於lru連結串列上等;

        _count:引用計數,標識核心中引用該page的次數,如果要操作該page,引用計數會+1,操作完成之後-1。當該值為0時,表示沒有引用該page的位置,所以該page可以被解除對映,這在記憶體回收的時候是有用的;

        _mapcount:  頁表被對映的次數,也就是說page同時被多少個程序所共享,初始值為-1,如果只被一個程序的頁表映射了,該值為0。

        _mapping有三種含義:

        a.如果mapping  =  0,說明該page屬於交換快取(swap cache); 當需要地址空間時會指定交換分割槽的地址空間swapper_space;

        b.如果mapping !=  0,  bit[0]  =  0,  說明該page屬於頁快取或者檔案對映,mapping指向檔案的地址空間address_space;

        c.如果mapping !=  0,  bit[0]  !=0 說明該page為匿名對映,mapping指向struct  anon_vma物件;

      (注意區分_count和_mapcount,_mapcount表示的是被對映的次數,而_count表示的是被使用的次數;被映射了不一定被使用,但是被使用之前肯定要先被對映)。

        index: 在對映的虛擬空間(vma_area)內的偏移;一個檔案可能只是映射了一部分,假設映射了1M的空間,那麼index指的是1M空間內的偏移,而不是在整個檔案內的偏移;

        private :  私有資料指標;

        lru:當page被使用者態使用或者是當做頁快取使用的時候,將該page連入zone中的lru連結串列,供記憶體回收使用;

        頁快取就是將一個檔案在記憶體中的所有物理頁所組成的一種樹形結構,我們稱之為基數樹,用於管理屬於同一個檔案在記憶體中的快取內容。

        如上所述,一個檔案在記憶體中對應的所有物理頁組成了一棵基數樹。而一個檔案在記憶體中具有唯一的inode結構標識,inode結構中有該檔案所屬的裝置及其識別符號,因而,根據一個inode能夠確定其對應的後備裝置。為了將檔案在實體記憶體中的頁快取和檔案及其後備裝置關聯起來,linux核心引入了address_space結構體。可以說address_space結構體是將頁快取和檔案系統關聯起來的橋樑,其組成如下:

struct address_space {
    struct inode*    host;/*指向與該address_space相關聯的inode節點*/
    struct radix_tree_root    page_tree;/*所有頁形成的基數樹根節點*/
    spinlock_t    tree_lock;/*保護page_tree的自旋鎖*/
    unsigned int    i_map_writable;/*VM_SHARED的計數*/
    struct prio_tree_root    i_map;         
    struct list_head    i_map_nonlinear;
    spinlock_t    i_map_lock;/*保護i_map的自旋鎖*/
    atomic_t    truncate_count;/*截斷計數*/
    unsigned long    nrpages;/*頁總數*/
    pgoff_t    writeback_index;/*回寫的起始位置*/
    struct address_space_operation*    a_ops;/*操作表*/
    unsigned long    flags;/*gfp_mask掩碼與錯誤標識*/
    struct backing_dev_info*    backing_dev_info;/*預讀資訊*/
    spinlock_t    private_lock;/*私有address_space鎖*/
    struct list_head    private_list;/*私有address_space連結串列*/
    struct address_space*    assoc_mapping;/*相關的緩衝*/
}
        下面對address_space成員中的變數做相關的解釋。

        host:  指向與該address_space相關聯的inode節點,inode節點與address_space之間是一一對應關係;

        struct radix_tree_root:指向的host檔案在該記憶體中對映的所有物理頁形成的基數樹的根節點,參考部落格。

        struct prio_tree_root:與該地址空間相關聯的所有程序的虛擬地址區間vm_area_struct所對應的整個程序地址空間mm_struct形成的優先查詢樹的根節點;vm_area_struct中如果有後備儲存,則存在prio_tree_node結構體,通過該prio_tree_node和prio_tree_root結構體,構成了所有與該address_space相關聯的程序的一棵優先查詢樹,便於查詢所有與該address_space相關聯的程序;

        下面列出struct prio_tree_root和struct  prio_tree_node的結構體。

struct  prio_tree_root {
    struct prio_tree_node*  prio_tree_root;
    unsigned short              index_bits;
};
struct prio_tree_node {
    struct prio_tree_node*  left;
    struct prio_tree_node*  right; 
    struct prio_tree_node*  parent;
    unsigned long                start;
    unsigned long                last;
};
         為了便於形成頁快取、檔案和程序之間關係的清晰思路,文章畫出一幅圖,如圖2所示。


                                                                     圖 2 頁快取及其相關結構

        從以上可以解釋可以看出,address_space成為構建頁快取和檔案、頁快取和共享該檔案的所有程序之間的橋樑。

        每個程序的地址空間使用mm_struct結構體標識,該結構體中包含一系列的由vm_area_struct結構體組成的連續地址空間連結串列。每個vm_area_struct中存在struct  file* vm_file用於指向該連續地址空間中所開啟的檔案,而vm_file通過struct file中的struct  path與struct  dentry相關聯。  struct dentry中通過inode指標指向inode,inode與address_space一一對應,至此形成了頁快取與檔案系統之間的關聯;為了便於查詢與某個檔案相關聯的所有程序,address_space中的prio_tree_root指向了所有與該頁快取相關聯的程序所形成的優先查詢樹的根節點。關於這種關係的詳細思路請參考圖1,這裡畫出其簡化圖,如圖3。


                                                             圖 3 頁快取、檔案系統、程序地址空間簡化關係圖


        這裡需要說明的linux中檔案系統的一點是,核心為每個程序在其地址空間中都維護了結構體struct* fd_array[]用於維護該程序地址空間中開啟的檔案的指標;同時核心為所有被開啟的檔案還維護了系統級的一個檔案描述符表用以記錄該系統開啟的所有檔案,供所有程序之間共享;每個被開啟的檔案都由一個對應的inode結構體表示,由系統級的檔案描述符表指向。所以,程序通過自己地址空間中的開啟檔案描述符表可以找到系統級的檔案描述符表,進而找到檔案。

頁快取、記憶體、檔案IO之間的關係
       關於檔案IO我們常說的兩句話“普通檔案IO需要複製兩次,記憶體對映檔案mmap只需要複製一次”。下面,我們對普通檔案IO做詳細的解釋。文章對頁快取和檔案IO做了詳細的介紹,不過都是英文的,本文在基於對上文理解、翻譯的基礎上,加入自己對於頁快取的理解。讀者可以選擇直接去看對應的英文原版說明。


        為了能夠深入理解頁快取和檔案IO操作之間的關係,假設系統中現在存在一個名為render的程序,該程序打開了檔案scene.dat,並且每次讀取其中的512B(一個扇區的大小),將讀取的檔案資料放入到堆分配的塊中(每個程序自己的地址空間對應的實體記憶體)。先以普通IO為例介紹一下讀取資料的過程,第一次讀取的過程大致如圖4(圖4-8來源於該文章)。

                            
                                                                              圖 4 程序render第一次讀取資料

        程序發起讀請求的過程如下:

        1.程序呼叫庫函式read()向核心發起讀檔案的請求;

        2.核心通過檢查程序的檔案描述符定位到虛擬檔案系統已經開啟的檔案列表項,呼叫該檔案系統對VFS的read()呼叫提供的介面;

        3.通過檔案表項鍊接到目錄項模組,根據傳入的檔案路徑在目錄項中檢索,找到該檔案的inode;

        4.inode中,通過檔案內容偏移量計算出要讀取的頁;

        5.通過該inode的i_mapping指標找到對應的address_space頁快取樹---基數樹,查詢對應的頁快取節點;

        (1)如果頁快取節點命中,那麼直接返回檔案內容;

        (2)如果頁快取缺失,那麼產生一個缺頁異常,首先建立一個新的空的物理頁框,通過該inode找到檔案中該頁的磁碟地址,讀取相應的頁填充該頁快取(DMA的方式將資料讀取到頁快取),更新頁表項;重新進行第5步的查詢頁快取的過程;

        6.檔案內容讀取成功;

        也就是說,所有的檔案內容的讀取(無論一開始是命中頁快取還是沒有命中頁快取)最終都是直接來源於頁快取。當將資料從磁碟複製到頁快取之後,還要將頁快取的資料通過CPU複製到read呼叫提供的緩衝區中,這就是普通檔案IO需要的兩次複製資料複製過程。其中第一次是通過DMA的方式將資料從磁碟複製到頁快取中,本次過程只需要CPU在一開始的時候讓出匯流排、結束之後處理DMA中斷即可,中間不需要CPU的直接干預,CPU可以去做別的事情;第二次是將資料從頁快取複製到程序自己的的地址空間對應的實體記憶體中,這個過程中需要CPU的全程干預,浪費CPU的時間和額外的實體記憶體空間。

        假如讀取了12KB的資料之後,那麼render程序的堆地址空間和相關的地址空間如圖5所示。


                                                  圖 5 讀取12KB資料之後,程序render的地址空間和頁快取示意圖

         看起來該過程很簡單,但是這其中存在著很多的知識點。首先,render使用了常規的read()系統呼叫讀取了12KB的資料,現在scene.dat中三個大小為4KB的頁也存在於頁快取中,就像先前所說的所有的檔案IO都是通過頁快取進行的。在X86架構的linux體系中,核心以4KB大小的頁為單位組織檔案中的資料,所以即使你從一個檔案中僅僅讀取幾個位元組的資料,那麼包含這些位元組的整個頁的資料都會從硬碟讀入頁快取中。這對於提高硬碟的吞吐量很有幫助,並且使用者通常每次讀取的資料不僅僅是隻有幾個位元組而已。頁快取記錄了每個4KB中的頁在檔案中的位置,如圖中的#0, #1等。

        然而,在一次檔案讀取的過程中,必須將檔案的內容從頁快取拷貝到使用者的空間。這個過程和缺頁異常(通過DMA調入需要的頁)不一樣,這個拷貝過程需要通過CPU進行,因此浪費了CPU的時間。另一個弊端就是浪費了實體記憶體,因為需要為同樣的資料在記憶體中維護兩個副本,如圖6 render程序的heap所對應的堆中的資料和頁快取中的資料存在重複,並且如果系統中有多個這樣的程序的話,那麼需要為每個程序維護同樣的一份資料副本,嚴重浪費了CPU的時間和實體記憶體空間。

        好在,通過記憶體對映IO---mmap,程序不但可以直接操作檔案對應的實體記憶體,減少從核心空間到使用者空間的資料複製過程,同時可以和別的程序共享頁快取中的資料,達到節約記憶體的作用。關於mmap的實現請參考部落格。

        當對映一個檔案到記憶體中的時候,核心將虛擬地址直接對映到頁快取中。正如部落格4中介紹的,當對映一個檔案的時候,如果檔案的內容不在實體記憶體中,作業系統不會將所對映的檔案部分的全部內容直接拷貝到實體記憶體中,而是在使用虛擬地址訪問實體記憶體的時候通過缺頁異常將所需要的資料調入記憶體中。如果檔案本身已經存在於頁快取中,則不再通過磁碟IO調入記憶體。如果採用共享對映的方式,那麼資料在記憶體中的佈局如圖6所示。


                                                                    圖 6 檔案共享對映示意圖

        由於頁快取的架構,當一個程序呼叫write系統呼叫的時候,對於檔案的更新僅僅是被寫到了檔案的頁快取中,相應的頁被標記為dirty。具體過程如下:

        前面5步和讀檔案是一致的,在address_space中查詢對應頁的頁快取是否存在:

        6.如果頁快取命中,直接把檔案內容修改寫在頁快取的頁中。寫檔案就結束了。這時候檔案修改位於頁快取,並沒有寫回到磁碟檔案中去。

        7.如果頁快取缺失,那麼產生一個頁缺失異常,建立一個頁快取頁,同時通過inode找到該檔案頁的磁碟地址,讀取相應的頁填充頁快取。此時快取頁命中,進行第6步。

        普通的IO操作需要將寫的資料從自己的程序地址空間複製到頁快取中,完成對頁快取的寫入;但是mmap通過虛擬地址(指標)可以直接完成對頁快取的寫入,減少了從使用者空間到頁快取的複製。        

        由於寫操作只是寫到了頁快取中,因此程序並沒有被阻塞到磁碟IO發生,因此當計算機崩潰的時候,寫操作所引起的改變可能並沒有發生在磁碟上。所以,對於一些要求嚴格的寫操作,比如資料庫系統,就需要呼叫fsync等操作及時將資料同步到磁碟上(雖然這中間也可能存在磁碟的驅動程式崩潰的情況)。讀操作與寫不同,一般會阻塞到程序讀取到資料(除非呼叫非阻塞IO,即使使用IO多路複用技術也是將程序阻塞在多個監聽描述符上,本質上還是阻塞的)。為了減輕讀操作的這種延遲,linux作業系統的核心使用了"預讀"技術,也就是當從磁碟中讀取你所需要的資料的時候,核心將會多讀取一些頁到頁快取中。

        普通檔案IO中所有的檔案內容的讀取(無論一開始是命中頁快取還是沒有命中頁快取)最終都是直接來源於頁快取。當將資料通過缺頁中斷從磁碟複製到頁快取之後,還要將頁緩衝的資料通過CPU複製到read呼叫提供的緩衝區中。這樣,必須通過兩次資料拷貝過程,才能完成使用者程序對檔案內容的獲取任務。寫操作也是一樣的,待寫入的buffer在使用者空間,必須將其先拷貝到核心空間對應的主存中,再寫回到磁碟中,也是需要兩次資料拷貝。mmap的使用減少了資料從使用者空間到頁快取的複製過程,提高了IO的效率,尤其是對於大檔案而言;對於比較小的檔案而言,由於mmap執行了更多的核心操作,因此其效率可能比普通的檔案IO更差。

        在專門介紹mmap的部落格中,我們說檔案對映分為私有對映(private)和共享對映(shared)兩種,二者之間的區別就是一個程序對檔案所做的改變能否被其他的程序所看到,且能否同步到後備的儲存介質中。那麼,如果一個程序僅僅是讀取檔案中的內容的話,那麼共享對映和私有對映對應的實體記憶體佈局如圖5所示。但是,如果採用私有對映的方式,且一個程序對檔案內容作出了改變,那麼會發生怎樣的情況呢?核心採用了寫時複製技術完成私有對映下對檔案內容的改動,下面舉例說明。

        假設系統中存在兩個程序分別為render和render3d,它們都私有對映同一個檔案scene.dat到記憶體中,然後render程序對對映的檔案做出了寫操作,如圖7所示。

 

                                                                              圖 7 私有對映寫檔案示意圖

        圖6中的“只讀”標誌不是說對映的內容是隻讀的,這僅僅是核心為了節省實體記憶體而採用的對於實體記憶體的一種“欺騙手段”而已。如果兩個程序只是讀取檔案中的內容,不做任何的改動,那麼檔案只在實體記憶體中保留一份;但是如果有一個程序,如render,要對檔案中的內容做出改動,那麼會觸發缺頁中斷,核心採用寫時複製技術,為要改動的內容對應的頁重新分配一個物理頁框,將並將被改動的內容對應的物理頁框中的資料複製到新分配的物理頁框中,再進行改動。此時新分配的物理頁框對於render而言是它自己“私有的”,別的程序是看不到的,也不會被同步到後備的儲存中。但是如果是共享對映,所有的程序都是共享同一塊頁快取的,此時被對映的檔案的資料在記憶體中只保留一份。任何一個程序對對映區進行讀或者寫,都不會導致對頁緩衝資料的複製。

        mmap的系統呼叫函式原型為void* mmap(void* addr, size_t len, int prot, int flag, int fd, off_t off)。其中,flag指定了是私有對映還是共享對映,私有對映的寫會引發缺頁中斷,然後複製對應的物理頁框到新分配的頁框中。prot指定了被對映的檔案是可讀、可寫、可執行還是不可訪問。如果prot指定的是可讀,但是卻對對映檔案執行寫操作,則此時卻缺頁中斷會引起段錯誤,而不是進行寫時複製。

        那麼此時存在另一個問題就是當最後一個render程序退出之後,儲存scene.dat的頁快取是不是會被馬上釋放掉?當然不是!在一個程序中開啟一個檔案使用完之後該程序退出,然後在另一個程序中使用該檔案這種情況通常是很常見的,頁快取的管理中必須考慮到這種情況。況且從頁快取中讀取資料的時間是ns級別,但是從硬碟中讀取資料的時間是ms級別,因此如果能夠在使用資料的時候命中頁快取,那麼對於系統的效能將非常有幫助。那麼,問題來了,什麼時候該檔案對應的頁快取要被換出記憶體呢?就是系統中的記憶體緊張,必須要換出一部分物理頁到硬碟中或者交換區中,以騰出更多的空間給即將要使用的資料的時候。所以只要系統中存在空閒的記憶體,那麼頁快取就不會被換出,直到到達頁快取的上限為止。是否換出某一頁快取不是由某一個程序決定的,而是由作業系統在整個系統空間中的資源分配決定的。畢竟,從頁快取中讀取資料要比從硬碟上讀取資料要快的多了。

        記憶體對映的一個典型應用就是動態共享庫的載入。圖8展示了兩個同一份程式的兩個例項使用動態共享庫時,程序的虛擬地址空間及對應的實體記憶體空間的佈局。


                                                                            圖 8 程序動態共享庫的載入
頁快取中資料如何實現和後備儲存之間的同步?
       普通檔案IO,都是將資料直接寫在頁快取上,那麼頁快取中的資料何時寫回後備儲存?怎麼寫回?

何時寫回
        1.空閒記憶體的值低於一個指定的閾值的時候,核心必須將髒頁寫回到後備儲存以釋放記憶體。因為只有乾淨的記憶體頁才可以回收。當髒頁被寫回之後就變為PG_uptodate標誌,變為乾淨的頁,核心就可以將其所佔的記憶體回收;

        2.當髒頁在記憶體中駐留的時間超過一個指定的閾值之後,核心必須將該髒頁寫回到後備儲存,以確定髒頁不會在記憶體中無限期的停留;

        3.當用戶程序顯式的呼叫fsync、fdatasync或者sync的時候,核心按照要求執行回寫操作。

由誰寫回
        為了能夠不阻塞寫操作,並且將髒頁及時的寫回後備儲存。linux在當前的核心版本中使用了flusher執行緒負責將髒頁回寫。

        為了滿足第一個何時回寫的條件,核心在可用記憶體低於一個閾值的時候喚醒一個或者多個flusher執行緒,將髒頁回寫;

        為了滿足第二個條件,核心將通過定時器定時喚醒flusher執行緒,將所有駐留時間超時的髒頁回寫。