我认为您的更新过程不安全。与 x86 不同,ARM 的指令缓存与数据缓存不一致,根据此自修改代码博客文章 https://community.arm.com/groups/processors/blog/2010/02/17/caches-and-self-modifying-code.
非跳转第一指令仍然可以被缓存,因此另一个线程可以进入该块。当执行到达块的第二个 i-cache 行时,可能会重新加载该行并看到部分修改的状态。
还有另一个问题:中断(或上下文切换)可能会导致仍在执行旧版本的线程中驱逐/重新加载缓存行。就地重写指令块要求您确保在修改内容后所有其他线程中的执行都已退出该指令块,以便新线程不会进入该指令块。即使对于一致的 I-cache(如 x86),并且即使代码块适合单个缓存行,这也是一个问题。
我认为没有任何方法可以使 ARM 上的就地重写同时安全且高效。
如果没有一致的 I 缓存,您也无法保证其他线程能够通过此设计及时看到代码更改,而无需在每次运行之前从 L1I 缓存中刷新块等极其昂贵的操作。
使用一致的 I-cache(x86 风格),您只需等待足够长的时间,以防止另一个线程完成旧版本的执行时出现任何可能的延迟。即使该块不执行任何 I/O 或系统调用,缓存未命中和上下文切换也是可能的。如果它以实时优先级运行,特别是在禁用中断的情况下,那么最坏的缓存只是缓存未命中,即不是很长。否则我不会打赌任何少于一两个时间片(也许 10 毫秒)的东西都是真正安全的。
这些幻灯片很好地概述了 ARM 缓存,主要关注 ARMv8 http://events.linuxfoundation.org/sites/events/files/slides/slides_17.pdf.
我实际上要引用另一张幻灯片(关于虚拟化 ARM) https://events.linuxfoundation.org/sites/events/files/slides/slides_10.pdf#page=8对于这个要点摘要,但我建议阅读 ELC2016 幻灯片,而不是虚拟化幻灯片。
在某些情况下,软件需要了解缓存:可执行代码加载/生成
- 需要 D 缓存清理以实现统一点 + I 缓存失效
- 可能来自 ARMv8 上的用户空间
- 需要在 ARMv7 上进行系统调用
D-cache 可以在有或没有写回的情况下失效(因此请确保清理/刷新而不是丢弃!)。您可以而且应该通过虚拟地址触发此操作(而不是立即刷新整个缓存,并且绝对不要为此使用按设置/方式刷新的内容)。
如果您在使 I-cache 失效之前没有清理 D-cache,则在 L2 丢失后,code-fetch 可以直接从主内存获取到非连贯 I-cache。 (无需在任何统一缓存中分配陈旧行,这MESI https://en.wikipedia.org/wiki/MESI_protocol会阻止,因为 L1D 的线路处于修改状态)。无论如何,从架构上来说,将 L1D 清理到 PoU 是必需的,并且无论如何都发生在非性能关键的编写器线程中,因此最好只是这样做,而不是试图推理对于特定的 ARM 微体系结构不这样做是否安全。请参阅 @Notlikethat 的评论,努力消除我对此的困惑。
有关从用户空间清除 I-cache 的更多信息,请参阅如何在 Linux 2.6.35 上从用户模式清除和无效 ARM v7 处理器缓存 https://stackoverflow.com/questions/6046716/how-clear-and-invalidate-arm-v7-processor-cache-from-user-mode-on-linux-2-6-35。海湾合作委员会的__clear_cache()
功能和Linuxsys_cacheflush
只适用于曾经的内存区域mmap
与PROT_EXEC
.
不要就地修改:使用新位置
在您计划拥有整个检测代码块的地方,放置一个间接跳转(或保存/恢复lr
和一个函数调用(如果你无论如何都要有一个分支)。每个块都有自己的跳转目标变量,可以自动更新。这里的关键是间接跳转的目标是data,所以它是一致的与来自写作线程的商店。
由于您以原子方式更新指针,因此使用者线程要么跳转到旧的代码块,要么跳转到新的代码块。
现在您的问题是确保没有核心在其 i-cache 中拥有新位置的陈旧副本。考虑到上下文切换的可能性,如果上下文切换没有完全刷新 i-cache,则包括当前的核心。
如果您为新块使用足够大的位置环形缓冲区,以便它们在足够长的时间内未使用而被驱逐,那么在实践中可能永远不会出现问题。不过,这听起来非常难以证明。
如果与其他线程运行这些动态修改块的频率相比更新频率较低,那么它可能足够便宜让发布线程触发其他线程中的缓存刷新写入新块后,但是before更新间接跳转指针以指向它。
强制其他线程刷新其缓存:
Linux 4.3 及更高版本有membarrier()系统调用 http://man7.org/linux/man-pages/man2/membarrier.2.html在返回之前,它将在系统中的所有其他核心上运行内存屏障(通常带有处理器间中断)(从而屏障所有进程的所有线程)。也可以看看这篇博文 https://www.msully.net/blog/2015/02/24/forcing-memory-barriers-on-other-cpus-with-mprotect2/描述一些用例(例如用户空间 RCU)以及mprotect()
作为备选。
不过,它似乎不支持刷新指令缓存。如果您正在构建自定义内核,您可以考虑添加对新内核的支持cmd
or flag
值意味着刷新指令缓存而不是(或同时)运行内存屏障。也许是flag
值可以是虚拟地址吗?这仅适用于地址适合的架构int
,除非您调整系统调用 API 以查看完整的寄存器宽度flag
对于你的新命令,但只有int
对现有的价值MEMBARRIER_CMD_SHARED
.
除了破解 membarrier() 之外,您还可以向消费者线程发送信号,并让它们的信号处理程序刷新 i-cache 的适当区域。这是异步的,因此生产者线程不知道何时可以安全地重用旧块。
IDK if munmap()
它会起作用,但它可能比必要的更昂贵(因为它必须修改页表并使相关的 TLB 条目无效)。
其他策略
您也许可以通过在共享变量中发布单调递增的序列号来执行某些操作(具有释放语义,因此它是按指令写入顺序排列的)。然后,消费者线程根据线程本地最高可见值检查序列号,如果有新内容,则使 i-cache 无效。这可以是每个块的或全局的。
这并不能直接解决检测运行旧块的最后一个线程何时离开它的问题,除非那些每个线程最高可见的计数器实际上不是线程本地的:仍然是每个线程,但生产者线程可以查看他们。它可以扫描它们以查找任何线程中的最低序列号,如果该序列号高于块未被引用时的序列号,则现在可以重用该块。小心虚假分享 https://software.intel.com/en-us/articles/avoiding-and-identifying-false-sharing-among-threads/:不要使用全局数组unsigned long
对于它,因为您希望每个线程的私有变量与其他线程本地内容位于单独的缓存行中。
另一种可能的技术:如果只有一个消费者线程,则生产者将跳转目标指针设置为指向不会更改的块(因此不需要刷新 i-cache)。该块(在消费者线程中运行)对 i-cache 的相应行执行高速缓存刷新,然后再次修改跳转目标指针,这次指向应该每次运行的块。
对于多个消费者线程,这会变得有点笨拙:也许每个消费者都有自己的私有跳转目标指针,而生产者会更新所有这些指针?