騰訊三面折戟,被mmap虐哭了
在面試失敗后的日子里,我開始瘋狂地查閱資料,惡補(bǔ) mmap 的相關(guān)知識。我這才發(fā)現(xiàn),mmap 原來是一種內(nèi)存映射文件的方法,它能將一個文件或者其它對象映射到進(jìn)程的地址空間,實現(xiàn)文件磁盤地址和進(jìn)程虛擬地址空間中一段虛擬地址的一一對應(yīng)關(guān)系 。
簡單來說,通過 mmap,進(jìn)程可以采用指針的方式讀寫操作這一段內(nèi)存,而系統(tǒng)會自動回寫臟頁面到對應(yīng)的文件磁盤上,這樣就完成了對文件的操作,而不必再頻繁調(diào)用 read、write 等系統(tǒng)調(diào)用函數(shù)。而且,內(nèi)核空間對這段區(qū)域的修改也能直接反映到用戶空間,從而實現(xiàn)不同進(jìn)程間的文件共享。這就好比是在進(jìn)程和文件之間搭建了一座直接溝通的橋梁,大大提高了數(shù)據(jù)交互的效率。接下來,讓我們層層剖析,探尋mmap映射文件的真實情況 。
Part1.Mmap是什么?
在日常開發(fā)中,磁盤 I/O 性能常常成為系統(tǒng)性能的瓶頸。傳統(tǒng)的文件讀寫方式,如使用 read 和 write 系統(tǒng)調(diào)用,存在著數(shù)據(jù)拷貝次數(shù)多、系統(tǒng)調(diào)用頻繁等問題,這在處理大文件或高并發(fā) I/O 場景時,會嚴(yán)重影響系統(tǒng)的性能和效率。而 mmap(Memory - Mapped Files),即內(nèi)存映射文件,正是為解決這些問題而出現(xiàn)的一種高效的文件訪問機(jī)制。
mmap 是一種內(nèi)存映射文件的方法,它將一個文件或者其它對象映射到進(jìn)程的地址空間,實現(xiàn)文件磁盤地址和進(jìn)程虛擬地址空間中一段虛擬地址的一一映射關(guān)系。簡單來說,就是讓進(jìn)程可以像訪問內(nèi)存一樣去訪問文件,而不需要頻繁地進(jìn)行系統(tǒng)調(diào)用和數(shù)據(jù)拷貝。通過 mmap,文件被映射到進(jìn)程的虛擬地址空間,進(jìn)程可以直接通過指針操作這段內(nèi)存,而系統(tǒng)會自動將修改后的內(nèi)容回寫到文件磁盤上。這不僅簡化了文件操作的編程模型,還大大提高了 I/O 操作的效率。
圖片
其函數(shù)原型為:void *mmap (void start, size_t length, int prot, int flags, int fd, off_t offset);int munmap(void start, size_t length);。下面介紹一下內(nèi)存映射的步驟:
- 用 open 系統(tǒng)調(diào)用打開文件,并返回描述符 fd。
- 用 mmap 建立內(nèi)存映射,并返回映射首地址指針 start。
- 對映射(文件)進(jìn)行各種操作,如顯示(printf)、修改(sprintf)等。
- 用 munmap (void *start, size_t length) 關(guān)閉內(nèi)存映射。
- 用 close 系統(tǒng)調(diào)用關(guān)閉文件 fd。
Part2.mmap 的工作原理
首先創(chuàng)建虛擬區(qū)間并完成地址映射,此時還沒有將任何文件數(shù)據(jù)拷貝至主存。當(dāng)進(jìn)程發(fā)起讀寫操作時,會訪問虛擬地址空間,通過查詢頁表,發(fā)現(xiàn)這段地址不在物理頁上,因為只建立了地址映射,真正的數(shù)據(jù)還沒有拷貝到內(nèi)存,因此引發(fā)缺頁異常。缺頁異常經(jīng)過一系列判斷,確定無非法操作后,內(nèi)核發(fā)起請求調(diào)頁過程。
最終會調(diào)用nopage函數(shù)把所缺的頁從文件在磁盤里的地址拷貝到物理內(nèi)存。之后進(jìn)程便可以對這片主存進(jìn)行讀寫,如果寫操作修改了內(nèi)容,一定時間后系統(tǒng)會自動回寫臟頁面到對應(yīng)的磁盤地址,完成了寫入到文件的過程。另外,也可以調(diào)用msync()來強(qiáng)制同步,這樣所寫的內(nèi)存就能立刻保存到文件中。
2.1虛擬地址與物理地址的映射機(jī)制
在深入了解 mmap 之前,我們需要先理解虛擬地址與物理地址的映射機(jī)制。以 64 位 CPU 為例,它采用的是 4 級頁表來實現(xiàn)虛擬地址到物理地址的映射 。雖然 64 位 CPU 虛擬地址長度理論上為 64 位,但在實際應(yīng)用中,48 位就足以滿足虛擬地址映射物理內(nèi)存的需求。
這 48 位虛擬地址被細(xì)分為五個部分,分別是 pgd 表偏移(9 位,對應(yīng)四級表)、pud 表偏移(9 位,對應(yīng)三級表)、pmd 表偏移(9 位,對應(yīng)二級表)、ptl 表偏移(9 位,對應(yīng)一級表)以及物理頁偏移(12 位)。這里可能大家會有疑問,為什么 pgd、pud、pmd、ptl 表偏移是 9 位,而物理頁偏移是 12 位呢?
先來說說 pgd、pud、pmd、ptl 表偏移。以 pgd 表為例,一張 pgd 表對應(yīng)一個物理頁,而一個物理頁的大小通常為 4KB。每個 pgd_t 表項占用 8 個字節(jié),通過計算可得一張 pgd 表能存儲 4*1024/8 = 512 個表項。因為 2 的 9 次方正好等于 512,所以采用 9 位的表偏移就能夠索引整張表的所有表項 。同理,pud、pmd、ptl 表也是基于相同的原理。
再看物理頁偏移,由于一個物理頁大小是 4KB,而物理頁訪問是以單字節(jié)為單位的,2 的 12 次方恰好是 4KB,所以物理頁偏移設(shè)置為 12 位,這樣就能準(zhǔn)確地定位到物理頁內(nèi)的每一個字節(jié) 。
當(dāng)進(jìn)行虛擬地址到物理地址的映射時,需要依次索引 pgd、pud、pmd、ptl 表。首先,查詢 pgd 表,通過 task_struct-> mm_struct-> pgd 成員找到 pgd 表物理頁首地址,再加上虛擬地址中的 pgd 表偏移,從而索引到 pgd_t 表項,完成 pgd 表查詢。接著,pgd_t 表項中存儲的是 pud 表物理頁首地址,依此類推,通過類似的方式完成 pud 表、pmd 表和 ptl 表的查詢。最后,ptl 表項存儲的是物理頁首地址,將其與虛擬地址中的物理頁偏移相加,就能成功定位到物理地址 。
2.2mmap 的實現(xiàn)步驟
了解了虛擬地址與物理地址的映射機(jī)制后,我們再來看看 mmap 具體是如何實現(xiàn)的,它主要包含以下三個關(guān)鍵步驟:
⑴進(jìn)程啟動映射過程,并在虛擬地址空間中為映射創(chuàng)建虛擬映射區(qū)域
進(jìn)程在用戶空間調(diào)用庫函數(shù) mmap,其函數(shù)原型為void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset)。這里的 start 參數(shù)指向欲映射的內(nèi)存起始地址,通常設(shè)為 NULL,讓系統(tǒng)自動選定地址;length 代表將文件中多大的部分映射到內(nèi)存;prot 指定映射區(qū)域的保護(hù)方式,如 PROT_READ 表示映射區(qū)域可被讀取,PROT_WRITE 表示可被寫入等;flags 影響映射區(qū)域的各種特性,調(diào)用時必須指定 MAP_SHARED 或 MAP_PRIVATE 等;fd 是要映射到內(nèi)存中的文件描述符;offset 為文件映射的偏移量,通常設(shè)為 0 。
調(diào)用 mmap 后,系統(tǒng)會在當(dāng)前進(jìn)程的虛擬地址空間中,尋找一段空閑的、滿足要求的連續(xù)虛擬地址。然后,為這片虛擬地址分配一個vm_area_struct結(jié)構(gòu),這個結(jié)構(gòu)就像是一個 “管家”,用于描述虛擬內(nèi)存區(qū)域的各種屬性,包括起始地址(vm_start)、結(jié)束地址(vm_end,vm_end 減去 vm_start 即為映射區(qū)域長度)、虛擬內(nèi)存訪問權(quán)限(vm_page_prot ,如 PROT_READ、PROT_WRITE 等)、內(nèi)存映射標(biāo)志(vm_page_flags ,如 MAP_SHARED 共享映射、MAP_PRIVATE 私有映射)等。完成初始化后,將這個新建的虛擬區(qū)結(jié)構(gòu)(vm_area_struct)插入進(jìn)程的虛擬地址區(qū)域鏈表或樹中,方便后續(xù)的管理和訪問 。
⑵調(diào)用內(nèi)核空間的系統(tǒng)調(diào)用函數(shù)mmap(不同于用戶空間函數(shù)),實現(xiàn)文件物理地址和進(jìn)程虛擬地址的一一映射關(guān)系
完成虛擬映射區(qū)域的創(chuàng)建后,接下來就要建立文件物理地址和進(jìn)程虛擬地址的一一映射關(guān)系。此時,會調(diào)用內(nèi)核空間的系統(tǒng)調(diào)用函數(shù) mmap(注意,此 mmap 函數(shù)不同于用戶空間函數(shù))。通過待映射的文件指針,在文件描述符表中找到對應(yīng)的文件描述符,進(jìn)而鏈接到內(nèi)核 “已打開文件集” 中該文件的文件結(jié)構(gòu)體(struct file),這個文件結(jié)構(gòu)體維護(hù)著與該已打開文件相關(guān)的各項信息 。
接著,通過該文件的文件結(jié)構(gòu)體,鏈接到 file_operations 模塊,調(diào)用內(nèi)核函數(shù) mmap,其原型為int mmap(struct file *filp, struct vm_area_struct *vma) 。內(nèi)核 mmap 函數(shù)會通過虛擬文件系統(tǒng) inode 模塊定位到文件磁盤物理地址,再通過 remap_pfn_range 函數(shù)建立頁表,最終實現(xiàn)文件物理地址和進(jìn)程虛擬地址的映射關(guān)系 。不過,此時僅僅是建立了地址映射,真正的數(shù)據(jù)還沒有拷貝到內(nèi)存中 。
⑶進(jìn)程發(fā)起對這片映射空間的訪問,引發(fā)缺頁異常,實現(xiàn)文件內(nèi)容到物理內(nèi)存(主存)的拷貝
注:前兩個階段僅在于創(chuàng)建虛擬區(qū)間并完成地址映射,但是并沒有將任何文件數(shù)據(jù)的拷貝至主存。真正的文件讀取是當(dāng)進(jìn)程發(fā)起讀或?qū)懖僮鲿r。
當(dāng)進(jìn)程發(fā)起對這片映射空間的訪問時,如果訪問的虛擬地址對應(yīng)的物理頁面還未加載到內(nèi)存中,就會引發(fā)缺頁異常 。缺頁異常會進(jìn)行一系列判斷,確定無非法操作后,內(nèi)核發(fā)起請求調(diào)頁過程。調(diào)頁過程會先在交換緩存空間(swap cache中尋找需要訪問的內(nèi)存頁,如果沒有找到,則調(diào)用nopage函數(shù)把所缺的頁從磁盤裝入到主存中 。
一旦數(shù)據(jù)被裝入主存,進(jìn)程就可以對這片主存進(jìn)行正常的讀或?qū)懖僮鳌H绻麑懖僮鞲淖兞藬?shù)據(jù)內(nèi)容,這些被修改的頁面會被標(biāo)記為 “臟頁面”。系統(tǒng)并不會立即將臟頁面回寫到磁盤,而是會在一定時間后自動回寫,當(dāng)然,也可以調(diào)用 msync 函數(shù)來強(qiáng)制同步,讓數(shù)據(jù)立即保存到文件里 。
Part3.mmap的 I/O模型
3.1基礎(chǔ)概念
mmap 也是一種零拷貝技術(shù)。傳統(tǒng)的 I/O 操作,數(shù)據(jù)往往需要在用戶空間和內(nèi)核空間多次拷貝,比如從磁盤讀取數(shù)據(jù)到內(nèi)核緩沖區(qū),再從內(nèi)核緩沖區(qū)復(fù)制到用戶緩沖區(qū),之后若要通過網(wǎng)絡(luò)發(fā)送,又得從用戶緩沖區(qū)拷貝到內(nèi)核的套接字緩沖區(qū),最后經(jīng)網(wǎng)卡發(fā)送出去,這一過程伴隨著多次上下文切換和數(shù)據(jù)拷貝,耗費大量 CPU 資源和時間。,其 I/O 模型如下圖所示:
圖片
#include <sys/mman.h>
void *mmap(
void *start,
size_t length,
int prot,
int flags,
int fd, off_t offset
)mmap 技術(shù)有如下特點:
- 利用 DMA 技術(shù)來取代 CPU 來在內(nèi)存與其他組件之間的數(shù)據(jù)拷貝,例如從磁盤到內(nèi)存,從內(nèi)存到網(wǎng)卡;
- 用戶空間的 mmap file 使用虛擬內(nèi)存,實際上并不占據(jù)物理內(nèi)存,只有在內(nèi)核空間的 kernel buffer cache 才占據(jù)實際的物理內(nèi)存;
- mmap() 函數(shù)需要配合 write() 系統(tǒng)調(diào)動進(jìn)行配合操作,這與 sendfile() 函數(shù)有所不同,后者一次性代替了 read() 以及 write();因此 mmap 也至少需要 4 次上下文切換;
- mmap 僅僅能夠避免內(nèi)核空間到用戶空間的全程 CPU 負(fù)責(zé)的數(shù)據(jù)拷貝,但是內(nèi)核空間內(nèi)部還是需要全程 CPU 負(fù)責(zé)的數(shù)據(jù)拷貝;
利用 mmap() 替換 read(),配合 write() 調(diào)用的整個流程如下:
- 用戶進(jìn)程調(diào)用 mmap(),從用戶態(tài)陷入內(nèi)核態(tài),將內(nèi)核緩沖區(qū)映射到用戶緩存區(qū);
- DMA 控制器將數(shù)據(jù)從硬盤拷貝到內(nèi)核緩沖區(qū)(可見其使用了 Page Cache 機(jī)制);
- mmap() 返回,上下文從內(nèi)核態(tài)切換回用戶態(tài);
- 用戶進(jìn)程調(diào)用 write(),嘗試把文件數(shù)據(jù)寫到內(nèi)核里的套接字緩沖區(qū),再次陷入內(nèi)核態(tài);
- CPU 將內(nèi)核緩沖區(qū)中的數(shù)據(jù)拷貝到的套接字緩沖區(qū);
- DMA 控制器將數(shù)據(jù)從套接字緩沖區(qū)拷貝到網(wǎng)卡完成數(shù)據(jù)傳輸;
- write() 返回,上下文從內(nèi)核態(tài)切換回用戶態(tài)。
通過mmap實現(xiàn)的零拷貝I/O進(jìn)行了4次用戶空間與內(nèi)核空間的上下文切換,以及3次數(shù)據(jù)拷貝;其中3次數(shù)據(jù)拷貝中包括了2次DMA拷貝和1次CPU拷貝。
3.2 mmap與常規(guī)文件操作的差異對比
當(dāng)我們使用常規(guī)文件操作,調(diào)用 read/fread 等函數(shù)來讀取文件時,數(shù)據(jù)需要經(jīng)歷一個較為復(fù)雜的拷貝過程。首先,數(shù)據(jù)會從磁盤被拷貝到內(nèi)核的頁緩存中,這是為了提高讀寫效率和保護(hù)磁盤而采用的頁緩存機(jī)制 。然而,由于頁緩存處于內(nèi)核空間,用戶進(jìn)程無法直接尋址,所以還需要將頁緩存中的數(shù)據(jù)頁再次拷貝到內(nèi)存對應(yīng)的用戶空間中。也就是說,僅僅是讀取文件內(nèi)容,就需要進(jìn)行兩次數(shù)據(jù)拷貝,才能完成進(jìn)程對文件內(nèi)容的獲取任務(wù) 。
而寫操作也是類似的情況,待寫入的 buffer 在內(nèi)核空間無法直接訪問,必須先從用戶空間拷貝至內(nèi)核空間對應(yīng)的主存,之后再寫回磁盤,同樣需要兩次數(shù)據(jù)拷貝 。并且,在這個過程中,每次進(jìn)行 read 或 write 操作,都需要進(jìn)行系統(tǒng)調(diào)用,這會導(dǎo)致用戶態(tài)和內(nèi)核態(tài)的上下文切換,頻繁的上下文切換也會消耗一定的系統(tǒng)資源 。
再看 mmap,它在操作文件時,創(chuàng)建新的虛擬內(nèi)存區(qū)域和建立文件磁盤地址和虛擬內(nèi)存區(qū)域映射這兩步,并沒有任何文件拷貝操作。當(dāng)進(jìn)程后續(xù)訪問數(shù)據(jù)時,如果發(fā)現(xiàn)內(nèi)存中并無數(shù)據(jù)而發(fā)起缺頁異常過程,由于已經(jīng)建立好的映射關(guān)系,此時只需要一次數(shù)據(jù)拷貝,就能從磁盤中將數(shù)據(jù)傳入內(nèi)存的用戶空間中,供進(jìn)程使用 。而且,mmap 只需要一次映射操作,之后進(jìn)程就可以像操作內(nèi)存一樣讀寫數(shù)據(jù),大大減少了系統(tǒng)調(diào)用的次數(shù) 。
通過下面這張圖,我們可以更加直觀地看到兩者的差異:
操作類型 | 常規(guī)文件操作 | mmap 操作 |
數(shù)據(jù)拷貝次數(shù) | 讀:磁盤到頁緩存,頁緩存到用戶空間,共 2 次;寫:用戶空間到內(nèi)核空間,內(nèi)核空間到磁盤,共 2 次 | 讀:磁盤到用戶空間,1 次;寫:用戶空間修改,系統(tǒng)自動回寫,無需額外拷貝(可調(diào)用 msync 強(qiáng)制同步) |
系統(tǒng)調(diào)用次數(shù) | 每次讀寫都需調(diào)用 read/write 等函數(shù),系統(tǒng)調(diào)用頻繁 | 僅在映射時調(diào)用一次 mmap 函數(shù),后續(xù)像操作內(nèi)存一樣讀寫,系統(tǒng)調(diào)用少 |
上下文切換次數(shù) | 每次系統(tǒng)調(diào)用伴隨用戶態(tài)與內(nèi)核態(tài)上下文切換,次數(shù)多 | 映射時一次上下文切換,后續(xù)操作少,上下文切換次數(shù)少 |
由此可見,mmap 通過減少數(shù)據(jù)拷貝次數(shù)和上下文切換次數(shù),在性能上具有明顯的優(yōu)勢,尤其是在處理大文件或者需要頻繁進(jìn)行文件 I/O 操作的場景下,這種優(yōu)勢更加突出 。
3.3mmap不是銀彈
mmap 不是銀彈,這意味著 mmap 也有其缺陷,在相關(guān)場景下的性能存在缺陷:
- 由于 MMAP 使用時必須實現(xiàn)指定好內(nèi)存映射的大小,因此 mmap 并不適合變長文件;
- 如果更新文件的操作很多,mmap 避免兩態(tài)拷貝的優(yōu)勢就被攤還,最終還是落在了大量的臟頁回寫及由此引發(fā)的隨機(jī) I/O 上,所以在隨機(jī)寫很多的情況下,mmap 方式在效率上不一定會比帶緩沖區(qū)的一般寫快;
- 讀/寫小文件(例如 16K 以下的文件),mmap 與通過 read 系統(tǒng)調(diào)用相比有著更高的開銷與延遲;同時 mmap 的刷盤由系統(tǒng)全權(quán)控制,但是在小數(shù)據(jù)量的情況下由應(yīng)用本身手動控制更好;
- mmap 受限于操作系統(tǒng)內(nèi)存大小:例如在 32-bits 的操作系統(tǒng)上,虛擬內(nèi)存總大小也就 2GB,但由于 mmap 必須要在內(nèi)存中找到一塊連續(xù)的地址塊,此時你就無法對 4GB 大小的文件完全進(jìn)行 mmap,在這種情況下你必須分多塊分別進(jìn)行 mmap,但是此時地址內(nèi)存地址已經(jīng)不再連續(xù),使用 mmap 的意義大打折扣,而且引入了額外的復(fù)雜性;
Part4.mmap技術(shù)的優(yōu)勢
4.1簡化用戶進(jìn)程編程
在用戶空間看來,通過 mmap 機(jī)制以后,磁盤上的文件仿佛直接就在內(nèi)存中,把訪問磁盤文件簡化為按地址訪問內(nèi)存。這樣一來,應(yīng)用程序自然不需要使用文件系統(tǒng)的 write(寫入)、read(讀取)、fsync(同步)等系統(tǒng)調(diào)用,因為現(xiàn)在只要面向內(nèi)存的虛擬空間進(jìn)行開發(fā)。但是,這并不意味著我們不再需要進(jìn)行這些系統(tǒng)調(diào)用,而是說這些系統(tǒng)調(diào)用由操作系統(tǒng)在 mmap 機(jī)制的內(nèi)部封裝好了。
①基于缺頁異常的懶加載
出于節(jié)約物理內(nèi)存以及 mmap 方法快速返回的目的,mmap 映射采用懶加載機(jī)制。具體來說,通過 mmap 申請 1000G 內(nèi)存可能僅僅占用了 100MB 的虛擬內(nèi)存空間,甚至沒有分配實際的物理內(nèi)存空間。當(dāng)你訪問相關(guān)內(nèi)存地址時,才會進(jìn)行真正的 write、read 等系統(tǒng)調(diào)用。CPU 會通過陷入缺頁異常的方式來將磁盤上的數(shù)據(jù)加載到物理內(nèi)存中,此時才會發(fā)生真正的物理內(nèi)存分配。
②數(shù)據(jù)一致性由 OS 確保
當(dāng)發(fā)生數(shù)據(jù)修改時,內(nèi)存出現(xiàn)臟頁,與磁盤文件出現(xiàn)不一致。mmap 機(jī)制下由操作系統(tǒng)自動完成內(nèi)存數(shù)據(jù)落盤(臟頁回刷),用戶進(jìn)程通常并不需要手動管理數(shù)據(jù)落盤。
4.2避免只讀操作時的 swap 操作
虛擬內(nèi)存帶來了種種好處,但是一個最大的問題在于所有進(jìn)程的虛擬內(nèi)存大小總和可能大于物理內(nèi)存總大小,因此當(dāng)操作系統(tǒng)物理內(nèi)存不夠用時,就會把一部分內(nèi)存 swap 到磁盤上。
在 mmap 下,如果虛擬空間沒有發(fā)生寫操作,那么由于通過 mmap 操作得到的內(nèi)存數(shù)據(jù)完全可以通過再次調(diào)用 mmap 操作映射文件得到。但是,通過其他方式分配的內(nèi)存,在沒有發(fā)生寫操作的情況下,操作系統(tǒng)并不知道如何簡單地從現(xiàn)有文件中(除非其重新執(zhí)行一遍應(yīng)用程序,但是代價很大)恢復(fù)內(nèi)存數(shù)據(jù),因此必須將內(nèi)存 swap 到磁盤上。
(1)高效的 I/O 操作方式,尤其在處理大文件或頻繁訪問文件內(nèi)容時性能優(yōu)勢明顯。
在 Linux 系統(tǒng)中,mmap 是一種非常高效的 I/O 操作方式。當(dāng)處理大文件或需要頻繁訪問文件內(nèi)容時,能夠帶來很大的性能優(yōu)勢。例如,當(dāng)一個進(jìn)程通過 mmap 映射一個文件時,操作系統(tǒng)會在進(jìn)程的地址空間中創(chuàng)建一個映射區(qū)域,使得進(jìn)程可以直接訪問這個文件而不需要進(jìn)行 read 或 write 系統(tǒng)調(diào)用。這種直接內(nèi)存訪問的方式,避免了傳統(tǒng)文件訪問中多次系統(tǒng)調(diào)用和數(shù)據(jù)復(fù)制的開銷,提高了文件訪問的效率。
(2)減少 CPU 和內(nèi)存開銷,具有更好的內(nèi)核態(tài)數(shù)據(jù)傳輸效率。
mmap 技術(shù)可以減少 CPU 和內(nèi)存的開銷。它通過將文件或設(shè)備映射到進(jìn)程的地址空間中,實現(xiàn)了直接內(nèi)存訪問,避免了內(nèi)核緩沖區(qū)和用戶空間緩沖區(qū)之間的數(shù)據(jù)復(fù)制。此外,mmap 還具有更好的內(nèi)核態(tài)數(shù)據(jù)傳輸效率,有助于減少數(shù)據(jù)傳輸時的內(nèi)存拷貝。例如,在 Kafka 中,Consumer 端對稀疏索引的操作使用了 mmap,將稀疏索引文件進(jìn)行內(nèi)存映射,不會招致系統(tǒng)調(diào)用以及額外的內(nèi)存復(fù)制開銷,從而提高了文件讀取效率。
(3)提升系統(tǒng)整體性能,改善用戶體驗。
合理地利用 mmap 技術(shù),能夠提升系統(tǒng)的整體性能,改善用戶體驗。在開發(fā)應(yīng)用程序時,可以考慮使用 mmap 技術(shù)來加速文件訪問、減少內(nèi)存拷貝、提高數(shù)據(jù)傳輸效率等方面。例如,在處理大文件時,mmap 可以不用把全部數(shù)據(jù)都加載到內(nèi)存,可以通過 MappedByteBuffer 的 position 來設(shè)置獲取數(shù)據(jù)的位置,還可以使用虛擬內(nèi)存來映射超過物理內(nèi)存大小的大文件。同時,mmap 也支持多進(jìn)程訪問和文件的共享,多個進(jìn)程可以共享同一個文件的內(nèi)容,從而減少內(nèi)存的使用,提高系統(tǒng)的性能。
Part5.mmap技術(shù)的應(yīng)用場景
5.1內(nèi)存映射 I/O,加速文件讀寫操作,適合處理大文件。
mmap 可以將文件直接映射到進(jìn)程的虛擬地址空間,避免了傳統(tǒng)文件讀寫中的多次系統(tǒng)調(diào)用和數(shù)據(jù)拷貝。在處理大文件時,這種方式尤其有效。例如,當(dāng)需要對一個大型數(shù)據(jù)文件進(jìn)行頻繁的讀寫操作時,使用 mmap 可以大大提高效率。通過內(nèi)存映射,進(jìn)程可以像訪問內(nèi)存一樣訪問文件數(shù)據(jù),減少了磁盤 I/O 的開銷。
參考資料中提到,進(jìn)程讀寫數(shù)據(jù)時,使用 mmap 進(jìn)行文件映射可以減少一次拷貝操作。磁盤文件直接加載到用戶空間,進(jìn)程可以通過指針直接操作文件,理論上比傳統(tǒng)的 read 和 write 操作要快。雖然在讀寫過程中可能會觸發(fā)大量中斷,但對于大文件的處理,mmap 仍然具有很大的優(yōu)勢。
5.2進(jìn)程間通信,多個進(jìn)程可通過共享內(nèi)存實現(xiàn)快速通信。
多個進(jìn)程可以通過共享內(nèi)存的方式,使用 mmap 來共享內(nèi)存段,實現(xiàn)進(jìn)程間快速通信。例如,在父子進(jìn)程或無親緣關(guān)系的進(jìn)程中,都可以將自身用戶空間映射到同一個文件或匿名映射到同一片區(qū)域,從而實現(xiàn)進(jìn)程間通信。
參考資料中提到,在進(jìn)程間通信的場景下,可以使用 mmap 將文件映射到內(nèi)存,多個進(jìn)程通過對同一文件的讀寫達(dá)到進(jìn)程間通信的目的。同時,共享匿名內(nèi)存也可以讓相關(guān)進(jìn)程共享一塊內(nèi)存區(qū)域,通常用于父子進(jìn)程。
5.3內(nèi)存分配,匿名映射可提供比 malloc 更靈活的內(nèi)存管理機(jī)制。
當(dāng)需要大塊的內(nèi)存,或者特定對齊要求的內(nèi)存時,mmap 的匿名映射可以提供比 malloc 更靈活的內(nèi)存管理機(jī)制。例如,當(dāng)需要分配的內(nèi)存大于一定閾值(如 128KB)時,glibc 會默認(rèn)使用 mmap 代替 brk 來分配內(nèi)存。
私有匿名映射最常見的用途是在 glibc 分配大塊的內(nèi)存中。同時,共享匿名映射也可以讓相關(guān)進(jìn)程共享一塊內(nèi)存區(qū)域,為內(nèi)存分配提供了更多的靈活性。
Part6如何使用mmap技術(shù)
6.1mmap使用細(xì)節(jié)
使用mmap需要注意的一個關(guān)鍵點是,mmap映射區(qū)域大小必須是物理頁大小(page_size)的整倍數(shù)(32位系統(tǒng)中通常是4k字節(jié))。原因是,內(nèi)存的最小粒度是頁,而進(jìn)程虛擬地址空間和內(nèi)存的映射也是以頁為單位。為了匹配內(nèi)存的操作,mmap從磁盤到虛擬地址空間的映射也必須是頁。
內(nèi)核可以跟蹤被內(nèi)存映射的底層對象(文件)的大小,進(jìn)程可以合法的訪問在當(dāng)前文件大小以內(nèi)又在內(nèi)存映射區(qū)以內(nèi)的那些字節(jié)。也就是說,如果文件的大小一直在擴(kuò)張,只要在映射區(qū)域范圍內(nèi)的數(shù)據(jù),進(jìn)程都可以合法得到,這和映射建立時文件的大小無關(guān)。
映射建立之后,即使文件關(guān)閉,映射依然存在。因為映射的是磁盤的地址,不是文件本身,和文件句柄無關(guān)。同時可用于進(jìn)程間通信的有效地址空間不完全受限于被映射文件的大小,因為是按頁映射。在上面的知識前提下,我們下面看看如果大小不是頁的整倍數(shù)的具體情況:
情形一:一個文件的大小是5000字節(jié),mmap函數(shù)從一個文件的起始位置開始,映射5000字節(jié)到虛擬內(nèi)存中。
分析:因為單位物理頁面的大小是4096字節(jié),雖然被映射的文件只有5000字節(jié),但是對應(yīng)到進(jìn)程虛擬地址區(qū)域的大小需要滿足整頁大小,因此mmap函數(shù)執(zhí)行后,實際映射到虛擬內(nèi)存區(qū)域8192個 字節(jié),5000~8191的字節(jié)部分用零填充。映射后的對應(yīng)關(guān)系如下圖所示:
圖片
此時:(1)讀/寫前5000個字節(jié)(0~4999),會返回操作文件內(nèi)容。(2)讀字節(jié)50008191時,結(jié)果全為0。寫50008191時,進(jìn)程不會報錯,但是所寫的內(nèi)容不會寫入原文件中 。(3)讀/寫8192以外的磁盤部分,會返回一個SIGSECV錯誤。
情形二:一個文件的大小是5000字節(jié),mmap函數(shù)從一個文件的起始位置開始,映射15000字節(jié)到虛擬內(nèi)存中,即映射大小超過了原始文件的大小。
分析:由于文件的大小是5000字節(jié),和情形一一樣,其對應(yīng)的兩個物理頁。那么這兩個物理頁都是合法可以讀寫的,只是超出5000的部分不會體現(xiàn)在原文件中。由于程序要求映射15000字節(jié),而文件只占兩個物理頁,因此8192字節(jié)~15000字節(jié)都不能讀寫,操作時會返回異常。如下圖所示:
圖片
此時:(1)進(jìn)程可以正常讀/寫被映射的前5000字節(jié)(0~4999),寫操作的改動會在一定時間后反映在原文件中。(2)對于5000~8191字節(jié),進(jìn)程可以進(jìn)行讀寫過程,不會報錯。但是內(nèi)容在寫入前均為0,另外,寫入后不會反映在文件中。(3)對于8192~14999字節(jié),進(jìn)程不能對其進(jìn)行讀寫,會報SIGBUS錯誤。(4)對于15000以外的字節(jié),進(jìn)程不能對其讀寫,會引發(fā)SIGSEGV錯誤。
情形三:一個文件初始大小為0,使用mmap操作映射了10004K的大小,即1000個物理頁大約4M字節(jié)空間,mmap返回指針ptr。
分析:如果在映射建立之初,就對文件進(jìn)行讀寫操作,由于文件大小為0,并沒有合法的物理頁對應(yīng),如同情形二一樣,會返回SIGBUS錯誤。但是如果,每次操作ptr讀寫前,先增加文件的大小,那么ptr在文件大小內(nèi)部的操作就是合法的。例如,文件擴(kuò)充4096字節(jié),ptr就能操作ptr ~ [ (char)ptr + 4095]的空間。只要文件擴(kuò)充的范圍在1000個物理頁(映射范圍)內(nèi),ptr都可以對應(yīng)操作相同的大小。這樣,方便隨時擴(kuò)充文件空間,隨時寫入文件,不造成空間浪費。
6.2函數(shù)定義及參數(shù)解釋
在 Linux 中,mmap 函數(shù)定義如下:void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);。參數(shù)解釋如下:
- addr:希望映射的起始地址,通常為 NULL,表示由內(nèi)核決定映射的地址。
- length:映射區(qū)域的大小(以字節(jié)為單位)。
- prot:映射區(qū)域的保護(hù)權(quán)限,決定映射的頁面是否可讀、可寫等。常見的權(quán)限選項包括:PROT_READ(可讀)、PROT_WRITE(可寫)、PROT_EXEC(可執(zhí)行)、PROT_NONE(無權(quán)限)。
- flags:映射的類型和行為控制。常見的標(biāo)志包括:MAP_SHARED(共享映射,對該內(nèi)存的修改會同步到文件)、MAP_PRIVATE(私有映射,對該內(nèi)存的修改不會影響原文件,寫時拷貝)、MAP_ANONYMOUS(匿名映射,不涉及文件,通常用于分配未初始化的內(nèi)存)。
- fd:文件描述符,指向要映射的文件。如果使用匿名映射,應(yīng)將 fd 設(shè)置為 -1,并且需要設(shè)置 MAP_ANONYMOUS 標(biāo)志。
- offset:文件映射的偏移量,必須是頁面大小的整數(shù)倍(通常為 4096 字節(jié))。
返回值:返回映射區(qū)域的起始地址,如果映射失敗,則返回 MAP_FAILED。
6.3mmap映射
在內(nèi)存映射的過程中,并沒有實際的數(shù)據(jù)拷貝,文件沒有被載入內(nèi)存,只是邏輯上被放入了內(nèi)存,具體到代碼,就是建立并初始化了相關(guān)的數(shù)據(jù)結(jié)構(gòu)(struct address_space),這個過程有系統(tǒng)調(diào)用mmap()實現(xiàn),所以建立內(nèi)存映射的效率很高。既然建立內(nèi)存映射沒有進(jìn)行實際的數(shù)據(jù)拷貝,那么進(jìn)程又怎么能最終直接通過內(nèi)存操作訪問到硬盤上的文件呢?
那就要看內(nèi)存映射之后的幾個相關(guān)的過程了。mmap()會返回一個指針ptr,它指向進(jìn)程邏輯地址空間中的一個地址,這樣以后,進(jìn)程無需再調(diào)用read或write對文件進(jìn)行讀寫,而只需要通過ptr就能夠操作文件。但是ptr所指向的是一個邏輯地址,要操作其中的數(shù)據(jù),必須通過MMU將邏輯地址轉(zhuǎn)換成物理地址,這個過程與內(nèi)存映射無關(guān)。
前面講過,建立內(nèi)存映射并沒有實際拷貝數(shù)據(jù),這時,MMU在地址映射表中是無法找到與ptr相對應(yīng)的物理地址的,也就是MMU失敗,將產(chǎn)生一個缺頁中斷,缺頁中斷的中斷響應(yīng)函數(shù)會在swap中尋找相對應(yīng)的頁面,如果找不到(也就是該文件從來沒有被讀入內(nèi)存的情況),則會通過mmap()建立的映射關(guān)系,從硬盤上將文件讀取到物理內(nèi)存中,如圖1中過程3所示。這個過程與內(nèi)存映射無關(guān)。如果在拷貝數(shù)據(jù)時,發(fā)現(xiàn)物理內(nèi)存不夠用,則會通過虛擬內(nèi)存機(jī)制(swap)將暫時不用的物理頁面交換到硬盤上,這個過程也與內(nèi)存映射無關(guān)。
mmap內(nèi)存映射的實現(xiàn)過程:
- 進(jìn)程啟動映射過程,并在虛擬地址空間中為映射創(chuàng)建虛擬映射區(qū)域
- 調(diào)用內(nèi)核空間的系統(tǒng)調(diào)用函數(shù)mmap(不同于用戶空間函數(shù)),實現(xiàn)文件物理地址和進(jìn)程虛擬地址的一一映射關(guān)系
- 進(jìn)程發(fā)起對這片映射空間的訪問,引發(fā)缺頁異常,實現(xiàn)文件內(nèi)容到物理內(nèi)存(主存)的拷貝
適合的場景
- 您有一個很大的文件,其內(nèi)容您想要隨機(jī)訪問一個或多個時間
- 您有一個小文件,它的內(nèi)容您想要立即讀入內(nèi)存并經(jīng)常訪問。這種技術(shù)最適合那些大小不超過幾個虛擬內(nèi)存頁的文件。(頁是地址空間的最小單位,虛擬頁和物理頁的大小是一樣的,通常為4KB。)
- 您需要在內(nèi)存中緩存文件的特定部分。文件映射消除了緩存數(shù)據(jù)的需要,這使得系統(tǒng)磁盤緩存中的其他數(shù)據(jù)空間更大 當(dāng)隨機(jī)訪問一個非常大的文件時,通常最好只映射文件的一小部分。映射大文件的問題是文件會消耗活動內(nèi)存。如果文件足夠大,系統(tǒng)可能會被迫將其他部分的內(nèi)存分頁以加載文件。將多個文件映射到內(nèi)存中會使這個問題更加復(fù)雜。
不適合的場景
- 您希望從開始到結(jié)束的順序從頭到尾讀取一個文件
- 這個文件有幾百兆字節(jié)或者更大。將大文件映射到內(nèi)存中會快速地填充內(nèi)存,并可能導(dǎo)致分頁,這將抵消首先映射文件的好處。對于大型順序讀取操作,禁用磁盤緩存并將文件讀入一個小內(nèi)存緩沖區(qū)
- 該文件大于可用的連續(xù)虛擬內(nèi)存地址空間。對于64位應(yīng)用程序來說,這不是什么問題,但是對于32位應(yīng)用程序來說,這是一個問題
- 該文件位于可移動驅(qū)動器上
- 該文件位于網(wǎng)絡(luò)驅(qū)動器上
示例代碼
//
// ViewController.m
// TestCode
//
// Created by zhangdasen on 2020/5/24.
// Copyright ? 2020 zhangdasen. All rights reserved.
//
#import "ViewController.h"
#import <sys/mman.h>
#import <sys/stat.h>
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
NSString *path = [NSHomeDirectory() stringByAppendingPathComponent:@"test.data"];
NSLog(@"path: %@", path);
NSString *str = @"test str2";
[str writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:nil];
ProcessFile(path.UTF8String);
NSString *result = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
NSLog(@"result:%@", result);
}
int MapFile(const char * inPathName, void ** outDataPtr, size_t * outDataLength, size_t appendSize)
{
int outError;
int fileDescriptor;
struct stat statInfo;
// Return safe values on error.
outError = 0;
*outDataPtr = NULL;
*outDataLength = 0;
// Open the file.
fileDescriptor = open( inPathName, O_RDWR, 0 );
if( fileDescriptor < 0 )
{
outError = errno;
}
else
{
// We now know the file exists. Retrieve the file size.
if( fstat( fileDescriptor, &statInfo ) != 0 )
{
outError = errno;
}
else
{
ftruncate(fileDescriptor, statInfo.st_size + appendSize);
fsync(fileDescriptor);
*outDataPtr = mmap(NULL,
statInfo.st_size + appendSize,
PROT_READ|PROT_WRITE,
MAP_FILE|MAP_SHARED,
fileDescriptor,
0);
if( *outDataPtr == MAP_FAILED )
{
outError = errno;
}
else
{
// On success, return the size of the mapped file.
*outDataLength = statInfo.st_size;
}
}
// Now close the file. The kernel doesn’t use our file descriptor.
close( fileDescriptor );
}
return outError;
}
void ProcessFile(const char * inPathName)
{
size_t dataLength;
void * dataPtr;
char *appendStr = " append_key2";
int appendSize = (int)strlen(appendStr);
if( MapFile(inPathName, &dataPtr, &dataLength, appendSize) == 0) {
dataPtr = dataPtr + dataLength;
memcpy(dataPtr, appendStr, appendSize);
// Unmap files
munmap(dataPtr, appendSize + dataLength);
}
}
@end6.5解除映射的方法
使用 mmap 后,必須調(diào)用 munmap 來解除映射,釋放分配的虛擬內(nèi)存。其函數(shù)定義如下:int munmap(void *addr, size_t length);。
- addr:要解除映射的內(nèi)存區(qū)域的起始地址。
- length:要解除映射的大小。
返回值:成功返回 0,失敗返回 -1。
⑴利用 mmap 訪問硬件,減少數(shù)據(jù)拷貝次數(shù)
mmap 可以將文件、設(shè)備等外部資源映射到內(nèi)存地址空間,進(jìn)程可以像訪問內(nèi)存一樣訪問文件數(shù)據(jù)或硬件資源。當(dāng)使用 mmap 訪問硬件時,數(shù)據(jù)可以直接從硬件設(shè)備通過 DMA 拷貝到內(nèi)核緩沖區(qū),然后進(jìn)程可以直接訪問這個緩沖區(qū),減少了數(shù)據(jù)拷貝的次數(shù)。例如,在嵌入式系統(tǒng)中,可以使用 mmap 將物理地址映射到用戶虛擬地址空間,實現(xiàn)對硬件設(shè)備的直接訪問。在進(jìn)行數(shù)據(jù)傳輸時,避免了傳統(tǒng)方式中從內(nèi)核空間到用戶空間的多次數(shù)據(jù)拷貝,提高了數(shù)據(jù)傳輸?shù)男省?/span>
⑵通過 mmap 實現(xiàn)將物理地址映射到用戶虛擬地址空間
- 打開 /dev/mem 文件獲得文件描述符 dev_mem_fd。
- 使用 mmap 函數(shù)進(jìn)行映射,將物理地址映射到用戶虛擬地址空間。例如,定義一個函數(shù) dma_mmap 來實現(xiàn)這個功能,函數(shù)原型為 int dma_mmap(unsigned long addr_p, unsigned int len, unsigned char** addr_v)。在這個函數(shù)中,首先打開 /dev/mem 文件,然后使用 mmap 函數(shù)進(jìn)行映射,最后返回虛擬地址。
- 使用映射后的虛擬地址進(jìn)行操作,例如讀寫硬件設(shè)備。
- 在使用完后,調(diào)用 dma_munmap 函數(shù)解除映射,釋放資源。函數(shù)原型為 unsigned int dma_munmap(unsigned char* addr_v, unsigned long addr_p, unsigned int len)。
⑶在嵌入式系統(tǒng)中,還可以通過以下方式實現(xiàn)物理地址到用戶虛擬地址空間的映射:
- 在驅(qū)動程序中,實現(xiàn) mmap 方法,建立虛擬地址到物理地址的頁表。例如,可以使用 remap_pfn_range 函數(shù)一次建立所有頁表,或者使用 nopage VMA 方法每次建立一個頁表。
- 在用戶空間程序中,使用 mmap 函數(shù)進(jìn)行映射,將文件描述符、映射大小、保護(hù)權(quán)限等參數(shù)傳入,獲得映射后的虛擬地址。然后可以通過這個虛擬地址對硬件設(shè)備進(jìn)行操作。
Part7.mmap使用技巧與注意事項
7.1 mmap函數(shù)參數(shù)解析
在使用 mmap 時,正確設(shè)置其函數(shù)參數(shù)至關(guān)重要。mmap 函數(shù)原型為void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset) ,下面我們來詳細(xì)解析每個參數(shù):
start:指向欲映射的內(nèi)存起始地址,通常設(shè)為 NULL ,讓系統(tǒng)自動選定合適的地址。若指定非 NULL 值,系統(tǒng)會嘗試從該地址開始映射,但可能會因為地址不可用或其他原因?qū)е掠成涫 @?,在某些系統(tǒng)中,指定的地址必須是頁面大小的整數(shù)倍,否則映射將無法成功 。
length:代表將文件中多大的部分映射到內(nèi)存,這個值必須大于 0 。它會自動調(diào)整為系統(tǒng)頁面大?。ㄍǔ?4KB)的整數(shù)倍。如果設(shè)置為 0,mmap 調(diào)用將失敗 。在處理大文件時,需要根據(jù)實際需求合理設(shè)置 length,避免設(shè)置過小導(dǎo)致多次映射,影響效率;也不能設(shè)置過大,以免占用過多內(nèi)存資源 。
prot:指定映射區(qū)域的保護(hù)方式,常見的取值有:
- PROT_READ:表示映射區(qū)域可被讀取,若文件以只讀方式打開,該權(quán)限必須設(shè)置 。
- PROT_WRITE:意味著映射區(qū)域可被寫入,如果需要對映射的文件進(jìn)行修改,需設(shè)置此權(quán)限 ,但要注意文件打開模式需允許寫入,否則 mmap 調(diào)用會失敗 。
- PROT_EXEC:說明映射區(qū)域可被執(zhí)行,常用于映射可執(zhí)行文件或共享庫 。
- PROT_NONE:表示映射區(qū)域不能被存取,一般較少單獨使用,常與其他權(quán)限組合來限制特定區(qū)域的訪問 。
- 這些權(quán)限可以通過邏輯或(|)操作進(jìn)行組合,如PROT_READ | PROT_WRITE表示映射區(qū)域可讀可寫 。
flags:影響映射區(qū)域的各種特性,調(diào)用時必須指定MAP_SHARED或MAP_PRIVATE :
- MAP_SHARED:對映射區(qū)域的寫入數(shù)據(jù)會復(fù)制回文件內(nèi),并且允許其他映射該文件的進(jìn)程共享。適用于進(jìn)程間通信、多進(jìn)程共享數(shù)據(jù)等場景 。例如,在數(shù)據(jù)庫緩存模塊中,多個進(jìn)程通過MAP_SHARED映射同一緩存文件,實現(xiàn)數(shù)據(jù)共享和實時更新 。
- MAP_PRIVATE:對映射區(qū)域的寫入操作會產(chǎn)生一個映射文件的復(fù)制,即私人的 “寫入時復(fù)制”(copy on write),對此區(qū)域作的任何修改都不會寫回原來的文件內(nèi)容。常用于需要對文件進(jìn)行臨時修改,而不希望影響原文件的場景 。
- 此外,還有其他一些可選標(biāo)志,如MAP_ANONYMOUS用于建立匿名映射,此時會忽略參數(shù) fd,不涉及文件,且映射區(qū)域無法和其他進(jìn)程共享;MAP_LOCKED將映射區(qū)域鎖定住,防止該區(qū)域被置換(swap),適用于對性能要求極高,不允許內(nèi)存被交換出去的場景 。
fd:要映射到內(nèi)存中的文件描述符。如果使用匿名內(nèi)存映射,即 flags 中設(shè)置了MAP_ANONYMOUS,fd 需設(shè)為 - 1 。有些系統(tǒng)不支持匿名內(nèi)存映射時,可以打開/dev/zero文件,然后對該文件進(jìn)行映射,達(dá)到類似匿名內(nèi)存映射的效果 。
offset:文件映射的偏移量,通常設(shè)置為 0,代表從文件最前方開始對應(yīng)。offset 必須是分頁大?。ㄒ话銥?4KB)的整數(shù)倍,否則 mmap 調(diào)用會失敗 。在某些情況下,可能需要從文件的特定位置開始映射,此時就需要正確計算并設(shè)置 offset 。
7.2錯誤處理與調(diào)試策略
在使用 mmap 過程中,難免會遇到各種錯誤,掌握正確的錯誤處理和調(diào)試方法是確保程序穩(wěn)定運行的關(guān)鍵。
常見的 mmap 錯誤及對應(yīng)的 errno 值如下:
- EACCES:表示存取權(quán)限有誤。如果是MAP_PRIVATE情況下,文件必須可讀;使用MAP_SHARED則要有PROT_WRITE權(quán)限,并且該文件要能寫入 。例如,當(dāng)以只讀方式打開文件,卻在 mmap 時設(shè)置了PROT_WRITE權(quán)限,就會返回此錯誤 。
- EBADF:參數(shù) fd 不是有效的文件描述詞。可能是文件描述符已關(guān)閉,或者從未正確打開過文件就使用其描述符進(jìn)行 mmap 操作 。
- EINVAL:一個或者多個參數(shù)無效。比如 start、length 或 offset 有一個不合法,如 length 為 0,offset 不是分頁大小的整數(shù)倍等 。
- EAGAIN:文件被鎖住,或是有太多內(nèi)存被鎖住。在系統(tǒng)資源緊張,內(nèi)存被大量占用或文件被其他進(jìn)程鎖定時,可能會出現(xiàn)此錯誤 。
- ENOMEM:內(nèi)存不足,或者進(jìn)程已超出最大內(nèi)存映射數(shù)量。當(dāng)系統(tǒng)內(nèi)存不足,無法為 mmap 分配足夠的內(nèi)存,或者進(jìn)程已經(jīng)達(dá)到系統(tǒng)允許的最大內(nèi)存映射數(shù)量限制時,會返回該錯誤 。
為了正確處理這些錯誤,我們可以在調(diào)用 mmap 后檢查其返回值。如果返回MAP_FAILED(其值為 (void *)-1),則說明調(diào)用失敗,此時應(yīng)通過errno獲取具體的錯誤碼,并根據(jù)錯誤碼進(jìn)行相應(yīng)的處理 。例如:
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
int main() {
int fd = open("test.txt", O_RDWR);
if (fd == -1) {
perror("open");
return 1;
}
void *ptr = mmap(NULL, 1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (ptr == MAP_FAILED) {
perror("mmap");
printf("Error number: %d\n", errno);
close(fd);
return 1;
}
// 后續(xù)操作
if (munmap(ptr, 1024) == -1) {
perror("munmap");
close(fd);
return 1;
}
close(fd);
return 0;
}當(dāng)遇到問題時,除了通過檢查返回值和errno來定位錯誤,還可以使用調(diào)試工具進(jìn)行深入分析,gdb 就是一個強(qiáng)大的調(diào)試工具 。假設(shè)我們有一個使用 mmap 的程序mmap_test,可以通過以下步驟使用 gdb 進(jìn)行調(diào)試:
- 編譯程序:在編譯時加上-g選項,以便生成調(diào)試信息,如gcc -g -o mmap_test mmap_test.c 。
- 啟動 gdb:運行g(shù)db mmap_test進(jìn)入 gdb 調(diào)試環(huán)境 。
- 設(shè)置斷點:使用break命令設(shè)置斷點,例如break main在main函數(shù)入口處設(shè)置斷點,或者break mmap在調(diào)用 mmap 函數(shù)的地方設(shè)置斷點 。
- 運行程序:輸入run命令運行程序,程序會在斷點處暫停 。
- 查看變量和堆棧信息:使用print命令查看變量的值,如print errno查看errno的值;使用backtrace(縮寫bt)命令查看函數(shù)調(diào)用堆棧,幫助定位錯誤發(fā)生的位置 。
- 單步執(zhí)行:通過next(縮寫n)命令單步執(zhí)行代碼,觀察程序的執(zhí)行流程和變量變化,逐步排查問題 。
7.3性能優(yōu)化建議
為了充分發(fā)揮 mmap 的性能優(yōu)勢,在使用過程中可以采取以下性能優(yōu)化建議:
合理設(shè)置映射區(qū)域大?。焊鶕?jù)實際需求,準(zhǔn)確設(shè)置映射區(qū)域的大?。╨ength 參數(shù))。如果映射區(qū)域過小,可能導(dǎo)致頻繁的映射和取消映射操作,增加系統(tǒng)開銷;而映射區(qū)域過大,會占用過多內(nèi)存資源,影響系統(tǒng)整體性能 。例如,在處理大文件時,可以根據(jù)文件的邏輯結(jié)構(gòu)和訪問模式,將文件劃分為合適大小的塊進(jìn)行映射,避免一次性映射整個大文件 。
避免頻繁映射和取消映射:每次映射和取消映射操作都需要系統(tǒng)進(jìn)行資源分配和回收,頻繁進(jìn)行這些操作會消耗大量的系統(tǒng)時間 。在程序設(shè)計中,應(yīng)盡量減少不必要的映射和取消映射操作 。例如,可以在程序初始化階段一次性完成所需的映射,在程序結(jié)束時再統(tǒng)一取消映射 。如果需要動態(tài)調(diào)整映射區(qū)域,可以考慮使用mremap函數(shù),它可以在不取消映射的情況下調(diào)整映射區(qū)域的大小 。
結(jié)合緩存機(jī)制:雖然 mmap 已經(jīng)減少了數(shù)據(jù)拷貝和系統(tǒng)調(diào)用次數(shù),但在某些場景下,結(jié)合緩存機(jī)制可以進(jìn)一步提升性能 。對于頻繁訪問的數(shù)據(jù),可以在用戶空間設(shè)置緩存,當(dāng)訪問數(shù)據(jù)時,先檢查緩存中是否存在,如果存在則直接從緩存中讀取,避免每次都通過 mmap 訪問文件 。這樣可以減少磁盤 I/O 操作,提高數(shù)據(jù)訪問速度 。同時,要注意緩存的一致性問題,當(dāng)數(shù)據(jù)發(fā)生變化時,及時更新緩存和文件 。
使用預(yù)讀和異步 I/O:在某些應(yīng)用場景中,如順序讀取大文件,可以使用madvise函數(shù)的MADV_SEQUENTIAL標(biāo)志來提示內(nèi)核進(jìn)行預(yù)讀優(yōu)化,讓內(nèi)核提前將數(shù)據(jù)讀入內(nèi)存,提高讀取效率 。此外,結(jié)合異步 I/O(如io_uring),可以在進(jìn)行 I/O 操作時不阻塞主線程,使程序能夠更高效地利用系統(tǒng)資源 。
注意內(nèi)存對齊:在訪問 mmap 映射的內(nèi)存時,要注意內(nèi)存對齊問題。不同的硬件架構(gòu)對內(nèi)存對齊有不同的要求,如果內(nèi)存訪問未對齊,可能會導(dǎo)致性能下降甚至硬件異常 。確保數(shù)據(jù)結(jié)構(gòu)和內(nèi)存訪問操作符合內(nèi)存對齊規(guī)則,可以提高程序的性能和穩(wěn)定性 。例如,在定義數(shù)據(jù)結(jié)構(gòu)時,可以使用特定的編譯指令(如#pragma pack)來控制數(shù)據(jù)結(jié)構(gòu)的對齊方式 。
Part8.mmap面試總結(jié)
mmap 的實現(xiàn)過程相當(dāng)復(fù)雜,主要分為三個階段。在第一個階段,進(jìn)程啟動映射過程,并在虛擬地址空間中為映射創(chuàng)建虛擬映射區(qū)域。這就像是在一片空地上規(guī)劃出一塊特定的區(qū)域,準(zhǔn)備用來建造房屋(映射)。進(jìn)程在用戶空間調(diào)用庫函數(shù) mmap,然后在當(dāng)前進(jìn)程的虛擬地址空間中,尋找一段空閑的滿足要求的連續(xù)的虛擬地址。找到合適的地址后,會為此虛擬區(qū)分配一個 vm_area_struct 結(jié)構(gòu),接著對這個結(jié)構(gòu)的各個域進(jìn)行初始化,就像為即將建造的房屋準(zhǔn)備好各種建筑材料和設(shè)計藍(lán)圖。最后,將新建的虛擬區(qū)結(jié)構(gòu)插入進(jìn)程的虛擬地址區(qū)域鏈表或樹中,方便后續(xù)快速訪問。
第二個階段是調(diào)用內(nèi)核空間的系統(tǒng)調(diào)用函數(shù) mmap,實現(xiàn)文件物理地址和進(jìn)程虛擬地址的一一映射關(guān)系。這一步就像是按照設(shè)計藍(lán)圖開始建造房屋,將規(guī)劃好的虛擬區(qū)域與實際的文件物理地址連接起來。通過待映射的文件指針,在文件描述符表中找到對應(yīng)的文件描述符,再通過文件描述符,鏈接到內(nèi)核 “已打開文件集” 中該文件的文件結(jié)構(gòu)體。每個文件結(jié)構(gòu)體維護(hù)著和這個已打開文件相關(guān)的各項信息,就像房屋的建筑圖紙上標(biāo)注著各種細(xì)節(jié)信息。
然后,通過該文件的文件結(jié)構(gòu)體,鏈接到 file_operations 模塊,調(diào)用內(nèi)核函數(shù) mmap。內(nèi)核 mmap 函數(shù)通過虛擬文件系統(tǒng) inode 模塊定位到文件磁盤物理地址,最后通過 remap_pfn_range 函數(shù)建立頁表,實現(xiàn)文件地址和虛擬地址區(qū)域的映射關(guān)系。此時,這片虛擬地址雖然已經(jīng)和文件物理地址建立了聯(lián)系,但還沒有將任何文件數(shù)據(jù)拷貝至主存,就像房屋雖然建好了框架,但里面還沒有擺放任何家具。
直到第三個階段,進(jìn)程發(fā)起對這片映射空間的訪問,引發(fā)缺頁異常,才真正實現(xiàn)文件內(nèi)容到物理內(nèi)存(主存)的拷貝。當(dāng)進(jìn)程的讀或?qū)懖僮髟L問虛擬地址空間這一段映射地址時,通過查詢頁表,發(fā)現(xiàn)這一段地址并不在物理頁面上,因為目前只建立了地址映射,真正的硬盤數(shù)據(jù)還沒有拷貝到內(nèi)存中,所以引發(fā)缺頁異常。缺頁異常會進(jìn)行一系列判斷,確定無非法操作后,內(nèi)核發(fā)起請求調(diào)頁過程。
調(diào)頁過程先在交換緩存空間中尋找需要訪問的內(nèi)存頁,如果沒有則調(diào)用 nopage 函數(shù)把所缺的頁從磁盤裝入到主存中。之后進(jìn)程即可對這片主存進(jìn)行讀或者寫的操作,如果寫操作改變了其內(nèi)容,一定時間后系統(tǒng)會自動回寫臟頁面到對應(yīng)磁盤地址,也即完成了寫入到文件的過程 。
回想起面試時,我對 mmap 的原理只是一知半解,回答得漏洞百出。對于它與普通文件讀寫操作的區(qū)別,我也沒有分析到位。普通文件讀寫操作通常會涉及多次數(shù)據(jù)拷貝,先將文件頁從磁盤拷貝到頁緩存中,由于頁緩存處在內(nèi)核空間,不能被用戶進(jìn)程直接尋址,所以還需要將頁緩存中數(shù)據(jù)頁再次拷貝到內(nèi)存對應(yīng)的用戶空間中,這樣就增加了數(shù)據(jù)傳輸?shù)臅r間和系統(tǒng)開銷。
而 mmap 則減少了這種數(shù)據(jù)拷貝的次數(shù),提高了讀寫效率 。在實際應(yīng)用場景方面,我更是考慮得不夠全面。mmap 在需要頻繁讀寫大文件、實現(xiàn)進(jìn)程間通信以及高效的內(nèi)存管理等場景中都有著廣泛的應(yīng)用。比如在一些大數(shù)據(jù)處理場景中,使用 mmap 可以大大提高數(shù)據(jù)的讀取速度,減少處理時間。
正是因為我對 mmap 的理解存在如此多的不足,才在面試中被這個問題難住,最終與騰訊的 offer 失之交臂。這次的經(jīng)歷讓我深刻認(rèn)識到,在技術(shù)學(xué)習(xí)的道路上,容不得半點馬虎和一知半解,每一個知識點都可能成為決定成敗的關(guān)鍵。























