`NSUserDefaults synchronize` 怎么运行得这么快?

How can `NSUserDefaults synchronize` runs so fast?

在我的应用程序中,我想为每个用户登录将用户设置保存在一个 plist 文件中,我写了 one class called CCUserSettings,它与 NSUserDefaults 具有几乎相同的界面,并且它读取和写入一个 plist与当前用户标识相关的文件。它有效但性能不佳。每次用户调用 [[CCUserSettings sharedUserSettings] synchronize] 时,我都会将 NSMutableDictionary(保留用户设置)写入 plist 文件,下面的代码显示 synchronize of CCUserSettings,省略了一些琐碎的细节。

- (BOOL)synchronize {
    BOOL r = [_settings writeToFile:_filePath atomically:YES];
    return r;
}

我想 NSUserDefaults 应该在我们调用 [[NSUserDefaults standardUserDefaults] synchronize] 时写入文件,但是它 运行 真的很快,我写了一个 demo 来测试,关键部分在下面,运行 1000 次 [[NSUserDefaults standardUserDefaults] synchronize][[CCUserSettings sharedUserSettings] synchronize] 在我的 iPhone6 上,结果是 0.45 秒对 9.16 秒。

NSDate *begin = [NSDate date];
for (NSInteger i = 0; i < 1000; ++i) {
    [[NSUserDefaults standardUserDefaults] setBool:(i%2==1) forKey:@"key"];
    [[NSUserDefaults standardUserDefaults] synchronize];
}
NSDate *end = [NSDate date];
NSLog(@"synchronize seconds:%f", [end timeIntervalSinceDate:begin]);


[[CCUserSettings sharedUserSettings] loadUserSettingsWithUserId:@"1000"];
NSDate *begin = [NSDate date];
for (NSInteger i = 0; i < 1000; ++i) {
    [[CCUserSettings sharedUserSettings] setBool:(i%2==1) forKey:@"_boolKey"];
    [[CCUserSettings sharedUserSettings] synchronize];
}
NSDate *end = [NSDate date];
NSLog(@"CCUserSettings modified synchronize seconds:%f", [end timeIntervalSinceDate:begin]);

结果显示,NSUserDefaults 比我的 CCUserSettings 快将近 20 倍。现在我开始怀疑"Does NSUserDefaults really write to the plist files every time we call synchronize method?",但如果不是,它如何保证在进程退出之前将数据写回文件(因为进程随时可能被杀死)?

这几天想出一个办法来提高自己的CCUserSettings,就是mmapMemory-mapped I/O。我可以将虚拟内存映射到一个文件,每次用户调用 synchronize,我用 NSPropertyListSerialization dataWithPropertyList:format:options:error: 方法创建一个 NSData 并将数据复制到该内存中,操作系统会将内存写回进程退出时的文件。但是我可能没有得到很好的性能,因为文件大小不固定,每次数据长度增加,我都必须重新mmap一个虚拟内存,我相信操作很耗时。

抱歉我的冗余细节,我只是想知道 NSUserDefaults 是如何实现如此好的性能的,或者任何人都可以提出一些好的建议来改进我的 CCUserSettings 吗?

最后我想出了一个解决方案来提高我的 CCUserSettings 和 mmap 的性能,我称之为 CCMmapUserSettings.

先决条件

CCUserSettingsNSUserDefaults中的synchronize方法将plist文件写回磁盘,耗时比较长,但在某些情况下必须调用,比如app运行时进入背景。即便如此,我们还是冒着丢失设置的风险:我们的应用程序可能会被系统杀死,因为它耗尽了内存或访问了一个它没有权限的地址,那时我们设置的设置是在最新的 synchronize 可能会输。

如果有办法在进程退出的时候把文件写入磁盘,一直修改内存中的设置,还是挺快的。但是有办法实现吗?

嗯,我找到了一个,它是mmap,mmap把一个文件映射到一个内存区域。完成后,就可以像程序中的数组一样访问该文件。所以我们可以像写文件一样修改内存。当进程退出时,内存会回写到文件中。

有两个链接支持我:

Does the OS (POSIX) flush a memory-mapped file if the process is SIGKILLed?

mmap, msync and linux process termination

使用mmap的问题

正如我在问题中提到的:

These days I come up with an idea to improve my CCUserSettings, it is mmap Memory-mapped I/O. I can map a virtual memory to a file and every time user calls synchronize, I create a NSData with NSPropertyListSerialization dataWithPropertyList:format:options:error: method and copy the data into that memory, operating system will write memory back to file when process exits. But I may not get a good performance because the file size is not fixed, every time the length of data increases, I have to remmap a virtual memory, I believe the operation is time consuming.

问题是:每次数据长度增加,我都要重新mmap一个虚拟内存,很耗时。

解决方案

现在我有一个解决方案:始终创建比我们需要的更大的大小,并将真实文件大小保持在文件的开头 4 个字节,并在 4 个字节之后写入真实数据。由于文件比我们需要的大,当数据平稳增长时,我们不需要每次调用synchronize都重新mmap内存。文件大小还有另一个限制:文件大小始终是 MEM_PAGE_SIZE(在我的应用程序中定义为 4096)的倍数。

同步方法:

- (BOOL)synchronize {
    if (!_changed) {
        return YES;
    }
    NSData *data = [NSPropertyListSerialization dataWithPropertyList:_settings format:NSPropertyListXMLFormat_v1_0 options:0 error:nil];
    // even if data.length + sizeof(_memoryLength) is a multiple of MEM_PAGE_SIZE, we need one more page.
    unsigned int pageCount = (unsigned int)(data.length + sizeof(_memoryLength)) / MEM_PAGE_SIZE + 1;
    unsigned int fileSize = pageCount * MEM_PAGE_SIZE;
    if (fileSize != _memoryLength) {
        if (_memory) {
            munmap(_memory, _memoryLength);
            _memory = NULL;
            _memoryLength = 0;
        }

        int res = ftruncate(fileno(_file), fileSize);
        if (res == -1) {
            // truncate file error
            fclose(_file);
            _file = NULL;
            return NO;
        }
        // re-map the file
        _memory = (unsigned char *)mmap(NULL, fileSize, PROT_READ|PROT_WRITE, MAP_SHARED, fileno(_file), 0);
        _memoryLength = (unsigned int)fileSize;
        if (_memory == MAP_FAILED) {
            _memory = NULL;
            fclose(_file);
            _file = NULL;
            return NO;
        }
#ifdef DEBUG
        NSLog(@"memory map file success, size is %@", @(_memoryLength));
#endif
    }

    if (_memory) {
        unsigned int length = (unsigned int)data.length;
        length += sizeof(length);
        memcpy(_memory, &length, sizeof(length));
        memcpy(_memory+sizeof(length), data.bytes, data.length);
    }
    return YES;
}

一个例子可以帮助描述我的想法:假设plist数据大小为5000字节,我需要写入的总字节数为4 + 5000 = 5004。我先写入4字节无符号整数,其值为5004然后写入5000 字节的数据。文件总大小应为 8192(2*MEM_PAGE_SIZE)。我创建更大文件的原因是我需要一个大缓冲区来减少重新映射内存的时间。

性能

{
    [[CCMmapUserSettings sharedUserSettings] loadUserSettingsWithUserId:@"1000"];
    NSDate *begin = [NSDate date];
    for (NSInteger i = 0; i < 1000; ++i) {
        [[CCMmapUserSettings sharedUserSettings] setBool:(i%2==1) forKey:@"_boolKey"];
        [[CCMmapUserSettings sharedUserSettings] synchronize];
    }
    NSDate *end = [NSDate date];
    NSLog(@"CCMmapUserSettings modified synchronize seconds:%f", [end timeIntervalSinceDate:begin]);
}

{
    [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"key"];
    NSDate *begin = [NSDate date];
    for (NSInteger i = 0; i < 1000; ++i) {
        [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"key"];
        [[NSUserDefaults standardUserDefaults] synchronize];
    }
    NSDate *end = [NSDate date];
    NSLog(@"NSUserDefaults not modified synchronize seconds:%f", [end timeIntervalSinceDate:begin]);
}

{
    NSDate *begin = [NSDate date];
    for (NSInteger i = 0; i < 1000; ++i) {
        [[NSUserDefaults standardUserDefaults] setBool:(i%2==1) forKey:@"key"];
        [[NSUserDefaults standardUserDefaults] synchronize];
    }
    NSDate *end = [NSDate date];
    NSLog(@"NSUserDefaults modified synchronize (memory not change) seconds:%f", [end timeIntervalSinceDate:begin]);
}

输出为:

CCMmapUserSettings modified synchronize seconds:0.037747
NSUserDefaults not modified synchronize seconds:0.479931
NSUserDefaults modified synchronize (memory not change) seconds:0.402940

说明CCMmapUserSettingsNSUserDefaults跑得快!!!

我不确定

CCMmapUserSettings 通过我的 iPhone6 (iOS 10.1.1) 的单位设置,但我真的不确定它是否适用于所有 iOS 版本,因为我还没有得到官方文档来确保用于映射文件的内存在进程退出时会立即写回磁盘,如果不是,是否会在设备关闭之前将其写回磁盘?

我想我必须研究关于 mmap 的系统行为,如果你们中有人知道,请分享。非常感谢。

在现代操作系统(iOS 8+、macOS 10.10+)上,调用同步时 NSUserDefaults 不会写入文件。当您调用 -set* 方法时,它会向名为 cfprefsd 的进程发送一条异步消息,该进程会存储新值、发送回复,然后稍后将文件写出。所有 -synchronize 所做的就是等待发送到 cfprefsd 的所有未完成消息以接收回复。

(编辑:如果愿意,您可以通过在 xpc_connection_send_message_with_reply 上设置符号断点然后设置用户默认值来验证这一点)