mmap共享存儲映射(存儲I/O映射)系列詳解

mmap共享存儲映射又稱爲存儲I/O映射,是Unix**共享內存**概念中的一種。
在Unix進程間通信中,大致有

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

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

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

首先,我們簡單的對比 管道與共享存儲映射。

管道

管道相關還可以看這篇文章:
https://blog.csdn.net/qq_36359022/article/details/79795218

請看下圖,左圖描述了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變量的地址一樣,但是映射到物理內存上就不同了。同理,共享存儲映射區的物理地址是相同的。

這裏寫圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章