1. 詳解內存映射系統調用 mmap
#includevoid*mmap(void*addr,size_tlength,intprot,intflags,intfd,off_toffset); //內核文件:/arch/x86/kernel/sys_x86_64.c SYSCALL_DEFINE6(mmap,unsignedlong,addr,unsignedlong,len, unsignedlong,prot,unsignedlong,flags, unsignedlong,fd,unsignedlong,off)
mmap 內存映射里所謂的內存其實指的是虛擬內存,在調用 mmap 進行匿名映射的時候(比如進行堆內存的分配),是將進程虛擬內存空間中的某一段虛擬內存區域與物理內存中的匿名內存頁進行映射,當調用 mmap 進行文件映射的時候,是將進程虛擬內存空間中的某一段虛擬內存區域與磁盤中某個文件中的某段區域進行映射。
而用于內存映射所消耗的這些虛擬內存位于進程虛擬內存空間的哪里呢 ?
筆者在之前的文章《一步一圖帶你深入理解 Linux 虛擬內存管理》 中曾為大家詳細介紹過進程虛擬內存空間的布局,在進程虛擬內存空間的布局中,有一段叫做文件映射與匿名映射區的虛擬內存區域,當我們在用戶態應用程序中調用 mmap 進行內存映射的時候,所需要的虛擬內存就是在這個區域中劃分出來的。
在文件映射與匿名映射這段虛擬內存區域中,包含了一段一段的虛擬映射區,每當我們調用一次 mmap 進行內存映射的時候,內核都會在文件映射與匿名映射區中劃分出一段虛擬映射區出來,這段虛擬映射區就是我們申請到的虛擬內存。
那么我們申請的這塊虛擬內存到底有多大呢 ?這就用到了 mmap 系統調用的前兩個參數:
addr : 表示我們要映射的這段虛擬內存區域在進程虛擬內存空間中的起始地址(虛擬內存地址),但是這個參數只是給內核的一個暗示,內核并非一定得從我們指定的 addr 虛擬內存地址上劃分虛擬內存區域,內核只不過在劃分虛擬內存區域的時候會優先考慮我們指定的 addr,如果這個虛擬地址已經被使用或者是一個無效的地址,那么內核則會自動選取一個合適的地址來劃分虛擬內存區域。我們一般會將 addr 設置為 NULL,意思就是完全交由內核來幫我們決定虛擬映射區的起始地址。
length :從進程虛擬內存空間中的什么位置開始劃分虛擬內存區域的問題解決了,那么我們要申請的這段虛擬內存有多大呢 ? 這個就是 length 參數的作用了,如果是匿名映射,length 參數決定了我們要映射的匿名物理內存有多大,如果是文件映射,length 參數決定了我們要映射的文件區域有多大。
addr,length 必須要按照 PAGE_SIZE(4K) 對齊。
如果我們通過 mmap 映射的是磁盤上的一個文件,那么就需要通過參數 fd 來指定要映射文件的描述符(file descriptor),通過參數 offset 來指定文件映射區域在文件中偏移。
在內存管理系統中,物理內存是按照內存頁為單位組織的,在文件系統中,磁盤中的文件是按照磁盤塊為單位組織的,內存頁和磁盤塊大小一般情況下都是 4K 大小,所以這里的 offset 也必須是按照 4K 對齊的。
而在文件映射與匿名映射區中的這一段一段的虛擬映射區,其實本質上也是虛擬內存區域,它們和進程虛擬內存空間中的代碼段,數據段,BSS 段,堆,棧沒有任何區別,在內核中都是 struct vm_area_struct 結構來表示的,下面我們把進程空間中的這些虛擬內存區域統稱為 VMA。
進程虛擬內存空間中的所有 VMA 在內核中有兩種組織形式:一種是雙向鏈表,用于高效的遍歷進程 VMA,這個 VMA 雙向鏈表是有順序的,所有 VMA 節點在雙向鏈表中的排列順序是按照虛擬內存低地址到高地址進行的。
另一種則是用紅黑樹進行組織,用于在進程空間中高效的查找 VMA,因為在進程虛擬內存空間中不僅僅是只有代碼段,數據段,BSS 段,堆,棧這些虛擬內存區域 VMA,尤其是在數據密集型應用進程中,文件映射與匿名映射區里也會包含有大量的 VMA,進程的各種動態鏈接庫所映射的虛擬內存在這里,進程運行過程中進行的匿名映射,文件映射所需要的虛擬內存也在這里。而內核需要頻繁地對進程虛擬內存空間中的這些眾多 VMA 進行增,刪,改,查。所以需要這么一個紅黑樹結構,方便內核進行高效的查找。
//進程虛擬內存空間描述符 structmm_struct{ //串聯組織進程空間中所有的VMA的雙向鏈表 structvm_area_struct*mmap;/*listofVMAs*/ //管理進程空間中所有VMA的紅黑樹 structrb_rootmm_rb; } //虛擬內存區域描述符 structvm_area_struct{ //vma在mm_struct->mmap雙向鏈表中的前驅節點和后繼節點 structvm_area_struct*vm_next,*vm_prev; //vma在mm_struct->mm_rb紅黑樹中的節點 structrb_nodevm_rb; }
![cac11924-ba81-11ee-8b88-92fbcf53809c.jpg](https://file1.elecfans.com/web2/M00/BF/04/wKgaomWwr6aADUFsAAHEt0TpsOc072.jpg)
上圖中的文件映射與匿名映射區里邊其實包含了大量的 VMA,這里只是為了清晰的給大家展示虛擬內存在內核中的組織結構,所以只畫了一個大的 VMA 來表示文件映射與匿名映射區,這一點大家需要知道。
mmap 系統調用的本質是首先要在進程虛擬內存空間里的文件映射與匿名映射區中劃分出一段虛擬內存區域 VMA 出來 ,這段 VMA 區域的大小用 vm_start,vm_end 來表示,它們由 mmap 系統調用參數 addr,length 決定。
structvm_area_struct{ unsignedlongvm_start;/*Ourstartaddresswithinvm_mm.*/ unsignedlongvm_end;/*Thefirstbyteafterourendaddress*/ }
隨后內核會對這段 VMA 進行相關的映射,如果是文件映射的話,內核會將我們要映射的文件,以及要映射的文件區域在文件中的 offset,與 VMA 結構中的 vm_file,vm_pgoff 關聯映射起來,它們由 mmap 系統調用參數 fd,offset 決定。
structvm_area_struct{ structfile*vm_file;/*Filewemapto(canbeNULL).*/ unsignedlongvm_pgoff;/*Offset(withinvm_file)inPAGE_SIZE*/ }
另外由 mmap 在文件映射與匿名映射區中映射出來的這一段虛擬內存區域同進程虛擬內存空間中的其他虛擬內存區域一樣,也都是有權限控制的。
比如上圖進程虛擬內存空間中的代碼段,它是與磁盤上 ELF 格式可執行文件中的 .text section(磁盤文件中各個區域的單元組織結構)進行映射的,存放的是程序執行的機器碼,所以在可執行文件與進程虛擬內存空間進行文件映射的時候,需要指定代碼段這個虛擬內存區域的權限為可讀(VM_READ),可執行的(VM_EXEC)。
數據段也是通過文件映射進來的,內核會將磁盤上 ELF 格式可執行文件中的 .data section 與數據段映射起來,在映射的時候需要指定數據段這個虛擬內存區域的權限為可讀(VM_READ),可寫(VM_WRITE)。
與代碼段和數據段不同的是,BSS段,堆,棧這些虛擬內存區域并不是從磁盤二進制可執行文件中加載的,它們是通過匿名映射的方式映射到進程虛擬內存空間的。
BSS 段中存放的是程序未初始化的全局變量,這段虛擬內存區域的權限是可讀(VM_READ),可寫(VM_WRITE)。
堆是用來描述進程在運行期間動態申請的虛擬內存區域的,所以堆也會具有可讀(VM_READ),可寫(VM_WRITE)權限,在有些情況下,堆也具有可執行(VM_EXEC)的權限,比如 Java 中的字節碼存儲在堆中,所以需要可執行權限。
棧是用來保存進程運行時的命令行參,環境變量,以及函數調用過程中產生的棧幀的,棧一般擁有可讀(VM_READ),可寫(VM_WRITE)的權限,但是也可以設置可執行(VM_EXEC)權限,不過出于安全的考慮,很少這么設置。
而在文件映射與匿名映射區中的情況就變得更加復雜了,因為文件映射與匿名映射區里包含了數量眾多的 VMA,尤其是在數據密集型應用進程里更是如此,我們每調用一次 mmap ,無論是匿名映射也好還是文件映射也好,都會在文件映射與匿名映射區里產生一個 VMA,而通過 mmap 映射出的這段 VMA 中的相關權限和標志位,是由 mmap 系統調用參數里的 prot,flags 決定的,最終會映射到虛擬內存區域 VMA 結構中的 vm_page_prot,vm_flags 屬性中,指定進程對這塊虛擬內存區域的訪問權限和相關標志位。
除此之外,進程運行過程中所依賴的動態鏈接庫 .so 文件,也是通過文件映射的方式將動態鏈接庫中的代碼段,數據段映射進文件映射與匿名映射區中。
structvm_area_struct{ /* *AccesspermissionsofthisVMA. */ pgprot_tvm_page_prot; unsignedlongvm_flags; }
我們可以通過 mmap 系統調用中的參數 prot 來指定其在進程虛擬內存空間中映射出的這段虛擬內存區域 VMA 的訪問權限,它的取值有如下四種:
#definePROT_READ0x1/*pagecanberead*/ #definePROT_WRITE0x2/*pagecanbewritten*/ #definePROT_EXEC0x4/*pagecanbeexecuted*/ #definePROT_NONE0x0/*pagecannotbeaccessed*/
PROT_READ 表示該虛擬內存區域背后映射的物理內存是可讀的。
PROT_WRITE 表示該虛擬內存區域背后映射的物理內存是可寫的。
PROT_EXEC 表示該虛擬內存區域背后映射的物理內存所存儲的內容是可以被執行的,該內存區域內往往存儲的是執行程序的機器碼,比如進程虛擬內存空間中的代碼段,以及動態鏈接庫通過文件映射的方式加載進文件映射與匿名映射區里的代碼段,這些 VMA 的權限就是 PROT_EXEC 。
PROT_NONE 表示這段虛擬內存區域是不能被訪問的,既不可讀寫,也不可執行。用于實現防范攻擊的 guard page。如果攻擊者訪問了某個 guard page,就會觸發 SIGSEV 段錯誤。除此之外,指定 PROT_NONE 還可以為進程預先保留這部分虛擬內存區域,雖然不能被訪問,但是當后面進程需要的時候,可以通過 mprotect 系統調用修改這部分虛擬內存區域的權限。
mprotect 系統調用可以動態修改進程虛擬內存空間中任意一段虛擬內存區域的權限。
我們除了要為 mmap 映射出的這段虛擬內存區域 VMA 指定訪問權限之外,還需要為這段映射區域 VMA 指定映射方式,VMA 的映射方式由 mmap 系統調用參數 flags 決定。內核為 flags 定義了數量眾多的枚舉值,下面筆者將一些非常重要且核心的枚舉值為大家挑選出來并解釋下它們的含義:
#defineMAP_FIXED0x10/*Interpretaddrexactly*/ #defineMAP_ANONYMOUS0x20/*don'tuseafile*/ #defineMAP_SHARED0x01/*Sharechanges*/ #defineMAP_PRIVATE0x02/*Changesareprivate*/
前邊我們介紹了 mmap 系統調用的 addr 參數,這個參數只是我們給內核的一個暗示并非是強制性的,表示我們希望內核可以根據我們指定的虛擬內存地址 addr 處開始創建虛擬內存映射區域 VMA。
但如果我們指定的 addr 是一個非法地址,比如 [addr , addr + length] 這段虛擬內存地址已經存在映射關系了,那么內核就會自動幫我們選取一個合適的虛擬內存地址開始映射,但是當我們在 mmap 系統調用的參數 flags 中指定了 MAP_FIXED, 這時參數 addr 就變成強制要求了,如果 [addr , addr + length] 這段虛擬內存地址已經存在映射關系了,那么內核就會將這段映射關系 unmmap 解除掉映射,然后重新根據我們的要求進行映射,如果 addr 是一個非法地址,內核就會報錯停止映射。
操作系統對于物理內存的管理是按照內存頁為單位進行的,而內存頁的類型有兩種:一種是匿名頁,另一種是文件頁。根據內存頁類型的不同,內存映射也自然分為兩種:一種是虛擬內存對匿名物理內存頁的映射,另一種是虛擬內存對文件頁的也映射,也就是我們常提到的匿名映射和文件映射。
當我們將 mmap 系統調用參數 flags 指定為 MAP_ANONYMOUS 時,表示我們需要進行匿名映射,既然是匿名映射,fd 和 offset 這兩個參數也就沒有了意義,fd 參數需要被設置為 -1 。當我們進行文件映射的時候,只需要指定 fd 和 offset 參數就可以了。
而根據 mmap 創建出的這片虛擬內存區域背后所映射的物理內存能否在多進程之間共享,又分為了兩種內存映射方式:
MAP_SHARED 表示共享映射,通過 mmap 映射出的這片內存區域在多進程之間是共享的,一個進程修改了共享映射的內存區域,其他進程是可以看到的,用于多進程之間的通信。
MAP_PRIVATE 表示私有映射,通過 mmap 映射出的這片內存區域是進程私有的,其他進程是看不到的。如果是私有文件映射,那么多進程針對同一映射文件的修改將不會回寫到磁盤文件上
這里介紹的這些 flags 參數枚舉值是可以相互組合的,我們可以通過這些枚舉值組合出如下幾種內存映射方式。
2. 私有匿名映射
MAP_PRIVATE | MAP_ANONYMOUS 表示私有匿名映射,我們常常利用這種映射方式來申請虛擬內存,比如,我們使用 glibc 庫里封裝的 malloc 函數進行虛擬內存申請時,當申請的內存大于 128K 的時候,malloc 就會調用 mmap 采用私有匿名映射的方式來申請堆內存。因為它是私有的,所以申請到的內存是進程獨占的,多進程之間不能共享。
這里需要特別強調一下 mmap 私有匿名映射申請到的只是虛擬內存,內核只是在進程虛擬內存空間中劃分一段虛擬內存區域 VMA 出來,并將 VMA 該初始化的屬性初始化好,mmap 系統調用就結束了。這里和物理內存還沒有發生任何關系。在后面的章節中大家將會看到這個過程。
當進程開始訪問這段虛擬內存區域時,發現這段虛擬內存區域背后沒有任何物理內存與其關聯,體現在內核中就是這段虛擬內存地址在頁表中的 PTE 項是空的。
或者 PTE 中的 P 位為 0 ,這些都是表示虛擬內存還未與物理內存進行映射。
關于頁表相關的知識,不熟悉的讀者可以回顧下筆者之前的文章 《一步一圖帶你構建 Linux 頁表體系》
這時 MMU 就會觸發缺頁異常(page fault),這里的缺頁指的就是缺少物理內存頁,隨后進程就會切換到內核態,在內核缺頁中斷處理程序中,為這段虛擬內存區域分配對應大小的物理內存頁,隨后將物理內存頁中的內容全部初始化為 0 ,最后在頁表中建立虛擬內存與物理內存的映射關系,缺頁異常處理結束。
當缺頁處理程序返回時,CPU 會重新啟動引起本次缺頁異常的訪存指令,這時 MMU 就可以正常翻譯出物理內存地址了。
mmap 的私有匿名映射除了用于為進程申請虛擬內存之外,還會應用在 execve 系統調用中,execve 用于在當前進程中加載并執行一個新的二進制執行文件:
#includeintexecve(constchar*filename,constchar*argv[],constchar*envp[])
參數 filename 指定新的可執行文件的文件名,argv 用于傳遞新程序的命令行參數,envp 用來傳遞環境變量。
既然是在當前進程中重新執行一個程序,那么當前進程的用戶態虛擬內存空間就沒有用了,內核需要根據這個可執行文件重新映射進程的虛擬內存空間。
既然現在要重新映射進程虛擬內存空間,內核首先要做的就是刪除釋放舊的虛擬內存空間,并清空進程頁表。然后根據 filename 打開可執行文件,并解析文件頭,判斷可執行文件的格式,不同的文件格式需要不同的函數進行加載。
linux 中支持多種可執行文件格式,比如,elf 格式,a.out 格式。內核中使用 struct linux_binfmt 結構來描述可執行文件,里邊定義了用于加載可執行文件的函數指針 load_binary,加載動態鏈接庫的函數指針 load_shlib,不同文件格式指向不同的加載函數:
staticstructlinux_binfmtelf_format={ .module=THIS_MODULE, .load_binary=load_elf_binary, .load_shlib=load_elf_library, .core_dump=elf_core_dump, .min_coredump=ELF_EXEC_PAGESIZE, };
staticstructlinux_binfmtaout_format={ .module=THIS_MODULE, .load_binary=load_aout_binary, .load_shlib=load_aout_library, };
在 load_binary 中會解析對應格式的可執行文件,并根據文件內容重新映射進程的虛擬內存空間。比如,虛擬內存空間中的 BSS 段,堆,棧這些內存區域中的內容不依賴于可執行文件,所以在 load_binary 中采用私有匿名映射的方式來創建新的虛擬內存空間中的 BSS 段,堆,棧。
BSS 段雖然定義在可執行二進制文件中,不過只是在文件中記錄了 BSS 段的長度,并沒有相關內容關聯,所以 BSS 段也會采用私有匿名映射的方式加載到進程虛擬內存空間中。
3. 私有文件映射
#includevoid*mmap(void*addr,size_tlength,intprot,intflags,intfd,off_toffset);
我們在調用 mmap 進行內存文件映射的時候可以通過指定參數 flags 為 MAP_PRIVATE,然后將參數 fd 指定為要映射文件的文件描述符(file descriptor)來實現對文件的私有映射。
假設現在磁盤上有一個名叫 file-read-write.txt 的磁盤文件,現在多個進程采用私有文件映射的方式,從文件 offset 偏移處開始,映射 length 長度的文件內容到各個進程的虛擬內存空間中,調用完 mmap 之后,相關內存映射內核數據結構關系如下圖所示:
為了方便描述,我們指定映射長度 length 為 4K 大小,因為文件系統中的磁盤塊大小為 4K ,映射到內存中的內存頁剛好也是 4K 。
當進程打開一個文件的時候,內核會為其創建一個 struct file 結構來描述被打開的文件,并在進程文件描述符列表 fd_array 數組中找到一個空閑位置分配給它,數組中對應的下標,就是我們在用戶空間用到的文件描述符。
而 struct file 結構是和進程相關的( fd 的作用域也是和進程相關的),即使多個進程打開同一個文件,那么內核會為每一個進程創建一個 struct file 結構,如上圖中所示,進程 1 和 進程 2 都打開了同一個 file-read-write.txt 文件,那么內核會為進程 1 創建一個 struct file 結構,也會為進程 2 創建一個 struct file 結構。
每一個磁盤上的文件在內核中都會有一個唯一的 struct inode 結構,inode 結構和進程是沒有關系的,一個文件在內核中只對應一個 inode,inode 結構用于描述文件的元信息,比如,文件的權限,文件中包含多少個磁盤塊,每個磁盤塊位于磁盤中的什么位置等等。
//ext4文件系統中的inode結構 structext4_inode{ //文件權限 __le16i_mode;/*Filemode*/ //文件包含磁盤塊的個數 __le32i_blocks_lo;/*Blockscount*/ //存放文件包含的磁盤塊 __le32i_block[EXT4_N_BLOCKS];/*Pointerstoblocks*/ };
那么什么是磁盤塊呢 ?我們可以類比內存管理系統,Linux 是按照內存頁為單位來對物理內存進行管理和調度的,在文件系統中,Linux 是按照磁盤塊為單位對磁盤中的數據進行管理的,它們的大小均是 4K 。
如下圖所示,磁盤盤面上一圈一圈的同心圓叫做磁道,磁盤上存儲的數據就是沿著磁道的軌跡存放著,隨著磁盤的旋轉,磁頭在磁道上讀寫硬盤中的數據。而在每個磁盤上,會進一步被劃分成多個大小相等的圓弧,這個圓弧就叫做扇區,磁盤會以扇區為單位進行數據的讀寫。每個扇區大小為 512 字節。
而在 Linux 的文件系統中是按照磁盤塊為單位對數據讀寫的,因為每個扇區大小為 512 字節,能夠存儲的數據比較小,而且扇區數量眾多,這樣在尋址的時候比較困難,Linux 文件系統將相鄰的扇區組合在一起,形成一個磁盤塊,后續針對磁盤塊整體進行操作效率更高。
只要我們找到了文件中的磁盤塊,我們就可以尋址到文件在磁盤上的存儲內容了,所以使用 mmap 進行內存文件映射的本質就是建立起虛擬內存區域 VMA 到文件磁盤塊之間的映射關系 。
調用 mmap 進行內存文件映射的時候,內核首先會在進程的虛擬內存空間中創建一個新的虛擬內存區域 VMA 用于映射文件,通過 vm_area_struct->vm_file 將映射文件的 struct flle 結構與虛擬內存映射關聯起來。
structvm_area_struct{ structfile*vm_file;/*Filewemapto(canbeNULL).*/ unsignedlongvm_pgoff;/*Offset(withinvm_file)inPAGE_SIZE*/ }
根據 vm_file->f_inode 我們可以關聯到映射文件的 struct inode,近而關聯到映射文件在磁盤中的磁盤塊 i_block,這個就是 mmap 內存文件映射最本質的東西。
站在文件系統的視角,映射文件中的數據是按照磁盤塊來存儲的,讀寫文件數據也是按照磁盤塊為單位進行的,磁盤塊大小為 4K,當進程讀取磁盤塊的內容到內存之后,站在內存管理系統的視角,磁盤塊中的數據被 DMA 拷貝到了物理內存頁中,這個物理內存頁就是前面提到的文件頁。
根據程序的時間局部性原理我們知道,磁盤文件中的數據一旦被訪問,那么它很有可能在短期內被再次訪問,所以為了加快進程對文件數據的訪問,內核會將已經訪問過的磁盤塊緩存在文件頁中。
一個文件包含多個磁盤塊,當它們被讀取到內存之后,一個文件也就對應了多個文件頁,這些文件頁在內存中統一被一個叫做 page cache 的結構所組織。
每一個文件在內核中都會有一個唯一的 page cache 與之對應,用于緩存文件中的數據,page cache 是和文件相關的,它和進程是沒有關系的,多個進程可以打開同一個文件,每個進程中都有有一個 struct file 結構來描述這個文件,但是一個文件在內核中只會對應一個 page cache。
文件的 struct inode 結構中除了有磁盤塊的信息之外,還有指向文件 page cache 的 i_mapping 指針。
structinode{ structaddress_space*i_mapping; }
page cache 在內核中是使用 struct address_space 結構來描述的:
structaddress_space{ //這里就是pagecache。里邊緩存了文件的所有緩存頁面 structradix_tree_rootpage_tree; }
關于 page cache 的詳細介紹,感興趣的讀者可以回看下 《從 Linux 內核角度探秘 JDK NIO 文件讀寫本質》 一文中的 “5. 頁高速緩存 page cache” 小節。
當我們理清了內存系統和文件系統這些核心數據結構之間的關聯關系之后,現在再來看,下面這幅 mmap 私有文件映射關系圖是不是清晰多了。
page cache 在內核中是使用基樹 radix_tree 結構來表示的,這里我們只需要知道文件頁是掛在 radix_tree 的葉子結點上,radix_tree 中的 root 節點和 node 節點是文件頁(葉子節點)的索引節點就可以了。
當多個進程調用 mmap 對磁盤上同一個文件進行私有文件映射的時候,內核只是在每個進程的虛擬內存空間中創建出一段虛擬內存區域 VMA 出來,注意,此時內核只是為進程申請了用于映射的虛擬內存,并將虛擬內存與文件映射起來,mmap 系統調用就返回了,全程并沒有物理內存的影子出現。文件的 page cache 也是空的,沒有包含任何的文件頁。
當任意一個進程,比如上圖中的進程 1 開始訪問這段映射的虛擬內存時,CPU 會把虛擬內存地址送到 MMU 中進行地址翻譯,因為 mmap 只是為進程分配了虛擬內存,并沒有分配物理內存,所以這段映射的虛擬內存在頁表中是沒有頁表項 PTE 的。
隨后 MMU 就會觸發缺頁異常(page fault),進程切換到內核態,在內核缺頁中斷處理程序中會發現引起缺頁的這段 VMA 是私有文件映射的,所以內核會首先通過 vm_area_struct->vm_pgoff 在文件 page cache 中查找是否有緩存相應的文件頁(映射的磁盤塊對應的文件頁)。
structvm_area_struct{ unsignedlongvm_pgoff;/*Offset(withinvm_file)inPAGE_SIZE*/ } staticinlinestructpage*find_get_page(structaddress_space*mapping, pgoff_toffset) { returnpagecache_get_page(mapping,offset,0,0); }
如果文件頁不在 page cache 中,內核則會在物理內存中分配一個內存頁,然后將新分配的內存頁加入到 page cache 中,并增加頁引用計數。
隨后會通過 address_space_operations 重定義的 readpage 激活塊設備驅動從磁盤中讀取映射的文件內容,然后將讀取到的內容填充新分配的內存頁。
staticconststructaddress_space_operationsext4_aops={ .readpage=ext4_readpage }
現在文件中映射的內容已經加載進 page cache 了,此時物理內存才正式登場,在缺頁中斷處理程序的最后一步,內核會為映射的這段虛擬內存在頁表中創建 PTE,然后將虛擬內存與 page cache 中的文件頁通過 PTE 關聯起來,缺頁處理就結束了,但是由于我們指定的私有文件映射,所以 PTE 中文件頁的權限是只讀的。
當內核處理完缺頁中斷之后,mmap 私有文件映射在內核中的關系圖就變成下面這樣:
此時進程 1 中的頁表已經建立起了虛擬內存與文件頁的映射關系,進程 1 再次訪問這段虛擬內存的時候,其實就等于直接訪問文件的 page cache。整個過程是在用戶態進行的,不需要切態。
現在我們在將視角切換到進程 2 中,進程 2 和進程 1 一樣,都是采用 mmap 私有文件映射的方式映射到了同一個文件中,雖然現在已經有了物理內存了(通過進程 1 的缺頁產生),但是目前還和進程 2 沒有關系。
因為進程 2 的虛擬內存空間中這段映射的虛擬內存區域 VMA,在進程 2 的頁表中還沒有 PTE,所以當進程 2 訪問這段映射虛擬內存時,同樣會產生缺頁中斷,隨后進程 2 切換到內核態,進行缺頁處理,這里和進程 1 不同的是,此時被映射的文件內容已經加載到 page cache 中了,進程 2 只需要創建 PTE ,并將 page cache 中的文件頁與進程 2 映射的這段虛擬內存通過 PTE 關聯起來就可以了。同樣,因為采用私有文件映射的原因,進程 2 的 PTE 也是只讀的。
現在進程 1 和進程 2 都可以根據各自虛擬內存空間中映射的這段虛擬內存對文件的 page cache 進行讀取了,整個過程都發生在用戶態,不需要切態,更不需要拷貝,因為虛擬內存現在已經直接映射到 page cache 了。
雖然我們采用的是私有文件映射的方式,但是進程 1 和進程 2 如果只是對文件映射部分進行讀取的話,文件頁其實在多進程之間是共享的,整個內核中只有一份。
但是當任意一個進程通過虛擬映射區對文件進行寫入操作的時候,情況就發生了變化,雖然通過 mmap 映射的時候指定的這段虛擬內存是可寫的,但是由于采用的是私有文件映射的方式,各個進程頁表中對應 PTE 卻是只讀的,當進程對這段虛擬內存進行寫入的時候,MMU 會發現 PTE 是只讀的,所以會產生一個寫保護類型的缺頁中斷,寫入進程,比如是進程 1,此時又會陷入到內核態,在寫保護缺頁處理中,內核會重新申請一個內存頁,然后將 page cache 中的內容拷貝到這個新的內存頁中,進程 1 頁表中對應的 PTE 會重新關聯到這個新的內存頁上,此時 PTE 的權限變為可寫。
從此以后,進程 1 對這段虛擬內存區域進行讀寫的時候就不會再發生缺頁了,讀寫操作都會發生在這個新申請的內存頁上,但是有一點,進程 1 對這個內存頁的任何修改均不會回寫到磁盤文件上,這也體現了私有文件映射的特點,進程對映射文件的修改,其他進程是看不到的,并且修改不會同步回磁盤文件中。
進程 2 對這段虛擬映射區進行寫入的時候,也是一樣的道理,同樣會觸發寫保護類型的缺頁中斷,進程 2 陷入內核態,內核為進程 2 新申請一個物理內存頁,并將 page cache 中的內容拷貝到剛為進程 2 申請的這個內存頁中,進程 2 頁表中對應的 PTE 會重新關聯到新的內存頁上, PTE 的權限變為可寫。
這樣一來,進程 1 和進程 2 各自的這段虛擬映射區,就映射到了各自專屬的物理內存頁上,而且這兩個內存頁中的內容均是文件中映射的部分,他們已經和 page cache 脫離了。
進程 1 和進程 2 對各自虛擬內存區的修改只能反應到各自對應的物理內存頁上,而且各自的修改在進程之間是互不可見的,最重要的一點是這些修改均不會回寫到磁盤文件中,這就是私有文件映射的核心特點。
我們可以利用 mmap 私有文件映射這個特點來加載二進制可執行文件的 .text , .data section 到進程虛擬內存空間中的代碼段和數據段中。
因為同一份代碼,也就是同一份二進制可執行文件可以運行多個進程,而代碼段對于多進程來說是只讀的,沒有必要為每個進程都保存一份,多進程之間共享這一份代碼就可以了,正好私有文件映射的讀共享特點可以滿足我們的這個需求。
對于數據段來說,雖然它是可寫的,但是我們需要的是多進程之間對數據段的修改相互之間是不可見的,而且對數據段的修改不能回寫到磁盤上的二進制文件中,這樣當我們利用這個可執行文件在啟動一個進程的時候,進程看到的就是數據段初始化未被修改的狀態。 mmap 私有文件映射的寫時復制(copy on write)以及修改不會回寫到映射文件中等特點正好也滿足我們的需求。
這一點我們可以在負責加載 elf 格式的二進制可執行文件并映射到進程虛擬內存空間的 load_elf_binary 函數,以及負責加載 a.out 格式可執行文件的 load_aout_binary 函數中可以看出。
staticintload_elf_binary(structlinux_binprm*bprm) { //將二進制文件中的.text.datasection私有映射到虛擬內存空間中代碼段和數據段中 error=elf_map(bprm->file,load_bias+vaddr,elf_ppnt, elf_prot,elf_flags,total_size); } staticintload_aout_binary(structlinux_binprm*bprm) { ............省略............. //將.text采用私有文件映射的方式映射到進程虛擬內存空間的代碼段 error=vm_mmap(bprm->file,N_TXTADDR(ex),ex.a_text, PROT_READ|PROT_EXEC, MAP_FIXED|MAP_PRIVATE|MAP_DENYWRITE|MAP_EXECUTABLE, fd_offset); //將.data采用私有文件映射的方式映射到進程虛擬內存空間的數據段 error=vm_mmap(bprm->file,N_DATADDR(ex),ex.a_data, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_FIXED|MAP_PRIVATE|MAP_DENYWRITE|MAP_EXECUTABLE, fd_offset+ex.a_text); ............省略............. }
4. 共享文件映射
#includevoid*mmap(void*addr,size_tlength,intprot,intflags,intfd,off_toffset);
我們通過將 mmap 系統調用中的 flags 參數指定為 MAP_SHARED , 參數 fd 指定為要映射文件的文件描述符(file descriptor)來實現對文件的共享映射。
共享文件映射其實和私有文件映射前面的映射過程是一樣的,唯一不同的點在于私有文件映射是讀共享的,寫的時候會發生寫時復制(copy on write),并且多進程針對同一映射文件的修改不會回寫到磁盤文件上。
而共享文件映射因為是共享的,多個進程中的虛擬內存映射區最終會通過缺頁中斷的方式映射到文件的 page cache 中,后續多個進程對各自的這段虛擬內存區域的讀寫都會直接發生在 page cache 上。
因為映射文件的 page cache 在內核中只有一份,所以對于共享文件映射來說,多進程讀寫都是共享的,由于多進程直接讀寫的是 page cache ,所以多進程對共享映射區的任何修改,最終都會通過內核回寫線程 pdflush 刷新到磁盤文件中。
下面這幅是多進程通過 mmap 共享文件映射之后的內核數據結構關系圖:
同私有文件映射方式一樣,當多個進程調用 mmap 對磁盤上的同一個文件進行共享文件映射的時候,內核中的處理都是一樣的,也都只是在每個進程的虛擬內存空間中,創建出一段用于共享映射的虛擬內存區域 VMA 出來,隨后內核會將各個進程中的這段虛擬內存映射區與映射文件關聯起來,mmap 共享文件映射的邏輯就結束了。
唯一不同的是,共享文件映射會在這段用于映射文件的 VMA 中標注是共享映射 —— MAP_SHARED
structvm_area_struct{ //MAP_SHARED共享映射 unsignedlongvm_flags; }
在 mmap 共享文件映射的過程中,內核同樣不涉及任何的物理內存分配,只是分配了一段虛擬內存,在共享映射剛剛建立起來之后,文件對應的 page cache 同樣是空的,沒有包含任何的文件頁。
由于 mmap 只是在各個進程中分配了虛擬內存,沒有分配物理內存,所以在各個進程的頁表中,這段用于文件映射的虛擬內存區域對應的頁表項 PTE 是空的,當任意進程對這段虛擬內存進行訪問的時候(讀或者寫),MMU 就會產生缺頁中斷,這里我們以上圖中的進程 1 為例,隨后進程 1 切換到內核態,執行內核缺頁中斷處理程序。
同私有文件映射的缺頁處理一樣,內核會首先通過 vm_area_struct->vm_pgoff 在文件 page cache 中查找是否有緩存相應的文件頁(映射的磁盤塊對應的文件頁)。如果文件頁不在 page cache 中,內核則會在物理內存中分配一個內存頁,然后將新分配的內存頁加入到 page cache 中。
然后調用 readpage 激活塊設備驅動從磁盤中讀取映射的文件內容,用讀取到的內容填充新分配的內存頁,現在物理內存有了,最后一步就是在進程 1 的頁表中建立共享映射的這段虛擬內存與 page cache 中緩存的文件頁之間的關聯。
這里和私有文件映射不同的地方是,私有文件映射由于是私有的,所以在內核創建 PTE 的時候會將 PTE 設置為只讀,目的是當進程寫入的時候觸發寫保護類型的缺頁中斷進行寫時復制 (copy on write)。
共享文件映射由于是共享的,PTE 被創建出來的時候就是可寫的,所以后續進程 1 在對這段虛擬內存區域寫入的時候不會觸發缺頁中斷,而是直接寫入 page cache 中,整個過程沒有切態,沒有數據拷貝。
現在我們在切換到進程 2 的視角中,雖然現在文件中被映射的這部分內容已經加載進物理內存頁,并被緩存在文件的 page cache 中了。但是現在進程 2 中這段虛擬映射區在進程 2 頁表中對應的 PTE 仍然是空的,當進程 2 訪問這段虛擬映射區的時候依然會產生缺頁中斷。
當進程 2 切換到內核態,處理缺頁中斷的時候,此時進程 2 通過 vm_area_struct->vm_pgoff 在 page cache 查找文件頁的時候,文件頁已經被進程 1 加載進 page cache 了,進程 2 一下就找到了,就不需要再去磁盤中讀取映射內容了,內核會直接為進程 2 創建 PTE (由于是共享文件映射,所以這里的 PTE 也是可寫的),并插入到進程 2 頁表中,隨后將進程 2 中的虛擬映射區通過 PTE 與 page cache 中緩存的文件頁映射關聯起來。
現在進程 1 和進程 2 各自虛擬內存空間中的這段虛擬內存區域 VMA,已經共同映射到了文件的 page cache 中,由于文件的 page cache 在內核中只有一份,它是和進程無關的,page cache 中的內容發生的任何變化,進程 1 和進程 2 都是可以看到的。
重要的一點是,多進程對各自虛擬內存映射區 VMA 的寫入操作,內核會根據自己的臟頁回寫策略將修改內容回寫到磁盤文件中。
內核提供了以下六個系統參數,來供我們配置調整內核臟頁回寫的行為,這些參數的配置文件存在于 proc/sys/vm 目錄下:
dirty_writeback_centisecs 內核參數的默認值為 500。單位為 0.01 s。也就是說內核默認會每隔 5s 喚醒一次 flusher 線程來執行相關臟頁的回寫。
drity_background_ratio :當臟頁數量在系統的可用內存 available 中占用的比例達到 drity_background_ratio 的配置值時,內核就會喚醒 flusher 線程異步回寫臟頁。默認值為:10。表示如果 page cache 中的臟頁數量達到系統可用內存的 10% 的話,就主動喚醒 flusher 線程去回寫臟頁到磁盤。
dirty_background_bytes :如果 page cache 中臟頁占用的內存用量絕對值達到指定的 dirty_background_bytes。內核就會喚醒 flusher 線程異步回寫臟頁。默認為:0。
dirty_ratio : dirty_background_* 相關的內核配置參數均是內核通過喚醒 flusher 線程來異步回寫臟頁。下面要介紹的 dirty_* 配置參數,均是由用戶進程同步回寫臟頁。表示內存中的臟頁太多了,用戶進程自己都看不下去了,不用等內核 flusher 線程喚醒,用戶進程自己主動去回寫臟頁到磁盤中。當臟頁占用系統可用內存的比例達到 dirty_ratio 配置的值時,用戶進程同步回寫臟頁。默認值為:20 。
dirty_bytes :如果 page cache 中臟頁占用的內存用量絕對值達到指定的 dirty_bytes。用戶進程同步回寫臟頁。默認值為:0。
內核為了避免 page cache 中的臟頁在內存中長久的停留,所以會給臟頁在內存中的駐留時間設置一定的期限,這個期限可由前邊提到的 dirty_expire_centisecs 內核參數配置。默認為:3000。單位為:0.01 s。也就是說在默認配置下,臟頁在內存中的駐留時間為 30 s。超過 30 s 之后,flusher 線程將會在下次被喚醒的時候將這些臟頁回寫到磁盤中。
關于臟頁回寫詳細的內容介紹,感興趣的讀者可以回看下 《從 Linux 內核角度探秘 JDK NIO 文件讀寫本質》 一文中的 “13. 內核回寫臟頁的觸發時機” 小節。
根據 mmap 共享文件映射多進程之間讀寫共享(不會發生寫時復制)的特點,常用于多進程之間共享內存(page cache),多進程之間的通訊。
5. 共享匿名映射
#includevoid*mmap(void*addr,size_tlength,intprot,intflags,intfd,off_toffset);
我們通過將 mmap 系統調用中的 flags 參數指定為 MAP_SHARED | MAP_ANONYMOUS ,并將 fd 參數指定為 -1 來實現共享匿名映射,這種映射方式常用于父子進程之間共享內存,父子進程之間的通訊。注意,這里需要和大家強調一下是父子進程,為什么只能是父子進程,筆者后面再給大家解答。
在筆者介紹完 mmap 的私有匿名映射,私有文件映射,以及共享文件映射之后,共享匿名映射看似就非常簡單了,由于不對文件進行映射,所以它不涉及到文件系統相關的知識,而且又是共享的,多個進程通過將自己的頁表指向同一個物理內存頁面不就實現共享匿名映射了嗎?
看起來簡單,實際上并沒有那么簡單,甚至可以說共享匿名映射是 mmap 這四種映射方式中最為復雜的,為什么這么說的 ?我們一起來看下共享匿名映射的映射過程。
首先和其他幾種映射方式一樣,mmap 只是負責在各個進程的虛擬內存空間中劃分一段用于共享匿名映射的虛擬內存區域而已,這點筆者已經強調過很多遍了,整個映射過程并不涉及到物理內存的分配。
當多個進程調用 mmap 進行共享匿名映射之后,內核只不過是為每個進程在各自的虛擬內存空間中分配了一段虛擬內存而已,由于并不涉及物理內存的分配,所以這段用于映射的虛擬內存在各個進程的頁表中對應的頁表項 PTE 都還是空的,如下圖所示:
當任一進程,比如上圖中的進程 1 開始訪問這段虛擬映射區的時候,MMU 會產生缺頁中斷,進程 1 切換到內核態,開始處理缺頁中斷邏輯,在缺頁中斷處理程序中,內核為進程 1 分配一個物理內存頁,并創建對應的 PTE 插入到進程 1 的頁表中,隨后用 PTE 將進程 1 的這段虛擬映射區與物理內存映射關聯起來。進程 1 的缺頁處理結束,從此以后,進程 1 就可以讀寫這段共享映射的物理內存了。
g
現在我們把視角切換到進程 2 中,當進程 2 訪問它自己的這段虛擬映射區的時候,由于進程 2 頁表中對應的 PTE 為空,所以進程 2 也會發生缺頁中斷,隨后切換到內核態處理缺頁邏輯。
當進程 2 開始處理缺頁邏輯的時候,進程 2 就懵了,為什么呢 ?原因是進程 2 和進程 1 進行的是共享映射,所以進程 2 不能隨便找一個物理內存頁進行映射,進程 2 必須和 進程 1 映射到同一個物理內存頁面,這樣才能共享內存。那現在的問題是,進程 2 面對著茫茫多的物理內存頁,進程 2 怎么知道進程 1 已經映射了哪個物理內存頁 ?
內核在缺頁中斷處理中只能知道當前正在缺頁的進程是誰,以及發生缺頁的虛擬內存地址是什么,內核根據這些信息,根本無法知道,此時是否已經有其他進程把共享的物理內存頁準備好了。
這一點對于共享文件映射來說特別簡單,因為有文件的 page cache 存在,進程 2 可以根據映射的文件內容在文件中的偏移 offset,從 page cache 中查找是否已經有其他進程把映射的文件內容加載到文件頁中。如果文件頁已經存在 page cache 中了,進程 2 直接映射這個文件頁就可以了。
structvm_area_struct{ unsignedlongvm_pgoff;/*Offset(withinvm_file)inPAGE_SIZE*/ } staticinlinestructpage*find_get_page(structaddress_space*mapping, pgoff_toffset) { returnpagecache_get_page(mapping,offset,0,0); }
由于共享匿名映射并沒有對文件映射,所以其他進程想要在內存中查找要進行共享的內存頁就非常困難了,那怎么解決這個問題呢 ?
既然共享文件映射可以輕松解決這個問題,那我們何不借鑒一下文件映射的方式 ?
共享匿名映射在內核中是通過一個叫做 tmpfs 的虛擬文件系統來實現的,tmpfs 不是傳統意義上的文件系統,它是基于內存實現的,掛載在 dev/zero 目錄下。
當多個進程通過 mmap 進行共享匿名映射的時候,內核會在 tmpfs 文件系統中創建一個匿名文件,這個匿名文件并不是真實存在于磁盤上的,它是內核為了共享匿名映射而模擬出來的,匿名文件也有自己的 inode 結構以及 page cache。
在 mmap 進行共享匿名映射的時候,內核會把這個匿名文件關聯到進程的虛擬映射區 VMA 中。這樣一來,當進程虛擬映射區域與 tmpfs 文件系統中的這個匿名文件映射起來之后,后面的流程就和共享文件映射一模一樣了。
structvm_area_struct{ structfile*vm_file;/*Filewemapto(canbeNULL).*/ }
最后,筆者來回答下在本小節開始處拋出的一個問題,就是共享匿名映射只適用于父子進程之間的通訊,為什么只能是父子進程呢 ?
因為當父進程進行 mmap 共享匿名映射的時候,內核會為其創建一個匿名文件,并關聯到父進程的虛擬內存空間中 vm_area_struct->vm_file 中。但是這時候其他進程并不知道父進程虛擬內存空間中關聯的這個匿名文件,因為進程之間的虛擬內存空間都是隔離的。
子進程就不一樣了,在父進程調用完 mmap 之后,父進程的虛擬內存空間中已經有了一段虛擬映射區 VMA 并關聯到匿名文件了。這時父進程進行 fork() 系統調用創建子進程,子進程會拷貝父進程的所有資源,當然也包括父進程的虛擬內存空間以及父進程的頁表。
long_do_fork(unsignedlongclone_flags, unsignedlongstack_start, unsignedlongstack_size, int__user*parent_tidptr, int__user*child_tidptr, unsignedlongtls) { .........省略.......... structpid*pid; structtask_struct*p; .........省略.......... //拷貝父進程的所有資源 p=copy_process(clone_flags,stack_start,stack_size, child_tidptr,NULL,trace,tls,NUMA_NO_NODE); .........省略.......... }
當 fork 出子進程的時候,這時子進程的虛擬內存空間和父進程的虛擬內存空間完全是一模一樣的,在子進程的虛擬內存空間中自然也有一段虛擬映射區 VMA 并且已經關聯到匿名文件中了(繼承自父進程)。
現在父子進程的頁表也是一模一樣的,各自的這段虛擬映射區對應的 PTE 都是空的,一旦發生缺頁,后面的流程就和共享文件映射一樣了。我們可以把共享匿名映射看作成一種特殊的共享文件映射方式。
6. 參數 flags 的其他枚舉值
#includevoid*mmap(void*addr,size_tlength,intprot,intflags,intfd,off_toffset);
在前邊的幾個小節中,筆者為大家介紹了 mmap 系統調用參數 flags 最為核心的三個枚舉值:MAP_ANONYMOUS,MAP_SHARED,MAP_PRIVATE。隨后我們通過這三個枚舉值組合出了四種內存映射方式:私有匿名映射,私有文件映射,共享文件映射,共享匿名映射。
到現在為止,筆者算是把 mmap 內存映射的核心原理及其在內核中的映射過程給大家詳細剖析完了,不過參數 flags 的枚舉值在內核中并不只是上述三個,除此之外,內核還定義了很多。在本小節的最后,筆者為大家挑了幾個相對重要的枚舉值給大家做一些額外的補充,這樣能夠讓大家對 mmap 內存映射有一個更加全面的認識。
#defineMAP_LOCKED0x2000/*pagesarelocked*/ #defineMAP_POPULATE0x008000/*populate(prefault)pagetables*/ #defineMAP_HUGETLB0x040000/*createahugepagemapping*/
經過前面的介紹我們知道,mmap 僅僅只是在進程虛擬內存空間中劃分出一段用于映射的虛擬內存區域 VMA ,并將這段 VMA 與磁盤上的文件映射起來而已。整個映射過程并不涉及物理內存的分配,更別說虛擬內存與物理內存的映射了,這些都是在進程訪問這段 VMA 的時候,通過缺頁中斷來補齊的。
如果我們在使用 mmap 系統調用的時候設置了 MAP_POPULATE ,內核在分配完虛擬內存之后,就會馬上分配物理內存,并在進程頁表中建立起虛擬內存與物理內存的映射關系,這樣進程在調用 mmap 之后就可以直接訪問這段映射的虛擬內存地址了,不會發生缺頁中斷。
但是當系統內存資源緊張的時候,內核依然會將 mmap 背后映射的這塊物理內存 swap out 到磁盤中,這樣進程在訪問的時候仍然會發生缺頁中斷,為了防止這種現象,我們可以在調用 mmap 的時候設置 MAP_LOCKED。
在設置了 MAP_LOCKED 之后,mmap 系統調用在為進程分配完虛擬內存之后,內核也會馬上為其分配物理內存并在進程頁表中建立虛擬內存與物理內存的映射關系,這里內核還會額外做一個動作,就是將映射的這塊物理內存鎖定在內存中,不允許它 swap,這樣一來映射的物理內存將會一直停留在內存中,進程無論何時訪問這段映射內存都不會發生缺頁中斷。
MAP_HUGETLB 則是用于大頁內存映射的,在內核中關于物理內存的調度是按照物理內存頁為單位進行的,普通物理內存頁大小為 4K。但在一些對于內存敏感的使用場景中,我們往往期望使用一些比普通 4K 更大的頁。
因為這些巨型頁要比普通的 4K 內存頁要大很多,而且這些巨型頁不允許被 swap,所以遇到缺頁中斷的情況就會相對減少,由于減少了缺頁中斷所以性能會更高。
另外,由于巨型頁比普通頁要大,所以巨型頁需要的頁表項要比普通頁要少,頁表項里保存了虛擬內存地址與物理內存地址的映射關系,當 CPU 訪問內存的時候需要頻繁通過 MMU 訪問頁表項獲取物理內存地址,由于要頻繁訪問,所以頁表項一般會緩存在 TLB 中,因為巨型頁需要的頁表項較少,所以節約了 TLB 的空間同時降低了 TLB 緩存 MISS 的概率,從而加速了內存訪問。
7. 大頁內存映射
在 64 位 x86 CPU 架構 Linux 的四級頁表體系下,系統支持的大頁尺寸有 2M,1G。我們可以在 /sys/kernel/mm/hugepages 路徑下查看當前系統所支持的大頁尺寸:
要想在應用程序中使用 HugePage,我們需要在內核編譯的時候通過設置 CONFIG_HUGETLBFS 和 CONFIG_HUGETLB_PAGE 這兩個編譯選項來讓內核支持 HugePage。我們可以通過 cat /proc/filesystems 命令來查看當前內核中是否支持 hugetlbfs 文件系統,這是我們使用 HugePage 的基礎。
因為 HugePage 要求的是一大片連續的物理內存,和普通內存頁一樣,巨型大頁里的內存必須是連續的,但是隨著系統的長時間運行,內存頁被頻繁無規則的分配與回收,系統中會產生大量的內存碎片,由于內存碎片的影響,內核很難尋找到大片連續的物理內存,這樣一來就很難分配到巨型大頁。
所以這就要求內核在系統啟動的時候預先為我們分配好足夠多的大頁內存,這些大頁內存被內核管理在一個大頁內存池中,大頁內存池中的內存全部是專用的,專門用于巨型大頁的分配,不能用于其他目的,即使系統中沒有使用巨型大頁,這些大頁內存就只能空閑在那里,另外這些大頁內存都是被內核鎖定在內存中的,即使系統內存資源緊張,大頁內存也不允許被 swap。而且內核大頁池中的這些大頁內存使用完了就完了,大頁池耗盡之后,應用程序將無法再使用大頁。
既然大頁內存池在內核啟動的時候就需要被預先創建好,而創建大頁內存池,內核需要首先知道內存池中究竟包含多少個 HugePage,每個 HugePage 的尺寸是多少 。我們可以將這些參數在內核啟動的時候添加到 kernel command line 中,隨后內核在啟動的過程中就可以根據 kernel command line 中 HugePage 相關的參數進行大頁內存池的創建。下面是一些 HugePage 相關的核心 command line 參數含義:
hugepagesz : 用于指定大頁內存池中 HugePage 的 size,我們這里可以指定 hugepagesz=2M 或者 hugepagesz=1G,具體支持多少種大頁尺寸由 CPU 架構決定。
hugepages:用于指定內核需要預先創建多少個 HugePage 在大頁內存池中,我們可以通過指定 hugepages=256 ,來表示內核需要預先創建 256 個 HugePage 出來。除此之外 hugepages 參數還可以有 NUMA 格式,用于告訴內核需要在每個 NUMA node 上創建多少個 HugePage。我們可以通過設置 hugepages=0:1,1:2 ... 來指定 NUMA node 0 上分配 1 個 HugePage,在 NUMA node 1 上分配 2 個 HugePage。
default_hugepagesz:用于指定 HugePage 默認大小。各種不同類型的 CPU 架構一般都支持多種 size 的 HugePage,比如 x86 CPU 支持 2M,1G 的 HugePage。arm64 支持 64K,2M,32M,1G 的 HugePage。這么多尺寸的 HugePage 我們到底該使用哪種尺寸呢 ? 這時就需要通過 default_hugepagesz 來指定默認使用的 HugePage 尺寸。
以上為大家介紹的是在內核啟動的時候(boot time)通過向 kernel command line 指定 HugePage 相關的命令行參數來配置大頁,除此之外,我們還可以在系統剛剛啟動之后(run time)來配置大頁,因為系統剛剛啟動,所以系統內存碎片化程度最小,也是一個配置大頁的時機:
在 /proc/sys/vm 路徑下有兩個系統參數可以讓我們在系統 run time 的時候動態調整當前系統中 default size (由 default_hugepagesz 指定)大小的 HugePage 個數。
nr_hugepages 表示當前系統中 default size 大小的 HugePage 個數,我們可以通過 echo HugePageNum > /proc/sys/vm/nr_hugepages 命令來動態增大或者縮小 HugePage (default size )個數。
nr_overcommit_hugepages 表示當系統中的應用程序申請的大頁個數超過 nr_hugepages 時,內核允許在額外申請多少個大頁。當大頁內存池中的大頁個數被耗盡時,如果此時繼續有進程來申請大頁,那么內核則會從當前系統中選取多個連續的普通 4K 大小的內存頁,湊出若干個大頁來供進程使用,這些被湊出來的大頁叫做 surplus_hugepage,surplus_hugepage 的個數不能超過 nr_overcommit_hugepages。當這些 surplus_hugepage 不在被使用時,就會被釋放回內核中。nr_hugepages 個數的大頁則會一直停留在大頁內存池中,不會被釋放,也不會被 swap。
nr_hugepages 有點像 JDK 線程池中的 corePoolSize 參數,(nr_hugepages + nr_overcommit_hugepages) 有點像線程池中的 maximumPoolSize 參數。
以上介紹的是修改默認尺寸大小的 HugePage,另外,我們還可以在系統 run time 的時候動態修改指定尺寸的 HugePage,不同大頁尺寸的相關配置文件存放在 /sys/kernel/mm/hugepages 路徑下的對應目錄中:
如上圖所示,當前系統中所支持的大頁尺寸相關的配置文件,均存放在對應 hugepages-hugepagesize 格式的目錄中,下面我們以 2M 大頁為例,進入到 hugepages-2048kB 目錄下,發現同樣也有 nr_hugepages 和 nr_overcommit_hugepages 這兩個配置文件,它們的含義和上邊介紹的一樣,只不過這里的是具體尺寸的 HugePage 相關配置。
我們可以通過如下命令來動態調整系統中 2M 大頁的個數:
echoHugePageNum>/sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
同理在 NUMA 架構的系統下,我們可以在 /sys/devices/system/node/node_id 路徑下修改對應 numa node 節點中的相應尺寸 的大頁個數:
echoHugePageNum>/sys/devices/system/node/node_id/hugepages/hugepages-2048kB/nr_hugepages
現在內核已經支持了大頁,并且我們從內核的 boot time 或者 run time 配置好了大頁內存池,我們終于可以在應用程序中來使用大頁內存了,內核給我們提供了兩種方式來使用 HugePage:
一種是本文介紹的 mmap 系統調用,需要在 flags 參數中設置 MAP_HUGETLB。另外內核提供了額外的兩個枚舉值來配合 MAP_HUGETLB 一起使用,它們分別是 MAP_HUGE_2MB 和 MAP_HUGE_1GB。
MAP_HUGETLB | MAP_HUGE_2MB 用于指定我們需要映射的是 2M 的大頁。
MAP_HUGETLB | MAP_HUGE_1GB 用于指定我們需要映射的是 1G 的大頁。
MAP_HUGETLB 表示按照 default_hugepagesz 指定的默認尺寸來映射大頁。
另一種是 SYSV 標準的系統調用 shmget 和 shmat。
本小節我們主要介紹 mmap 系統調用使用大頁的方式:
intmain(void) { addr=mmap(addr,length,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS|MAP_HUGETLB,-1,0); return0; }
MAP_HUGETLB 只能支持 MAP_ANONYMOUS 匿名映射的方式使用 HugePage
當我們通過 mmap 設置了 MAP_HUGETLB 進行大頁內存映射的時候,這個映射過程和普通的匿名映射一樣,同樣也是首先在進程的虛擬內存空間中劃分出一段虛擬映射區 VMA 出來,同樣不涉及物理內存的分配,不一樣的地方是,內核在分配完虛擬內存之后,會在大頁內存池中為映射的這段虛擬內存預留好大頁內存,相當于是把即將要使用的大頁內存先鎖定住,不允許其他進程使用。這些被預留好的 HugePage 個數被記錄在上圖中的 resv_hugepages 文件中。
當進程在訪問這段虛擬內存的時候,同樣會發生缺頁中斷,隨后內核會從大頁內存池中將這部分已經預留好的 resv_hugepages 分配給進程,并在進程頁表中建立好虛擬內存與 HugePage 的映射。關于進程頁表如何映射內存大頁的詳細內容,感興趣的同學可以回看下之前的文章 《一步一圖帶你構建 Linux 頁表體系》。
由于這里我們調用 mmap 映射的是 HugePage ,所以系統調用參數中的 addr,length 需要和大頁尺寸進行對齊,在本例中需要和 2M 進行對齊。
前邊也提到了 MAP_HUGETLB 需要和 MAP_ANONYMOUS 配合一起使用,只能支持匿名映射的方式來使用 HugePage。那如果我們想使用 mmap 對文件進行大頁映射該怎么辦呢 ?
這就用到了前面提到的 hugetlbfs 文件系統:
hugetlbfs 是一個基于內存的文件系統,類似前邊介紹的 tmpfs 文件系統,位于 hugetlbfs 文件系統下的所有文件都是被大頁支持的,也就說通過 mmap 對 hugetlbfs 文件系統下的文件進行文件映射,默認都是用 HugePage 進行映射。
hugetlbfs 下的文件支持大多數的文件系統操作,比如:open , close , chmod , read 等等,但是不支持 write 系統調用,如果想要對 hugetlbfs 下的文件進行寫入操作,那么必須通過文件映射的方式將 hugetlbfs 中的文件通過大頁映射進內存,然后在映射內存中進行寫入操作。
所以在我們使用 mmap 系統調用對 hugetlbfs 下的文件進行大頁映射之前,首先需要做的事情就是在系統中掛載 hugetlbfs 文件系統到指定的路徑下。
mount-thugetlbfs-ouid=,gid=,mode=,pagesize=,size=,min_size=,nr_inodes=none/mnt/huge
上面的這條命令用于將 hugetlbfs 掛載到 /mnt/huge 目錄下,從此以后只要是在 /mnt/huge 目錄下創建的文件,背后都是由大頁支持的,也就是說如果我們通過 mmap 系統調用對 /mnt/huge 目錄下的文件進行文件映射,缺頁的時候,內核分配的就是內存大頁。
只有在 hugetlbfs 下的文件進行 mmap 文件映射的時候才能使用大頁,其他普通文件系統下的文件依然只能映射普通 4K 內存頁。
mount 命令中的 uid 和 gid 用于指定 hugetlbfs 根目錄的 owner 和 group。
pagesize 用于指定 hugetlbfs 支持的大頁尺寸,默認單位是字節,我們可以通過設置 pagesize=2M 或者 pagesize=1G 來指定 hugetlbfs 中的大頁尺寸為 2M 或者 1G。
size 用于指定 hugetlbfs 文件系統可以使用的最大內存容量是多少,單位同 pagesize 一樣。
min_size 用于指定 hugetlbfs 文件系統可以使用的最小內存容量是多少。
nr_inodes 用于指定 hugetlbfs 文件系統中 inode 的最大個數,決定該文件系統中最大可以創建多少個文件。
當 hugetlbfs 被我們掛載好之后,接下來我們就可以直接通過 mmap 系統調用對掛載目錄 /mnt/huge 下的文件進行內存映射了,當缺頁的時候,內核會直接分配大頁,大頁尺寸是 pagesize。
intmain(void) { fd=open(“/mnt/huge/test.txt”,O_CREAT|O_RDWR); addr=mmap(0,MAP_LENGTH,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0); return0; }
這里需要注意是,通過 mmap 映射 hugetlbfs 中的文件的時候,并不需要指定 MAP_HUGETLB 。而我們通過 SYSV 標準的系統調用 shmget 和 shmat 以及前邊介紹的 mmap ( flags 參數設置 MAP_HUGETLB)進行大頁申請的時候,并不需要掛載 hugetlbfs。
在內核中一共支持兩種類型的內存大頁,一種是標準大頁(hugetlb pages),也就是上面內容所介紹的使用大頁的方式,我們可以通過命令 grep Huge /proc/meminfo 來查看標準大頁在系統中的使用情況:
和標準大頁相關的統計參數含義如下:
HugePages_Total 表示標準大頁池中大頁的個數。HugePages_Free 表示大頁池中還未被使用的大頁個數(未被分配)。
HugePages_Rsvd 表示大頁池中已經被預留出來的大頁,這個預留大頁是什么意思呢 ?我們知道 mmap 系統調用只是為進程分配一段虛擬內存而已,并不會分配物理內存,當 mmap 進行大頁映射的時候也是一樣。不同之處在于,內核為進程分配完虛擬內存之后,還需要為進程在大頁池中預留好本次映射所需要的大頁個數,注意此時只是預留,還并未分配給進程,大頁池中被預留好的大頁不能被其他進程使用。這時 HugePages_Rsvd 的個數會相應增加,當進程發生缺頁的時候,內核會直接從大頁池中把這些提前預留好的大頁內存映射到進程的虛擬內存空間中。這時 HugePages_Rsvd 的個數會相應減少。系統中真正剩余可用的個數其實是 HugePages_Free - HugePages_Rsvd。
HugePages_Surp 表示大頁池中超額分配的大頁個數,這個概念其實筆者前面在介紹 nr_overcommit_hugepages 參數的時候也提到過,nr_overcommit_hugepages 參數表示最多能超額分配多少個大頁。當大頁池中的大頁全部被耗盡的時候,也就是 /proc/sys/vm/nr_hugepages 指定的大頁個數全部被分配完了,內核還可以超額為進程分配大頁,超額分配出的大頁個數就統計在 HugePages_Surp 中。
Hugepagesize 表示系統中大頁的默認 size 大小,單位為 KB。
Hugetlb 表示系統中所有尺寸的大頁所占用的物理內存總量。單位為 KB。
內核中另外一種類型的大頁是透明大頁 THP (Transparent Huge Pages),這里的透明指的是應用進程在使用 THP 的時候完全是透明的,不需要像使用標準大頁那樣需要系統管理員對系統進行顯示的大頁配置,在應用程序中也不需要向標準大頁那樣需要顯示指定 MAP_HUGETLB , 或者顯示映射到 hugetlbfs 里的文件中。
透明大頁的使用對用戶完全是透明的,內核會在背后為我們自動做大頁的映射,透明大頁不需要像標準大頁那樣需要提前預先分配好大頁內存池,透明大頁的分配是動態的,由內核線程 khugepaged 負責在背后默默地將普通 4K 內存頁整理成內存大頁給進程使用。但是如果由于內存碎片的因素,內核無法整理出內存大頁,那么就會降級為使用普通 4K 內存頁。但是透明大頁這里會有一個問題,當碎片化嚴重的時候,內核會啟動 kcompactd 線程去整理碎片,期望獲得連續的內存用于大頁分配,但是 compact 的過程可能會引起 sys cpu 飆高,應用程序卡頓。
透明大頁是允許 swap 的,這一點和標準大頁不同,在內存緊張需要 swap 的時候,透明大頁會被內核默默拆分成普通 4K 內存頁,然后 swap out 到磁盤。
透明大頁只支持 2M 的大頁,標準大頁可以支持 1G 的大頁,透明大頁主要應用于匿名內存中,可以在 tmpfs 文件系統中使用。
在我們對比完了透明大頁與標準大頁之間的區別之后,我們現在來看一下如何使用透明大頁,其實非常簡單,我們可以通過修改 /sys/kernel/mm/transparent_hugepage/enabled 配置文件來選擇開啟或者禁用透明大頁:
always 表示系統全局開啟透明大頁 THP 功能。這意味著每個進程都會去嘗試使用透明大頁。
never 表示系統全局關閉透明大頁 THP 功能。進程將永遠不會使用透明大頁。
madvise 表示進程如果想要使用透明大頁,需要通過 madvise 系統調用并設置參數 advice 為 MADV_HUGEPAGE 來建議內核,在 addr 到 addr+length 這片虛擬內存區域中,需要使用透明大頁來映射。
#includeintmadvise(voidaddr,size_tlength,intadvice);
一般我們會首先使用 mmap 先映射一段虛擬內存區域,然后通過 madvise 建議內核,將來在缺頁的時候,需要為這段虛擬內存映射透明大頁。由于背后需要通過內核線程 khugepaged 來不斷的掃描整理系統中的普通 4K 內存頁,然后將他們拼接成一個大頁來給進程使用,其中涉及內存整理和回收等耗時的操作,且這些操作會在內存路徑中加鎖,而 khugepaged 內核線程可能會在錯誤的時間啟動掃描和轉換大頁的操作,造成隨機不可控的性能下降。
另外一點,透明大頁不像標準大頁那樣是提前預分配好的,透明大頁是在系統運行時動態分配的,在內存緊張的時候,透明大頁和普通 4K 內存頁的分配過程一樣,有可能會遇到直接內存回收(direct reclaim)以及直接內存整理(direct compaction),這些操作都是同步的并且非常耗時,會對性能造成非常大的影響。
前面在 cat /proc/meminfo 命令中顯示的 AnonHugePages 就表示透明大頁在系統中的使用情況。另外我們可以通過 cat /proc/pid/smaps | grep AnonHugePages 命令來查看某個進程對透明大頁的使用情況。
總結
本文筆者從五個角度為大家詳細介紹了 mmap 的使用方法及其在內核中的實現原理,這五個角度分別是:
私有匿名映射,其主要用于進程申請虛擬內存,以及初始化進程虛擬內存空間中的 BSS 段,堆,棧這些虛擬內存區域。
私有文件映射,其核心特點是背后映射的文件頁在多進程之間是讀共享的,多個進程對各自虛擬內存區的修改只能反應到各自對應的文件頁上,而且各自的修改在進程之間是互不可見的,最重要的一點是這些修改均不會回寫到磁盤文件中。我們可以利用這些特點來加載二進制可執行文件的 .text , .data section 到進程虛擬內存空間中的代碼段和數據段中。
共享文件映射,多進程之間讀寫共享(不會發生寫時復制),常用于多進程之間共享內存(page cache),多進程之間的通訊。
共享匿名映射,用于父子進程之間共享內存,父子進程之間的通訊。父子進程之間需要依賴 tmpfs 中的匿名文件來實現共享內存。是一種特殊的共享文件映射。
大頁內存映射,這里我們介紹了標準大頁與透明大頁兩種大頁類型的區別與聯系,以及他們各自的實現原理和使用方法。
審核編輯:劉清
-
JAVA
+關注
關注
19文章
2976瀏覽量
105211 -
BSS
+關注
關注
0文章
19瀏覽量
12264 -
Linux開發
+關注
關注
0文章
34瀏覽量
6961 -
虛擬內存
+關注
關注
0文章
77瀏覽量
8092
原文標題:3 萬字 + 40 張圖 | 拆解 mmap 內存映射的本質!
文章出處:【微信號:小林coding,微信公眾號:小林coding】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
編譯例程partition_mmap,報錯no such vaddr range怎么解決?
Linux的mmap文件內存映射機制
dma_alloc_coherent申請內存的訪問速度,請問有什么辦法能加快訪問mmap的DMA內存?
mmap()函數映射到內存中出現bus error的錯誤
使用UARTLite IP如何找到內存映射IO方法
在arm里怎樣實現mmap編寫驅動和應用共享內存呢
mmap系統調用和vmalloc獲取地址空間
mmap作為Linux內存管理的關鍵之一
![<b class='flag-5'>mmap</b>作為Linux<b class='flag-5'>內存</b>管理的關鍵之一](https://file.elecfans.com/web1/M00/90/9F/pIYBAFzFbz6AWybbAACKhEIqimI082.png)
評論