mmap 共享存儲映射 -存儲 I-O 映射- 詳解

mmap 共享存儲映射又稱爲存儲 I/O 映射,是 Unix** 共享內存 ** 概念中的一種。

在 Unix 進程間通信中,大致有

1. 管道                  pipe(),用於父子進程間通信(不考慮傳遞描述符)
2. FIFO(有名管道)       非父子進程也能使用,以文件打通
3. 文件                  文件操作,效率可想而知
4. 本地套接字             最穩定,也最複雜.套接字採用Unix域
5. 共享內存               傳遞最快,消耗最小,傳遞數據過程不涉及系統調用
6. 信號                  數據固定且短小

其中,共享內存是 IPC(進程間通信)中最快的,一旦共享內存映射到共享它的進程的地址空間中,這些進程的數據傳遞就不再涉及內核,因爲它會以指針的方式讀寫內存,不涉及系統級調用。

一、管道與共享存儲映射對比

管道

請看下圖,左圖描述了 fork() 前通過 pipe() 開啓管道的示意圖,假設父進程從文件 A 中讀取數據並通過管道傳遞給子進程,由子進程執行某些操作後寫入文件 B。

首先,進程的數據區位於 0-3G 的虛擬地址空間中,3G-4G 爲內核區,注意,文件 A 和文件 B 並不是存儲在內核區,這裏只是示意。並且,本次父子進程完全按照最早期 Unix 的實現講解,也就是說父子進程完全獨立的空間,不涉及到後來的寫時複製等技術。

(1)父進程通過系統調用 read() 從文件 A 讀取數據的過程中,父進程的狀態切換到內核態,讀取數據並保存到父進程空間中的 buf 中,再切換回用戶態。這裏發生了第一次數據的拷貝。

(2)父進程通過系統調用 write() 將讀取的數據從 buf 中拷貝到管道的過程中,父進程狀態切換到內核態,向管道寫入數據,再切換回用戶態。這裏發生第二次數據拷貝。

(3)子進程通過系統調用 read() 從管道讀取數據的過程中,子進程狀態切換到內核態,讀取數據並保存到子進程空間中的 buf 中,再切換回用戶態。這裏發生第三次數據拷貝。

(4)子進程通過系統調用 write() 將讀取的數據從 buf 中拷貝到文件 B 的過程中,子進程狀態切換到內核態,向文件 B 寫入數據,再切換回用戶態。這裏發生第四次數據拷貝。

可以看到,這裏發生了四次數據拷貝都是再內核與某個進程間進行的,這種開銷往往更大(比存粹在內核中或單個進程內複製數據的開銷更大)

因此,通過管道進行數據傳遞在編程上簡單,而實際開銷是作爲一個追求極致效率的程序員所不允許的。接着我們來看看共享存儲映射的開銷是怎樣的呢?

共享存儲映射 (存儲 I/O 映射)

請看下圖,該圖描述了父進程使用 mmap() 使用共享存儲映射,fork() 後,fork 會對內存映射文件進行特殊處理,也就是父進程在調用 fork() 之前創建的內存映射關係由子進程共享。該方式只有兩次系統系統調用。而之前有四次調用

因此,父子進程可以通過指針對該內存區域進行讀寫操作,以完成數據通信。

該方法的奇特之處在於,進程間通信的 I/O 操作在內核的掩蓋下完成,對內存的直接存取操作不涉及系統調用,避免了進程狀態的頻繁切換與系統調用。

(1)使用 mmap() 建立共享存儲映射區

(2)父進程 fork(),子進程共享該區域

(3)父進程讀取文件 A 中的數據的過程中,切換至內核態,根據 mmap 返回的指針 ptr,將數據拷貝到共享區域,再切換回來。這裏發生第一次數據拷貝。

(4)子進程根據 ptr 指針從內存讀取數據到文件 B,切換到內核態,write 數據到文件 B,再切換回來。這裏發生第二次數據拷貝。

注意:這裏是父進程直接 copy 文件 A 到共享區,子進程從共享區 copy 數據到文件 B。

共享存儲映射是將磁盤上的文件映射到進程的虛擬地址空間,其物理支撐是物理內存,而進程通信時就是通過物理內存來傳遞數據,而不是寫入磁盤再讀出來。

二、mmap 函數

mmap 函數把一個文件或一個 Posix 共享內存區對象映射到調用進程的地址空間。

(1)使用普通文件以提供內存映射 I/O

(2)使用特殊文件以提供匿名內存映射

#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
若成功則返回被映射區的起始地址,若出錯則返回MAP_FAILED 

addr:指定被映射到進程空間內的起始地址,通常設爲 NULL,代表讓系統自動選定地址,映射成功後返回該地址。
len:映射到調用進程地址空間中的字節數。
prot:內存映射區域的保護方式。常用 PROT_READ | PROT_WRITE 
         PROT_EXEC 映射區域可被執行
         PROT_READ 映射區域可被讀取
         PROT_WRITE 映射區域可被寫入
         PROT_NONE 映射區域不能存取
flags:MAP_SHARED 和 MAP_PRIVATE 必須指定一個,其他可選。
         MAP_SHARED 調用進程對被映射數據所作修改對於共享該對象的所有進程可見,並且改變其底層支撐(物理內存) 並不是改變內存數據就馬上寫回磁盤。這個取決於虛擬存儲的實現。
         MAP_PRIVATE 調用進程對被映射數據所作的修改只對該進程可見,而不改變其底層支撐(物理內存) 
         MAP_FIXED 用於準確解釋addr參數,從移植性考慮不應指定它,如果沒有指定,而addr不是空指針,那麼addr如何處置取決於實現。不爲空的addr值通常被當作有關該內存區應如何具體定位的線索。可移植的代碼應把addr指定爲空指針,並且不指定MAP_FIXED
         MAP_ANON 匿名映射時用

fd:要映射到內存中的文件描述符。如果使用匿名內存映射時,即flags中設置了MAP_ANON,fd設爲-1。

offset:文件映射的偏移量,通常設置爲0,代表從文件最前方開始對應,offset必須是分頁大小的整數倍(一般是4096的整數倍)。

使用普通文件進行存儲映射

int main(int argc, char **argv)
{  
   /*忽略命令行參數處理步驟*/
   int fd, zero = 0;
   fd = open(argv[1], O_RDWR | O_CREAT, 0644);
   write(fd, &zero, sizeof(int));
   ptr = mmap(NULL, sizeof(int), 
              PROT_READ | PROT_WRITE | MAP_SHARED,
              fd, 0);
   close(fd);
   /*
     這裏父子進程同步(信號量)的使用ptr進行數據交換
     且退出exit(0)
    */
}

匿名內存映射

/* BSD  匿名 */
int main(int argc, char **argv)
{  
   /*忽略命令行參數處理步驟*/
   int fd;
   ptr = mmap(NULL, sizeof(int), 
              PROT_READ | PROT_WRITE | MAP_SHARED | MAP_ANON,
              -1, 0);
   /*
     這裏父子進程同步(信號量)的使用ptr進行數據交換
     且退出exit(0)
    */
}

/* SVR4 /dev/zero  特殊文件 */
int main(int argc, char **argv)
{  
   /*忽略命令行參數處理步驟*/
   int fd;
   fd = open("/dev/zero", O_RDWR);
   ptr = mmap(NULL, sizeof(int), 
              PROT_READ | PROT_WRITE | MAP_SHARED,
              fd, 0);
   close(fd);
   /*
     這裏父子進程同步(信號量)的使用ptr進行數據交換
     且退出exit(0)
    */
}

三、mmap [文件大小與映射大小] 討論

回顧第二大點討論的 mmap 函數的參數,offset 參數作爲文件偏移,爲什麼要強調要 4096(分頁大小)的整數倍呢?mmap 和頁大小有關係嗎?

該部分請讀者一定要知道三個概念:虛擬地址空間 —- 物理內存 —- 磁盤

首先來看,當一個進程調用 mmap 成功後,將一個文件映射到該進程的地址空間中,現在該進程可以用返回的指針 ptr 對內存進行數據操作。物理內存中數據的變化什麼時候寫入到磁盤取決於虛擬存儲的實現,因此,並不是寫入數據到內存就馬上寫回磁盤。當然也可以調用 msync 函數進行磁盤數據同步。

文件大小等於映射區大小的情況

當我們用普通文件作映射區時,如果文件大小時 5000,並且我們也用 5000 的映射區時 (不是頁面的整數), 雖然映射區大小爲 5000,但仍能夠在一定程度上越界訪問。這其實是因爲內核的內存保護是以頁面爲單位的,5000 大小分得的物理頁面支撐實際上是 2 個頁面 (8192 大小)。

在 0-4999 可以使用 ptr 進行正常的讀寫訪問,而 5000-8191 這一段裏,內核是允許我們讀寫的,但是不會寫入。注意,是允許讀寫,但寫不進去。就是說內核允許寫操作,但內核又不執行這個寫操作。

當超過了物理頁面支撐後的任何操作都是不合規矩的,引發 SIGSEGV 信號。

文件大小遠小於映射區大小的情況

這次文件大小仍然是 5000,而映射區大小我們改爲 15000。物理頁面支撐 2 個頁面大小 (8192 大小)。

在訪問 0-4999 是沒有問題的,5000-8191 這段允許讀寫但不執行寫入操作。當超過物理頁面支撐以後的空間分爲兩種情況

(1)超過物理頁面但是沒有超過映射區大小 –> 引發 SIGBUS 信號

(2)超過物理頁面且超過映射區大小 —> 引發 SIGSEGV 信號

由此我們可以看出,mmap 映射時物理頁面上面並不是單純的以我們填入的數據分配,內核仍然會對文件本身的大小進行檢查。

可以總結如下:

(1)沒超過物理頁面,沒超過映射區大小 —> 正常讀寫

(2)沒超過物理頁面,超過映射區大小 —> 內核允許讀寫但不執行寫入操作

(3)超過物理頁面,沒有超過映射區大小 —> 引發 SIGBUS 信號

(4)超過物理頁面, 超過映射區大小 —> 引發 SIGSEGV 信號

四、父子進程存儲映射的地址分佈

首先闡述前提條件,父進程 fork 後,子進程以最早期的方式講解(不涉及寫時複製等技術)。

fork() 後,子進程是父進程的副本,子進程獲得父進程的數據空間、堆、棧、等副本,正文部分共享,PCB 進程控制塊獨享。

也就是說,父子進程在物理內存上是完全兩個不同的進程。

考慮一個場景:父進程在 fork 出子進程之前調用 mmap,因此父子進程依靠該共享存儲映射區進行進程間通信。那麼,父子進程的用戶空間、物理內存、磁盤是個什麼情況呢?

父進程 fork 之前,mmap 成功返回一個 ptr 指針指向共享存儲映射區的首地址。而共享存儲映射區是位於進程空間的虛擬地址空間裏,內核根據其實現將對應到物理內存的某個區域上,而 fork 之後,fork 會對 mmap 產生的這段共享存儲映射進行特殊處理,因此,當子進程複製得到這部分的副本時,ptr 指針仍然指向對應的物理內存的那個區域。

這樣就會產生一個疑惑,是不是子進程複製得到的這些數據的物理地址和父進程的一樣呢?

答案是不同的,雖然後來在寫時複製技術上不算錯,但這裏我們談論的是最早的實現,也就是說,除了 PCB 和正文,其他部分基本上都被複制了,父子進程在物理內存上是存放在不同區域的,而共享存儲映射的這部分物理區域是相同的。

綜上,我們編寫一個測試代碼以驗證我們的說法

該代碼的意思是:

(1)在父進程 fork 之前成功調用了 mmap 函數,我們將共享存儲映射的大小設置爲一個 int 大小的空間,將 ptr 指向的那塊物理內存賦值爲 1,局部變量 i 的值爲 1。

(2)然後 fork,程序先將父進程睡眠 1s,儘可能的保證子進程先運行,因此子進程打印出的 ptr 指向的數據應該是 1,i 值也爲 1。然後將 ptr 指向的數據改爲 2,i 值改爲 2。接着子進程睡眠

(3)父進程開始執行,如果說共享映射區的物理區域真的是共享的,那麼子進程修改的數據父進程就可以打印出 2。而事實確實是我們預期的。

(4)父進程打印數據:*ptr 爲 2,i 值爲 1。

(5)可以看到,父子進程在物理內存上的地址空間是不同的,i 並沒有被共享,而 mmap 產生的共享存儲映射區則確確實實是共享的。

然而問題又出現了,發現上圖的地址了嗎,父子進程對同一個變量的地址是相同的,ptr 的地址,ptr 指向的那個地方的地址,以及 i 的地址,父子進程打印出來一樣,代碼以睡眠的方式保證了四次打印時父子進程都是沒有結束的。

那麼,在父子數據地址相同,並且滿足局部變量不共享,共享存儲映射區共享的情況下,系統是怎麼實現的呢?

答案:各位讀者,請記住 2 個概念:虛擬地址空間 — 物理內存

父子進程所在的是用戶空間,其地址可以說是邏輯地址,而邏輯地址與真實物理地址的對應關係由 mmu 來完成,因此,父子進程的 i 變量的地址一樣,但是映射到物理內存上就不同了。同理,共享存儲映射區的物理地址是相同的。

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