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

标签 c++ multithreading assembly arm self-modifying

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

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

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

    然而并不清楚在执行器线程端做什么(它不受控制,我们只是控制我们编写的机器代码块)。我们要不要在修改代码块的第一条指令之前写一些指令屏障?

    最佳答案

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

    非跳转的第一条指令仍然可以被缓存,因此另一个线程可以进入该块。当执行到达块的第 2 个 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 幻灯片,而不是虚拟化幻灯片。

    Software needs to be aware of caches in a few cases: Executable code loading / generation

    • Requires a D-cache clean to Point of Unification + I-cache invalidation
    • Possible from userspace on ARMv8
    • Requires a system call on ARMv7


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

    如果在使 I-cache 失效之前没有清理 D-cache,则 code-fetch 可以在 L2 中丢失后直接从主内存中提取到非一致性 I-cache。 (无需在任何统一缓存中分配陈旧的行,这是 MESI 会阻止的,因为 L1D 的行处于修改状态)。 在任何情况下,从架构上都需要将 L1D 清理到 PoU ,并且无论如何都会发生在非性能关键的编写器线程中,因此最好只是这样做,而不是试图推断不用于特定的 ARM 微体系结构是否安全。请参阅@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仅适用于 mmap 的内存区域与 PROT_EXEC 同行.

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

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

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

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

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

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

    强制其他线程刷新缓存 :

    Linux 4.3 及更高版本具有 membarrier() system call这将在返回之前在系统中的所有其他内核上运行内存屏障(通常带有处理器间中断)(从而阻止所有进程的所有线程)。另见 this blog post描述一些用例(如用户空间 RCU)和 mprotect()作为备选。

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

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

    IDK if munmap()它会起作用,但它可能比必要的更昂贵(因为它必须修改页表并使相关的 TLB 条目无效)。

    其他策略

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

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

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

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

    关于c++ - 当一个线程正在编写另一个线程可能同时执行的代码时,如何在 ARM 上进行同步?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/39295261/

    相关文章:

    c++ - 快速排序模板不起作用,使我的程序无响应

    c++ - Visual Studio 2005 上 64 位操作系统的驱动程序构建错误

    c++ - 如何从开始在对象的成员函数中执行的 c++ boost thread_group 创建新线程?

    java - 如何将参数传递给 Java 线程?

    python - 同时运行两个线程,它们都操作单个变量

    c++ - 在 C++ 中将二进制代码转换为汇编程序,反之亦然? (图书馆,变形)

    c++ - std::cout 声明如何工作?

    c++ - 在 QML 中的 VideoOutput 上显示用 C++ 创建的相机

    c - 使用 "core clock cycles"的 Intel CPU 上的时序代码?

    汇编中的 Linux 64 命令行参数