关于Linux中匿名映射内存的问题

7
我正在考虑使用虚拟内存系统来实现透明的数据转换(例如将int转换为float)以处理一些数字数据。我的基本想法是,我编写的库可以将您需要的数据文件映射到内存中,同时映射一个大小合适的匿名区域来保存转换后的数据,并将指针返回给用户。
匿名区域是可读/可写保护的,因此每当用户通过指针访问数据时,每个新页面都会导致段错误,我可以捕获它,然后透明地从映射的文件中转换数据并修复权限以允许访问继续进行。到目前为止,这部分工作很好。
然而,有时我会映射非常大的文件(数百GB),并且由于匿名内存代理访问它,很快就会开始占用交换空间,因为匿名页面被放到磁盘上。我的想法是,如果我在写入转换后的数据到匿名页面后能够显式地将其脏位设置为false,则操作系统将仅丢弃它们,并在以后重新访问时按需进行零填充。
但是,要使此方法生效,我认为我必须将脏位设置为false,并说服操作系统在将页面交换出去时将其设置为只读,以便我可以重新捕获随后的段错误并按需重新转换数据。经过一些研究,我认为除非进行内核编程,否则不可能做到这一点,但我想问问是否有更多了解虚拟内存系统的人知道可以实现这一点的方法。

1
说实话,如果你的计划涉及到一个库捕获SIGSEGV信号,那么你对使用该库的人们做了一些可怕的事情。为什么不直接设置一个“此浮点数尚未转换”的值(例如,负0),以便您可以测试是否已经进行了转换? - tbert
抱歉我说“库”,但这实际上只是我和几个密切的同事使用的头文件,不适用于一般消费。还有更多的类型转换需要完成,不仅仅是int->float,那只是一个例子。 - gct
我仍然会基于“听起来是一个概念上简单的过度复杂的方法”原则提出异议,但这是你花费在开发/调试上的时间,所以尽情发挥吧。 - tbert
当涉及到我想要编写的实际应用程序的代码清晰度时,虽然概念上很简单,但能够通过指针访问数据具有很多好处。无需缓冲区或读/写。 - gct
一个更好的解决方案可能是编写一个FUSE文件系统,将转换后的数据呈现为文件 - 您的最终应用程序可以只需mmap()该文件,当访问mmap区域以按需创建文件内容时,将调用您的FUSE文件系统。 - caf
2个回答

2
这里有一个想法(虽然没有经过测试):对于转换后的数据,根据需要使用mmapmunmap单独分配页面。由于这些页面由匿名内存支持,因此在取消映射时它们应该被丢弃。Linux将合并相邻的映射成为一个VMA,因此这可能具有可接受的开销。
当然,需要一种机制来触发取消映射。您可以维护一个LRU结构,并在需要引入新页面时驱逐旧页面,从而保持映射区域的大小不变。

这是一个想法...我可以修改我的sigsegv处理程序,执行munmap/mmap以保持内存使用的恒定大小。你认为mremap足够吗,还是我需要重新映射具有固定地址的页面? - gct
新页面必须映射到导致故障的地址(因为用户期望在那里找到它)。我会使用MAP_FIXED映射匿名内存来实现这个效果。mremap可能会使得在范围中间删除页面更加困难。我认为性能主要受到触及页表的影响,而且mmapmremap都必须这样做;无论哪种方式都会很慢。 - Greg Inozemtsev
@tbert 这在Linux上是可能的(https://dev59.com/GWgt5IYBdhLWcg3w-SP1),但绝对不具备可移植性。 - Greg Inozemtsev
@gct 实际上,在多线程程序中,您可能需要同时使用mmapmremap来保持安全。首先,您将在临时地址中映射一个页面,填充它,然后重新映射到正确的位置。否则,不同的线程可能会看到部分填充的页面,因为SIGSEGV可以是线程定向的 - Greg Inozemtsev
所有的建议都很好,这个软件只适用于Linux,所以如果我必须玩一些不可移植的游戏来得到我想要的东西,那么我就没问题,只要最终是安全的不可移植的东西。 - gct

2

在你早先相关问题的建议基础上,我认为以下方案(仅适用于Linux系统,不具备可移植性)应该相当可靠:

  • 使用socketpair(AF_UNIX, SOCK_DGRAM, 0, &sv)建立一个数据报套接字对,并为SIGSEGV设置信号处理程序。(即使其他进程可能截断数据文件,您也不需要担心SIGBUS。)

  • 信号处理程序使用write()size_t addr = siginfo->si_addr;写入其套接字的末尾。然后从它写入的套接字中读取一个字节(阻塞--这基本上只是可靠的sleep()--所以记得处理EINTR),并返回。

    请注意,即使有多个线程在同一时间或附近出现故障,也没有竞争条件。信号只会被重新引发,直到映射被修复。

    如果套接字通信出现任何问题,可以使用sigaction().sa_handler = SIG_DFL恢复默认的SIGSEGV信号处理程序,这样当相同的信号被重新引发时,整个进程就像正常情况下一样死亡。

  • 单独的线程读取套接字对的另一端,用于检测SIGSEGV故障的地址,执行所需的所有映射和文件I/O,并最终将零字节写入套接字对的同一端,以让真正的信号处理程序知道映射现在应该已经修复了。

    这基本上是“真正”的信号处理程序,没有实际信号处理程序的缺点。请记住,同一个线程将保持重新引发相同的信号,直到映射被修复,因此单独线程和SIGSEGV信号之间的任何竞争条件都是无关紧要的。

  • 有一个PROT_NONEMAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE映射,与原始数据文件的大小相匹配。

    为了减少实际RAM成本--使用MAP_NORESERVE,您既不使用RAM也不使用SWAP进行映射,但对于千兆字节的数据,页面表条目本身需要相当多的RAM--,您可以尝试使用MAP_HUGETLB。它将使用大页,因此条目数量明显减少,但我不确定当最终打孔常规页面大小的空洞时是否存在问题;您可能必须一直使用大页。

    这是您的“用户空间”将用于访问数据的“完整”映射。

  • 为原始或脏(分别)转换数据创建一个PROT_READPROT_READ | PROT_WRITEMAP_PRIVATE | MAP_ANONYMOUS映射。如果您的“用户空间”几乎总是修改数据,则可以始终将转换后的数据视为“脏”,但否则,您可以通过首先将转换后的数据映射为PROT_READ来避免不必要的未修改数据写入;如果它出现故障,mprotect()PROT_READ | PROT_WRITE并标记它为脏(因此需要转换并保存回文件)。我将这两个阶段称为“清洁”和“

    使用单独的线程来处理故障条件,避免了所有异步信号安全函数问题。read()、write()和sigaction()都是异步信号安全的。
    您只需要一个全局pthread_mutex_t来避免内核将最近移动的空洞(从内存区域mremap())交给另一个线程的情况;您还可以使用它来保护您的内部数据结构(指针链,如果支持多个并发文件映射)。
    不应该有竞争条件(除非其他线程使用mmap()或mremap(),这由上述互斥量处理)。当“脏”页或页面组被移开时,在它被转换和保存之前,它变得无法访问其他线程;即使是另一个线程的完全并发访问也应该完美地处理:页面将简单地从文件中重新读取,并重新转换。(如果经常发生这种情况,您可能希望缓存最近保存的页面组。)
    我建议使用大的页面组,比如2M或更多,而不是单个页面,以减少开销。最优大小取决于您的应用程序访问模式,但是巨大的页面大小(如果您的架构支持)是一个非常好的起点。
    如果您的数据结构未对齐到页面或页面组,则应缓存完整的转换后的第一个和最后一个记录(仅部分位于页面或页面组中)。这通常使转换回存储格式变得更加容易。
    如果您知道或可以检测到文件中的典型访问模式,则可能应该使用posix_fadvise()来告诉内核;POSIX_FADV_WILLNEED和POSIX_FADV_DONTNEED最有用。它有助于内核避免在页面缓存中保留实际数据文件的不必要页面。
    最后,您可能考虑添加第二个特殊线程,以异步方式将脏记录转换并写回磁盘。如果您确保两个线程在第一个线程仍想重新读取由第二个线程正在写入磁盘的记录时不会混淆,那么也不会有其他问题--但是异步写入很可能会增加大多数访问模式下的吞吐量,除非您无论如何都受到I/O限制,或者真正缺乏RAM(相对而言)。
    为什么要使用read()和write()而不是另一个内存映射?因为需要虚拟内存结构的内核开销。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接