內存映射 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 內存映射的實現過程,總的來說可以分爲三個階段:
(一)進程啓動映射過程,並在虛擬地址空間中爲映射創建虛擬映射區域
-
1、進程在用戶空間調用庫函數 mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
-
2、在當前進程的虛擬地址空間中,尋找一段空閒的滿足要求的連續的虛擬地址
-
3、爲此虛擬區分配一個 vm_area_struct 結構,接着對這個結構的各個域進行了初始化
-
4、將新建的虛擬區結構(vm_area_struct)插入進程的虛擬地址區域鏈表或樹中
(二)調用內核空間的系統調用函數 mmap(不同於用戶空間函數),實現文件物理地址和進程虛擬地址的一一映射關係
-
5、爲映射分配了新的虛擬地址區域後,通過待映射的文件指針,在文件描述符表中找到對應的文件描述符,通過文件描述符,鏈接到內核 “已打開文件集” 中該文件的文件結構體(struct file),每個文件結構體維護着和這個已打開文件相關各項信息。
-
6、通過該文件的文件結構體,鏈接到 file_operations 模塊,調用內核函數 mmap,其原型爲:int mmap(struct file *filp, struct vm_area_struct *vma),不同於用戶空間庫函數。
-
7、內核 mmap 函數通過虛擬文件系統 inode 模塊定位到文件磁盤物理地址。
-
8、通過 remap_pfn_range 函數建立頁表,即實現了文件地址和虛擬地址區域的映射關係。此時,這片虛擬地址並沒有任何數據關聯到主存中。
(三)進程發起對這片映射空間的訪問,引發缺頁異常,實現文件內容到物理內存(主存)的拷貝
注:前兩個階段僅在於創建虛擬區間並完成地址映射,但是並沒有將任何文件數據的拷貝至主存。真正的文件讀取是當進程發起讀或寫操作時。
-
9、進程的讀或寫操作訪問虛擬地址空間這一段映射地址,通過查詢頁表,發現這一段地址並不在物理頁面上。因爲目前只建立了地址映射,真正的硬盤數據還沒有拷貝到內存中,因此引發缺頁異常。
-
10、缺頁異常進行一系列判斷,確定無非法操作後,內核發起請求調頁過程。
-
11、調頁過程先在交換緩存空間(swap cache)中尋找需要訪問的內存頁,如果沒有則調用 nopage 函數把所缺的頁從磁盤裝入到主存中。
-
12、之後進程即可對這片主存進行讀或者寫的操作,如果寫操作改變了其內容,一定時間後系統會自動回寫髒頁面到對應磁盤地址,也即完成了寫入到文件的過程。
注:修改過的髒頁面並不會立即更新迴文件中,而是有一段時間的延遲,可以調用 msync() 來強制同步, 這樣所寫的內容就能立即保存到文件裏了。
1.3mmap 和常規文件操作的區別
常規文件操作需要從磁盤到頁緩存 (處於內核空間,不能被用戶進程直接尋址),再到用戶主存的兩次數據拷貝。而 mmap 操控文件,只需要從磁盤到用戶主存的一次數據拷貝過程(創建新的虛擬內存區域,建議文件磁盤地址和虛擬內存區域映射,一次拷貝)。
我們首先簡單的回顧一下常規文件系統操作(調用 read/fread 等類函數)中,函數的調用過程:
-
1、進程發起讀文件請求。
-
2、內核通過查找進程文件符表,定位到內核已打開文件集上的文件信息,從而找到此文件的 inode。
-
3、inode 在 address_space 上查找要請求的文件頁是否已經緩存在頁緩存中。如果存在,則直接返回這片文件頁的內容。
-
4、如果不存在,則通過 inode 定位到文件磁盤地址,將數據從磁盤複製到頁緩存。之後再次發起讀頁面過程,進而將頁緩存中的數據發給用戶進程。
總結來說,常規文件操作爲了提高讀寫效率和保護磁盤,使用了頁緩存機制。這樣造成讀文件時需要先將文件頁從磁盤拷貝到頁緩存中,由於頁緩存處在內核空間,不能被用戶進程直接尋址,所以還需要將頁緩存中數據頁再次拷貝到內存對應的用戶空間中。這樣,通過了兩次數據拷貝過程,才能完成進程對文件內容的獲取任務。寫操作也是一樣,待寫入的 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 被設爲以下的某個值:
-
EACCES:訪問出錯
-
EAGAIN:文件已被鎖定,或者太多的內存已被鎖定
-
EBADF:fd 不是有效的文件描述詞
-
EINVAL:一個或者多個參數無效
-
ENFILE:已達到系統對打開文件的限制
-
ENODEV:指定文件所在的文件系統不支持內存映射
-
ENOMEM:內存不足,或者進程已超出最大內存映射數量
-
EPERM:權能不足,操作不允許
-
ETXTBSY:已寫的方式打開文件,同時指定 MAP_DENYWRITE 標誌
-
SIGSEGV:試着向只讀區寫入
-
SIGBUS:試着訪問不屬於進程的內存區
參數
-
start:映射區的開始地址
-
length:映射區的長度
-
prot:期望的內存保護標誌,不能與文件的打開模式衝突。是以下的某個值,可以通過 or 運算合理地組合在一起
-
PROT_EXEC:頁內容可以被執行
-
PROT_READ:頁內容可以被讀取
-
PROT_WRITE:頁可以被寫入
-
PROT_NONE:頁不可訪問
-
flags:指定映射對象的類型,映射選項和映射頁是否可以共享。它的值可以是一個或者多個以下位的組合體
-
MAP_FIXED // 使用指定的映射起始地址,如果由 start 和 len 參數指定的內存區重疊於現存的映射空間,重疊部分將會被丟棄。如果指定的起始地址不可用,操作將會失敗。並且起始地址必須落在頁的邊界上。
-
MAP_SHARED // 與其它所有映射這個對象的進程共享映射空間。對共享區的寫入,相當於輸出到文件。直到 msync() 或者 munmap() 被調用,文件實際上不會被更新。
-
MAP_PRIVATE // 建立一個寫入時拷貝的私有映射。內存區域的寫入不會影響到原文件。這個標誌和以上標誌是互斥的,只能使用其中一個。
-
MAP_DENYWRITE // 這個標誌被忽略。
-
MAP_EXECUTABLE // 同上
-
MAP_NORESERVE // 不要爲這個映射保留交換空間。當交換空間被保留,對映射區修改的可能會得到保證。當交換空間不被保留,同時內存不足,對映射區的修改會引起段違例信號。
-
MAP_LOCKED // 鎖定映射區的頁面,從而防止頁面被交換出內存。
-
MAP_GROWSDOWN // 用於堆棧,告訴內核 VM 系統,映射區可以向下擴展。
-
MAP_ANONYMOUS // 匿名映射,映射區不與任何文件關聯。
-
MAP_ANON //MAP_ANONYMOUS 的別稱,不再被使用。
-
MAP_FILE // 兼容標誌,被忽略。
-
MAP_32BIT // 將映射區放在進程地址空間的低 2GB,MAP_FIXED 指定時會被忽略。當前這個標誌只在 x86-64 平臺上得到支持。
-
MAP_POPULATE // 爲文件映射通過預讀的方式準備好頁表。隨後對映射區的訪問不會被頁違例阻塞。
-
MAP_NONBLOCK // 僅和 MAP_POPULATE 一起使用時纔有意義。不執行預讀,只爲已存在於內存中的頁面建立頁表入口。
-
fd:有效的文件描述詞。如果 MAP_ANONYMOUS 被設定,爲了兼容問題,其值應爲 - 1
-
offset:被映射對象內容的起點
相關函數
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 效率更高。
示例
- 創建文件 zz,並用 test.c 代碼向文件中寫入數據。
- 分別指定不同大小的字符數寫入,比較操作時長。
1.6mmap 使用細節
1、使用 mmap 需要注意的一個關鍵點是,mmap 映射區域大小必須是物理頁大小 (page_size) 的整倍數(32 位系統中通常是 4k 字節)。原因是,內存的最小粒度是頁,而進程虛擬地址空間和內存的映射也是以頁爲單位。爲了匹配內存的操作,mmap 從磁盤到虛擬地址空間的映射也必須是頁。
2、內核可以跟蹤被內存映射的底層對象(文件)的大小,進程可以合法的訪問在當前文件大小以內又在內存映射區以內的那些字節。也就是說,如果文件的大小一直在擴張,只要在映射區域範圍內的數據,進程都可以合法得到,這和映射建立時文件的大小無關。具體情形參見 “情形三”。
3、映射建立之後,即使文件關閉,映射依然存在。因爲映射的是磁盤的地址,不是文件本身,和文件句柄無關。同時可用於進程間通信的有效地址空間不完全受限於被映射文件的大小,因爲是按頁映射。
在上面的知識前提下,我們下面看看如果大小不是頁的整倍數的具體情況:
情形一:一個文件的大小是 5000 字節,mmap 函數從一個文件的起始位置開始,映射 5000 字節到虛擬內存中。
分析:因爲單位物理頁面的大小是 4096 字節,雖然被映射的文件只有 5000 字節,但是對應到進程虛擬地址區域的大小需要滿足整頁大小,因此 mmap 函數執行後,實際映射到虛擬內存區域 8192 個 字節,5000~8191 的字節部分用零填充。映射後的對應關係如下圖所示:
此時:
-
讀 / 寫前 5000 個字節(0~4999),會返回操作文件內容。
-
讀字節 50008191 時,結果全爲 0。寫 50008191 時,進程不會報錯,但是所寫的內容不會寫入原文件中 。
-
讀 / 寫 8192 以外的磁盤部分,會返回一個 SIGSECV 錯誤。
情形二:一個文件的大小是 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 內存映射和共享內存的區別
-
1、mmap 保存到實際硬盤,實際存儲並沒有反映到主存上。優點:儲存量可以很大(多於主存);缺點:進程間讀取和寫入速度要比主存的要慢。——每個進程地址空間中開闢出一塊空間進行映射
-
2、shm 保存到物理存儲器(主存),實際的儲存量直接反映到主存上。優點,進程間訪問速度(讀寫)比磁盤要快;缺點,儲存量不能非常大(多於主存)——每個進程最終會映射到同一塊物理內存
-
3、mmap 系統調用並不是完全爲了用於共享內存而設計的。它本身提供了不同於一般對普通文件的訪問方式,進程可以像讀寫內存一樣對普通文件的操作。而 Posix 或系統 V 的共享內存 IPC 則純粹用於共享目的,當然 mmap() 實現共享內存也是其主要應用之一。
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 系統在編程上提供的共享內存方案有三種:
-
mmap 內存共享映射
-
XSI 共享內存
-
POSIX 共享內存
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 實現了一種可以在父子進程之間共享內存地址的方式。
-
父進程將 flags 參數設置 MAP_SHARED 方式通過 mmap 申請一段內存。內存可以映射某個具體文件(fd),也可以不映射具體文件(fd 置爲 - 1,flag 設置爲 MAP_ANONYMOUS).
-
父進程調用 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 緩存內存地址的映射壓力。但是需要注意一些地方:
-
大頁內存不能交換(SWAP)
-
使用不當時可能造成更大的內存泄露
-
大頁內存需要使用 root 權限
-
需要修改系統配置
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 庫。
-
shm_open 的 SHMPATH 參數是一個路徑,這個路徑默認放在系統的 / dev/shm 目錄下。這是 shm_open 封裝好的,保證文件一定在 tmpfs 下。
-
使用 ftruncate 改變共享內存的大小,實際就是改變文件的長度。
-
shm_unlink 實際就是 unlink 系統調用的封裝。如果不做 unlink 操作,那麼文件會一直存在 / dev/shm 目錄下。
-
關閉共享內存描述符,使用 close.
2.7 修改共享內存內核配置
SHMMAX 一個進程可以在它的虛擬地址空間分配給一個共享內存端的最大大小(單位是字節)
echo 2147483648 > /proc/sys/kernel/shmmax
或
sysctl -w kernel.shmmax=2147483648
或
echo "kenerl.shmmax=2147483648" >> /etc/sysctl.conf
SHMMNI 系統範圍內共享內存段的數量
echo 4096 > /proc/sys/kernel/shmmni
或
sysctl -w kernel.shmmni=4096
或
echo "kernel.shmmni=4096" >> /etc/sysctl.conf
SHMALL 這個參數設置了系統範圍內共享內存可以使用的頁數。單位是 PAGE_SIZE(通常是 4096,可以通過getconf PAGE_SIZE
獲得)
echo 2097152 > /proc/sys/kernel/shmall
或
sysctl -w kernel.shmall=2097152
或
echo "kernel.shmall=2097152" >> /etc/sysctl.conf
移除共享內存執行ipcs -m
查看系統所有的共享內存。如果status
字段是dest
,表明這段共享內存需要被刪除。
ipcs -m -i $shmid
內存映射的主要優勢包括:
-
高效讀寫:由於文件直接映射到內存中,讀取和寫入數據就像對待普通變量一樣高效。
-
省去複製開銷:不需要將數據從磁盤複製到用戶空間或者從用戶空間複製回磁盤,節省了額外的數據複製開銷。
-
簡化編程:使用內存映射可以將文件視爲一個連續的字節數組,在訪問文件時不需要關注具體的 IO 操作,簡化了編程邏輯。
-
共享內存:多個進程可以同時映射同一個文件,實現共享數據,並且修改會反映在所有進程中。
然而,使用內存映射也有一些注意事項:
-
內存消耗:如果處理大型文件或者多個文件,可能佔用較大的物理內存空間。
-
文件更新同步:當多個進程同時訪問並修改同一個文件時,需要考慮如何進行同步,以避免數據不一致的問題。
-
適用性限制:內存映射主要適用於對文件進行順序讀寫和隨機訪問,不適合頻繁修改文件內容的場景。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/3ESYiWl618Pwq4AlXXcgCA