当我们实现Sublime Merge的git部分时,我们选择mmap
用于读取git目标文件。事实证明,这比我们最初想象的要困难得多。mmap
在桌面应用程序中使用存在一些严重的警告,这是原因:
假设您正在读取某种二进制格式,则整个应用程序都需要数据。您采用最简单的方法,并使用简单的方法read
来获取文件的全部内容。您释放软件后,有人走过来说:“我需要解析这个3GB的文件,但我只有2GB的内存。您能否做到这一点,以便不占用太多内存?”。当然,您想帮助他们,因此您进行了一些搜索,得出结论,内存映射是解决此问题的完美解决方案。
内存映射文件的工作方式是将整个文件映射到虚拟地址空间,然后使用页面错误来确定将哪些块加载到物理内存中。本质上,它允许您访问文件,就好像您已将整个内容读入内存一样,而实际上并没有这样做。至关重要的是,只需要对代码库进行少量更改即可:
至此,您已经解决了内存使用问题。您已修复该错误,人们很高兴,而且一切都很好,直到您获得另一张支持票为止。您的程序因SIGBUS而崩溃。
SIGBUS(总线错误)是当您尝试访问尚未物理映射的内存时发生的信号。这与SIGSEGV(分段错误)的不同之处在于,当地址无效时会发生分段故障,而总线错误则表示地址有效,但我们无法读写。
事实证明,该票证来自使用网络驱动器的某人。当您的内存映射文件打开时,他们的网络碰巧断开了连接,并且由于该文件不再存在,因此操作系统无法为您将其加载到ram中,而是提供了SIGBUS。
由于操作系统是按需加载文件,因此您现在遇到了一个奇妙的问题,即从地址到内存映射文件的任意读取都可能会在某些时候失败。
幸运的是,在POSIX系统上,我们有信号处理程序,而SIGBUS是我们可以处理的信号。您需要做的就是在程序启动时为SIGBUS注册一个信号处理程序,然后跳回到我们的代码以处理那里的故障。
可悲的是我们的代码实际上有一些边缘情况,我们应该考虑:
信号处理程序是全局的,但信号本身是每个线程的。因此,您需要通过将我们所有的数据线程都设置为本地来确保您不会与其他任何线程混淆。通过确保我们已经调用setjmp
过,我们还增加了一些鲁棒性longjmp
。
使用setjmp
和longjmp
从信号处理程序发出信号实际上是不安全的。它似乎引起不确定的行为,尤其是在MacOS上。相反,我们必须使用sigsetjmp
和siglongjmp
。由于我们要退出信号处理程序,因此我们需要该信号处理程序不要阻塞任何将来的信号,因此我们还必须传递SA_NODEFER
给sigaction
。
这开始变得相当复杂,特别是如果您有多个可能发生SIGBUS的地方。让我们将因素分解为功能,使逻辑更简洁。
在那里,您只需要记住始终调用install_signal_handlers
每个应用程序,并使用来包装所有file
访问safe_mmap_try
。烦人,但易于管理。所以现在您已经介绍了POSIX系统,但是Windows呢?
Windows没有mmap
,但确实有MapViewOfFile
。两者都实现了内存映射文件,但是有一个重要的区别:Windows对该文件保持锁定,不允许删除。即使使用Windows标志FILE_SHARE_DELETE
删除也不起作用。当我们期望另一个应用程序从我们下面删除文件(例如git垃圾收集)时,这是一个问题。
使用Windows API解决此问题的一种方法是从根本上完全禁用系统文件缓存,这只会使一切变得异常缓慢。Sublime Merge处理此问题的方式是通过释放空闲时的内存映射文件。它不是一个很好的解决方案,但可以。
Windows也没有SIGBUS信号,但是您可以在其中简单地使用结构化异常处理safe_mmap_try
:
现在一切正常,您的应用程序可以在Windows上运行。但是随后您决定想要一些崩溃报告,以使将来更容易发现问题。因此,您添加了Google Breakpad,但是您不知道您刚刚又破坏了Linux和MacOS…
使用信号处理程序的问题是它们在线程和库之间是全局的。如果您已经或已经添加了Breakpad之类的库,该库在内部使用信号,那么您将破坏以前的安全内存映射。
Breakpad在Linux初始化时注册信号处理程序,其中包括一个用于SIGBUS的信号处理程序。这些信号处理程序相互覆盖,因此安装顺序很重要。对于这些类型的情况,没有一个好的解决方案:您不能简单地设置和重置信号处理程序,safe_mmap_try
因为这会破坏多线程应用程序。在Sublime HQ,我们的解决方案是将信号处理程序中未处理的SIGBUS转换为SIGSEGV。不是特别优雅,但这是一个合理的折衷方案。
在MacOS上,事情变得更加复杂。XNU是MacOS内核,它基于最早的微内核之一Mach。Mach具有信号的异步,基于消息的异常处理机制,而不是信号。出于兼容性原因,还支持信号,其中Mach例外优先。如果诸如Breakpad之类的库注册并处理了Mach异常消息,它将防止触发信号。当然这与我们的信号处理不符。到目前为止,我们发现的唯一解决方法是修补Breakpad以不处理SIGBUS。
第三方库是一个问题,因为信号是可以从任何地方访问的全局状态。唯一可用的解决方案是不令人满意的解决方法。
内存映射可能不使用物理内存,但确实需要虚拟地址空间。在32位平台上,您的地址空间约为4GB。因此,尽管您的应用程序可能不使用4GB的内存,但是如果您尝试将内存映射到一个太大的文件,它将耗尽地址空间。其结果与内存不足相同。
遗憾的是,这没有其他问题的解决方法,这是内存映射方式的一个严格限制。现在,您可以将代码库重写为不将整个文件映射到内存中,或者在32位系统上崩溃,或者不支持32位。
在Sublime Merge和Sublime Text 3.2中,我们采用了“不支持32位”的方法。Sublime Merge没有可用的32位版本,并且Sublime Text在32位版本上禁用git集成。
我之前提到过,您可以重写代码以不使用内存映射。您可以使用诸如pread
仅将文件所需的部分复制到内存中的功能,而不是将长寿命的指针传递到整个代码库中的内存映射文件中。最初mmap
,这种方法不如使用优雅,但是它避免了您原本会遇到的所有问题。
通过Sublime Merge读取git目标文件的方式的一些快速基准测试,其pread
速度与mmap
Linux上的速度差不多。事后看来,很难证明使用mmap
over 是合理的pread
,但是现在野兽已经被驯服了,几乎没有理由再改变了。
如果您认为我们错过了某些事情或犯了一个错误,请在论坛上给我们留言。我们希望这对您中的某些人将来的工作有所帮助。如果您还没有,请查看我们的git客户端Sublime Merge。