Is lockless hashing without std::atomics guaranteed to be thread-safe in C++11?
考虑以下对多线程搜索算法的无锁哈希表的尝试(本文的启发)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| struct Data
{
uint64_t key;
uint64_t value;
};
struct HashEntry
{
uint64_t key_xor_value;
uint64_t value;
};
void insert_data(Data const& e, HashEntry* h, std::size_t tableOffset)
{
h[tableOffset].key_xor_value = e.key ^ e.value;
h[tableOffset].value = e.value;
}
bool data_is_present(Data const& e, HashEntry const* h, std::size_t tableOffset)
{
auto const tmp_key_xor_value = h[tableOFfset].key_xor_value;
auto const tmp_value = h[tableOffset].value;
return e.key == (tmp_key_xor_value ^ tmp_value);
} |
其思想是,HashEntry结构存储Data结构的两个64位字的xor-ed组合。如果两个线程对HashEntry结构的两个64位字进行了交叉读/写操作,那么读线程可以通过再次执行xor-ing并与原始key进行比较来检测这一点。因此,如果散列条目被破坏,可能会降低效率,但如果解码后的检索密钥与原始密钥匹配,仍然可以保证正确性。
本文提到,它基于以下假设:
For the remainder of this discussion, assume that 64 bit memory
read/write operations are atomic, that is the entire 64 bit value is
read/written in one cycle.
我的问题是:上面的代码没有显式地使用EDCOX1(4)保证在C++ 11中是线程安全的吗?或者单个64位字可以被同时读/写损坏吗?即使在64位平台上?这与旧的C++ 98标准有什么不同呢?
本标准的报价将不胜感激。
更新:根据HansBoehm关于"良性"数据竞争的这篇令人惊叹的文章,一个简单的方法就是让编译器取消insert_data()和data_is_present()的两个xor,返回true,例如,如果它找到一个类似于
1 2 3
| insert_data(e, h, t);
if (data_is_present(e, h, t)) // optimized to true as if in single-threaded code
read_and_process(e, h, t); // data race if other thread has written |
- 除了其他任何事情,就标准而言,每个线程都可以有自己独立的缓存。如果您不使用原子或其他形式的同步,那么就不能保证该缓存永远被刷新。因此,除了是否读取一致的数据(这就是问题所在),您可能会分别担心所读取的数据有多陈旧。即使硬件保证了缓存一致性,编译器也可以在寄存器中缓存它喜欢的任何内容。
- 哦,而且EDOCX1的字段(0)可能在单独的缓存行上,所以另一个合法的事情是,一个是新的,另一个是过时的,永远的,data_is_present总是返回false。很明显,任何事情都是合法的,因为数据竞争是UB,我只是举一些例子,这些例子可能实际发生,只需要对您习惯的硬件保证进行合理的更改,或者只需要编译器进行合理的优化。本标准不采用x64;-)
- @SteveJessop data_is_present()总是返回false是低效的,但不是错误的。当实际是false时返回true,将导致真正的错误。如果有人在X64平台上运行此代码,而编译器知道该目标,那么这怎么可能发生呢?我只是问,因为使用这种类型代码的人并没有真正报告这种行为(当然,他们看起来可能不太难)。
- 我不确定X64上是否会发生这种情况,但是如果你问标准保证是什么,你得到的回报是很多人都在考虑其他平台。IIRC ARM的32位多CPU架构通常没有一致的缓存。我不知道它们的64位是否相同,但对于这个问题,您的问题"即使是在64位平台上"的答案可能是"是"。假设没有人在他们的系统上发现过这个代码中的错误,那么要么它在他们的系统上工作(至少几乎一直都是这样),要么他们没有彻底测试它。
- 我认为,在最终一致的缓存中,原子的、顺序的、64位的操作足以使这段代码工作。所以问题实际上是(a)硬件还是(b)64位系统上的编译器是否无法提供这些属性。答案是允许它失败(正如Nicol所说),对于使用此代码的人来说,它可能没有失败,它是否失败可能取决于依赖于实现的事情,例如优化级别或MSVC对volatile的特殊意义(如果使用volatile和msvc)。
- @史蒂文杰索普,我终于找到了一个简单的方法打破它的基础上,汉斯博姆的论文。可能需要一个足够智能和优化的编译器,以及哈希项与核心的比例非常小,但最终会被咬到。
C++ 11规范几乎定义了一个线程试图读取或写入另一个线程正在写入的内存位置的尝试,这是由于未定义的行为(没有使用原子或互斥体来防止一个线程在另一个线程写入时的读/写)。
单独的编译器可以使其安全,但是C++ 11规范本身不提供覆盖。同时读从来不是问题;它是在一个线程中写,而在另一个线程中读/写。
And how is this different from the old C++98 Standard?
C++ 98/03标准没有提供关于线程的任何覆盖。就C++ 98/03内存模型而言,线程并不是一种可能发生的事情。
- 型+UB为1。但在任何一个像样的现代64位体系结构/编译器上,对64位变量的读/写不是原子的吗?由于这个代码已经存在于现实世界中,人们被咬的可能性有多大?
- 型@不,他们不会的。虽然体系结构能够进行这样的原子读/写,但是编译器不会知道它们是必需的,因此只会在它们是最佳的时候使用它们,令人惊讶的是,它们并不总是最佳的。
- 型@戴维施瓦茨TNX。所以,如果没有被咬过,人们通常都很幸运?上面的代码通常用于有2-8个线程的国际象棋程序中,并且哈希值超过1米。我想这不太可能会破坏任何程序,但很高兴知道这是不保证的(从来没有,就这一点而言)。
- 型@他们可能很幸运。他们可能已经检查了装配代码。但这是非常非常糟糕的。编译器的下一个版本,甚至CPU升级都可能破坏这种代码。如果您绝对需要性能(如基准测试等所示),那么它可能是您"最不糟糕"的选择。但没人应该从这样开始。
- @Rhalbersma-原子类型解决了两个问题:撕裂(当只写入或读取了一部分值时切换线程)和可见性(确保其他线程看到写入值的线程中所做的更改)。即使64位存储被保证只需要一个总线周期,也不能解决可见性问题。正如其他人所说的,C++标准非常清楚:在另一个线程正在读取位置时,在没有语言级同步的情况下写入一个位置,会产生未定义的行为。
我认为它不太依赖于编译器,而是依赖于您正在使用的CPU(它的指令集)。我不认为这个假设是非常可移植的。
- 型恰恰相反,它完全取决于编译器。例如,即使CPU有64位原子读/写操作,如果编译器不总是选择使用它们,这也没有帮助。即使CPU没有原子64位读/写操作,如果编译器意识到代码需要它们,并使用锁或其他机制创建它们,这也不会造成伤害。
- 型他说的是std::atomic,不是CPU内部。
- 型@Davidschwartz,如果一个CPU支持64位原子读/写,而编译器不使用它,那么这似乎是不正确的。不管是哪种方式,都希望64位读/写操作是可移植的,而不显式地将它们定义为可移植的,或者保护它们是不可移植的。
- 型@布雷迪:为什么在编译器出于某种原因认为非原子操作更好的情况下,这是"不正确的"?(x86上也有这种情况!)什么规则要求它生成比它所能生成的更糟糕的代码?
- 型@Davidschwartz,我想不出这样一个上下文(这并不意味着太多):如果编译器正确地决定了什么时候应该是原子的,那么你是正确的。只是出于好奇,能不能请你参考一下这个案子。谢谢!
- 型@布雷迪:一些常见的情况是:1)编译器认为大部分时间都会有写操作,因此即使没有写操作来避免条件分支,也要写回"未更改"的值;2)编译器"能告诉"写操作没有效果,因此不会写操作。3)如果编译器消除了一个写操作,因为它"能够告诉"随后的写操作会在读取值之前更改该值。在所有这三种情况下,编译器都会推断出如果没有其他线程是正确的,因此您不会得到最自然的原子程序集。
- 型@davidschwartz如果为insert_entry()和data_is_present()生成的汇编代码确实使用原子64位读/写,那么对于该平台,一切都是安全的,对吗?
- 型@Rhalbersma:如果您手工检查生成的程序集,并且您非常清楚该指令集的安全性,那么生成的程序集是安全的。但是,编译器的下一个版本或优化标志的更改可能会破坏您的工作。仅仅指定适当的原子操作就容易得多,这样就可以保证获得正确生成的程序集。(所以,虽然这是可行的,但我无法想象在任何情况下,这将是您的最佳选择。)
- 型@因此,如果没有明确地将它们定义为原子的话,我最初的观点是不可移植的。
代码完全坏了。如果编译器的分析表明整体效果是相同的,那么它有很大的自由来重新排序指令。例如,在insert_data中,不能保证key_xor_value将在value之前更新,更新是否在写回缓存之前在临时寄存器上完成,更不用说当这些缓存更新(无论它们在机器代码语言和CPU指令执行管道中的"顺序"是什么)将被刷新时。从更新核心或核心(如果上下文切换了中间函数)的私有缓存中读取,以便对其他线程可见。编译器甚至可以使用32位寄存器逐步进行更新,这取决于CPU、编译32位还是64位、编译选项等。
原子操作往往需要类似于cas(比较和交换)类型的指令,或者volatile和内存屏障指令,这些指令可以在核心缓存中同步数据并强制执行某些排序。
- Atomic做的比这多一点;它还保证编译器不会对读和/或写重新排序。加载/存储操作有标志来告诉编译器您希望它有多严格。最简单的形式具有完整的顺序一致性,也为优化留下了最少的空间,但通常是在进一步推理如何使代码更高效之前的最佳起点。这是对标准的一个很好的补充;我在2015年初把我对这些东西的抽象抛到了窗外(在那之前,主流工具链太少了,怪癖太多)。