内存分配函数是否表明内存内容不再使用?

10
处理数据流时,例如来自网络的请求,通常会使用一些临时存储器。例如,URL可能会分成多个字符串,每个字符串可能会从堆中分配内存。这些实体的使用通常是短暂的,总内存量通常相对较小,并且应适合于CPU缓存。
释放临时字符串所使用的内存时,字符串内容可能仅存在于缓存中。然而,CPU不知道内存已被解除分配:解除分配只是内存管理系统中的更新。因此,当CPU缓存用于其他内存时,CPU可能会不必要地将未使用的内容写入实际内存 - 除非内存释放以某种方式向CPU指示不再使用该内存。因此,问题变为:
释放内存的内存管理函数是否以某种方式指示相应内存的内容可以被丢弃?甚至有没有办法向CPU指示不再使用内存?(至少对于某些CPU:显然,不同的架构之间可能存在差异)。由于不同的实现可能在质量上有所不同,可能会或可能不会做任何花哨的事情,因此问题实际上是是否有任何内存管理实现指示内存未使用?
我确实意识到,始终使用相同的内存区域可能是一种缓解策略,以避免不必要的写入实际内存。在这种情况下,将使用相同的缓存内存。同样,内存分配可能总是产生相同的内存,从而避免不必要的内存传输。但是,可能我不需要依赖任何这些适用性技术。

1
@FUZxxl:Kühl先生非常清楚这些差异,我可以向您保证。但是CPU不知道它是在执行free()还是delete,因此我们可以安全地忽略这些差异。 - MSalters
1
@FUZxxl:那么呢?问题在于这些函数的实现。这本质上是不可移植的代码,事实上,问题是关于C和C++内存分配器使用CPU缓存控制指令的。 - MSalters
如果问题与语言无关,则应使用 [tag:language-agnostic] 标签。请不要提出“C和C ++”问题,这是不鼓励的。如果您对两者都感兴趣,请分别提出有关C和C ++的问题。@MSalters - fuz
1
@FUZxxl:语言无关的问题在于对于95%的编程语言来说这个问题毫无意义。而且我们也没有标签来描述“高性能语言,可以编译成本地应用程序,并具有确定性显式内存释放”的情况。 - MSalters
1
@FUZxxl 对于许多主题,C和C++语言的许多问题可以用相同的方式回答;如果不能,则区分它们是有用的。这样做可以使答案更加丰富,因此我不反对标记[c]和[c++]。OP的问题特别容易有一个共同的答案,因为glibc不太可能使用libstdc++没有的缓存技巧。顺便说一下,大多数语言的运行时/解释器都是基于C库构建的!尽管最高悬赏金,但我也分享OP对缺乏曝光的担忧,只有88个浏览量。 - Iwillnotexist Idonotexist
显示剩余3条评论
5个回答

9

编号

您提到的缓存操作(将缓存内存标记为未使用并且不回写到主内存中)称为无回写缓存行失效。这是通过具有操作数的特殊指令执行的,该操作数可能(也可能不)指示要失效的缓存行的地址。

在我所熟悉的所有架构中,这个指令是特权级别的,我认为这是有充分理由的。这意味着用户模式代码不能使用该指令;只有内核可以。否则将会出现无法想象的恶意欺骗、数据丢失和拒绝服务等问题。

因此,没有任何内存分配器能够实现您提出的方案;它们只是没有(在用户模式下)执行此操作所需的工具。

架构支持

  • x86x86-64架构有特权的invd指令,它可以使所有内部高速缓存无需写回而失效,并指示外部高速缓存也无效。这是唯一能够无需写回而使其失效的指令,但它确实是一个粗暴的武器。
    • 非特权的clflush指令指定了一个受害者地址,但在使其失效之前会进行写回,因此我只是简单地提到它。
    • 所有这些指令的文档都在英特尔的SDMs第2卷中。
  • ARM架构通过向协处理器15、寄存器7写入数据来执行无需写回的高速缓存失效:MCR p15, 0, <Rd>, c7, <CRm>, <Opcode_2>。可以指定一个受害者高速缓存行。对该寄存器的写操作是特权的。
  • PowerPCdcbi,它允许您指定一个受害者,dci没有,以及两个指令缓存版本,但所有四个都是特权的(参见第1400页)
  • MIPS有{{link4:CACHE}}指令,可以指定一个受害者。自MIPS指令集v5.04以来,它是特权的,但在6.04中,Imagination Technologies使事情变得混乱了,现在不再清楚哪些是特权的和哪些不是。

因此,这排除了在用户模式下不刷新/写回缓存无效的使用。

内核模式?

然而,我认为在内核模式下出于许多原因仍然是一个坏主意:

Linux的分配器kmalloc()为不同大小的分配分配不同的区域。特别地,它为每个小于等于192字节的分配大小以8字节为步长分配一个区域。这意味着对象可能比缓存线更接近彼此或部分重叠下一个对象,并且使用无效化可能会使正确在高速缓存中且尚未写回的附近对象被强制退出。这是错误的。该问题加剧了缓存行可能相当大(在x86-64上为64字节),而且在整个缓存层次结构中大小不一定相同的事实。例如,Pentium 4具有64B L1缓存行但128B L2缓存行。
这使得释放时间与要释放的对象的缓存行数量成正比。
它的好处非常有限;L1缓存的大小通常在KB级别,因此几千次刷新将完全清空它。此外,缓存可能已经在您没有提示的情况下刷新了数据,因此您的失效比毫无用处更糟:内存带宽被使用,但您不再拥有缓存中的行,因此当它下次被部分写入时,它需要被重新获取。
下一次内存分配器返回该块,可能很快,其用户将遭受保证的缓存未命中并从主RAM获取,而他本可以拥有一个脏未刷新的行或一个干净的刷新行。保证的缓存未命中和从主RAM获取的成本比自动智能地在缓存硬件中某个地方插入失效要高得多。
循环和刷新这些行所需的额外代码浪费了指令缓存空间。
对于前面提到的循环需要的几十个周期,更好的用途是继续执行有用的工作,同时让缓存和内存子系统的可观带宽写回您的脏缓存行。
最后,对于短暂的、小的分配,有将其分配在堆栈上的选项。

内存分配器的实际应用

  • 著名的dlmalloc不会使已释放的内存无效。
  • glibc不会使已释放的内存无效。
  • jemalloc不会使已释放的内存无效。
  • musl-libc的malloc()不会使已释放的内存无效。

它们都不会使内存无效,因为它们不能这样做。仅仅为了使缓存行失效就进行系统调用既慢又会导致更多的缓存流量,因为要进行上下文切换。


1
非常好的回答!虽然我并不完全同意反对内核模式使用的第一个论点。内核代码被要求达到更高的正确性标准,这是有充分理由的。使仍然持有有效数据的缓存行无效是可能出现的大约一千种错误之一,每一种都是错误的。而且这也很容易避免,因为每个区域都有已知的分配大小;只要在此大小小于缓存行大小时不进行刷新即可。 - MSalters
@MSalters 嗯,问题在于操作小于缓存行大小、未对齐的对象或部分跨越两个缓存行的对象并不罕见。而且这种情况即将变得更加频繁;在x86-64中,缓存行已经是64 B,在英特尔Kabylake中将增加到256 B。以那样的速度进行无效化是不可行的...你还让我想到了另一个问题。缓存不必共享相同的缓存行大小。据我所知,Pentium 4在L1中使用64 B行,但在L2中使用128 B - Iwillnotexist Idonotexist
1
x86没有单独使某个缓存行失效的指令,因为invd会清除所有内容。至于L1/L2大小差异,这可能不是一个真正的问题。丢弃L1行将消除对L2的写入。这增加了L2行保持“未修改”的机会,这意味着没有主内存写入。然而,L2行的另一半可能已经被修改,这意味着整个行被(正确地)写回,并更新了右半部分。 - MSalters
@MSalters,您描述的不是一种失效吗?失效旨在从整个高速缓存层次结构中驱逐该地址,而不使用写回,从而导致未来访问从主存储器中获取。如果在L1中使左半部分失效,则它也必须从L2中消失;否则,L1的缺失将由L2服务。同时将两个缓存在写入到内存将无法实现失效而不带写回以及OP的目标。依我之见,唯一正确的解决方案是同时失效右半部分,并将其记录为操作系统开发人员的问题,否则需要指定指令来确定它们所涉及的缓存。 - Iwillnotexist Idonotexist
1
这并不是绝对的失效,而是一种优化。L2 比 L1 大得多;抛弃 L1 行因此比抛弃 L2 行更值得。而且,我们可能因为其他原因需要写回 L2 行,但这还远非确定性事件。如果有一半未被修改,另一半从 L1 丢弃,那么当行被逐出时,我们仍然可以节省 L2 -> 内存写操作。 - MSalters

2
我不知道有任何架构会愿意向软件(用户甚至内核)暴露其缓存一致性协议,就像这样进行操作。这将创建实际上无法处理的警告。 请注意,用户启动的刷新是可以接受的曝光,但绝不会威胁到内存一致性。
举个例子,想象一下你有一个带有临时数据的缓存行,你不再需要它了。由于已经写入,它在缓存中处于“修改”状态。 现在,你想要一个机制告诉缓存避免写回,但这意味着你创建了一个竞争条件 - 如果其他人在你应用此技巧之前寻找该行,他会从核心中窥探出来并收到更新的数据。如果你的核心先行动了,新数据就会丢失 - 因此,内存中该地址的结果取决于竞争。
您可能会争辩说,在多线程编程中经常出现这种情况,但是即使在运行单个线程时也可能发生这种情况(如果缓存已满,CPU可能会自愿提前驱逐某条线路,或者某个更低的包容级别失去它)。更糟糕的是,这打破了整个虚拟内存呈现为平面的前提,缓存版本仅由CPU维护以提高性能,但不能破坏一致性或一致性(除了一些文档化的多线程情况,这取决于内存排序模型,可以通过软件保护来克服)。
编辑:如果您愿意扩展您所考虑的“内存”定义,您可以寻找非一致性类型的内存,这些内存在定义和实现上有所不同,但其中一些可能提供您所需的功能。一些架构公开了“scratchpad”内存,由用户控制,允许快速访问而无需缓存一致性的麻烦(但也没有其好处)。一些架构甚至进一步提供可配置的硬件,允许您选择是将主内存缓存到其中,还是将其用作临时存储区域。

+1,完全正确。这正是我在回答中提到的“变态的诡计、数据丢失和拒绝服务”的情况。我想到的其中一件事就是两个线程共享一个超线程核心。 - Iwillnotexist Idonotexist

1
你在这里提出了许多相关的问题。加粗的那个是最容易回答的。当你使用任何类似于通用释放的方式释放内存时,唯一要表达的就是“我不再需要这个了”。你还在暗示,“我不关心你对它做什么”。这个“我不关心”实际上就是你问题的答案。你没有说“你可以丢弃这个”,而是说“我不在乎你是否丢弃它”。
为了回答你有关CPU支持的问题,MSI协议是一种基本的缓存一致性协议。其中,I状态表示“无效”,这就是你可以实现“未使用内存”状态的方式。为了做到这一点,你需要创建一个非通用语义的释放接口,也就是这种释放意味着“这个内存不再使用,并且你应该避免将其写回主内存”。请注意,这种语义对于CPU的行为有一个要求,而通用版本则没有。为了实现这一点,你需要与CPU缓存协调地分配内存,然后使用CPU可用的指令来使缓存项失效。你几乎肯定需要编写汇编代码才能使其正常工作,以避免对内存模型的错误假设(使用显式缓存管理指令会导致这种假设)。

我个人已经有一段时间没有在这个层面上工作了,所以我不熟悉到处都有什么可用的技术,也就是说,这种技术是否可以被合理地移植。英特尔CPU具有INVLPG指令。这里的讨论应该是您关注的下一个阶段的良好起点:何时执行或不执行INVLPG,MOV到CR3以最小化TLB刷新


INVLPG 无效化 TLB 条目;这与数据缓存完全不同。此外,它是一条特权指令,因此您不能从用户空间代码中使用它。您链接的页面讨论了在内核模式下执行的活动,而不是在用户模式下执行的活动。正如我在下面的答案中指出的那样,在任何平台上都不能和不应该编写用户模式的无效化-无回写代码。 - Iwillnotexist Idonotexist
@我不存在我不曾存在。正如我在答案中所说,我不熟悉你可能如何实现这一点的具体细节;而根据你的回答,显然你是了解的。我提到这个指令只是为了指出当你弄清楚想要实现什么后,你需要开始寻找的方向。最重要的是,原始问题是关于这些调用的正确语义,这也是我关注的重点。 - eh9
非泛型版本的释放确实是应该避免写回的。这意味着它不是一个真正的“必须”要求。请注意,这特别是在内存分配器的上下文中,通常作为C或C++实现的一部分。在那里,“汇编”并不是一个缺点,编译器一直在使用它。 - MSalters

1
这主要取决于实现和所使用的库。分配和释放的内存往往会被快速重新分配。大多数分配都是在比页面小得多的小块中进行的,需要时才会写入备份存储。
而今天,RAM大小通常非常大,当操作系统开始将脏页写入备份存储时,无论如何都会遇到麻烦。如果您拥有16 GB的RAM,您不会写入100 KB或1 MB,而是会写入几GB,您的计算机将变得非常缓慢。用户将通过不使用使用过多内存的应用程序来避免这种情况。

我对CPU缓存级别比写入后备存储更感兴趣。然而,CPU缓存与主内存之间的关系确实类似于主内存与后备存储之间的关系。 - Dietmar Kühl
1
实际上有显著的不同之处。已删除的文件并没有被刷新到磁盘上。这种不同之处的原因是要丢弃此类页面所需的相关簿记与需要丢弃脏的L1行的簿记相似,但是不将页面写入磁盘所节省的空间大得多。 - MSalters

1
相当多的分配器将“空闲块列表”存储在自由块中。也就是说,当您调用该解除分配函数时,分配的块会被拼接到自由列表中,这可能意味着用前向和后向指针覆盖旧数据。这些写入将至少覆盖分配的第一部分。
分配器使用的第二种技术是积极回收内存。如果下一个分配可以与最新的解除分配匹配,那么缓存很可能没有刷新到主内存。
你的想法的问题在于每个单独的写入实际上并不是那么昂贵,而找出什么可以丢弃会涉及相当多的昂贵的簿记工作。你不能真正地进行系统调用。这意味着您需要在每个应用程序中进行簿记(这是合理的:这些小块的解除分配通常将内存返回给应用程序,而不是操作系统)。这反过来又意味着应用程序需要了解CPU缓存设计,这绝非恒定不变。应用程序甚至需要知道不同的缓存一致性方案!

考虑到应用程序通过内存管理系统与内存进行交互,该内存管理系统的默认实现至少随编译器/标准库一起提供,我认为应用程序级别的代码不需要了解缓存布局。相反,“只有”内存管理系统需要知道(在这个级别上,也会知道已释放内存的哪部分继续用于内存管理系统的目的)。 - Dietmar Kühl
@DietmarKühl:在这里,“应用程序”是指“非内核”。在x86术语中,是第3环而不是第0环。实际上,您不仅可以通过标准库来实现这一点,还可以通过让操作系统将一个代码页映射到每个进程中来实现相关指令的完整性。 - MSalters

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