【问题标题】:How can `NSUserDefaults synchronize` runs so fast?`NSUserDefaults synchronize` 怎么跑得这么快?
【发布时间】:2017-04-30 15:01:31
【问题描述】:

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

- (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 秒 vs 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 倍。现在我开始怀疑“NSUserDefaults 真的每次我们调用synchronize 方法时都会写入plist 文件吗?”,但如果没有,它如何保证在进程退出之前将数据写回文件(作为进程可能随时被杀死)?

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

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

【问题讨论】:

  • 这是一篇关于NSUserDefaults 的好文章:dscoder.com/defaults.html。它的作者是苹果公司的一名工程师,所以可以肯定地假设他知道他在说什么:)
  • @Losiowaty 感谢您的链接,但我认为它谈到了 mac ox 中的实现,因为它说“设置一个值将(最终,它是异步的,并且稍后在另一个进程中发生)写将整个 plist 输出到磁盘,无论变化多么小。”。如果你修改 NSUserDefaults 并在没有synchronize 的情况下杀死你的应用程序,设置将不会写入文件,所以我认为在 iOS 中没有另一个进程写入文件。
  • 如果您修改 NSUserDefaults 并终止应用程序,您必须在终止按钮上非常 快才能丢失数据。几毫秒左右。这在 iOS 8 中发生了变化;在此之前,以这种方式丢失数据要容易得多。

标签: ios nsuserdefaults mmap virtual-memory


【解决方案1】:

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

先决条件

CCUserSettingsNSUserDefaults 方法中的synchronize 将plist 文件写回磁盘,这会花费大量时间,但我们必须在某些情况下调用它,例如当应用程序进入后台时。即便如此我们还是冒着丢失设置的风险:我们的应用程序可能会被系统杀死,因为它耗尽了内存或访问了它没有权限的地址,当时我们在最新的synchronize之后设置的设置可能输了。

如果有一种方法可以在进程退出时将文件写入磁盘,我们可以随时修改内存中的设置,速度非常快。但是有没有办法做到这一点?

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

有两个链接支持我:

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

mmap, msync and linux process termination

使用mmap的问题

正如我在问题中提到的:

这几天我想出了一个改进我的 CCUserSettings 的想法,它是 mmap 内存映射 I/O。我可以将虚拟内存映射到文件,并且每次用户调用同步时,我都会使用 NSPropertyListSerialization dataWithPropertyList:format:options:error: 方法创建一个 NSData 并将数据复制到该内存中,操作系统将在进程退出时将内存写回文件.但是因为文件大小不是固定的,所以我可能得不到很好的性能,每次数据长度增加,我都要重新映射一个虚拟内存,我相信这个操作很耗时。

问题是:每次数据长度增加,我都要重新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

显示CCMmapUserSettings运行速度比NSUserDefaults快!!!

我不确定

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

我想我必须研究一下mmap的系统行为,如果有谁知道,请分享。非常感谢。

【讨论】:

  • FWIW,在 iOS 8 及更高版本上通常不需要在 NSUserDefaults 上调用 -synchronize。这样做只会减慢您的程序速度(如果您要调用 exit() 并且需要等待数据安全退出进程,有时这不是一件坏事)。如果可以卸载底层卷、需要原子性、发生内核恐慌或其他进程可能会弄乱文件,则使用 mmap() 是危险的。小心使用:)
  • 如果只有一个进程可以访问该文件,因为它只运行在iOS平台上,并且只有一个线程执行mmap并修改内存,这还危险吗?我真的很喜欢它,因为它跑得太快了!!!
  • 如果在写入数据的过程中系统崩溃,你仍然会有一些风险,但是是的,如果你能像这样仔细控制使用,使用mmap的风险要小得多。使写入对系统崩溃具有鲁棒性的典型模式是使用 mkstemp() 创建一个临时文件,写入该文件,fsync,然后 rename() 覆盖原始文件。遗憾的是,正如您所见,速度较慢。
【解决方案2】:

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

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

【讨论】:

  • 感谢您的回答!但是如果IO操作发生在另一个进程上,为什么我在调用exit之前设置的设置丢失了?
  • 我丢失了在我调用exit 之前设置的设置并不意味着你是假的,也许cfprefsd 进程需要我们的进程发送一些其他消息。我只是想知道:) 顺便说一句,你是怎么知道的,你能发布一些参考资料吗?
  • 可能发生的原因有两个。第一个是最简单的:消息发送是异步的,因此您的 exit() 调用可能发生在消息实际离开您的进程之前。第二个更微妙:cfprefsd 必须验证您的沙箱权限,以确保允许您访问这些首选项。检查沙盒权限需要您的进程仍在运行。
  • 刚刚检查了xpc_connection_send_message_with_reply,你是对的。
猜你喜欢
  • 2012-09-19
  • 2018-12-14
  • 1970-01-01
  • 1970-01-01
  • 2020-12-19
  • 2011-06-02
  • 2012-03-18
  • 2014-05-09
  • 2016-03-19
相关资源
最近更新 更多