iOS 文件 IO 与 mmap

91 阅读18分钟

一、常规文件访问

以 NSUserDefaults 为例,看下传统文件读写流程。

1、NSUserDefaults 基本使用

NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; // 写
[defaults setObject:@"testValue" forKey:@"testKey"]; // 读 
NSString *value = [defaults objectForKey:@"testKey"]; 

iOS 还提供了强制同步的 API:

[[NSUserDefaults standardUserDefaults] synchronize]; 

在早期的 API 中,往往需要开发者会主动调用 synchronize 以确保同步。但 Apple 当前文档已经提示,不要再调用该 API:

Waits for any pending asynchronous updates to the defaults database and returns; this method is unnecessary and shouldn't be used. 

原因是 iOS 已经提供了更智能和高效的机制来自动管理 NSUserDefaults 数据的持久化,系统会在以下几个时机进行数据同步:

  • 应用进入后台时。
  • 应用即将终止时。
  • 系统检测到内存压力时。

而开发者主动调用 synchronize 可能会带来一系列问题,如阻塞主线程、数据冲突等。

2、NSUserDefaults 读、写流程

(1) 写流程

NSUserDefaults 在调用类似 setObject:forKey: 写 API 后,数据同步时内部实际上是通过调用 writeToFile:options:error: 将数据写入磁盘的。
writeToFile:options:error: 的内部实现会使用 openwrite 和 close 系统调用来实现数据持久化:

  • 打开文件(open):

    • 创建一个文件描述符,用于写操作。
    • int fd = open(filePath, O_WRONLY | O_CREAT | O_TRUNC, 0644);
  • 写入数据(write):

    • 将序列化的 plist 数据写入文件。
    • ssize_t bytesWritten = write(fd, serializedData, length);
  • 关闭文件(close):

    • 关闭文件描述符,保存更改。
    • close(fd);

写主要流程总结:

  • 在 open 系统调用时,就从用户空间切换到了内核空间,在内核空间获取到文件描述符 fd 后再返回给用户空间。即执行完 open 之后,就回到了用户空间。

    • write 也是系统调用,也会导致从用户空间切换到内核空间,在内核空间中,操作系统执行实际的数据写操作,其主要流程如下:

      • 内核从用户空间缓冲区读取数据,并暂时存储到内核缓冲区(页缓存)。

      • 内核将数据从内核缓冲区写入到磁盘。

        • 如果缓存写(write-back)机制启用,数据写入到内核的缓存区后,不会立即写入到磁盘,当缓存区达到一定条件时再批量写入。
  • 数据写完成后,使用 close 系统调用关闭文件描述符,释放资源。

    • 内核会从进程的文件描述符表中移除对应条目,使该文件描述符变得无效。
    • 内核会减少与文件描述符关联的文件对象的引用计数,如果减少后的引用计数为零,表示没有其他进程或文件描述符引用该文件对象,内核会释放与文件对象相关的所有资源。
    • 内核更新文件的访问时间和修改时间,并将这些元数据和缓存的数据写入磁盘,以确保文件系统的一致性。

其中:

  • 内核缓冲区

    • 内核缓冲区是操作系统内核用来暂时存储数据的内存区域,是物理内存中的一部分。它们位于内核空间,用户态程序无法直接访问,前述的文件读取时,内核缓冲区指的是页缓存(page cache)。
  • 用户空间缓冲区

    • 用户空间缓冲区是由用户态程序分配并使用的内存区域,也是物理内存的一部分。它们位于用户空间,程序可以直接访问和操作这些区域。

(2) 读流程

当使用 NSUserDefaults 的 objectForKey 方法来读取数据时,底层流程如下:

  • 读取内存缓存:

    • 调用 objectForKey: 方法时,NSUserDefaults 首先尝试从内存中的字典(缓存)中读取数据。
    • NSString *value = [[NSUserDefaults standardUserDefaults] objectForKey:@"exampleKey"];
  • 缓存未命中,读取文件:

    • 如果数据不在内存缓存中,则 NSUserDefaults 从磁盘的 plist 文件中读取数据。
  • 打开文件(open):

    • 使用 open 系统调用以只读模式打开 plist 文件。
    • int fd = open("/path/to/plist", O_RDONLY);
  • 读取数据(read):

    • 使用 read 系统调用将文件内容读入内存缓冲区。
    • ssize_t bytesRead = read(fd, buffer, bufferSize);
  • 关闭文件(close):

    • 数据读取完成后,使用 close 系统调用关闭文件描述符,释放资源。
    • close(fd);
  • 反序列化数据:

    • 将从文件中读取的 plist 数据反序列化,并存入内存缓存,供后续读取。

读主要流程总结:

  • 在 open 系统调用时,就从用户空间切换到了内核空间,在内核空间获取到文件描述符 fd 后再返回给用户空间。即执行完 open 之后,就回到了用户空间。

  • read 也是系统调用,也会导致从用户空间切换到内核空间,在内核空间中,操作系统执行实际的数据读取操作,并返回结果给用户空间,其主要流程如下:

    • 由于系统调用 read,CPU 切换到内核态,操作系统内核接管控制。

    • 内核通过已打开的文件描述符找到对应的文件,先检查内核缓冲区是否已经缓存了所需的文件数据。

      • 如果内核缓冲区命中缓存,则将缓存拷贝至用户缓冲区。
      • 如果内核缓冲区没有命中缓存,则先从磁盘读取数据到内核缓冲区,再将内核缓冲区缓存拷贝至用户空间缓冲区。
    • 将用户空间缓冲区中的内容返回。

      • 因为用户态进程只能访问用户空间的内容。
  • 数据读取完成后,使用 close 系统调用关闭文件描述符,释放资源。

(3) 简单总结

由前述的读写流程可知,读写的核心操作,分别是 readwrite 两个系统调用。
对于上述 readwrite 系统调用的主要流程,可用下图表示:

由此可知,传统文件读写,存在下面两个对性能影响较大的操作:

  • 至少两次数据的拷贝。

  • 需要将文件内容拷贝到用户空间缓冲区,增加内存占用。

    • 将 readwrite 系统调用时参数指定大小的内容拷贝到用户空间缓冲区。

二、mmap

1、基本使用

mmap 是一个系统调用,它会从用户空间切换到内核空间执行相关操作,然后再返回到用户空间。它用于将文件映射到进程的虚拟内存,使得文件内容可以像内存一样被访问。
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)。
  • fd:要映射的文件描述符。
  • offset:文件中开始映射的偏移量。

使用示例:

#import <Foundation/Foundation.h> #import <sys/mman.h> 
#import <sys/stat.h> 
#import <fcntl.h> 
#import <unistd.h>  
int main(int argc, const char * argv[])
{    
    @autoreleasepool {         // 文件路径         
    NSString *fileName = @"example.txt";         
    NSString *documentDirectory = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];         
    NSString *filePath = [documentDirectory stringByAppendingPathComponent:fileName];               NSFileManager *fileManager = [NSFileManager defaultManager];         
    if (![fileManager fileExistsAtPath:filePath]) {             
        NSLog(@"File does not exist at path: %@", filePath);             
        return EXIT_FAILURE;         
     }                
     // 打开文件,读写模式         
     int fd = open([filePath fileSystemRepresentation], O_RDWR);         
     if (fd == -1) {             
         perror("Error opening file");             
         return EXIT_FAILURE;         
      }         
      // 获取文件大小         
      struct stat sb;         
      if (fstat(fd, &sb) == -1) {             
          perror("Error getting file size");             
          close(fd);             
          return EXIT_FAILURE;         
      }          
      size_t filesize = sb.st_size;          
      // 将文件映射到内存,读写模式         
      char *mapped = mmap(NULL, filesize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);       
      if (mapped == MAP_FAILED) {             
          perror("Error mapping file");             
          close(fd);             
          return EXIT_FAILURE;         
      }          
      // 关闭文件描述符         
      close(fd);          
      // 读取文件内容并打印到控制台         
      NSData *fileData = [NSData dataWithBytes:mapped length:filesize];         
      NSString *fileContent = [[NSString alloc] initWithData:fileData encoding:NSUTF8StringEncoding];         
      NSLog(@"Original file content: %@", fileContent);          
      // 修改文件内容(假设文件内容是文本)         
      const char *newText = "Hello, mmap!";         
      size_t newTextLength = strlen(newText);         
      if (newTextLength <= filesize) {             
          memcpy(mapped, newText, newTextLength);             
          NSLog(@"Modified file content: %s", mapped);             
          // 同步修改到文件             
          if (msync(mapped, filesize, MS_SYNC) == -1) {                 
              perror("Error syncing file");             
          }        
      } else {             
          NSLog(@"New text is larger than the file size, not modifying.");         
      }          
      // 取消映射         
      if (munmap(mapped, filesize) == -1) {             
          perror("Error unmapping file");             
           return EXIT_FAILURE;         
      }     
   }    
 return 0; 
} 

上述使用总结:

  • 读文件

    • 打开文件 (open):

      • 调用 open 系统调用以只读模式打开文件,获得文件描述符。
      • 示例: int fd = open("example.txt", O_RDONLY);
    • 获取文件大小 (fstat):

      • 调用 fstat 获取文件的大小,以确定映射的长度。
      • 示例: struct stat sb; fstat(fd, &sb); size_t filesize = sb.st_size;
    • 映射文件 (mmap):

      • 调用 mmap 将文件映射到内存,指定映射区域的保护标志为只读。
      • 示例: char *mapped = mmap(NULL, filesize, PROT_READ, MAP_SHARED, fd, 0);
    • 读取数据:

      • 通过访问映射的内存区域读取文件内容。
      • 示例: printf("File content: %s", mapped);
    • 取消映射 (munmap):

      • 调用 munmap 取消内存映射,释放映射区域。
      • 示例: munmap(mapped, filesize);
    • 关闭文件 (close):

      • 关闭文件描述符。
      • 示例: close(fd);
  • 写文件

    • 打开文件 (open):

      • 调用 open 系统调用以读写模式打开文件,获得文件描述符。
      • 示例: int fd = open("example.txt", O_RDWR);
    • 获取文件大小 (fstat):

      • 调用 fstat 获取文件的大小,以确定映射的长度。
      • 示例: struct stat sb; fstat(fd, &sb); size_t filesize = sb.st_size;
    • 映射文件 (mmap):

      • 调用 mmap 将文件映射到内存,指定映射区域的保护标志为读写。
      • 示例: char *mapped = mmap(NULL, filesize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    • 修改数据:

      • 通过访问映射的内存区域修改文件内容。
      • 示例: memcpy(mapped, "New content", strlen("New content"));
    • 同步修改 (msync):

      • 调用 msync 将修改同步到磁盘。
      • 示例: msync(mapped, filesize, MS_SYNC);
    • 取消映射 (munmap):

      • 调用 munmap 取消内存映射,释放映射区域。
      • 示例: munmap(mapped, filesize);
    • 关闭文件 (close):

      • 关闭文件描述符。
      • 示例: close(fd);

2、mmap 原理

(1) 读

操作系统为每个进程分配了虚拟内存,mmap 则是将文件映射到进程的虚拟内存,使得文件内容可以像内存一样被访问。

注意,这里仅是映射,文件的内容并不会立即被拷贝到用户空间的虚拟内存中。mmap 只是将文件与进程的虚拟地址空间关联起来。映射区域的页面在物理内存中并不会马上被分配,文件的实际数据仍然驻留在磁盘上。

当首次访问进行虚拟内存映射区域时,操作系统通过缺页中断将需要的文件页面从磁盘加载到物理内存中。

该主要流程如下:

  • 映射文件到虚拟内存

    • 使用 mmap 系统调用将文件映射到进程的虚拟地址空间。此时,文件的内容还没有实际加载到物理内存中,只有一个虚拟内存区域被分配。
  • 访问映射的虚拟内存地址

    • 应用程序尝试访问映射的虚拟内存地址。这种访问通常包括读取操作。
  • 缺页中断

    • MMU 将虚拟地址转换为物理地址时,发现该页不在物理内存中,产生一个缺页中断(Page Fault)。
  • 内核处理缺页中断

    • 内核捕获缺页中断,并检查相应的虚拟内存页表项。它会发现该页是映射的文件的一部分,而实际数据尚未加载。
  • 加载页面

    • 内核从磁盘读取对应的文件内容,将其加载到物理内存中。通常,这个操作会将整个页(通常是 4KB)读取到物理内存中。
  • 更新页表

    • 内核更新进程的页表,将虚拟地址映射到新的物理内存地址,并设置相应的权限(只读或读写等)。
  • 继续执行

    • 缺页中断处理完成后,进程重新开始执行,并继续访问已加载到内存中的数据。

(2) 写

写主要流程如下:

  • 映射文件到虚拟内存

    • 与读流程相同,使用 mmap 系统调用将文件映射到进程的虚拟地址空间。
  • 访问映射的虚拟内存地址

    • 应用程序尝试写入映射的虚拟内存地址。这可能会触发缺页中断,如果该页尚未加载到物理内存中。
  • 缺页中断

    • 如果写入的页尚未在物理内存中,则会触发缺页中断,内核开始处理。
  • 加载页面

    • 内核从磁盘读取对应的文件内容,并将其加载到物理内存中。这通常是为了保持文件的一致性,即使是在写操作时。
  • 更新页表

    • 内核更新页表,将虚拟地址映射到物理内存中的副本页,并设置相应的权限(读写)。
  • 写入数据

    • 应用程序将数据写入物理内存。
  • 脏页标记

    • 修改的页被标记为脏页,表示内存中的数据与磁盘上的数据不同步。
  • 同步到磁盘

    • 内核的页回写机制负责将脏页的数据写回到磁盘。这个过程可以通过 msync 系统调用手动触发,也可以通过内核定期的页面回收机制自动进行。
  • 清除脏页标记

    • 一旦数据写回到磁盘,脏页标记被清除,表示内存和磁盘数据再次同步。

操作系统内核并不会立刻把 mmap 映射的页缓存同步到磁盘,同步内存到磁盘有 4 个时机:

  • 调用 msync 函数主动进行数据同步。
  • 调用 munmap 函数对文件进行解除映射关系时。
  • 进程退出时。
  • 系统关机时。

对于“进程退出时”、“系统关机时”两个时机,由操作系统自动完成,操作系统在对应时机自动完成同步到磁盘的工作。也就是说,一旦将内容写入到物理内存后,即使 APP 发生 Crash,操作系统也可以将这些数据写入到磁盘。

(3) 简单总结

使用 mmap 读写文件时,mmap 会在磁盘、进程虚拟内存之间建立内存映射关系。当进程读写文件时,是针对虚拟内存进行读写的,当 MMU 检测到物理内存中并不存在虚拟内存对应的内容时,会触发缺页中断,将文件内容拷贝到物理内存中。后续再对访问虚拟内存中的内容时,在 MMU 的配合下,就访问操作物理内存中对应的内容了。

注意,虚拟内存地址空间实际上是一个映射表,指向物理内存中的实际数据,虚拟内存并不直接存储数据。进程访问虚拟内存时,是通过虚拟内存地址空间的映射关系,直接访问物理内存中的数据。所以访问虚拟内存时,并不涉及往虚拟内存的数据拷贝,只是地址的映射和访问。也就是说,文件内容会存在于两个地方:物理内存、磁盘。

该过程可以用下图表示:

mmap 相较于传统文件读写方式,主要区别如下:

  • 文件内容拷贝次数不同

    • 传统方式需要两次拷贝:磁盘 -> 内核缓冲区 -> 用户空间缓冲区

      • 因为进程只能访问自己用户空间缓冲区,不能访问内核缓冲区,所以必须把内容拷贝到用户空间缓冲区才能访问。
      • 内核缓冲区、用户空间缓冲区都是物理内存的一部分,所以同一份内容,会在物理内存中存在两份。
    • mmap 方式仅需一次拷贝:磁盘 -> 物理内存

      • 由于 mmap 有更少的数据拷贝次数,所以在效率更高、内存占用更少
  • 文件内容加载时机不同

    • 传统方式:直接将文件内容加载到了物理内存。

    • mmap 方式:访问具体内容时才会加载到物理内存。

      • 因为文件数据在访问时才被加载到内存中。相较于直接读取文件到内存缓冲区,mmap 不需要一次性加载整个文件,降低了内存使用的峰值。
  • 写入时机不同

    • 传统方式:需要进程显式同步,进程如果发生 Crash,数据可能无法及时写入到磁盘。
    • mmap 方式:操作系统可以自动完成,可以不需要进程参与。操作系统的页面缓存机制可以提高写入的可靠性,减少了数据丢失的风险。

三、MMKV 与 mmap

MMKV 是腾讯开源的高性能 Key/Value 存储库,MMKV 使用 mmap 进行文件读写,数据序列化使用的是 protobuf 协议,其性能比传统文件读写方式更好。

其中 Protobuf(Protocol Buffers)是由 Google 开发的一种序列化数据格式,广泛用于数据的高效序列化和反序列化。Protobuf 提供了一种结构化数据的描述语言(.proto 文件),能够生成高效的二进制序列化代码,并支持多种编程语言。
.proto 后缀的文件(person.proto)消息结构示例:

   syntax = "proto3";  
   message Person {   
       int32 id = 1;   
       string name = 2;   
       string email = 3;
   } 

Protobuf(Protocol Buffers)是一种轻量级、跨平台的序列化数据格式,常用于前后端或服务之间的数据交换。其通信流程通常包括以下步骤:

  • 定义消息结构(.proto 文件)

    • 开发者首先在 .proto 文件中定义数据结构,包括消息类型、字段类型和字段编号。
    • Protobuf 建议在修改 .proto 文件时,不要删除或更改已有字段的编号或类型,而是通过添加新字段的方式进行扩展。这样可以确保旧版本和新版本之间的兼容性。
  • 生成代码

    • 使用 protoc 编译器根据 .proto 文件生成客户端和服务端对应语言的代码(如 Java、Python、Objective-C 等)。这些代码包含了序列化和反序列化功能。
    • 也就是说,各端使用相同或兼容的 .proto 文件生成代码。
  • 序列化(客户端或服务端)

    • 客户端或服务端将需要发送的数据对象序列化为 Protobuf 二进制格式。序列化是将数据结构按照字段编号和类型编码成紧凑的二进制数据。

      • Protobuf 消息的每个字段,都有一个编号,例如前面 Person 的 id 编号为 1、name 编号为 2,在序列化为二进制时,字段名并不参与序列化,而是使用编号代替字段名。所以只要前、后端字段编号一样即可,字段名无须保持一致。
  • 数据传输

    • 序列化后的二进制数据通过网络传输到另一端。由于 Protobuf 数据非常紧凑,传输效率较高。
  • 反序列化(客户端或服务端)

    • 接收端收到二进制数据后,使用生成的代码进行反序列化,将二进制数据转换回原始的对象结构(Model),供应用程序使用。

该过程可用下图表示:

MMKV 的 key 限定 string 字符串类型,而 value 则多种多样(int/bool/double 等),MMKV 先将 value 通过 protobuf 协议序列化成统一的内存块(buffer),确保无论 value 是什么类型,存储使用的类型都是统一的,最后再将这些 KV 对象使用 protobuf 序列化并写入内存中。

该过程伪代码如下:

message KV {         
    string key = 1;         
    buffer value = 2; 
}  
- (BOOL)setInt32:(int32_t)value forKey:(NSString*)key {         
    auto data = PBEncode(value);         
    return [self setData:data forKey:key]; 
}  
- (BOOL)setData:(NSData*)data forKey:(NSString*)key {         
    auto kv = KV { key, data };         
    auto buf = PBEncode(kv);         
    return [self write:buf]; 
} 

标准 protobuf 不提供增量更新的能力,每次写入都必须全量写入。考虑到主要使用场景是频繁地进行写入更新,MMKV 需要有增量更新的能力:将增量 kv 对象序列化后,直接 append 到内存末尾;这样同一个 key 会有新旧若干份数据,最新的数据在最后;那么只需在程序启动第一次打开 MMKV 时,不断用后读入的 value 替换之前的值,就可以保证数据是最新有效的。

使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。例如同一个 key 不断更新的话,是可能耗尽几百 M 甚至上 G 空间,而事实上整个 kv 文件就这一个 key,不到 1k 空间就存得下。这明显是不可取的。我们需要在性能和空间上做个折中:以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式;当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重结果;排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。

该过程伪代码如下:

- (BOOL)append:(NSData*)data {         
   if (space >= data.length) {                 
       append(fd, data);         
   } else {                 
       newData = unique(m_allKV);                 
       if (total_space >= newData.length) {                         
           write(fd, newData);                 
       } else {                         
           while (total_space < newData.length) {                                 
               total_space *= 2;                         
           }                         
           ftruncate(fd, total_space);                         
           write(fd, newData);                 
       }       
    } 
}

转载www.lixkit.com/posts/15823…