輕鬆突破文件 IO 瓶頸:內存映射 mmap 技術

一、mmap 基礎概念


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

如下圖所示:

mmap 具有如下的特點:

  1. mmap 嚮應用程序提供的內存訪問接口是內存地址連續的,但是對應的磁盤文件的 block 可以不是地址連續的;

  2. mmap 提供的內存空間是虛擬空間(虛擬內存),而不是物理空間(物理內存),因此完全可以分配遠遠大於物理內存大小的虛擬空間(例如 16G 內存主機分配 1000G 的 mmap 內存空間);

  3. mmap 負責映射文件邏輯上一段連續的數據(物理上可以不連續存儲)映射爲連續內存,而這裏的文件可以是磁盤文件、驅動假造出的文件(例如 DMA 技術)以及設備;

  4. mmap 由操作系統負責管理,對同一個文件地址的映射將被所有線程共享,操作系統確保線程安全以及線程可見性;

  5. mmap 的設計很有啓發性。基於磁盤的讀寫單位是 block(一般大小爲 4KB),而基於內存的讀寫單位是地址(雖然內存的管理與分配單位是 4KB)。

換言之,CPU 進行一次磁盤讀寫操作涉及的數據量至少是 4KB,但是進行一次內存操作涉及的數據量是基於地址的,也就是通常的 64bit(64 位操作系統)。mmap 下進程可以採用指針的方式進行讀寫操作,這是值得注意的。

二. 虛擬內存?虛擬空間?


其實是一個概念,前一篇對於這個詞沒有確切的定義,現在定義一下:

虛擬空間就是進程看到的所有地址組成的空間,虛擬空間是某個進程對分配給它的所有物理地址(已經分配的和將會分配的)的重新映射。

而虛擬內存,爲啥叫虛擬內存,是因爲它就不是真正的內存,是假的,因爲它是由地址組成的空間,所以在這裏,使用虛擬空間這個詞更加確切和易懂。(不過虛擬內存這個詞也不算錯)

虛擬空間原理

物理內存

首先,物理地址實際上也不是連續的,通常是包含作爲主存的 DRAM 和 IO 寄存器

以前的 CPU(如 X86)是爲 IO 劃分單獨的地址空間,所以不能用直接訪問內存的方式(如指針)IO,只能用專門的方法(in/read/out/write)諸如此類。現在的 CPU 利用 PCI 總線將 IO 寄存器映射到物理內存,所以出現了基於內存訪問的 IO。還有一點補充的,就如同進程空間有一塊內核空間一樣,物理內存也會有極小一部分是不能訪問的,爲內核所用。

三個總線

這裏再補充下三個總線的知識,即:地址總線、數據總線、控制總線

比如 CPU 通過控制總線發送讀取命令,同時用地址總線發送要讀取的數據虛地址,經過 MMU 後到內存

內存通過數據總線將數據傳輸給 CPU。虛擬地址的空間和指令集的地址長度有關,不一定和物理地址長度一致,比如現在的 64 位處理器,從 VA 角度看來,可以訪問 64 位的地址,但地址總線長度只有 48 位,所以你可以訪問一個位於 2^52 這個位置的地址。

虛擬內存地址轉換(虛地址轉實地址)

上面已經明確了虛擬內存是虛擬空間,即地址的集合這一概念。基於此,來說說原理。

如果還記得操作系統課程裏面提到的虛地址,那麼這個虛地址就是虛擬空間的地址了,虛地址通過轉換得到實地址,轉換方式課程內也講得很清楚,虛地址頭部包含了頁號(段地址和段大小,看存儲模式:頁存儲、段存儲,段頁式),剩下部分是偏移量,經過 MMU 轉換成實地址。

存儲方式

虛擬地址頭部爲頁號通過查詢頁表得到物理頁號,假設一頁時 1K,那麼頁號 * 偏移量就得到物理地址

虛擬地址頭部爲段號,段表中找到段基地址加上偏移量得到實地址

二、mmap 原理


mmap 函數創建一個新的 vm_area_struct 結構,並將其與文件 / 設備的物理地址相連。

vm_area_struct:

linux 使用 vm_area_struct 來表示一個獨立的虛擬內存區域,一個進程可以使用多個 vm_area_struct 來表示不用類型的虛擬內存區域(如堆,棧,代碼段,MMAP 區域等)。

vm_area_struct 結構中包含了區域起始地址。同時也包含了一個 vm_opt 指針,其內部可引出所有針對這個區域可以使用的系統調用函數。從而,進程可以通過 vm_area_struct 獲取操作這段內存區域所需的任何信息。

進程通過 vma 操作內存,而 vma 與文件 / 設備的物理地址相連,系統自動回寫髒頁面到對應的文件磁盤上(或寫入到設備地址空間),實現內存映射文件。

首先創建虛擬區間並完成地址映射,此時還沒有將任何文件數據拷貝至主存。當進程發起讀寫操作時,會訪問虛擬地址空間,通過查詢頁表,發現這段地址不在物理頁上,因爲只建立了地址映射,真正的數據還沒有拷貝到內存,因此引發缺頁異常。缺頁異常經過一系列判斷,確定無非法操作後,內核發起請求調頁過程。

最終會調用 nopage 函數把所缺的頁從文件在磁盤裏的地址拷貝到物理內存。之後進程便可以對這片主存進行讀寫,如果寫操作修改了內容,一定時間後系統會自動回寫髒頁面到對應的磁盤地址,完成了寫入到文件的過程。另外,也可以調用 msync() 來強制同步,這樣所寫的內存就能立刻保存到文件中。

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

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

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

(三)進程發起對這片映射空間的訪問,引發缺頁異常,實現文件內容到物理內存(主存)的拷貝

注:前兩個階段僅在於創建虛擬區間並完成地址映射,但是並沒有將任何文件數據的拷貝至主存。真正的文件讀取是當進程發起讀或寫操作時。

注:修改過的髒頁面並不會立即更新迴文件中,而是有一段時間的延遲,可以調用 msync() 來強制同步, 這樣所寫的內容就能立即保存到文件裏了。

三、mmap 的 I/O 模型


mmap 也是一種零拷貝技術,其 I/O 模型如下圖所示:、

#include <sys/mman.h>
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)

mmap 技術有如下特點:

  1. 利用 DMA 技術來取代 CPU 來在內存與其他組件之間的數據拷貝,例如從磁盤到內存,從內存到網卡;

  2. 用戶空間的 mmap file 使用虛擬內存,實際上並不佔據物理內存,只有在內核空間的 kernel buffer cache 才佔據實際的物理內存;

  3. mmap() 函數需要配合 write() 系統調動進行配合操作,這與 sendfile() 函數有所不同,後者一次性代替了 read() 以及 write();因此 mmap 也至少需要 4 次上下文切換;

  4. mmap 僅僅能夠避免內核空間到用戶空間的全程 CPU 負責的數據拷貝,但是內核空間內部還是需要全程 CPU 負責的數據拷貝;

利用 mmap() 替換 read(),配合 write() 調用的整個流程如下:

  1. 用戶進程調用 mmap(),從用戶態陷入內核態,將內核緩衝區映射到用戶緩存區;

  2. DMA 控制器將數據從硬盤拷貝到內核緩衝區(可見其使用了 Page Cache 機制);

  3. mmap() 返回,上下文從內核態切換回用戶態;

  4. 用戶進程調用 write(),嘗試把文件數據寫到內核裏的套接字緩衝區,再次陷入內核態;

  5. CPU 將內核緩衝區中的數據拷貝到的套接字緩衝區;

  6. DMA 控制器將數據從套接字緩衝區拷貝到網卡完成數據傳輸;

  7. write() 返回,上下文從內核態切換回用戶態。

通過 mmap 實現的零拷貝 I/O 進行了 4 次用戶空間與內核空間的上下文切換,以及 3 次數據拷貝;其中 3 次數據拷貝中包括了 2 次 DMA 拷貝和 1 次 CPU 拷貝

四、mmap 的優勢


  1. 簡化用戶進程編程

在用戶空間看來,通過 mmap 機制以後,磁盤上的文件彷彿直接就在內存中,把訪問磁盤文件簡化爲按地址訪問內存。這樣一來,應用程序自然不需要使用文件系統的 write(寫入)、read(讀取)、fsync(同步)等系統調用,因爲現在只要面向內存的虛擬空間進行開發。

但是,這並不意味着我們不再需要進行這些系統調用,而是說這些系統調用由操作系統在 mmap 機制的內部封裝好了。

當發生數據修改時,內存出現髒頁,與磁盤文件出現不一致。mmap 機制下由操作系統自動完成內存數據落盤(髒頁回刷),用戶進程通常並不需要手動管理數據落盤。

讀寫效率提高:避免內核空間到用戶空間的數據拷貝

簡而言之,mmap 被認爲快的原因是因爲建立了頁到用戶進程的虛地址空間映射,以讀取文件爲例,避免了頁從內核空間拷貝到用戶空間。

  1. 避免只讀操作時的 swap 操作

虛擬內存帶來了種種好處,但是一個最大的問題在於所有進程的虛擬內存大小總和可能大於物理內存總大小,因此當操作系統物理內存不夠用時,就會把一部分內存 swap 到磁盤上。

在 mmap 下,如果虛擬空間沒有發生寫操作,那麼由於通過 mmap 操作得到的內存數據完全可以通過再次調用 mmap 操作映射文件得到。但是,通過其他方式分配的內存,在沒有發生寫操作的情況下,操作系統並不知道如何簡單地從現有文件中(除非其重新執行一遍應用程序,但是代價很大)恢復內存數據,因此必須將內存 swap 到磁盤上。

  1. 節約內存

由於用戶空間與內核空間實際上共用同一份數據,因此在大文件場景下在實際物理內存佔用上有優勢。

  1. mmap 不是銀彈

mmap 不是銀彈,這意味着 mmap 也有其缺陷,在相關場景下的性能存在缺陷:

  1. 由於 MMAP 使用時必須實現指定好內存映射的大小,因此 mmap 並不適合變長文件;

  2. 如果更新文件的操作很多,mmap 避免兩態拷貝的優勢就被攤還,最終還是落在了大量的髒頁回寫及由此引發的隨機 I/O 上,所以在隨機寫很多的情況下,mmap 方式在效率上不一定會比帶緩衝區的一般寫快;

  3. 讀 / 寫小文件(例如 16K 以下的文件),mmap 與通過 read 系統調用相比有着更高的開銷與延遲;同時 mmap 的刷盤由系統全權控制,但是在小數據量的情況下由應用本身手動控制更好;

  4. mmap 受限於操作系統內存大小:例如在 32-bits 的操作系統上,虛擬內存總大小也就 2GB,但由於 mmap 必須要在內存中找到一塊連續的地址塊,此時你就無法對 4GB 大小的文件完全進行 mmap,在這種情況下你必須分多塊分別進行 mmap,但是此時地址內存地址已經不再連續,使用 mmap 的意義大打折扣,而且引入了額外的複雜性;

  5. mmap 的適用場景

mmap 的適用場景實際上非常受限,在如下場合下可以選擇使用 mmap 機制:

  1. 多個線程以只讀的方式同時訪問一個文件,這是因爲 mmap 機制下多線程共享了同一物理內存空間,因此節約了內存;

  2. mmap 非常適合用於進程間通信,這是因爲對同一文件對應的 mmap 分配的物理內存天然多線程共享,並可以依賴於操作系統的同步原語;

  3. mmap 雖然比 sendfile 等機制多了一次 CPU 全程參與的內存拷貝,但是用戶空間與內核空間並不需要數據拷貝,因此在正確使用情況下並不比 sendfile 效率差;

6.mmap 使用細節

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

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

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

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

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

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

此時: (1)讀 / 寫前 5000 個字節(0~4999),會返回操作文件內容。 (2)讀字節 50008191 時,結果全爲 0。寫 50008191 時,進程不會報錯,但是所寫的內容不會寫入原文件中 。 (3)讀 / 寫 8192 以外的磁盤部分,會返回一個 SIGSECV 錯誤。情形二:一個文件的大小是 5000 字節,mmap 函數從一個文件的起始位置開始,映射 15000 字節到虛擬內存中,即映射大小超過了原始文件的大小。

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

此時: (1)進程可以正常讀 / 寫被映射的前 5000 字節 (0~4999),寫操作的改動會在一定時間後反映在原文件中。 (2)對於 5000~8191 字節,進程可以進行讀寫過程,不會報錯。但是內容在寫入前均爲 0,另外,寫入後不會反映在文件中。 (3)對於 8192~14999 字節,進程不能對其進行讀寫,會報 SIGBUS 錯誤。 (4)對於 15000 以外的字節,進程不能對其讀寫,會引發 SIGSEGV 錯誤。情形三:一個文件初始大小爲 0,使用 mmap 操作映射了 10004K 的大小,即 1000 個物理頁大約 4M 字節空間,mmap 返回指針 ptr。

分析:如果在映射建立之初,就對文件進行讀寫操作,由於文件大小爲 0,並沒有合法的物理頁對應,如同情形二一樣,會返回 SIGBUS 錯誤。但是如果,每次操作 ptr 讀寫前,先增加文件的大小,那麼 ptr 在文件大小內部的操作就是合法的。例如,文件擴充 4096 字節,ptr 就能操作 ptr ~ [(char)ptr + 4095] 的空間。只要文件擴充的範圍在 1000 個物理頁(映射範圍)內,ptr 都可以對應操作相同的大小。這樣,方便隨時擴充文件空間,隨時寫入文件,不造成空間浪費。

五、mmap 映射


在內存映射的過程中,並沒有實際的數據拷貝,文件沒有被載入內存,只是邏輯上被放入了內存,具體到代碼,就是建立並初始化了相關的數據結構(struct address_space),這個過程有系統調用 mmap() 實現,所以建立內存映射的效率很高。 既然建立內存映射沒有進行實際的數據拷貝,那麼進程又怎麼能最終直接通過內存操作訪問到硬盤上的文件呢?那就要看內存映射之後的幾個相關的過程了。 mmap() 會返回一個指針 ptr,它指向進程邏輯地址空間中的一個地址,這樣以後,進程無需再調用 read 或 write 對文件進行讀寫,而只需要通過 ptr 就能夠操作文件。但是 ptr 所指向的是一個邏輯地址,要操作其中的數據,必須通過 MMU 將邏輯地址轉換成物理地址,這個過程與內存映射無關。 前面講過,建立內存映射並沒有實際拷貝數據,這時,MMU 在地址映射表中是無法找到與 ptr 相對應的物理地址的,也就是 MMU 失敗,將產生一個缺頁中斷,缺頁中斷的中斷響應函數會在 swap 中尋找相對應的頁面,如果找不到(也就是該文件從來沒有被讀入內存的情況),則會通過 mmap() 建立的映射關係,從硬盤上將文件讀取到物理內存中,如圖 1 中過程 3 所示。這個過程與內存映射無關。 如果在拷貝數據時,發現物理內存不夠用,則會通過虛擬內存機制(swap)將暫時不用的物理頁面交換到硬盤上,這個過程也與內存映射無關。mmap 內存映射的實現過程:

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

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

  3. 進程發起對這片映射空間的訪問,引發缺頁異常,實現文件內容到物理內存(主存)的拷貝

適合的場景

不適合的場景

示例代碼

//
//  ViewController.m
//  TestCode
//
//  Created by zhangdasen on 2020/5/24.
//  Copyright © 2020 zhangdasen. All rights reserved.
//

#import "ViewController.h"
#import <sys/mman.h>
#import <sys/stat.h>
@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSString *path = [NSHomeDirectory() stringByAppendingPathComponent:@"test.data"];
    NSLog(@"path: %@", path);
    NSString *str = @"test str2";
    [str writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:nil];
    
    ProcessFile(path.UTF8String);
    NSString *result = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
    NSLog(@"result:%@", result);
}


int MapFile(const char * inPathName, void ** outDataPtr, size_t * outDataLength, size_t appendSize)
{
    int outError;
    int fileDescriptor;
    struct stat statInfo;
    
    // Return safe values on error.
    outError = 0;
    *outDataPtr = NULL;
    *outDataLength = 0;
    
    // Open the file.
    fileDescriptor = open( inPathName, O_RDWR, 0 );
    if( fileDescriptor < 0 )
    {
        outError = errno;
    }
    else
    {
        // We now know the file exists. Retrieve the file size.
        if( fstat( fileDescriptor, &statInfo ) != 0 )
        {
            outError = errno;
        }
        else
        {
            ftruncate(fileDescriptor, statInfo.st_size + appendSize);
            fsync(fileDescriptor);
            *outDataPtr = mmap(NULL,
                               statInfo.st_size + appendSize,
                               PROT_READ|PROT_WRITE,
                               MAP_FILE|MAP_SHARED,
                               fileDescriptor,
                               0);
            if( *outDataPtr == MAP_FAILED )
            {
                outError = errno;
            }
            else
            {
                // On success, return the size of the mapped file.
                *outDataLength = statInfo.st_size;
            }
        }
        
        // Now close the file. The kernel doesn’t use our file descriptor.
        close( fileDescriptor );
    }
    
    return outError;
}


void ProcessFile(const char * inPathName)
{
    size_t dataLength;
    void * dataPtr;
    char *appendStr = " append_key2";
    int appendSize = (int)strlen(appendStr);
    if( MapFile(inPathName, &dataPtr, &dataLength, appendSize) == 0) {
        dataPtr = dataPtr + dataLength;
        memcpy(dataPtr, appendStr, appendSize);
        // Unmap files
        munmap(dataPtr, appendSize + dataLength);
    }
}
@end
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/FYsdbluirA3o4-WRRJnCzw