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

FeelTouch發表於2019-05-11

轉載:https://blog.csdn.net/qq_36359022/article/details/79992287

參考:https://blog.csdn.net/hj605635529/article/details/73163513

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變數的地址一樣,但是對映到實體記憶體上就不同了。同理,共享儲存對映區的實體地址是相同的。

è¿éåå¾çæè¿°

相關文章