linux如何感知通過mmap進行的文件修改


一、問題
對於mmap將內容映射到地址空間,從而讓應用程序可以像操作內存一樣來操作文件內容,這是操作系統為用戶態程序提供的一個便利,它的確可以將繁瑣的文件操作轉換為碼農喜聞樂見的內存操作,更重要的是它可以將文件內容的讀寫達到按需加載,只有在真正使用到文件內容的時候才會觸發文件內容的讀取,當然寫回也是如此。
和文件的讀取相比,寫入的實現想起來可能更加復雜一些(如果你理解這個功能的底層實現基礎並曾經考慮過這樣問題的話):我們考慮這樣的一個場景,用戶通過mmap將文件的內容映射到自己的地址空間,訪問文件中某個位置的數據,修改地方的內容。在這個過程中,第一次觸發的讀操作會引起操作系統的缺頁異常,並導致操作系統按需將這個頁面的內容讀入內存,建立頁面的映射,這個看起來還比較直觀。但是在寫入的時候呢?此時應用程序是通過內存操作的,而這個內存映射已經建立,此時並不會觸發操作系統的缺頁異常,這個地方真正的做到了讓用戶感覺到是在訪問內存,並且連操作系統也有這個錯覺(相當於入戲太深,最后把自己也感動了),操作系統如果對這個寫入沒有任何感知的話,操作系統怎么知道這個mmap的文件內容哪里被修改(進而需要寫回磁盤)了呢?
二、如何識別write系統調用的修改內容
write系統調用本身就指明文件要修改的位置、長度和內容,通過這個接口來修改文件的內容對操作系統來說是一種喜聞樂見的朴素文件修改方式。依然以最簡單的ext2文件系統為例,其中對於文件的寫入接口經過一番暫時忽略的輾轉,走到我們關心的路徑為__generic_file_aio_write_nolock===>>>generic_file_buffered_write===>>>generic_commit_write===>>>__block_commit_write===>>>mark_buffer_dirty,在這個路徑中,操作系統可以明確的感知到一個文件的哪些buffer被修改,從而在進行特定操作的時候(close,fsync、pdflush等)寫回硬盤,所以說對於通過write接口修改的文件內容操作系統可以輕松識別,也就是我們通常所說的vanilla水平。
三、mmap修改的內容
為了說明這里的問題,我使用mmap的man手冊自帶的例子簡單修改了下,就是下面的代碼:
tsecer@harry: cat mmapwriteback.cpp 
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
 
#define handle_error(msg) \
   do { perror(msg); exit(EXIT_FAILURE); } while (0)
 
int
main(int argc, char *argv[])
{
   char *addr;
   int fd;
   struct stat sb;
   off_t offset, pa_offset;
   size_t length;
   ssize_t s;
 
   if (argc < 3) {
       fprintf(stderr, "%s file offset [length]\n", argv[0]);
       exit(EXIT_FAILURE);
   }
 
   fd = open(argv[1], O_RDWR);
   if (fd == -1)
       handle_error("open");
 
   if (fstat(fd, &sb) == -1)           /* To obtain file size */
       handle_error("fstat");
 
   offset = atoi(argv[2]);
   pa_offset = offset & ~(sysconf(_SC_PAGE_SIZE) - 1);
       /* offset for mmap() must be page aligned */
 
   if (offset >= sb.st_size) {
       fprintf(stderr, "offset is past end of file\n");
       exit(EXIT_FAILURE);
   }
 
   if (argc == 4) {
       length = atoi(argv[3]);
       if (offset + length > sb.st_size)
           length = sb.st_size - offset;
               /* Can't display bytes past end of file */
 
   } else {    /* No length arg ==> display to end of file */
       length = sb.st_size - offset;
   }
 
   addr = (char*)mmap((void*)NULL, length + offset - pa_offset, PROT_READ | PROT_WRITE,
               MAP_SHARED, fd, pa_offset);
   if (addr == MAP_FAILED)
       handle_error("mmap");
       
        close(fd);
   s = write(STDOUT_FILENO, addr + offset - pa_offset, length);
   if (s != length) {
       if (s == -1)
           handle_error("write");
 
       fprintf(stderr, "partial write");
       exit(EXIT_FAILURE);
   }
        *(addr + offset - pa_offset) = 'A';
        sleep(1000);
   exit(EXIT_SUCCESS);
}
這里代碼需要注意的是: 在將文件映射到地址空間之后,應用通過s = write(STDOUT_FILENO, addr + offset - pa_offset, length);以讀取的形式訪問了這個映射后的地址空間,在這次訪問之后,被映射的文件內容會被讀入內存,並建立起這個頁面和應用地址空間之間的映射,此后應用進程對該地址(所在的頁面的所有內容)訪問都不會觸發訪問異常。那么問題來了:在接下來的 *(addr + offset - pa_offset) = 'A';語句中修改了內存地址,進而是修改了文件內容,那么操作系統如何知道我修改了這個地方的內容了呢
四、2.6.21內核版本的x86實現
事實上,在之前的討論中,我忽略了CPU對於這種情況的支持,對於386系列的CPU來說,它的MMU單元對於這種情況在頁表項中提供了支持。具體來說,當一個頁面被寫入時,CPU會在這個頁面對應的PTE(Page Table Entry)中設置一個dirty標志位來表示對應的頁面內容被修改了。
即使如此,操作系統在什么時候來識別這個寄存在PTE中的頁面標志位呢?這個我想到兩個場景:一個是系統需要內存頁面從而嘗試將某些頁面換出到磁盤的時候,另一個是這個修改內容的落地問題。這兩個都是最為基礎的重要功能,可以說是一個操作系統的基本功能。
1、頁面回收時的處理
由於此時的頁面dirty標志是放在CPU寫入的PTE中,而不是放在內核的頁面管理結構(struct page)中,所以通過常規的PageDirty接口並不能判斷處這個文件的內容被修改了。在頁面回收的時候,如果此處判斷有誤,那么前面例子中修改的內容將會隨着頁面的回收而丟失,所以說操作系統肯定會處理這種情況的。具體的流程在shrink_page_list===>>>try_to_unmap===>>>try_to_unmap_file===>>>try_to_unmap_one
/* Move the dirty bit to the physical page now the pte is gone. */
if (pte_dirty(pteval))
set_page_dirty(page);
這里在映射斷鏈的時候將pte中保存的dirty狀態保存在頁面(struct page)中,從而保證了頁面回收時臟頁面的識別。
2、進程退出時的處理
回收時的邏輯判斷只是一個可能的場景,在一些生存期較短的進程中可能永遠也不可能出現(就好像電影中的羅曼蒂克一樣),而更為常見、也是關鍵的是在常規情況下,這些臟頁面是如何落地的。
大家都知道,在進程創建的時候需要有不少工作要准備,這一點連用戶態的代碼也有感知,比方說fork、文件描述符的操作等,但是,更為復雜、同時也是更容易被大家遺忘的是回收工作,這個道理在很多場景下同樣成立。例如,設計一個內存分配算法比較簡單,但是考慮到回收之后的碎片整理就比較麻煩;開發(並污染)一個環境比較簡單,治理就比較麻煩;講一個段子比較簡單,冷場的時候hold住場面就比較麻煩。同樣,即使用戶覺得通過fork/exec系統調用來創建一個進程比較麻煩,事實上一個進程的退出更加麻煩。在進程退出的時候,需要大量的資源釋放和回收(更不要說一個進程如何“釋放”自己的task_struct這種高難度動作,可以想像嘗試自己把自己埋葬到棺材里、並立上墓碑這個行為),這些回收包括文件、信號量、線程組、模塊等,而這些大的模塊可能又包含了一些小的模塊,例如socket的關閉、robus futex的退出、共享內存的引用計數減少等。
在這些所有的釋放操作中,和我們這里討論的問題有聯系的就是這里的exit_mm===>>>mmput===>>>exit_mmap===>>>unmap_vmas===>>>unmap_page_range===>>>zap_pud_range===>>>zap_pmd_range===>>>zap_pte_range
if (PageAnon(page))
anon_rss--;
else {
if (pte_dirty(ptent))
set_page_dirty(page);
這是將pte中的dirtry轉換到內核通用的page中的最后步驟。
五、如何平滑落地
基於上面的分析,一個mmap的文件內容只會在內存嘗試頁面回收或者進程退出的時候才會寫回磁盤。不過實際測試上面的代碼並不是這個效果,其中的語句為
        *(addr + offset - pa_offset) = 'A';
        sleep(1000);
也就是在通過內存修改文件內容之后進程進行休眠,不退出進程。在我現在使用的3.11內核版本中執行這個實驗可以發現,經過一段時間之后,即使sleep沒有退出,這個修改還是體現在了磁盤上,這說明還有其它的路徑會將修改內容寫回磁盤(從代碼上看,感覺2.6.21版本應該沒有這個功能,不過沒有環境沒法測試,所以也就不確定)。下面是我測試時使用的環境
tsecer@harry: uname -a
Linux localhost.localdomain 3.11.10-301.fc20.x86_64 #1 SMP Thu Dec 5 14:01:17 UTC 2013 x86_64 x86_64 x86_64 GNU/Linux
tsecer@harry: 
其實想一下也可以知道:系統不應該將這么多的修改都累積到進程退出的時候落地,而應該分批、平滑寫回磁盤。這樣可以減少IO操作的峰值壓力,而且更為重要的是不會在系統突然斷電的時候丟失所有的應用修改內容,這一點對於需要長時間運行的服務程序來說尤為重要。
六、如何第一時間感知頁面修改
其實,之前的分析也並沒有錯,只是內核使用了更為復雜的策略:為了實時感知用戶對於頁面的寫操作,在建立頁目錄的時候先屏蔽掉可寫屬性,從而在寫操作的時候觸發一次訪問異常,進而在訪問異常中處理臟頁面的寫回問題。具體的實現依然在do_mmap_pgoff函數中:
if (vma_wants_writenotify(vma))
vma->vm_page_prot =
protection_map[vm_flags & (VM_READ|VM_WRITE|VM_EXEC)];
這里映射的內容是取消了vm_flags 屬性中的VM_SHARED屬性,而這個屬性對應的protection_map數組中沒有MAP_SHARED的將不會具有PROT_WRITE屬性,從而寫操作將會觸發異常。下面是protection_map數組的內容
/* description of effects of mapping type and prot in current implementation.
 * this is due to the limited x86 page protection hardware.  The expected
 * behavior is in parens:
 *
 * map_type prot
 * PROT_NONE PROT_READ PROT_WRITE PROT_EXEC
 * MAP_SHARED r: (no) no r: (yes) yes r: (no) yes r: (no) yes
 * w: (no) no w: (no) no w: (yes) yes w: (no) no
 * x: (no) no x: (no) yes x: (no) yes x: (yes) yes
 *
 * MAP_PRIVATE r: (no) no r: (yes) yes r: (no) yes r: (no) yes
 * w: (no) no w: (no) no w: (copy) copy w: (no) no
 * x: (no) no x: (no) yes x: (no) yes x: (yes) yes
 *
 */
pgprot_t protection_map[16] = {
__P000, __P001, __P010, __P011, __P100, __P101, __P110, __P111,
__S000, __S001, __S010, __S011, __S100, __S101, __S110, __S111
};
以比較新的3.12.6版本為例(很可能2.6.26版本已經有這個功能了),這個寫異常的觸發函數為filemap_page_mkwrite,其中有我們最為關注的set_page_dirty操作,就是在這里感知了頁面的修改行為。
int filemap_page_mkwrite(struct vm_area_struct *vma, struct vm_fault *vmf)
{
……
set_page_dirty(page);
wait_for_stable_page(page);
out:
sb_end_pagefault(inode->i_sb);
return ret;
}
七、多次修改的處理及效率問題
在filemap_page_mkwrite執行之后,會取消掉頁面的寫保護屬性,也就是將頁面修改為可寫狀態,然后內核線程會定時將這些臟數據寫回磁盤。但是接下來的問題是,把頁面寫回磁盤之后,如果用戶再次修改頁面內容,此時操作系統將如何感知這個修改呢?注意:此時頁面的寫保護狀態已經解除,不能在寫保護異常中感知頁面修改。當然此時直觀的做法就是始終保留頁面的寫保護,但是這樣對於系統壓力很大,每次修改觸發一次頁面訪問異常,操作系統的大部分時間都用在了異常處理,內核相對於用戶程序是喧賓奪主了。
所以內核采用的是一種更見友好的方式,就是在頁面首次觸發寫保護之后取消頁面寫保護,從而減少頁面訪問異常的次數,然后在內核線程將頁面寫回磁盤之后再次把頁面寫保護開啟,從而周而復始。這個操作的大致流程如下面關系鏈所示,其中的pte_wrprotect就是再次開啟頁面的保護,從而再下次修改的時候再次感知頁面修改。
do_writepages===>>>ext4_writepages===>>>mpage_prepare_extent_to_map===>>>mpage_process_page_bufs===>>>mpage_submit_page===>>>clear_page_dirty_for_io===>>>page_mkclean===>>>page_mkclean_file===>>>page_mkclean_one
if (pte_dirty(*pte) || pte_write(*pte)) {
pte_t entry;
 
flush_cache_page(vma, address, pte_pfn(*pte));
entry = ptep_clear_flush(vma, address, pte);
entry = pte_wrprotect(entry);
entry = pte_mkclean(entry);
set_pte_at(mm, address, pte, entry);
ret = 1;
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM