使用内存屏障在C中测试无锁定缓冲区复制



我有几个关于内存障碍的问题。

假设我有以下C代码(它将从C++和C代码中运行,因此原子论是不可能的),它将一个数组写入另一个数组。多个线程可能会调用thread_func(),我想确保my_str只有在完全初始化后才会返回。在这种情况下,缓冲区的最后一个字节不能为0。因此,检查最后一个字节是否为0就足够了。

由于编译器/CPU重新排序,这可能是一个问题,因为最后一个字节可能在前一个字节之前写入,导致my_str返回时带有部分复制的缓冲区。所以为了解决这个问题,我想使用一个记忆屏障。互斥锁当然可以工作,但对我来说太重了。

请记住,所有线程都会用相同的输入调用thread_func(),因此即使多个线程调用init()几次,只要最后thread_func()返回一个有效的my_str,并且初始化后的所有后续调用都直接返回my_str,就可以了。

请告诉我以下所有不同的代码方法是否有效,或者在某些情况下是否存在问题,除了找到问题的解决方案外,我还想了解更多关于内存障碍的信息。

  1. __sync_bool_compare_and_swap位于最后一个字节。如果我理解正确的话,任何内存存储/加载都不会被重新排序,而不仅仅是发送到命令的特定变量的存储/加载。这是正确的吗?如果是这样的话,我希望这能起到作用,因为之前所有字节的写入都应该在屏障移动之前完成。

    #define STR_LEN 100
    static uint8_t my_str[STR_LEN] = {0};
    static void init(uint8_t input_buf[STR_LEN])
    {
    for (int i = 0; i < STR_LEN - 1; ++i) {
    my_str[i] = input_buf[i];
    }
    __sync_bool_compare_and_swap(my_str, 0, input_buf[STR_LEN - 1]);
    }
    const char * thread_func(char input_buf[STR_LEN])
    {
    if (my_str[STR_LEN - 1] == 0) {
    init(input_buf);
    }
    return my_str;
    }
    
  2. CCD_ 9。我希望这也能起作用,但会比第一个慢。

    static void init(char input_buf[STR_LEN])
    {
    for (int i = 0; i < STR_LEN; ++i) {
    __sync_bool_compare_and_swap(my_str + i, 0, input_buf[i]);
    }
    }
    
  3. __sync_synchronize。我希望这也能起作用,但这比(2)慢还是快?__sync_bool_compare_and_swap也应该是一个完整的屏障,那么哪个更可取呢?

    static void init(char input_buf[STR_LEN])
    {
    for (int i = 0; i < STR_LEN; ++i) {
    __sync_synchronize();
    my_str[i] = input_buf[i];
    }
    }
    
  4. CCD_ 12。据我所知,__sync_synchronize既是硬件内存屏障,也是软件内存屏障。因此,由于编译器不能告诉use_sync的值,因此不应该重新排序。并且只有当CCD_ 15为真时,才会进行HW重新排序。这是正确的吗?

    static void init(char input_buf[STR_LEN], bool use_sync)
    {
    for (int i = 0; i < STR_LEN; ++i) {
    if (use_sync) {
    __sync_synchronize();
    }
    my_str[i] = input_buf[i];
    }
    }
    

GNU C遗留__sync内建不建议用于新代码,正如手册所说。

使用__atomic内建函数,它可以采用类似C11stdatomic的内存顺序参数。但它们仍然是内建的,并且仍然适用于未声明为_Atomic的普通类型,因此使用它们就像C++20std::atomic_ref一样。在C++20中,使用std::atomic_ref<unsigned char>(my_str[STR_LEN - 1]),但C没有提供等效的,因此您必须使用编译器内置程序来手动滚动它

只需在编写器中使用发布存储单独执行最后一个存储,而不是RMW,并且绝对不是每个字节之间的完整内存屏障(__sync_synchronize())!!!这比必要的要慢得多,并且会破坏使用memcpy的任何优化。此外,您需要最后一个字节的存储至少是RELEASE,而不是普通存储,这样读者就可以与它同步。另请参阅谁害怕一个糟糕的优化编译器re:如果你试图只使用屏障而不是原子加载或存储来传递无锁定代码,那么编译器是如何破坏你的代码的。(它是为Linux内核代码编写的,其中宏将使用*(volatile char*)用__ATOMIC_RELAXED`手动滚动接近__atomic_store_n的内容)

所以类似的东西

__atomic_store_n(&my_str[STR_LEN - 1], input_buf[STR_LEN - 1], __ATOMIC_RELEASE);

当存在并发写入程序时,thread_func中的if (my_str[STR_LEN - 1] == 0)加载当然是数据竞赛UB。

为了安全起见,它需要是一个获取加载,如__atomic_load_n(&my_str[STR_LEN - 1], __ATOMIC_ACQUIRE) == 0,因为您需要一个加载非0值的线程,才能通过另一个运行init()的线程查看所有其他存储。(它将发布存储到该位置,创建获取/发布同步,并保证这些线程之间的关系在发生之前。)

请参阅https://preshing.com/20120913/acquire-and-release-semantics/


在ISO C和ISO C++中,以非原子方式写入相同的值也是UB。请参阅在C++中写入相同值的竞赛条件和其他。

但在实践中,除了clang -fsanitize=thread之外,它应该是好的。理论上,DeathStation9000可以通过存储value+1,然后减去1来实现非原子存储,因此内存中暂时存在不同的值。但AFAIK并没有真正的编译器可以做到这一点。我会看看你正在尝试的任何新编译器/ISA组合上生成的asm,只是为了确保。

这将很难测试;init的东西在每次程序调用中只能竞争一次但没有一种完全安全的方法可以做到这一点,这对性能来说并不完全糟糕,AFAIK也许用转换为_Atomic unsigned char*typedef _Atomic unsigned long __attribute__((may_alias)) aliasing_atomic_ulong;作为手动复制循环的构建块来执行init?


奖励问题:循环内的if(use_sync) __sync_synchronize()

  1. 由于编译器无法判断use_sync的值,因此不应重新排序

可以对类似if(use_sync) { slow barrier loop } else { no-barrier loop }的asm进行优化。这被称为";"loop unstritching":制作两个循环并分支一次以决定运行哪个,而不是每次迭代。GCC从3.4开始就能够进行优化(在某些情况下)。因此,这挫败了你试图利用编译器的编译方式来欺骗它进行比源代码实际需要的更多的排序。

只有当use_sync为真时,才会进行硬件重新排序。

是的,该部分是正确的。

此外,use_sync的内联和恒定传播可以很容易地克服这一点,除非use_syncvolatile全局或其他什么此时,您还可以创建一个单独的_Atomic unsigned char array_init_doneflag/guard变量

您可以使用它进行互斥,方法是让线程尝试将其设置为1int old = guard.exchange(1),比赛的获胜者是运行init的人,同时旋转等待(或C++20.wait(1))保护变量变为2-1或其他什么,比赛的胜利者将在完成init后设置。

看看GCC为非常量初始化的static本地变量所做的asm-GCC;他们用获取负载检查一个guard变量,只进行锁定,让一个线程执行runonce-init,其他线程等待结果。IIRC有一个问答;A关于你自己用原子学做这件事。

相关内容

  • 没有找到相关文章

最新更新