一、常規文件操作
常規文件操作(read/write)有那幾個重要步驟:
- 進程發起讀文件請求
- 內核通過查找進程文件符表,定位到內核已打開文件集上的文件信息,從而找到此文件的 inode
- inode 在 address_space 上查找要請求的文件頁是否已經緩存在內核頁高速緩沖中。如果存在,則直接返回這片文件頁的內容
- 如果不存在,則通過 inode 定位到文件磁盤地址,將數據從磁盤復制到內核頁高速緩沖。之后再次發起讀頁面過程,進而將內核頁高速緩沖中的數據發給用戶進程
需要注意的幾點:
- 常規文件操作為了提高讀寫效率和保護磁盤,使用了頁緩存機制。由於頁緩存處在內核空間,不能被用戶進程直接尋址,所以需要將頁緩存中數據頁再次拷貝到內存對應的用戶空間中
- read/write 是系統調用很耗時,如下圖,它首先將文件內容從硬盤拷貝到內核空間的一個緩沖區,然后再將這些數據拷貝到用戶空間,實際上完成了兩次數據拷貝
- 如果兩個進程都對磁盤中的一個文件內容進行訪問,那么這個內容在物理內存中有三份:進程 A 的地址空間 + 進程 B 的地址空間 + 內核頁高速緩沖空間
- 寫操作也是一樣,待寫入的 buffer 在內核空間不能直接訪問,必須要先拷貝至內核空間對應的主存,再寫回磁盤中(延遲寫回),也是需要兩次數據拷貝
二、mmap內存映射
2.1 mmap 介紹
在日常開發中偶爾會遇到 mmap,它最常用到的場景是 MMKV,其次用到的是日志打印。雖然都已經被封裝好,但也需要了解下 mmap 的基本原理和過程。
進程是 App 運行的基本單位,進程之間相對獨立。iOS 系統中 App 運行的內存空間地址是虛擬空間地址,存儲數據是在各自的沙盒。
當我們在 App 中去讀寫沙盒中的文件時,我們會使用 NSFileManager 去查找文件,然后可以使用 NSData 去加載二進制數據。文件操作的更底層實現過程,是使用 linux 的 read()
、write()
函數直接操作文件句柄(也叫文件描述符、fd)。
在操作系統層面,當 App 讀取一個文件時,實際是有兩步:
- 將文件從磁盤讀取到物理內存;
- 從系統空間拷貝到用戶空間(可以認為是復制到系統給 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 內存映射的實現過程,總的來說可以分為三個階段:
- 進程啟動映射過程,並在虛擬地址空間中為映射創建虛擬映射區域
- 調用內核空間的系統調用函數 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 的局限,具體的好處可以見官網。
但是同樣由於其獨特設計,在數據量較大、操作頻繁的場景下,會產生性能問題。
這里的使用給出兩個建議:
- 不要全部用 defaultMMKV,根據業務大的類型做聚合,避免某一個 MMKV 數據過大,特別是對於某些只會出現一次的新手引導、紅點之類的邏輯,盡可能按業務聚合,使用多個 MMKV 的對象;
- 對於需要頻繁讀寫的數據,可以在內存持有一份數據緩存,必要時再更新到 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,
...
};
- Mapped 的意思是使用 mmap,那么 ifSafe 是什么意思呢?
- 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實例及原理分析