Linux Mmap 映射:優化文件訪問和共享內存

Linux 中的 Mmap(Memory Map)是一種內存映射機制,它允許將文件或設備的一部分映射到進程的虛擬內存空間。通過使用 Mmap,進程可以直接訪問被映射對象的內容,而無需進行傳統的讀取和寫入操作。

在內存映射過程中,操作系統會將文件數據按頁(通常是 4KB)進行劃分,並在物理內存和虛擬地址空間之間建立對應關係。當進程需要訪問文件時,它只需要使用指針來讀寫相應的內存地址即可,而無需手動調用 read() 或 write() 函數進行 I/O 操作。這種直接訪問的方式可以提高讀寫效率,並且簡化了程序邏輯。

一、進程共享內存與 mmap 的關係

共享內存允許兩個或多個進程共享一給定的存儲區,因爲數據不需要來回複製,所以是最快的一種進程間通信機制。共享內存可以通過 mmap() 映射普通文件 (特殊情況下還可以採用匿名映射)機制實現,也可以通過系統 V 共享內存機制實現。應用接口和原理很簡單,內部機制複雜。爲了實現更安全通信,往往還與信號 燈等同步機制共同使用。

mmap 的機制如:就是在磁盤上建立一個文件,每個進程存儲器裏面,單獨開闢一個空間來進行映射。如果多進程的話,那麼不會對實際的物理存儲器(主存)消耗太大。
shm 的機制:每個進程的共享內存都直接映射到實際物理存儲器裏面。

1、mmap 保存到實際硬盤,實際存儲並沒有反映到主存上。

2、shm 保存到物理存儲器(主存),實際的儲存量直接反映到主存上。

使用上看:如果分配的存儲量不大,那麼使用 shm;如果存儲量大,那麼使用 shm。

系統調用 mmap() 用於共享內存的兩種方式:

(1)使用普通文件提供的內存映射:適用於任何進程之間;此時,需要打開或創建一個文件,然後再調用 mmap();典型調用代碼如下:

fd=open(name, flag, mode);
if(fd<0)
...
ptr=mmap(NULL, len , PROT_READ|PROT_WRITE, MAP_SHARED , fd , 0);

通過 mmap() 實現共享內存的通信方式有許多特點和要注意的地方,我們將在範例中進行具體說明。

(2)使用特殊文件提供匿名內存映射:適用於具有親緣關係的進程之間;由於父子進程特殊的親緣關係,在父進程中先調用 mmap(),然後調用 fork()。那麼在調用 fork() 之後,子進程繼承父進程匿名映射後的地址空間,同樣也繼承 mmap() 返回的地址,這樣,父子進程就可以通過映射區 域進行通信了。注意,這裏不是一般的繼承關係。一般來說,子進程單獨維護從父進程繼承下來的一些變量。而 mmap() 返回的地址,卻由父子進程共同維護。

內存映射文件與虛擬內存有些類似,通過內存映射文件可以保留一個地址空間的區域,同時將物理存儲器提交給此區域,只是內存文件映射的物理存儲器來自一個已 經存在於磁盤上的文件,而非系統的頁文件,而且在對該文件進行操作之前必須首先對文件進行映射,就如同將整個文件從磁盤加載到內存。由此可以看出,使用內 存映射文件處理存儲於磁盤上的文件時,將不必再對文件執行 I/O 操作,這意味着在對文件進行處理時將不必再爲文件申請並分配緩存,所有的文件緩存操作均由 系統直接管理,由於取消了將文件數據加載到內存、數據從內存到文件的回寫以及釋放內存塊等步驟,使得內存映射文件在處理大數據量的文件時能起到相當重要的 作用。另外,實際工程中的系統往往需要在多個進程之間共享數據,如果數據量小,處理方法是靈活多變的,如果共享數據容量巨大,那麼就需要藉助於內存映射文 件來進行。實際上,內存映射文件正是解決本地多個進程間數據共享的最有效方法。

Linux 給我們提供了豐富的內部進程通信機制,包括共享內存、內存映射文件、先入先出(FIFO)、接口(sockets)以及多種用於同步的標識。在本文中,我們主要討論一下共享內存和內存映射文件技術。

一般來說,內部進程通信(interprocess communication)也就是 IPC,是指兩個或兩個以上進程以及兩個或者兩個以上線程之間進行通信聯繫。每個 IPC 機制都有不同的強項或者弱點, 不過沒有一個 IPC 機制包含內建的同步方法。因此程序員不但需要自己在程序中實現同步,而且還需要爲了利用 IPC 機制而自己開發通信協議。

1.1 共享內存

使用共享內存和使用 malloc 來分配內存區域很相似。使用共享內存的方法是:

下面是個例子:

 //建立共享內存區域
  intshared_id;
  char *region;
  const intshm_size = 1024;
  
  shared_id = shmget(IPC_PRIVATE,//保證使用唯一ID
            shm_size,
            IPC_CREAT | IPC_EXCL |//創建一個新的內存區域
            S_IRUSR | S_IWUSR);//使當前用戶可以讀寫這個區域
  
  //交叉進程或生成進程.
  
  //將新建的內存區域放入進程/線程
  region = (char*) shmat(segment_id, 0, 0);
  
  //其他程序代碼
  ...
  
  //將各個進程/線程分離出來
  shmdt(region);
  
  //破壞掉共享內存區域
  shmctl(shared_id, IPC_RMID, 0);

共享內存是 Linux 中最快速的 IPC 方法。他也是一個雙向過程,共享區域內的任何進程都可以讀寫內存。這個機制的不利方面是其同步和協議都不受程序員控制,你必須確保將句柄傳遞給了子進程和線程。

1.2 內存映射文件

內存映射文件不僅僅用於 IPC,在其他進程中它也有很大作用。如果你需要將一個分配的緩衝區初始化爲零,只要記住 / dev/zero 。你也可以通過將文件映射到內存中以提高其性能。它使你可以像讀寫字符串一樣讀寫文件。下面是個例子:

const char filename[] = "testfile";
  intfd;
  char *mapped_mem;
  const intflength = 1024;
  fd = open(filename, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
  lseek(fd, flength + 1, SEEK_SET);
  write(fd, "\0", 1);
  lseek(fd, 0, SEEK_SET);
  
  mapped_mem = mmap(0,
           flength,
           PROT_WRITE, //允許寫入
           MAP_SHARED,//寫入內容被立即寫入到文件
           fd,
           0);
  
  close(fd);
  
  //使用映射區域.
  ...
  
  munmap(file_memory, flength);

利用內存映射來處理 IPC 的好處是在整個過程中你不需要處理句柄:只要打開文件並把它映射在合適的位置就行了。你可以在兩個不相關的進程間使用內存映射文件。

使用內存映射的缺點是速度不如共享內存快。如果湊巧文件很大,所需要的虛擬內存就會很大,這樣會造成整體性能下降。

二、mmap 共享內存

mmap 是一種內存映射的方法,即將一個文件或其他對象映射到進程的地址空間,實現文件磁盤地址和進程虛擬地址空間中的一段虛擬地址的一一映射關係。實現這樣的映射之後,進程就可以採用指針的方式讀寫操作這一段內存,而系統會自動回寫髒頁面到對應的文件磁盤上,即完成了對文件的操作而不必調用 read、write 等函數調用。相反,內核空間對這段區域的修改也直接反映到用戶空間,從而實現不同進程之間的文件共享。如下圖所示:

虛擬內存區域(vm_area_struct)是進程的虛擬地址空間中的一個同質區間,即具有同樣特性的連續地址範圍。上圖中所示的 text 數據段(代碼段)、初始數據段、BSS 數據段、堆、棧和內存映射,都是一個獨立的虛擬內存區域。由上圖可以看出,進程的虛擬地址空間,由多個虛擬內存區域構成。

linux 內核使用 vm_area_struct 結構來表示一個獨立的虛擬內存區域,由於每個不同質的虛擬內存區域功能和內部機制都不同,因此一個進程使用多個 vm_area_struct 結構來分別表示不同類型的虛擬內存區域。各個 vm_area_struct 結構使用鏈表或者樹形結構鏈接,方便進程快速訪問。

vm_area_struct 結構中包含區域起始和終止地址以及其他相關信息,同時也包含一個 vm_ops 指針,其內部可引出所有針對這個區域可以使用的系統調用函數。mmap 函數就是要創建一個新的 vm_area_struct 結構,並將其與文件的物理磁盤地址相連。

三、mmap 內存映射原理

mmap 內存映射的實現過程,總的來說可以分爲三個階段:

(一)進程啓動映射過程,並在虛擬地址空間中爲映射創建虛擬映射區域

(二)調用內核空間的系統調用函數 mmap(不同於用戶空間函數),實現文件物理地址和進程虛擬地址的一一映射關係

(三)進程發起對這片映射空間的訪問,引發缺頁異常,實現文件內容到物理內存(主存)的拷貝

注:前兩個階段僅在於創建虛擬區間並完成地址映射,但是並沒有將任何文件數據的拷貝至主存。真正的文件讀取是當進程發起讀或寫操作時。

注:修改過的髒頁面並不會立即更新迴文件中,而是有一段時間的延遲,可以調用 msync() 來強制同步, 這樣所寫的內容就能立即保存到文件裏了。

四、文件讀寫的基本流程

4.1 讀文件

1、進程調用庫函數向內核發起讀文件請求;

2、內核通過檢查進程的文件描述符定位到虛擬文件系統的已打開文件列表表項;

3、調用該文件可用的系統調用函數 read()

3、read() 函數通過文件表項鍊接到目錄項模塊,根據傳入的文件路徑,在目錄項模塊中檢索,找到該文件的 inode;

4、在 inode 中,通過文件內容偏移量計算出要讀取的頁;

5、通過 inode 找到文件對應的 address_space;

6、在 address_space 中訪問該文件的頁緩存樹,查找對應的頁緩存結點:

7、文件內容讀取成功。

4.2 寫文件

前 5 步和讀文件一致,在 address_space 中查詢對應頁的頁緩存是否存在:

6、如果頁緩存命中,直接把文件內容修改更新在頁緩存的頁中。寫文件就結束了。這時候文件修改位於頁緩存,並沒有寫回到磁盤文件中去。

7、如果頁緩存缺失,那麼產生一個頁缺失異常,創建一個頁緩存頁,同時通過 inode 找到文件該頁的磁盤地址,讀取相應的頁填充該緩存頁。此時緩存頁命中,進行第 6 步。

8、一個頁緩存中的頁如果被修改,那麼會被標記成髒頁。髒頁需要寫回到磁盤中的文件塊。有兩種方式可以把髒頁寫回磁盤:

同時注意,髒頁不能被置換出內存,如果髒頁正在被寫回,那麼會被設置寫回標記,這時候該頁就被上鎖,其他寫請求被阻塞直到鎖釋放。

五、mmap 和常規文件操作的區別

函數的調用過程:

總結來說,常規文件操作爲了提高讀寫效率和保護磁盤,使用了頁緩存機制。這樣造成讀文件時需要先將文件頁從磁盤拷貝到頁緩存中,由於頁緩存處在內核空間,不能被用戶進程直接尋址,

所以還需要將頁緩存中數據頁再次拷貝到內存對應的用戶空間中。這樣,通過了兩次數據拷貝過程,才能完成進程對文件內容的獲取任務。

寫操作也是一樣,待寫入的 buffer 在內核空間不能直接訪問,必須要先拷貝至內核空間對應的主存,再寫回磁盤中(延遲寫回),也是需要兩次數據拷貝。

而使用 mmap 操作文件中,創建新的虛擬內存區域和建立文件磁盤地址和虛擬內存區域映射這兩步,沒有任何文件拷貝操作。而之後訪問數據時發現內存中並無數據而發起的缺頁異常過程,

可以通過已經建立好的映射關係,只使用一次數據拷貝,就從磁盤中將數據傳入內存的用戶空間中,供進程使用。

總而言之,常規文件操作需要從磁盤到頁緩存再到用戶主存的兩次數據拷貝。而 mmap 操控文件,只需要從磁盤到用戶主存的一次數據拷貝過程。

說白了,mmap 的關鍵點是實現了用戶空間和內核空間的數據直接交互而省去了空間不同數據不通的繁瑣過程。因此 mmap 效率更高。

5.1mmap 優點

1、對文件的讀取操作跨過了頁緩存,減少了數據的拷貝次數,用內存讀寫取代 I/O 讀寫,提高了文件讀取效率。

2、實現了用戶空間和內核空間的高效交互方式。兩空間的各自修改操作可以直接反映在映射的區域內,從而被對方空間及時捕捉。

3、提供進程間共享內存及相互通信的方式。不管是父子進程還是無親緣關係的進程,都可以將自身用戶空間映射到同一個文件或匿名映射到同一片區域。從而通過各自對映射區域的改動,達到進程間通信和進程間共享的目的。

同時,如果進程 A 和進程 B 都映射了區域 C,當 A 第一次讀取 C 時通過缺頁從磁盤複製文件頁到內存中;但當 B 再讀 C 的相同頁面時,雖然也會產生缺頁異常,但是不再需要從磁盤中複製文件過來,而可直接使用已經保存在內存中的文件數據。

4、可用於實現高效的大規模數據傳輸。內存空間不足,是制約大數據操作的一個方面,解決方案往往是藉助硬盤空間協助操作,補充內存的不足。但是進一步會造成大量的文件 I/O 操作,極大影響效率。這個問題可以通過 mmap 映射很好的解決。換句話說,但凡是需要用磁盤空間代替內存的時候,mmap 都可以發揮其功效。

5.2mmap 使用細節

1、使用 mmap 需要注意的一個關鍵點:mmap 映射區域大小必須是物理頁大小 (page_size) 的整倍數(32 位系統中通常是 4k 字節)。原因是,內存的最小粒度是頁,而進程虛擬地址空間和內存的映射也是以頁爲單位。爲了匹配內存的操作,mmap 從磁盤到虛擬地址空間的映射也必須是頁。

2、內核可以跟蹤被內存映射的底層對象(文件)的大小,進程可以合法的訪問在當前文件大小以內又在內存映射區以內的那些字節。也就是說,如果文件的大小一直在擴張,只要在映射區域範圍內的數據,進程都可以合法得到,這和映射建立時文件的大小無關。具體情形參見 “情形三”。

3、映射建立之後,即使文件關閉,映射依然存在。因爲映射的是磁盤的地址,不是文件本身,和文件句柄無關。同時可用於進程間通信的有效地址空間不完全受限於被映射文件的大小,因爲是按頁映射。

5.3 使用案例

情形一:一個文件的大小是 5000 字節,mmap 函數從一個文件的起始位置開始,映射 5000 字節到虛擬內存中。

分析:因爲單位物理頁面的大小是 4096 字節,雖然被映射的文件只有 5000 字節,但是對應到進程虛擬地址區域的大小需要滿足整頁大小,因此 mmap 函數執行後,實際映射到虛擬內存區域 8192 個 字節,5000~8191 的字節部分用零填充。映射後的對應關係如下圖所示:

此時:

情形二:一個文件的大小是 5000 字節,mmap 函數從一個文件的起始位置開始,映射 15000 字節到虛擬內存中,即映射大小超過了原始文件的大小。

分析:由於文件的大小是 5000 字節,和情形一一樣,其對應的兩個物理頁。那麼這兩個物理頁都是合法可以讀寫的,只是超出 5000 的部分不會體現在原文件中。由於程序要求映射 15000 字節,而文件只佔兩個物理頁,因此 8192 字節~ 15000 字節都不能讀寫,操作時會返回異常。如下圖所示:

此時:

情形三:一個文件初始大小爲 0,使用 mmap 操作映射了 1000*4K 的大小,即 1000 個物理頁大約 4M 字節空間,mmap 返回指針 ptr。

分析:如果在映射建立之初,就對文件進行讀寫操作,由於文件大小爲 0,並沒有合法的物理頁對應,如同情形二一樣,會返回 SIGBUS 錯誤。

但是如果,每次操作 ptr 讀寫前,先增加文件的大小,那麼 ptr 在文件大小內部的操作就是合法的。例如,文件擴充 4096 字節,ptr 就能操作 ptr ~ [(char)ptr + 4095] 的空間。只要文件擴充的範圍在 1000 個物理頁(映射範圍)內,ptr 都可以對應操作相同的大小。

這樣,方便隨時擴充文件空間,隨時寫入文件,不造成空間浪費。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5vzy4vVZrOMgAEspZqBZsQ