当一个线程正在编写另一个线程可能同时执行的代码时,如何在ARM上同步?

Ser*_*tch 5 c++ assembly multithreading arm self-modifying

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

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

对于代码编写器线程,我理解使用std::memory_order_releaseC++ 11 进行最终编写就足够了.

但是,不清楚在执行程序线程端要做什么(它失控,我们只控制我们编写的机器代码块).在修改代码块的第一条指令之前,我们应该写一些指令屏障吗?

Pet*_*des 5

我认为您的更新程序不安全。根据此自修改代码博客文章,与x86不同,ARM的指令缓存与数据缓存并不协调。

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

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

我认为没有任何方法可以使ARM上的安全性和高效性同时就地重写。

如果没有相干的I缓存,您也无法保证其他线程将通过这种设计迅速看到代码更改,而没有诸如每次运行之前从L1I缓存中清除块之类的昂贵开销。

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


这些幻灯片很好地概述了ARM缓存,主要侧重于ARMv8

实际上,我将为该要点摘要引用另一张幻灯片(关于虚拟化ARM),但是我建议阅读ELC2016幻灯片,而不是虚拟化幻灯片。

在某些情况下,软件需要了解缓存:可执行代码的加载/生成

  • 要求将D-cache清理到统一点+ I-cache失效
  • 可以从ARMv8上的用户空间访问
  • 需要在ARMv7上进行系统调用

D-cache可以在有或没有回写的情况下失效(因此请确保您进行清理/刷新而不是丢弃!)。您可以并且应该通过虚拟地址来触发此操作(而不是立即刷新整个缓存,并且绝对不要为此使用按组/方式进行刷新)。

如果在使I-cache无效之前没有清理D-cache,则代码提取可能会在L2中丢失后直接从主内存中提取到非一致性I-cache中。(没有在任何统一缓存中分配陈旧的行,由于L1D的行处于“已修改”状态,因此MESI可以防止此行) 无论如何,在架构上都需要将L1D清理到PoU,并且无论如何都需要在非性能关键的写入器线程中进行,因此最好这样做,而不是尝试对不适合特定的ARM微体系结构是否安全。请参阅@Notlikethat的注释,以消除我对此的困惑。

有关从用户空间清除I-cache的更多信息,请参见Linux 2.6.35上如何从用户模式清除和使ARM v7处理器缓存无效。GCC的__clear_cache()功能和Linux sys_cacheflush仅在使用mmapping的内存区域上起作用PROT_EXEC


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

在您打算拥有完整的检测代码块的地方,请执行一次间接跳转(或者lr如果要创建分支,则进行一次保存/恢复和一个函数调用)。每个块都有自己的跳转目标变量,可以自动对其进行更新。这里的关键是间接跳转的目的地是data,因此它与写入线程中的存储保持一致

由于您以原子方式更新指针,因此使用者线程可以跳转到旧的或新的代码块。

现在,您的问题是确保没有任何核心在其i缓存中具有新位置的陈旧副本。 如果上下文切换没有完全刷新i缓存,则考虑到上下文切换(包括当前核心)的可能性。

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

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


强制其他线程刷新其缓存

Linux 4.3和更高版本有一个membarrier()系统调用,它将在返回之前在系统中的所有其他内核上运行内存屏障(通常带有处理器间中断)(从而屏障所有进程的所有线程)。另请参阅此博客文章,其中描述了一些用例(例如用户空间RCU),并mprotect()作为替代。

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


除了入侵membarrier()之外,您还可以将信号发送到使用者线程,并使它们的信号处理程序刷新i缓存的适当区域。那是异步的,因此生产者线程不知道何时可以安全地重用旧块。

IDK如果munmap()可以使用的话,但是它可能比必要的要昂贵(因为它必须修改页表并使相关的TLB条目无效)。


其他策略

您可能可以通过在共享变量中发布单调递增的序列号来进行某些操作(具有释放语义,因此按指令写入顺序进行排序)。然后,消费者线程会根据线程局部的最高查看者检查序列号,并在有新内容的情况下使i缓存无效。这可以是逐块或全局的。

这并不能直接解决检测运行旧块的最后一个线程何时离开的问题,除非这些按线程查看次数最多的计数器实际上不是线程本地的:仍然按线程运行,但生产者线程可以查看他们。它可以扫描它们以查找任何线程中的最低序列号,并且如果该序列号高于未引用块时的序列号,则可以重新使用它。小心错误共享:不要使用它的全局数组unsigned long,因为您希望每个线程的私有变量都与其他线程本地的东西放在单独的缓存行中。


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

在具有多个使用者线程的情况下,这显得有些笨拙:也许每个使用者都有自己的专用跳转目标指针,而生产者会更新所有指针?