iOS mmap


一、常規文件操作

常規文件操作(read/write)有那幾個重要步驟:

  1. 進程發起讀文件請求
  2. 內核通過查找進程文件符表,定位到內核已打開文件集上的文件信息,從而找到此文件的 inode
  3. inode 在 address_space 上查找要請求的文件頁是否已經緩存在內核頁高速緩沖中。如果存在,則直接返回這片文件頁的內容
  4. 如果不存在,則通過 inode 定位到文件磁盤地址,將數據從磁盤復制到內核頁高速緩沖。之后再次發起讀頁面過程,進而將內核頁高速緩沖中的數據發給用戶進程

需要注意的幾點:

  1. 常規文件操作為了提高讀寫效率和保護磁盤,使用了頁緩存機制。由於頁緩存處在內核空間,不能被用戶進程直接尋址,所以需要將頁緩存中數據頁再次拷貝到內存對應的用戶空間中
  2. read/write 是系統調用很耗時,如下圖,它首先將文件內容從硬盤拷貝到內核空間的一個緩沖區,然后再將這些數據拷貝到用戶空間,實際上完成了兩次數據拷貝
  3. 如果兩個進程都對磁盤中的一個文件內容進行訪問,那么這個內容在物理內存中有三份:進程 A 的地址空間 + 進程 B 的地址空間 + 內核頁高速緩沖空間
  4. 寫操作也是一樣,待寫入的 buffer 在內核空間不能直接訪問,必須要先拷貝至內核空間對應的主存,再寫回磁盤中(延遲寫回),也是需要兩次數據拷貝

Linux 內核剖析


二、mmap內存映射

2.1 mmap 介紹

在日常開發中偶爾會遇到 mmap,它最常用到的場景是 MMKV,其次用到的是日志打印。雖然都已經被封裝好,但也需要了解下 mmap 的基本原理和過程。

進程是 App 運行的基本單位,進程之間相對獨立。iOS 系統中 App 運行的內存空間地址是虛擬空間地址,存儲數據是在各自的沙盒

當我們在 App 中去讀寫沙盒中的文件時,我們會使用 NSFileManager 去查找文件,然后可以使用 NSData 去加載二進制數據。文件操作的更底層實現過程,是使用 linux 的 read()write() 函數直接操作文件句柄(也叫文件描述符、fd)。

在操作系統層面,當 App 讀取一個文件時,實際是有兩步:

  1. 將文件從磁盤讀取到物理內存;
  2. 從系統空間拷貝到用戶空間(可以認為是復制到系統給 App 統一分配的內存)。

iOS 系統使用頁緩存機制,通過 MMU(Memory Management Unit)將虛擬內存地址和物理地址進行映射,並且由於進程的地址空間和系統的地址空間不一樣,所以還需要多一次拷貝。

而 mmap 將磁盤上文件的地址信息與進程用的虛擬邏輯地址進行映射,建立映射的過程與普通的內存讀取不同:正常的是將文件拷貝到內存,mmap 只是建立映射而不會將文件加載到內存中


在內存映射的過程中,並沒有實際的數據拷貝,文件沒有被載入內存,只是邏輯上被放入了內存,具體到代碼,就是建立並初始化了相關的數據結構(struct address_space),這個過程由系統調用 mmap() 實現,所以建立內存映射的效率很高。

既然建立內存映射沒有進行實際的數據拷貝,那么進程又怎么能最終直接通過內存操作訪問到硬盤上的文件呢?那就要看內存映射之后的幾個相關的過程了。

mmap() 會返回一個指針 ptr,它指向進程邏輯地址空間中的一個地址,這樣以后,進程無需再調用 read 或 write 對文件進行讀寫,而只需要通過 ptr 就能夠操作文件。但是 ptr 所指向的是一個邏輯地址,要操作其中的數據,必須通過 MMU 將邏輯地址轉換成物理地址,如上圖中過程 2 所示。這個過程與內存映射無關。

前面講過,建立內存映射並沒有實際拷貝數據,這時,MMU 在地址映射表中是無法找到與 ptr 相對應的物理地址的,也就是 MMU 失敗,將產生一個缺頁中斷,缺頁中斷的中斷響應函數會在 swap 中尋找相對應的頁面,如果找不到(也就是該文件從來沒有被讀入內存的情況),則會通過 mmap() 建立的映射關系,從硬盤上將文件讀取到物理內存中,如上圖中過程 3 所示。這個過程與內存映射無關。

如果在拷貝數據時,發現物理內存不夠用,則會通過虛擬內存機制(swap)將暫時不用的物理頁面交換到硬盤上,如圖1中過程4所示。這個過程也與內存映射無關。

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

  1. 進程啟動映射過程,並在虛擬地址空間中為映射創建虛擬映射區域
  2. 調用內核空間的系統調用函數 mmap(不同於用戶空間函數),實現文件物理地址和進程虛擬地址的一一映射關系
  3. 進程發起對這片映射空間的訪問,引發缺頁異常,實現文件內容到物理內存(主存)的拷貝

認真分析mmap:是什么 為什么 怎么用

2.2 適合的場景

  • 有一個很大的文件,因為映射有額外的性能消耗,所以適用於頻繁讀操作的場景;(單次使用的場景不建議使用)
  • 有一個小文件,它的內容您想要立即讀入內存並經常訪問。這種技術最適合那些大小不超過幾個虛擬內存頁的文件。(頁是地址空間的最小單位,虛擬頁和物理頁的大小是一樣的,通常為 4KB。)
  • 需要在內存中緩存文件的特定部分。文件映射消除了緩存數據的需要,這使得系統磁盤緩存中的其他數據空間更大

當隨機訪問一個非常大的文件時,通常最好只映射文件的一小部分。映射大文件的問題是文件會消耗活動內存。如果文件足夠大,系統可能會被迫將其他部分的內存分頁以加載文件。將多個文件映射到內存中會使這個問題更加復雜。

2.3 不適合的場景

  • 希望從開始到結束的順序從頭到尾讀取一個文件
  • 文件有幾百兆字節或者更大。將大文件映射到內存中會快速地填充內存,並可能導致分頁,這將抵消首先映射文件的好處。對於大型順序讀取操作,禁用磁盤緩存並將文件讀入一個小內存緩沖區
  • 該文件大於可用的連續虛擬內存地址空間。對於 64 位應用程序來說,這不是什么問題,但是對於 32 位應用程序來說,這是一個問題。32 位虛擬內存最大是 4GB,可以只映射部分。
  • 因為每次操作內存會同步到磁盤,所以不適用於移動磁盤或者網絡磁盤上的文件;
  • 變長文件不適用;

三、iOS 中的 mmap

官網的 demo 為例,其他的代碼很簡明直接,核心就在於mmap函數。

/**
 *  @param  start  映射開始地址,設置 NULL 則讓系統決定映射開始地址
 *  @param  length  映射區域的長度,單位是 Byte
 *  @param  prot  映射內存的保護標志,主要是讀寫相關,是位運算標志;(記得與下面fd對應句柄打開的設置一致)
 *  @param  flags  映射類型,通常是文件和共享類型
 *  @param  fd  文件句柄
 *  @param  off_toffset  被映射對象的起點偏移
 */
void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset);

*outDataPtr = mmap(NULL,
                   size,
                   PROT_READ|PROT_WRITE,
                   MAP_FILE|MAP_SHARED,
                   fileDescriptor,
                   0);

用官網的代碼做參考,寫了一個讀寫的例子:

#import "ViewController.h"
#import <sys/mman.h>
#import <sys/stat.h>

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_key";
    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);
    }
}

@interface ViewController ()

@end

@implementation ViewController

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

在 iOS 的應用實例:

四、MMKV 和 mmap

NSUserDefault 是常見的緩存工具,但是數據有時會同步不及時,比如說在 crash 前保存的值很容易出現保存失敗的情況,在 App 重新啟動之后讀取不到保存的值。

MMKV 很好的解決了 NSUserDefault 的局限,具體的好處可以見官網

但是同樣由於其獨特設計,在數據量較大、操作頻繁的場景下,會產生性能問題。

這里的使用給出兩個建議:

  1. 不要全部用 defaultMMKV,根據業務大的類型做聚合,避免某一個 MMKV 數據過大,特別是對於某些只會出現一次的新手引導、紅點之類的邏輯,盡可能按業務聚合,使用多個 MMKV 的對象;
  2. 對於需要頻繁讀寫的數據,可以在內存持有一份數據緩存,必要時再更新到 MMKV;

五、NSData 與 mmap

NSData 有一個靜態方法和 mmap 有關系。

+ (id)dataWithContentsOfFile:(NSString *)path options:(NSDataReadingOptions)readOptionsMask error:(NSError **)errorPtr;

typedef NS_OPTIONS(NSUInteger, NSDataReadingOptions) {

    // Hint to map the file in if possible and safe. 在保證安全的前提下使用 mmap
    NSDataReadingMappedIfSafe =   1UL << 0,
    // Hint to get the file not to be cached in the kernel. 不要緩存。如果該文件只會讀取一次,這個設置可以提高性能
    NSDataReadingUncached = 1UL << 1,
    // Hint to map the file in if possible. This takes precedence over NSDataReadingMappedIfSafe if both are given.  總使用 mmap
    NSDataReadingMappedAlways API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0)) = 1UL << 3,
    ...
};
  1. Mapped 的意思是使用 mmap,那么 ifSafe 是什么意思呢?
  2. NSDataReadingMappedIfSafe 和 NSDataReadingMappedAlways 有什么區別?

如果使用 mmap,則在 NSData 的生命周期內,都不能刪除對應的文件。

如果文件是在固定磁盤,非可移動磁盤、網絡磁盤,則滿足 NSDataReadingMappedIfSafe。對 iOS 而言,這個 NSDataReadingMappedIfSafe = NSDataReadingMappedAlways。

那什么情況下應該用對應的參數?

如果文件很大,直接使用 dataWithContentsOfFile 方法,會導致 load 整個文件,出現內存占用過多的情況;此時用 NSDataReadingMappedIfSafe,則會使用 mmap 建立文件映射,減少內存的占用。

使用場景:視頻加載。視頻文件通常比較大,但是使用的過程中不會同時讀取整個視頻文件的內容,可以使用 mmap 優化。

六、總結

mmap 就是文件的內存映射。

通常讀取文件是將文件讀取到內存,會占用真正的物理內存;而 mmap 是用進程的內存虛擬地址空間去映射實際的文件中,這個過程由操作系統處理。mmap 不會為文件分配物理內存,而是相當於將內存地址指向文件的磁盤地址,后續對這些內存進行的讀寫操作,會由操作系統同步到磁盤上的文件。

iOS 中使用 mmap 可以用 c 方法的 mmap(),也可以使用 NSData 的接口帶上NSDataReadingMappedIfSafe 參數。前者自由度更大,后者用於讀取數據。

七、內容來源

落影loyinglin & iOS的文件內存映射——mmap
mmap 蘋果官方文檔
NSDataReadingMappedIfSafe
小涼介 & iOS內存映射mmap詳解
linux中的頁緩存和文件IO
從內核文件系統看文件讀寫過程
linux內存映射mmap原理分析
mmap實例及原理分析


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM