【问题标题】:How to synchronize on ARM when one thread is writing code which the other thread may be executing concurrently?当一个线程正在编写另一个线程可能同时执行的代码时,如何在 ARM 上进行同步?
【发布时间】:2016-09-02 14:49:25
【问题描述】:

考虑一个多核 ARM 处理器。一个线程正在修改可能由另一个线程同时执行的机器代码块。修改线程进行以下类型的更改:

  1. 将机器代码块标记为跳过:它将跳转指令作为代码块的第一条指令,因此执行它的人应该跳过其余指令,跳过整个代码块。
  2. 将机器代码块标记为执行:它从第二条指令开始写入其余指令,然后用代码块的预期第一条指令原子地替换第一条指令(跳转)。

对于代码编写线程,我理解在 C++11 中使用 std::memory_order_release 进行最终写入就足够了。

但是不清楚executor线程端要做什么(这是失控的,我们只是控制我们写的机器代码块)。我们要不要在修改代码块的第一条指令之前写一些指令屏障?

【问题讨论】:

  • @dwelch,它与编译器工具有关:一些热门的检测机制。应该可以在运行时打开和关闭检测,这样检测在关闭时不会使代码变慢。
  • 它只是一个共享资源,两个线程使用的一些公共ram,适用常规做法。这里没什么特别的。
  • 你显然不能进入那个块并在它运行时修改运行代码,你没有足够的关于运行逻辑的信息能够以干净的方式做到这一点,所以你必须对待它作为共享资源,只有在其他资源不使用它时才触摸它。
  • @dwelch,据我了解,特殊之处在于内存包含代码,而不是数据。
  • ARM 架构参考手册有很多相关章节。请参阅A3.5.4 指令的并发修改和执行,可能是B2.2.9 缓存和分支预测器维护操作的排序

标签: c++ multithreading assembly arm self-modifying


【解决方案1】:

我认为您的更新程序不安全。根据self-modifying-code blog post,与 x86 不同,ARM 的指令缓存与数据缓存不一致。

非跳转的第一条指令仍然可以被缓存,因此另一个线程可以进入该块。当执行到达块的第二个 i-cache 行时,可能会重新加载并看到部分修改状态。

还有另一个问题:中断(或上下文切换)可能会导致仍在执行旧版本的线程中逐出/重新加载缓存行。 就地重写指令块要求您确保所有其他线程中的执行在您修改内容后都已退出该块,以便新线程不会进入它。即使使用一致的 I-cache(如 x86),即使代码块适合单个缓存行。

我认为没有任何方法可以在 ARM 上同时确保就地重写既安全又高效。

如果没有一致的 I 缓存,您也无法保证其他线程会在这种设计下及时看到代码更改,而无需在每次运行之前从 L1I 缓存中刷新块等极其昂贵的事情。

使用一致的 I-cache(x86 样式),您可以等待足够长的时间,以等待另一个线程完成旧版本执行的任何可能延迟。即使该块不执行任何 I/O 或系统调用,缓存未命中和上下文切换也是可能的。如果它以实时优先级运行,尤其是在禁用中断的情况下,那么最差缓存只是缓存未命中,即不是很长。否则我不会打赌少于一两个时间片(可能是 10 毫秒)的任何​​东西都是非常安全的。


These slides have a nice overview of ARM caches, mostly focusing on ARMv8.

我实际上将引用 another slide (about virtualizing ARM) 来获取此要点摘要,但我建议阅读 ELC2016 幻灯片,而不是虚拟化幻灯片。

软件在某些情况下需要注意缓存:可执行代码加载/生成

  • 需要对统一点进行 D-cache 清理 + I-cache 失效
  • 可能来自 ARMv8 上的用户空间
  • 需要 ARMv7 上的系统调用

D-cache 可以在有或没有回写的情况下失效(所以请确保你清理/刷新而不是丢弃!)。您可以并且应该通过虚拟地址触发此操作(而不是一次刷新整个缓存,并且绝对不要为此使用 set/way 刷新)。

如果您在使 I-cache 无效之前没有清理 D-cache,则在 L2 中丢失代码后,代码提取可能会直接从主内存中提取到非一致的 I-cache 中。 (没有在任何统一缓存中分配陈旧的行,MESI 会阻止,因为 L1D 的行处于修改状态)。 无论如何,在架构上需要清理 L1D 到 PoU,并且无论如何都发生在非性能关键的写入线程中,所以最好只是这样做而不是试图推理它是否安全用于特定的 ARM 微体系结构。请参阅 cmets 了解 @Notlikethat 为消除我对此的困惑所做的努力。

有关从用户空间清除 I-cache 的更多信息,请参阅How clear and invalidate ARM v7 processor cache from User Mode on Linux 2.6.35。 GCC 的 __clear_cache() 函数和 Linux sys_cacheflush 仅适用于 mmapped 和 PROT_EXEC 的内存区域。


不要就地修改:使用新位置

在您计划拥有整个检测代码块的地方,放置一个间接跳转(或保存/恢复lr 和一个函数调用,如果您无论如何都要有一个分支)。每个块都有自己的跳转目标变量,可以自动更新。这里的关键是,间接跳转的目的地是数据,因此它与写入线程中的存储是一致的

由于您以原子方式更新指针,消费者线程要么跳转到旧代码块,要么跳转到新代码块。

现在您的问题是确保没有核心在其 i-cache 中具有新位置的陈旧副本。 考虑到上下文切换的可能性,包括当前核心,如果上下文切换不'不要完全刷新 i-cache。

如果您为新块使用足够大的位置环形缓冲区,以便它们闲置足够长的时间以被驱逐,那么在实践中可能永远不会出现问题。不过,这听起来很难证明。

如果与其他线程运行这些动态修改块的频率相比更新频率较低,那么在写入新块后让发布线程在其他线程中触发缓存刷新可能足够便宜,但是之前更新间接跳转指针指向它。


强制其他线程刷新其缓存

Linux 4.3 及更高版本有一个membarrier() system call,它将在系统返回之​​前在系统中的所有其他内核上运行内存屏障(通常带有处理器间中断)(从而屏蔽所有进程的所有线程)。另请参阅 this blog post 描述一些用例(如用户空间 RCU)和 mprotect() 作为替代方案。

不过,它似乎不支持刷新指令缓存。如果您正在构建自定义内核,您可以考虑添加对新的cmdflag 值的支持,这意味着刷新指令缓存而不是(或同时)运行内存屏障。也许flag 值可能是一个虚拟地址?这仅适用于地址适合 int 的架构,除非您调整系统调用 API 以查看新 cmd 的 flag 的完整寄存器宽度,但仅适用于现有 @ 的 int 值987654343@.


除了破解 membarrier() 之外,您还可以向消费者线程发送信号,并让他们的信号处理程序刷新 i-cache 的适当区域。这是异步的,所以生产者线程不知道何时可以安全地重用旧块。

IDK 如果munmap()ing 它会起作用,但它可能比必要的成本更高(因为它必须修改页表并使相关的 TLB 条目无效)。


其他策略

您可以通过在共享变量中发布单调递增的序列号来做一些事情(具有发布语义,因此它是按指令写入排序的)。然后消费者线程根据线程本地最高可见的序列号检查序列号,如果有新内容,则使 i-cache 无效。这可以是每个块或全局的。

这并不能直接解决检测运行旧块的最后一个线程何时离开它的问题,除非那些每线程最高可见计数器实际上不是线程本地的:仍然是每线程但生产者线程可以看看他们。它可以扫描它们以查找任何线程中的最低序列号,如果该序列号高于未引用块时的序列号,则现在可以重用它。注意false sharing:不要为它使用unsigned long 的全局数组,因为您希望每个线程的私有变量与其他线程本地内容位于单独的缓存行中。


另一种可能的技术:如果只有一个消费者线程,则生产者将跳转目标指针设置为指向不会更改的块(因此不需要刷新 i-cache)。该块(在消费者线程中运行)为 i-cache 的相应行执行缓存刷新,然后再次修改跳转目标指针,这次指向应该每次运行的块。

对于多个消费者线程,这有点笨拙:也许每个消费者都有自己的私有跳转目标指针,而生产者更新所有这些?

【讨论】:

  • “幻灯片没有说明为什么需要刷新 L1D,因为它应该与 L2 保持一致” - 呃,这不是一个很好的例子,因为它是从具有来宾不知道的缓存级别的管理程序的角度来看,以及来宾执行 set/way 操作(不适用于 SMP 情况)。将 VA 的“D-cache”清理到 PoU(它会影响所需的多个级别)是您需要关心的全部内容。 FWIW,ELC had a more relevant presentation this year.
  • @Notlikethat 感谢您的链接。但是我仍然不明白为什么必须手动/明确地由 VA 清理“D-cache”到 PoU。如果在刷新 I-cache 之前使用适当的屏障来确保存储被提交到 L1D(而不仅仅是存储缓冲区),那么问题出在哪里? PoU 统一缓存与整个 CPU 中的所有其他数据缓存是一致的,对吧?因此,当 L1I 尝试从中读取数据时,它最终必须获取当前位于 L1D (此内核或另一个内核的)修改行中的数据。显式清理只是在一个线程中实现屏障的一种方法吗?
  • 在这种情况下,我们在一个线程中写入数据,并且只刷新 i-cache + 在另一个线程中执行新代码,我认为我们更加安全。如果消费者线程在指针或序列计数器上使用获取负载,则看到指向块的指针意味着更新的块内容也对其可见(假设生产者以正确的顺序使用释放存储)。所以 i-cache 失效后的代码获取应该像数据加载一样看到更新?或者这是我的推理出了问题,统一缓存可能会从内存中重新获取以满足 i-cache 未命中?
  • 数据缓存仅在数据访问方面相互一致; L1I 未命中可能会在统一的 L2 中查找,但这不会导致 L2 上升并窥探 L1D - 拥有非一致 I-cache 的主要原因之一(在单处理器情况下)它们是从)演变而来的)是不必拥有这种窥探机制的简单性和省电。
  • ...或者它甚至可能根本无法填充 - 例如在 Cortex-A7 上,all 取行直接分配到相应的 L1,而统一的 L2 仅分配来自 L1D 的驱逐。缓存疯了。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2021-10-12
  • 1970-01-01
  • 2019-02-27
  • 2020-05-02
  • 2012-07-04
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多