內存映射 mmap:高效讀寫數據的利器

內存映射是一種操作系統提供的技術,將磁盤上的文件映射到進程的地址空間中,使得文件可以像訪問內存一樣被讀寫。通過內存映射,可以避免頻繁的磁盤 IO 操作,從而提升讀寫性能。

內存映射文件,是由一個文件到一塊內存的映射。Win32 提供了允許應用程序把文件映射到一個進程的函數 (CreateFileMapping)。內存映射文件與虛擬內存有些類似,通過內存映射文件可以保留一個地址空間的區域,同時將物理存儲器提交給此區域,內存文件映射的物理存儲器來自一個已經存在於磁盤上的文件,而且在對該文件進行操作之前必須首先對文件進行映射。使用內存映射文件處理存儲於磁盤上的文件時,將不必再對文件執行 I/O 操作,使得內存映射文件在處理大數據量的文件時能起到相當重要的作用。

一、概念

內存映射是通過將磁盤文件映射到進程的地址空間中來實現的。在這種方式下,操作系統會將文件的某個區域映射到進程的虛擬地址空間中,從而使得程序可以直接訪問這個區域的數據。

在 Linux 系統中,內存映射是通過調用 mmap() 函數來實現的。這個函數的原型如下:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
/*
addr:指定映射的起始地址。如果爲 NULL,則由操作系統自動選擇一個地址。
length:指定映射的長度。
prot:指定映射區域的保護方式。可以是以下幾種值的組合:
    PROT_READ:可讀。
    PROT_WRITE:可寫。
    PROT_EXEC:可執行。
flags:指定映射區域的標誌。可以是以下幾種值的組合:
    MAP_SHARED:與其他進程共享映射區域。
    MAP_PRIVATE:不與其他進程共享映射區域。
    MAP_FIXED:指定映射區域的起始地址。如果指定了這個標誌,則 addr 參數必須爲非 NULL。
    MAP_ANONYMOUS:不映射任何文件,而是映射一段匿名的內存區域。
fd:指定要映射的文件描述符。
offset:指定要映射的文件的偏移量。
*/

下面代碼的作用是讀取一個文件的內容,並將其輸出到屏幕上。然後,它將文件的內容修改爲 "Hello, world!",並將修改後的內容寫回到磁盤中。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
 
int main(int argc, char *argv[]) {
    int fd;
    char *addr;
    struct stat sb;
 
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <file>\n", argv[0]);
        exit(EXIT_FAILURE);
    }
 
    fd = open(argv[1], O_RDWR);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }
 
    if (fstat(fd, &sb) == -1) {
        perror("fstat");
        exit(EXIT_FAILURE);
    }
 
    addr = mmap(NULL, sb.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (addr == MAP_FAILED) {
        perror("mmap");
        exit(EXIT_FAILURE);
    }
 
    printf("The file content is: %s\n", addr);
 
    strcpy(addr, "Hello, world!");
 
    if (msync(addr, sb.st_size, MS_SYNC) == -1) {
        perror("msync");
        exit(EXIT_FAILURE);
    }
 
    if (munmap(addr, sb.st_size) == -1) {
        perror("munmap");
        exit(EXIT_FAILURE);
    }
 
    close(fd);
 
    return 0;
}

與 Direct IO 的不同

內存映射和 Direct IO 都是用來提高文件讀寫性能的技術,但它們之間有一些不同。

首先,內存映射是將文件映射到進程的地址空間中,而 Direct IO 是直接使用文件描述符進行讀寫操作。因此,內存映射可以充分利用虛擬內存系統的優勢,而 Direct IO 則可以避免緩存的影響。

其次,內存映射可以實現文件的共享訪問,而 Direct IO 則不行。這是因爲 Direct IO 會繞過文件系統緩存,而文件系統緩存是用來實現文件共享訪問的。

最後在內存映射中,修改過的數據會被緩存在內存中,並不會立即寫回到磁盤中。如果需要將數據寫回到磁盤中,可以使用 msync() 函數或者 munmap() 函數來實現。而 Direct IO 是可以直接將數據寫入磁盤的。

1.1mmap 是什麼

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

如下圖所示:

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

而爲內存映射服務的地址空間處在堆棧之間的空餘部分。linux 內核使用 vm_area_struct 結構來表示一個獨立的虛擬內存區域,由於每個不同質的虛擬內存區域功能和內部機制都不同,因此一個進程使用多個 vm_area_struct 結構來分別表示不同類型的虛擬內存區域。各個 vm_area_struct 結構使用鏈表或者樹形結構鏈接,方便進程快速訪問,如下圖所示:

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

1.2mmap 內存映射原理

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

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

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

(三)進程發起對這片映射空間的訪問,引發缺頁異常,實現文件內容到物理內存(主存)的拷貝
注:前兩個階段僅在於創建虛擬區間並完成地址映射,但是並沒有將任何文件數據的拷貝至主存。真正的文件讀取是當進程發起讀或寫操作時。

  1. 9、進程的讀或寫操作訪問虛擬地址空間這一段映射地址,通過查詢頁表,發現這一段地址並不在物理頁面上。因爲目前只建立了地址映射,真正的硬盤數據還沒有拷貝到內存中,因此引發缺頁異常。

  2. 10、缺頁異常進行一系列判斷,確定無非法操作後,內核發起請求調頁過程。

  3. 11、調頁過程先在交換緩存空間(swap cache)中尋找需要訪問的內存頁,如果沒有則調用 nopage 函數把所缺的頁從磁盤裝入到主存中。

  4. 12、之後進程即可對這片主存進行讀或者寫的操作,如果寫操作改變了其內容,一定時間後系統會自動回寫髒頁面到對應磁盤地址,也即完成了寫入到文件的過程。
    注:修改過的髒頁面並不會立即更新迴文件中,而是有一段時間的延遲,可以調用 msync() 來強制同步, 這樣所寫的內容就能立即保存到文件裏了。

1.3mmap 和常規文件操作的區別

常規文件操作需要從磁盤到頁緩存 (處於內核空間,不能被用戶進程直接尋址),再到用戶主存的兩次數據拷貝。而 mmap 操控文件,只需要從磁盤到用戶主存的一次數據拷貝過程(創建新的虛擬內存區域,建議文件磁盤地址和虛擬內存區域映射,一次拷貝)。

我們首先簡單的回顧一下常規文件系統操作(調用 read/fread 等類函數)中,函數的調用過程:

總結來說,常規文件操作爲了提高讀寫效率和保護磁盤,使用了頁緩存機制。這樣造成讀文件時需要先將文件頁從磁盤拷貝到頁緩存中,由於頁緩存處在內核空間,不能被用戶進程直接尋址,所以還需要將頁緩存中數據頁再次拷貝到內存對應的用戶空間中。這樣,通過了兩次數據拷貝過程,才能完成進程對文件內容的獲取任務。寫操作也是一樣,待寫入的 buffer 在內核空間不能直接訪問,必須要先拷貝至內核空間對應的主存,再寫回磁盤中(延遲寫回),也是需要兩次數據拷貝。

而使用 mmap 操作文件中,創建新的虛擬內存區域和建立文件磁盤地址和虛擬內存區域映射這兩步,沒有任何文件拷貝操作。而之後訪問數據時發現內存中並無數據而發起的缺頁異常過程,可以通過已經建立好的映射關係,只使用一次數據拷貝,就從磁盤中將數據傳入內存的用戶空間中,供進程使用。

總而言之,常規文件操作需要從磁盤到頁緩存再到用戶主存的兩次數據拷貝。而 mmap 操控文件,只需要從磁盤到用戶主存的一次數據拷貝過程。說白了,mmap 的關鍵點是實現了用戶空間和內核空間的數據直接交互而省去了空間不同數據不通的繁瑣過程。因此 mmap 效率更高。

1.4mmap 的優點

mmap 系統調用使得進程之間通過映射同一個普通文件實現共享內存。普通文件被映射到進程地址空間後,進程可以像訪問普通內存一樣對文件進行訪問,不必再調用 read(),write()等操作。mmap 並不分配空間, 只是將文件映射到調用進程的地址空間裏, 然後你就可以用 memcpy 等操作寫文件, 而不用 write() 了. 寫完後用 msync() 同步一下, 你所寫的內容就保存到文件裏了. 不過這種方式沒辦法增加文件的長度, 因爲要映射的長度在調用 mmap() 的時候就決定了.

mmap 優點共有一下幾點:

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

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

3、提供進程間共享內存及相互通信的方式。不管是父子進程還是無親緣關係的進程,都可以將自身用戶空間映射到同一個文件或匿名映射到同一片區域。從而通過各自對映射區域的改動,達到進程間通信和進程間共享的目的。
同時,如果進程 A 和進程 B 都映射了區域 C,當 A 第一次讀取 C 時通過缺頁從磁盤複製文件頁到內存中;但當 B 再讀 C 的相同頁面時,雖然也會產生缺頁異常,但是不再需要從磁盤中複製文件過來,而可直接使用已經保存在內存中的文件數據。

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

1.5mmap 相關函數

函數原型

void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

返回說明

成功執行時,mmap() 返回被映射區的指針。失敗時,mmap() 返回 MAP_FAILED[其值爲 (void *)-1],error 被設爲以下的某個值:

參數

相關函數

int munmap( void * addr, size_t len )

成功執行時,munmap() 返回 0。失敗時,munmap 返回 - 1,error 返回標誌和 mmap 一致;該調用在進程地址空間中解除一個映射關係,addr 是調用 mmap() 時返回的地址,len 是映射區的大小;當映射關係解除後,對原來映射地址的訪問將導致段錯誤發生。

int msync( void *addr, size_t len, int flags )

一般說來,進程在映射空間的對共享內容的改變並不直接寫回到磁盤文件中,往往在調用 munmap()後才執行該操作。可以通過調用 msync() 實現磁盤上文件內容與共享內存區的內容一致。

int msync( void *addr, size_t len, int flags )

一般說來,進程在映射空間的對共享內容的改變並不直接寫回到磁盤文件中,往往在調用 munmap()後才執行該操作,可以通過調用 msync() 實現磁盤上文件內容與共享內存區的內容一致。

爲什麼 mmap 效率高於 read/write?

常規文件操作爲了提高讀寫效率和保護磁盤,使用了頁緩存機制。這樣造成讀文件時需要先將文件頁從磁盤拷貝到頁緩存中,由於頁緩存處在內核空間,不能被用戶進程直接尋址,所以還需要將頁緩存中數據頁再次拷貝到內存對應的用戶空間中。這樣,通過了兩次數據拷貝過程,才能完成進程對文件內容的獲取任務。寫操作也是一樣,待寫入的 buffer 在內核空間不能直接訪問,必須要先拷貝至內核空間對應的主存,再寫回磁盤中(延遲寫回),也是需要兩次數據拷貝。

而使用 mmap 操作文件中,創建新的虛擬內存區域和建立文件磁盤地址和虛擬內存區域映射這兩步,沒有任何文件拷貝操作。而之後訪問數據時發現內存中並無數據而發起的缺頁異常過程,可以通過已經建立好的映射關係,只使用一次數據拷貝,就從磁盤中將數據傳入內存的用戶空間中,供進程使用。

即常規文件操作需要從磁盤到頁緩存再到用戶主存的兩次數據拷貝。而 mmap 操控文件,只需要從磁盤到用戶主存的一次數據拷貝過程。因此 mmap 效率更高。

示例

  1. 創建文件 zz,並用 test.c 代碼向文件中寫入數據。

  1. 分別指定不同大小的字符數寫入,比較操作時長。

1.6mmap 使用細節

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

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

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

在上面的知識前提下,我們下面看看如果大小不是頁的整倍數的具體情況:

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

此時:

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

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

此時:

進程可以正常讀/寫被映射的前5000字節(0~4999),寫操作的改動會在一定時間後反映在原文件中。
對於5000~8191字節,進程可以進行讀寫過程,不會報錯。但是內容在寫入前均爲0,另外,寫入後不會反映在文件中。
對於8192~14999字節,進程不能對其進行讀寫,會報SIGBUS錯誤。
對於15000以外的字節,進程不能對其讀寫,會引發SIGSEGV錯誤。

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

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

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

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

二、共享內存

共享內存就是容許兩個不相關的進程訪問同一個邏輯內存。共享內存是在兩個正在運行的進程之間共享和傳遞數據的一種很是有效的方式。不一樣進程之間共享的內存一般安排爲同一段物理內存。進程能夠將同一段共享內存鏈接到它們本身的地址空間中,全部進程均可以訪問共享內存中的地址,就好像它們是由用 C 語言函數 malloc 分配的內存同樣。而若是某個進程向共享內存寫入數據,所作的改動將當即影響到能夠訪問同一段共享內存的任何其餘進程。

共享內存區是最快的 IPC 形式。一旦這樣的內存映射到共享它的進程的地址空間,這些進程間數據傳遞不再涉及到內核,換句話說是進程不再通過執行進入內核的系統調用來傳遞彼此的數據。

用管道或者消息隊列傳遞數據:

用共享內存傳遞數據:

共享內存有兩種方式,即 shm(shared memory) 和 mmap 方式。前者直接共享物理內存,後者通過一箇中間文件間接共享內存。

2.1 內存映射和共享內存的區別

2.2 共享內存常用的接口

int shm_open(const char *name, int oflag, mode_t mode); //用於創建或者打開共享內存文件,操作的文件一定是位於tmpfs文件系統裏的,存放目錄就是/dev/shm
void *mmap(void *addr, size_t length, int port, int flags, int fd, off_t offset);//將打開的文件映射到內存
int munmap(void *addr, size_t length);//取消內存映射
int shm_unlink(const char *name);//刪除/dev/shm目錄的文件
int ftruncate(int fd, off_t length);//重置文件大小

2.3 共享內存用處

使用文件或者管道進行進程間通信會有很多侷限性。管道只能在父進程和子進程間使用;通過文件共享,在處理效率上又差一些,而且訪問文件描述符不如訪問內存地址方便。

Linux 系統在編程上提供的共享內存方案有三種:

2.4mmap 內存共享映射

mmap 本來是存儲映射功能。它可以將一個文件映射到內存中,在程序裏就可以直接使用內存地址對文件內容進行訪問。

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int port, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

Linux 通過系統調用 fork 派生出的子進程和父進程共用內存地址空間,Linux 的 mmap 實現了一種可以在父子進程之間共享內存地址的方式。

  1. 父進程將 flags 參數設置 MAP_SHARED 方式通過 mmap 申請一段內存。內存可以映射某個具體文件(fd),也可以不映射具體文件(fd 置爲 - 1,flag 設置爲 MAP_ANONYMOUS).

  2. 父進程調用 fork 產生子進程,之後在父子進程內都可以訪問到 mmap 所返回的地址,就可以共享內存了。

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <sys/wait.h>
#include <sys/mman.h>

#define COUNT 100

int do_child(int *count)
{
        int interval;

        // critical section
        interval = *count;
        interval++;
        usleep(1);
        *count = interval;
        // critical section

        exit(0);
}

int main()
{
    pid_t pid;
    int count;
    int *shm_p;

    shm_p = (int *)mmap(NULL, sizeof(int), PROT_WRITE|PROT_READ, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
    if(MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }

    *shm_p = 0;

    for(count = 0; count < COUNT; count++) {
        pid = fork();
        if(pid < 0) {
            perror("fork()");
            exit(1);
        }

        if(pid == 0) {
            do_child(shm_p);
        }
    }

    for(count = 0; count < COUNT; count++) {
        wait(NULL);
    }

    printf("shm_p: %d\n", *shm_p);
    munmap(shm_p, sizeof(int));
    exit(0);
}

這段共享內存的使用是有競爭條件的。進程間通信不僅僅是通信這麼簡單,還要處理類似的這樣的臨界區代碼。在這裏,可以採用文件鎖進行處理。但是共享內存使用文件鎖顯得不太協調。除了不方便和效率低下以外,文件鎖還不能進行更高級的進程控制。這裏可以使用信號量這種更高級的進程同步控制原語來實現相關功能。

下面這段程序用來幫助理解 mmap 的內存佔用情況:

#include<unistd.h>
#include<stdlib.h>
#include<stdio.h>
#include<errno.h>
#include<fcntl.h>
#include<string.h>
#include<sys/file.h>
#include<sys/wait.h>
#include<sys/mman.h>

#define COUNT 100
#define MEMSIZE 1024*1024*1023*2

int main()
{
    pid_t pid;
    int count;
    void *shm_p;

    shm_p = mmap(NULL, MEMSIZE, PROT_WRITE|PROT_READ, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
    if(MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }

    bzero(shm_p, MEMSIZE);

    sleep(3000);

    munmap(shm_p, MEMSIZE);
    exit(0);
}

申請了一段近 2G 的內存,並置 0. 觀察內存變化

[zorro@zorrozou-pc0 sharemem]$ free -g
              total        used        free      shared  buff/cache   available
Mem:             15           2           2           0          10          11
Swap:            31           0          31
[zorro@zorrozou-pc0 sharemem]$ ./mmap_mem &
[1] 32036
[zorro@zorrozou-pc0 sharemem]$ free -g
              total        used        free      shared  buff/cache   available
Mem:             15           2           0           2          12           9
Swap:            31           0          31

可以看出,這段內存被記錄到 shared 和 buff/cache 中了。mmap 有一個缺點,那就是共享的內存只能在父進程和 fork 產生的子進程間使用,除此之外的其它進程無法得到共享內存段的地址。

2.5XSI 共享內存

XSI 是 X/Open 組織對 UNIX 定義的一套接口標準(X/Open System Interface)。XSI 共享內存在 Linux 底層的實現實際上跟 mmap 沒有什麼本質不同,只是在使用方法上有所區別。

#include<sys/ipc.h>
#include<sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

#include<sys/types.h>
#include<sys/shm.h>

void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);

shmget 的第三個參數,指定創建標誌。支持的標誌爲:IPC_CREAT、IPC_EXCL。從 Linux 2.6 之後,還引入了支持大頁的共享內存,標誌爲:SHM_HUGETLB、SHM_HUGE_2MB 等。shemget 除了可以創建一個新的共享內存外,還可以訪問一個已經存在的內存,此時可以將 shmflg 置爲 0,不加任何標誌打開。

shmget 返回的 int 類型的 shmid 類似於文件描述符,注意只是類似,而並非同樣的實現,所以,不能用 select、poll、epoll 這樣的方法去控制一個 XSI 共享內存。對於一個 XSI 共享內存,其 key 是系統全局唯一的,這就方便其它進程使用同樣的 key,打開同樣一段共享內存,以便進行進程間通信。而是用 fork 產生的子進程,可以直接通過 shmid 訪問到相關共享內存段。這就是 key 的本質:系統中對 XSI 共享內存的全局唯一表示符。

#include<sys/types.h>
#include<sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);

key 是通過 ftok 函數,使用一個約定好的文件名和 proj_id 生成的。ftok 不會創建文件,所以必須指定一個存在並且進程可以訪問的 pathname 路徑。另外,ftok 並不是根據文件的路徑和文件名生成 key 的,在具體實現上,它使用的是指定文件的 inode 編號和文件所在設備的設備編號。所以,不同的文件名也可能得到同一個 key(不同的文件名指向同一個 inode,硬鏈接)。同樣的文件名也不一定就能得到相同的 key,一個文件名有可能被刪除重建,這種行爲會導致 inode 變化。

#include<unistd.h>
#include<stdlib.h>
#include<stdio.h>
#include<errno.h>
#include<fcntl.h>
#include<string.h>
#include<sys/file.h>
#include<sys/wait.h>
#include<sys/mman.h>
#include<sys/ipc.h>
#include<sys/shm.h>
#include<sys/types.h>

#define COUNT 100
#define PATHNAME "/etc/passwd"

int do_child(int proj_id)
{
    int interval;
    int *shm_p, shm_id;
    key_t shm_key;

    if((shm_key = ftok(PATHNAME, proj_id)) == -1) {
        perror("ftok()");
        exit(1);
    }

    shm_id = shmget(shm_key, sizeof(int), 0);
    if(shm_id < 0)
    {
        perror("shmget()");
        exit(1);
    }

    //使用shmat將相關共享內存映射到本進程的內存地址
    shm_p = (int *)shmat(shm_id, NULL, 0);
    if((void *)shm_p == (void *)-1)
    {
        perror("shmat()");
        exit(1);
    }

    // critical section
    interval = *shm_p;
    interval++;
    usleep(1);
    *shm_p = interval;
    // critical section

    //使用shmdt解除本進程內存對共享內存的地址映射,本操作不會刪除共享內存
    if(shmdt(shm_p) < 0){
        perror("shmdt()");
        exit(1);
    }

    exit(0);
}

int main()
{
    pid_t pid;
    int count;
    int *shm_p;
    int shm_id, proj_id;
    key_t shm_key;

    proj_id = 1234;

    if((shm_key = ftok(PATHNAME, proj_id)) == -1)
    {
        perror("ftok()");
        exit(1);
    }

    //使用shm_key創建一個共享內存,如果系統中已經存在此共享內存,則報錯退出。創建出來的共享內存權限爲0600
    shm_id = shmget(shm_key, sizeof(int), IPC_CREAT|IPC_EXCL|0600);
    if(shm_id < 0) {
        perror("shmget()");
        exit(1);
    }

    shm_p = (int *)shmat(shm_id, NULL, 0);
    if((void *)shm_p == (void *) -1)
    {
        perror("shmat()");
        exit(1);
    }

    *shm_p = 0;

    for(count = 0; count < COUNT; count++) {
        pid = fork();
        if(pid < 0) {
            perror("fork()");
            exit(1);
        }

        if(pid == 0) {
            do_child(proj_id);
        }
    }

    for(count = 0; count < COUNT; count ++) {
        wait(NULL);
    }

    printf("shm_p: %d\n", *shm_p);

    if(shmdt(shm_p) < 0) {
        perror("shmdt()");
        exit(1);
    }

    if(shmctl(shm_id, IPC_RMID, NULL) < 0) {
        perror("shmctl");
        exit(1);
    }

    exit(0);
}

在某些情況下,也可以不通過一個 key 來創建共享內存。此時可以在 key 的參數所在位置填 IPC_PRIVATE, 這樣內核會在保證不衝突的共享內存段 id 的情況下新建一段共享內存。因爲只能是創建,所以 flag 位一定是 IPC_CREAT。可以將 shmid 傳給子進程。

當獲取到 shmid 之後,就可以使用 shmat 來進行地址映射。shmat 之後,通過訪問返回的當前進程的虛擬地址就可以訪問到共享內存段了。注意使用之後要調用 shmdt 解除映射,否則對於長期運行的程序,可能會造成虛擬內存地址泄露。shmdt 並不能刪除共享內存段,只是解除共享內存段和進程虛擬地址的映射關係。只要 shmid 對應的共享內存段還存在,就可以使用 shmat 繼續映射使用。想要刪除一個共享內存段,需要使用 shmctl 的 IPC_RMID 指令處理,或者在命令行中使用 ipcrm 刪除指定的共享內存 id 或 key。

shmctl 還可以查看、修改共享內存的相關屬性,可以在 man 2 shmctl 中查看。在系統中還可以使用 ipcs -m 命令查看系統中所有共享內存的信息。

ipcs - provide information on ipc facilities
ipcs [-asmq] [-tclup]
ipcs [-smq] -i id

-m 共享內存
-q 消息隊列
-s 信號量數組
-a all(缺省)

輸出選項:

-t time
-p pid
-c creator
-l limits
-u summary

在 Linux 系統中,使用 XSI 共享內存調用 shmget 時,可以通過設置 shmflg 參數來申請大頁內存(huge pages)。

SHM_HUGETLB(since Linux 2.6)
SHM_HUGE_2MB, SHM_HUGE_1GB(since Linux 3.8)

使用大頁內存的好處是提高內核對內存管理的處理效率。因爲在相同內存大小的情況下,使用大頁內存(2M 一頁)將比使用一般內存頁(4K 一頁)的內存頁管理的數量大大減少,從而減少內存頁表項的緩存壓力和 CPU cache 緩存內存地址的映射壓力。但是需要注意一些地方:

shm_id = shmget(IPC_PRIVATE, MEMSIZE, SHM_HUGETLB|0600)

如果要申請 2G 以下的大頁內存,需要系統預留 2G 以上的大頁內存。

echo 2048 > /proc/sys/vm/nr_hugepages
cat /proc/meminfo | grep -i huge
    AnonHugePages:      841728 KB
    HugePages_Total:    2020
    HugePages_Free:     2020
    HugePages_Rsvd:     0
    HugePages_Surp:     0
    Hugepagesize:       2048 kB

2048 是頁數,每頁 2M。

還需要注意共享內存的限制:

echo 2147483648 > /proc/sys/kernel/shmmax
echo 33554432 > /proc/sys/kernel/shmall

/proc/sys/kernel/shmall:限制系統用在共享內存上的內存頁總數。一頁一般是 4k(可以通過 getconf PAGE_SIZE 查看)

/proc/sys/kernel/shmmax:限制一個共享內存段的最大長度,單位是字節

/proc/sys/kernel/shmmni:限制整個系統可以創建的最大的共享內存段的個數

2.6POSIX 共享內存

POSIX 共享內存實際上毫無新意,它本質上是 mmap 對文件的共享方式映射,只不過映射的是 tmpfs 文件系統上的文件。

tmpfs 是將一部分內存空間用作文件系統,一般掛在 / dev/shm 目錄。

Linux 提供的 POSIX 共享內存,實際上就是在 / dev/shm 下創建一個文件,並將其 mmap 之後映射其內存地址即可。可以通過 man shm_overview 查看使用方法。

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <sys/wait.h>
#include <sys/mman.h>

#define COUNT 100
#define SHMPATH "shm"

int do_child(char * shmpath)
{
    int interval, shmfd, ret;
    int *shm_p;
    // 使用shm_open訪問一個已經創建的POSIX共享內存
    shmfd = shm_open(shmpath, O_RDWR, 0600);
    if (shmfd < 0) {
        perror("shm_open()");
        exit(1);
    }

    // 用mmap將對應的tmpfs文件映射到本進程內存 */
    shm_p = (int *)mmap(NULL, sizeof(int), PROT_WRITE|PROT_READ, MAP_SHARED, shmfd, 0);
    if (MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }
    /* critical section */
    interval = *shm_p;
    interval++;
    usleep(1);
    *shm_p = interval;
    /* critical section */

    munmap(shm_p, sizeof(int));
    close(shmfd);

    exit(0);
}

int main()
{
    pid_t pid;
    int count, shmfd, ret;
    int *shm_p;

    /* 創建一個POSIX共享內存 */
    shmfd = shm_open(SHMPATH, O_RDWR|O_CREAT|O_TRUNC, 0600);
    if (shmfd < 0) {
        perror("shm_open()");
        exit(1);
    }
    /* 使用ftruncate設置共享內存段大小 */
    ret = ftruncate(shmfd, sizeof(int));
    if (ret < 0) {
        perror("ftruncate()");
        exit(1);
    }
    /* 使用mmap將對應的tmpfs文件映射到本進程內存 */
    shm_p = (int *)mmap(NULL, sizeof(int), PROT_WRITE|PROT_READ, MAP_SHARED, shmfd, 0);
    if (MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }
    *shm_p = 0;

    for (count=0;count<COUNT;count++) {
        pid = fork();
        if (pid < 0) {
            perror("fork()");
            exit(1);
        }

        if (pid == 0) {
            do_child(SHMPATH);
        }
    }

    for (count=0;count<COUNT;count++) {
        wait(NULL);
    }

    printf("shm_p: %d\n", *shm_p);
    munmap(shm_p, sizeof(int));
    close(shmfd);
    shm_unlink(SHMPATH);
    exit(0);
}

編譯該段代碼的時候需要指定一個庫,-lrt,這是 linux 的 real time 庫。

2.7 修改共享內存內核配置

SHMMAX 一個進程可以在它的虛擬地址空間分配給一個共享內存端的最大大小(單位是字節)

echo 2147483648 > /proc/sys/kernel/shmmax
或
sysctl -w kernel.shmmax=2147483648echo "kenerl.shmmax=2147483648" >> /etc/sysctl.conf

SHMMNI 系統範圍內共享內存段的數量

echo 4096 > /proc/sys/kernel/shmmni
或
sysctl -w kernel.shmmni=4096echo "kernel.shmmni=4096" >> /etc/sysctl.conf

SHMALL 這個參數設置了系統範圍內共享內存可以使用的頁數。單位是 PAGE_SIZE(通常是 4096,可以通過getconf PAGE_SIZE獲得)

echo 2097152 > /proc/sys/kernel/shmall
或
sysctl -w kernel.shmall=2097152echo "kernel.shmall=2097152" >> /etc/sysctl.conf

移除共享內存執行ipcs -m查看系統所有的共享內存。如果status字段是dest,表明這段共享內存需要被刪除。

ipcs -m -i $shmid

內存映射的主要優勢包括:

  1. 高效讀寫:由於文件直接映射到內存中,讀取和寫入數據就像對待普通變量一樣高效。

  2. 省去複製開銷:不需要將數據從磁盤複製到用戶空間或者從用戶空間複製回磁盤,節省了額外的數據複製開銷。

  3. 簡化編程:使用內存映射可以將文件視爲一個連續的字節數組,在訪問文件時不需要關注具體的 IO 操作,簡化了編程邏輯。

  4. 共享內存:多個進程可以同時映射同一個文件,實現共享數據,並且修改會反映在所有進程中。

然而,使用內存映射也有一些注意事項:

  1. 內存消耗:如果處理大型文件或者多個文件,可能佔用較大的物理內存空間。

  2. 文件更新同步:當多個進程同時訪問並修改同一個文件時,需要考慮如何進行同步,以避免數據不一致的問題。

  3. 適用性限制:內存映射主要適用於對文件進行順序讀寫和隨機訪問,不適合頻繁修改文件內容的場景。

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