如何安全地实现“使用未初始化的内存来获取乐趣和利润”?

时间:2017-05-04 18:09:26

标签: c++ data-structures valgrind

我想使用https://research.swtch.com/sparse中描述的技巧在C ++中构建一个密集的整数集。这种方法允许自己读取未初始化的内存,从而实现了良好的性能。

如何在不触发未定义行为的情况下实现此数据结构,并且不会与Valgrand或ASAN等工具发生冲突?

编辑:似乎响应者正在关注“未初始化”这个词,并在语言标准的上下文中对其进行解释。对我而言,这可能是一个糟糕的词语选择 - 这里“未初始化”仅意味着它的价值对于算法的正确运行并不重要。显然可以安全地实现这个数据结构(LLVM在SparseMultiSet中实现它)。我的问题是,最好和最有效的方法是什么?

2 个答案:

答案 0 :(得分:4)

我可以看到你可以采取的四种基本方法。这些不仅适用于C ++,而且适用于大多数其他低级语言,如可以实现未初始化访问但不允许允许,而且最后一个适用于更高级别的安全&#34 ;语言。

忽略标准,以通常的方式实施

这是语言律师讨厌的一种疯狂伎俩!尽管如此,不要惊慌失措 - 这个解决方案之后的解决方案不会违反规则,所以如果你属于规则 - 粘合剂种类,那么就跳过这一部分。

该标准大多使用未初始化的值 undefined ,它允许的少数漏洞(例如,将一个未定义的值复制到另一个)并不能真正给你足够的绳索来实际实现你想要的 - 即使在限制性稍差的C中(参见例如this answer覆盖C11,这解释了虽然访问indeterminiate值可能不会直接触发 UB 任何结果也是不确定的,实际上这些价值似乎有机会获得访问权限。

所以你只需要实现它,知道大多数或所有当前编译器只是将它编译成预期的代码,并且知道你的代码不符合标准。

至少gcc clang icc valgrindlibcis_member没有利用非法访问权做任何疯狂的事情。当然,测试并不全面,即使你可以构建一个,行为也可能在新版本的编译器中发生变化。

如果在单独的编译单元中编译访问未初始化内存的方法的实现,那么您将是最安全的 - 这样可以很容易地检查它是否正确(只需检查一次程序集)并使其成为可能几乎不可能(在LTGC之外)编译器做任何棘手的事情,因为它无法证明是否正在访问未初始化的值。

但是,这种方法理论上不安全,你应该非常仔细地检查编译后的输出,并且如果你采取它,还有额外的安全措施。

如果采用这种方法,像sparse_array::is_member(unsigned long): mov rax, QWORD PTR [rdi+16] mov rdx, QWORD PTR [rax+rsi*8] xor eax, eax cmp rdx, QWORD PTR [rdi] jnb .L1 mov rax, QWORD PTR [rdi+8] cmp QWORD PTR [rax+rdx*8], rsi sete al 这样的工具很可能会报告未初始化的读取错误。

现在这些工具在汇编级别工作,一些未初始化的读取可能没问题(例如,参见快速标准库实现的下一个项目),因此他们实际上不会立即报告未初始化的读取,但是而是有各种启发式方法来确定实际使用的无效值。例如,他们可以避免报告错误,直到他们确定未初始化的值用于确定条件跳转的方向,或者根据启发式不可跟踪/可恢复的某些其他动作。你可能能够让编译器发出读取未初始化内存的代码,但根据这种启发式方法是安全的。

更有可能的是,你无法做到这一点(因为这里的逻辑非常微妙,因为它依赖于两个数组中值之间的关系),所以你可以使用抑制您选择的工具中的选项可以隐藏错误。例如,valgrind可以基于my test进行抑制 - 事实上,默认情况下已经有很多这样的抑制条目用于隐藏各种标准库中的误报。

由于它基于堆栈跟踪工作,如果读取发生在内联代码中,您可能会遇到困难,因为对于每个调用站点,堆栈顶部将是不同的。你可以避免这种情况,我确保函数没有内联。

使用程序集

标准中的定义不明确,通常在装配级别定义明确。这就是为什么编译器和标准库经常以比你用C或C ++更快的方式实现的东西:用汇编编写的calloc例程已经针对特定的架构并且不用担心关于语言规范中的各种警告,可以在各种硬件上快速运行。

通常情况下,在汇编中实施任何大量的代码都是一项代价高昂的工作,但这只是少数代码,因此根据您定位的平台数量可能是可行的。您甚至不需要自己编写方法 - 只需编译C ++版本(或使用stack trace并复制程序集。calloc函数,例如 1 ,看起来像:

malloc

依靠sbrk魔法

如果使用godbolt 2 ,则显式请求基础分配器的归零内存。现在,mmap正确版本可能只是调用mmap然后将返回的内存清零,但实际实现依赖于操作系统级内存分配例程({ {1}}和calloc,通常会在任何具有受保护内存的操作系统(即所有大内存)上返回归零内存,以避免再次将内存归零。

实际上,对于大型分配,通常通过映射所有零的特殊零页来实现像匿名calloc这样的调用来满足这一要求。当(如果有的话)写入内存时,写时复制实际上是分配新页面。因此,大型归零内存区域的分配可能是免费的,因为操作系统已经需要将页面归零。

在这种情况下,在calloc(N) if (can satisfy a request for N bytes from allocated-then-freed memory) memset those bytes to zero and return them else ask the OS for memory, return it directly because it is zeroed 之上实现稀疏集可能与名义上未初始化的版本一样快,同时安全且符合标准。

Calloc Caveats

您当然应该进行测试以确保malloc的行为符合预期。优化的行为通常只会在您的程序初始化大量长期归零的内存大约" up-front"时才会发生。也就是说,优化calloc的典型逻辑是这样的:

new

基本上,free基础设施(也是delete和朋友的基础)有一个(可能是空的)内存池,它已经从操作系统请求并且通常首先尝试在那里分配。这个池由来自操作系统的最后一个块请求的内存组成,但没有分发(例如,因为用户请求了32个字节,但是分配的请求来自操作系统的块在1 MB块中,所以剩下很多),以及随后通过callocsparse_set或其他任何方式返回的内存。该池中的内存具有任意值,如果从该池中可以满足malloc,则无法获得魔法,因为必须进行零初始化。

另一方面,如果必须从操作系统分配内存,你就会获得魔力。因此,这取决于您的用例:如果您经常创建和销毁sparse_set个对象,通常只需从内部calloc池中提取并支付归零成本。如果你有一个长期居住的/dev/zero对象会占用大量内存,那么它们可能是通过询问操作系统来分配的,而且你几乎可以免费获得归零。

好消息是,如果您不想依赖上面的sparse行为(实际上,在您的操作系统或您的分配器上,它甚至可能不会以这种方式进行优化),您可以通常通过手动映射int32_t来为您的分配复制行为。在提供它的操作系统上,这可以保证您获得"便宜的"行为。

使用延迟初始化

对于完全与平台无关的解决方案,您可以简单地使用另一个跟踪数组初始化状态的数组。

首先,您选择一些粒子,您将在其中跟踪初始化,并使用位图,其中每个位跟踪sparse数组的粒度的初始化状态。

例如,假设您选择粒子为4个元素,并且数组中元素的大小为4个字节(例如,sparse值):您需要1位来跟踪每4个元素* 4个字节/元素* 8位/字节,这是分配内存中小于1% 3 的开销。

现在,您只需在访问is_member之前检查此数组中的相应位。这会增加访问bool sparse_set::is_member(size_t i){ bool init = is_init[i >> INIT_SHIFT] & (1UL << (i & INIT_MASK)); return init && sparse[i] < n && dense[sparse[i]] == i; } 数组的一些小成本,但不会改变总体复杂性,并且检查仍然非常快。

例如,您的mov rax, QWORD PTR [rdi+24] mov rdx, rsi shr rdx, 8 mov rdx, QWORD PTR [rax+rdx*8] xor eax, eax bt rdx, rsi jnc .L2 ... 功能现在calloc

is_member

x86(gcc)上生成的程序集现在以:

开头
clear

.L2:     保留

这些都与位图检查相关联。这一切都会很快(并且通常不在关键路径上,因为它不是数据流的一部分)。

一般来说,这种方法的成本取决于你的集合的密度,以及你调用的函数 - iterate是关于这种方法的最坏情况,因为有些函数(例如is_init )根本没有受到影响,其他人(例如INIT_COVERAGE)可以批量检查is_init只检查每个sparse元素一次(意味着开销会再次发生)示例值约为1%。

有时这种方法比OP链接中建议的方法更快,特别是当处理元素不在集合中时 - 在这种情况下,is_init检查将失败并经常快捷地删除剩余的代码,在这种情况下,你有一个比calloc数组的大小小得多(使用示例粒度大小256倍)的工作集,所以你可以大大减少到的错过DRAM或外部缓存级别。

颗粒大小本身是这种方法的重要可调参数。直观地说,当第一次访问粒子所覆盖的元素时,更大的粒度会支付更大的初始化成本,但会节省内存和前期calloc初始化成本。你可以想出一个在简单的情况下找到最佳尺寸的公式 - 但行为也取决于&#34;聚类&#34;价值观和其他因素。最后,使用动态颗粒大小来覆盖不同工作负载下的基础是完全合理的 - 但这是以可变班次为代价的。

真正懒惰的解决方案

值得注意的是dense lazy init 解决方案之间存在相似之处:在需要时懒惰地初始化内存块,但sparse解决方案通过具有页表和TLB条目的MMU魔术在硬件中隐式跟踪这一点,而 lazy init 解决方案在软件中执行此操作,并使用位图明确跟踪已分配的粒度。

硬件方法具有几乎免费的优势(对于&#34;命中&#34;无论如何),因为它使用CPU中始终存在的虚拟内存支持来检测未命中,但软件案例具有便携性的优点,可以精确控制颗粒大小等。

你实际上可以结合使用这些方法,制作一个不使用位图的懒惰方法,甚至根本不需要mmap数组:只需分配你的{{1} } PROT_NONEsparse的数组,因此每当您从sparse数组中的未分配页面读取时就会出错。你抓住了这个错误并在... && dense[sparse[i]] == i数组中分配了一个页面,其中一个标记值表示&#34;不存在&#34;对于每个元素。

对于&#34; hot&#34;这是最快的。 case:您不再需要任何i检查。

缺点是:

  • 您的代码实际上不可移植,因为您需要实现故障处理逻辑,这通常是特定于平台的。
  • 您无法控制粒度:它必须是页面粒度(或其多倍)。如果您的集合非常稀疏(比如占4096个元素中的少于1个)并且均匀分布,那么您最终需要支付高初始化成本,因为您需要处理故障并为每个元素初始化整页值。
  • 未命中(即,对不存在的设置元素进行非插入访问)即使在该范围内不存在元素也需要分配页面,或者每次都会非常慢(发生故障)

1 此实施没有&#34;范围检查&#34; - 即,它不会检查MAX_ELEM是否大于MAX_ELEM - 根据您的使用情况,您可能需要检查此项。我的实现使用了calloc的模板参数,这可能会导致代码稍微快一点,但也会变得更加臃肿,而且您也可以将max size设为类成员。

2 实际上,唯一的要求是你使用了一些调用new int[size]()的东西或执行等效的零填充优化,但基于我的测试更多惯用的C ++方法,如{ {1}}只需进行分配,然后执行memsetgcc 优化malloc后跟memset进入calloc,但如果您试图避免使用无论如何都是C例程!

3 准确地说,你需要一个额外的位来跟踪sparse数组的每128位。

答案 1 :(得分:2)

如果我们改回你的问题:

  

什么代码从未初始化的内存中读取而没有用于捕获未初始化内存读取的跳闸工具?

然后答案变得清晰 - 这是不可能的。你能找到的任何一种方法都代表了Valgrind中一个可以修复的错误。

也许在没有UB的情况下可以获得相同的性能,但是你对问题的限制&#34;我想...使用技巧......允许自己阅读未初始化的内存&# 34;保证UB。任何避免UB的竞争方法都不会使用你喜欢的技巧。