x86 上 Java 的最小侵入性编译障碍

Posted

技术标签:

【中文标题】x86 上 Java 的最小侵入性编译障碍【英文标题】:Least intrusive compile barrier for Java on x86 【发布时间】:2013-01-17 14:31:46 【问题描述】:

如果我有一个 Java 进程通过共享的 ByteBuffer 或类似的方式与其他进程交互,那么 C/C++ 中编译器屏障的侵入性最小的等价物是什么?不需要可移植性 - 我对 x86 特别感兴趣。

例如,我有 2 个进程根据伪代码读取和写入内存区域:

p1:
    i = 0
    while true:
      A = 0
      //Write to B
      A = ++i

p2:
    a1 = A
    //Read from B
    a2 = A

    if a1 == a2 and a1 != 0:
      //Read was valid

由于 x86 上严格的内存排序(加载到单独的位置不重新排序,读取到单独的位置不重新排序)这在 C++ 中不需要任何内存屏障,只是每次写入和每次读取之间的编译屏障(即 asm易挥发的)。

如何以最便宜的方式在 Java 中实现相同的排序约束。有什么比写到 volatile 更不打扰的吗?

【问题讨论】:

【参考方案1】:

sun.misc.Unsafe.putOrdered 应该做你想做的事 - 一个带有 volatile 在 x86 上隐含的锁的存储。我相信编译器不会在它周围移动指令。

这与 AtomicInteger 和朋友上的lazySet 相同,但不能直接与 ByteBuffer 一起使用。

volatileAtomicThings 类不同,该方法适用于您使用它的特定写入,而不是成员的定义,因此使用它并不意味着读取任何内容。

看起来您正在尝试实现类似seqlock 的东西——这意味着您需要避免在读取版本计数器A 和读取/写入数据本身之间重新排序。一个普通的 int 不会削减它 - 因为 JIT 可能会做各种顽皮的事情。我的建议是为您的计数器使用 volatile int,然后使用 putOrdered 将其写入。这样,您无需为易失性写入付出代价(通常是十几个周期或更多),同时获得易失性读取隐含的编译器屏障(并且这些读取的硬件屏障是无操作的,使它们快速)。

说了这么多,我认为你在这里处于灰色地带,因为lazySet 不是正式记忆模型的一部分,也不能完全符合发生前的推理,所以你需要更深入的了解实际的 JIT 和硬件实现,看看是否可以通过这种方式组合。

最后,即使使用 volatile 读写(忽略 lazySet),从 java 内存模型的角度来看,我不认为您的 seqlock 是合理的,因为 volatile 写入仅在该写入之间设置了一个发生前然后在另一个线程上读取,以及写入线程中的早期操作,但不在写入线程上的读取和写入之后的操作之间。换句话说,它是单向栅栏,而不是双向栅栏。我相信即使读取 A == N 两次,读取线程也可以看到以版本 N+1 写入您的共享区域。

评论中的澄清:

Volatile 只设置了单向障碍。它与 WinTel 在某些 API 中使用的获取/释放语义非常相似。例如,假设 A、Bv 和 C 最初都为零:

Thread 1:
A = 1;   // 1
Bv = 1;  // 2
C = 1;   // 3

Thread 2:

int c = C;  // 4
int b = Bv; // 5
int a = A;  // 6

在这里,只有 Bv 是易失的。这两个线程在概念上与您的 seqlock 写入器和读取器执行类似的操作 - 线程 1 以一个顺序写入一些内容,线程 2 以相反的顺序读取相同的内容,并尝试从中推理排序。

如果线程 2 有 b == 1,那么 a == 1 总是,因为 1 发生在 2 之前(程序顺序),5 发生在 6 之前(程序顺序),最关键的是 2 发生在 5 之前,因为 5 读取在 2 处写入的值。因此,Bv 的写入和读取就像栅栏一样。上面 (2) 的东西不能“移到下面” (2),下面的东西 (5) 不能“移到上面” 5. 请注意,我只将每个线程的移动直接限制在一个线程中,但不能同时限制两个线程的移动,这将我们带到下一个示例:

与上述相同,您可能会假设如果 a == 0,那么 c == 0 也一样,因为 C 在 a 之后写入,在 a 之前读取。但是,挥发物不能保证这一点。特别是,上述发生之前的推理并不能阻止 (3) 被移到 (2) 之上,正如线程 2 所观察到的那样,它们也不能阻止 (4) 被推到 (5) 之下。

更新:

让我们具体看看你的例子。

我认为可能发生的是,展开 p1 中发生的写入循环。

p1:

i = 0
A = 0
// (p1-1) write data1 to B
A = ++i;  // (p1-2) 1 assigned to A

A=0  // (p1-3)
// (p1-4) write data2 to B
A = ++i;  // (p1-5) 2 assigned to A

p2:

a1 = A // (p2-1)
//Read from B // (p2-2)
a2 = A // (p2-3)

if a1 == a2 and a1 != 0:

假设 p2 看到 a1 和 a2 为 1。这意味着在 p2-1 和 p1-2(以及扩展为 p1-1)之间,以及 p2-3 和 p1-2 之间之前发生过。但是,在 p2 和 p1-4 中的任何内容之间都有发生之前。所以事实上,我相信在 p2-2 对 B 的读取可以观察到在 p1-4 的第二次(可能部分完成)读取,这可以“超越”p1-2 和 p1-3 的易失性写入。

这很有趣,我认为您可能仅凭这一点就提出一个新问题 - 忘记更快的障碍 - 即使使用 volatile,这是否也有效?

【讨论】:

同意 JVM 不移动指令,但 CPU 可能不移动。 是的,但它仍然具有特定的语义 - 特别是不与其他存储重新排序,这在 x86 上就像普通存储一样简单,这就是 putOrdered 和lazySet 在该平台上实现的方式。所以该方法应该做OP想要的。当然,确切的语义记录很少,因此可能需要仔细检查 JDK 源代码。 谢谢,这看起来很合适。在阅读方面呢? 我在上面添加了更多细节来解决这个问题。我担心即使完全 volatile 读/写,您的 seqlock 也不起作用(理论上,我认为它可能在实践中起作用),因为 volatile 读取意味着您将看到在相应写入之前发生的所有事情,但请不要阻止您看到在那篇文章之后发生的更多事情。 感谢您的更新。为了确认(我不精通 Java,所以请原谅我的无知),您认为 volatile 读取只能保证针对早期写入而不是早期读取的排序,这意味着从 B 读取或从 A 的初始读取可能是从 A 的第二次读取重新排序?那么唯一的解决方案是原始写入 volatile?【参考方案2】:

您可以使用lazySet,它可以比设置易失性字段快10 倍,因为它不会停止CPU 管道。例如AtomicLong lazySet 或者您可以根据需要使用 Unsafe 等效项。

【讨论】:

以上是关于x86 上 Java 的最小侵入性编译障碍的主要内容,如果未能解决你的问题,请参考以下文章

可点击的icon按钮 无障碍 ARIA 可访问性

如何在x86上使用gcc强制执行内存排序

确定使用障碍物(栅栏)的位置

Linux 的无障碍设置如何操作?

企业该如何解决DevOps转型道路上的常见障碍?

洛谷P1649 [USACO07OCT]障碍路线Obstacle Course BFS 最小转弯