【问题标题】:Does this envelope implementation correctly use C++11 atomics?这个信封实现是否正确使用 C++11 原子?
【发布时间】:2020-01-25 02:06:57
【问题描述】:

我编写了一个简单的“信封”类,以确保我正确理解 C++11 原子语义。我有一个标题和一个有效负载,作者清除标题,填充有效负载,然后用增加的整数填充标题。这个想法是,阅读器然后可以读取标头,memcpy 出有效负载,再次读取标头,如果标头相同,则阅读器可以假设他们成功复制了有效负载。读者可能会错过一些更新是可以的,但他们不能得到一个撕裂的更新(其中有来自不同更新的字节混合)。只有一个读者和一个作者。

写入器使用释放内存顺序,读取器使用获取内存顺序。

memcpy 是否存在被原子存储/加载调用重新排序的风险?或者负载可以相互重新排序吗?这对我来说永远不会中止,但也许我很幸运。

#include <iostream>
#include <atomic>
#include <thread>
#include <cstring>

struct envelope {
    alignas(64) uint64_t writer_sequence_number = 1;
    std::atomic<uint64_t> sequence_number;
    char payload[5000];

    void start_writing()
    {
        sequence_number.store(0, std::memory_order::memory_order_release);
    }

    void publish()
    {
        sequence_number.store(++writer_sequence_number, std::memory_order::memory_order_release);
    }

    bool try_copy(char* copy)
    {
        auto before = sequence_number.load(std::memory_order::memory_order_acquire);
        if(!before) {
            return false;
        }
        ::memcpy(copy, payload, 5000);
        auto after = sequence_number.load(std::memory_order::memory_order_acquire);
        return before == after;
    }
};

envelope g_envelope;

void reader_thread()
{
    char local_copy[5000];
    unsigned messages_received = 0;
    while(true) {
        if(g_envelope.try_copy(local_copy)) {
            for(int i = 0; i < 5000; ++i) {
                // if there is no tearing we should only see the same letter over and over
                if(local_copy[i] != local_copy[0]) {
                    abort();
                }
            }
            if(messages_received++ % 64 == 0) {
                std::cout << "successfully received=" << messages_received << std::endl;
            }
        }
    }
}

void writer_thread()
{
    const char alphabet[] = {"ABCDEFGHIJKLMNOPQRSTUVWXYZ"};
    unsigned i = 0;
    while(true) {
        char to_write = alphabet[i % (sizeof(alphabet)-1)];
        g_envelope.start_writing();
        ::memset(g_envelope.payload, to_write, 5000);
        g_envelope.publish();
        ++i;
    }
}

int main(int argc, char** argv)
{
    std::thread writer(&writer_thread);
    std::thread reader(&reader_thread);

    writer.join();
    reader.join();

    return 0;
}

【问题讨论】:

  • @Tas 不错,已修复。尽管如此,仍然永远不会中止;)
  • 我看不出有什么可以阻止这些重新排序(try_copy):::memcpy(copy, payload, 5000);auto after = sequence_number.load(std::memory_order::memory_order_acquire); 我觉得应该是release?同样,start_writing 似乎应该使用 acquire
  • @DavisHerring 哎呀,已修复
  • ` = {"ABCDEFGHIJKLMNOPQRSTUVWXYZ"}` 是一个奇怪的初始化。
  • @curiousguy 为什么?就可以完成 ASCII 数学运算?

标签: c++ multithreading thread-safety memory-model stdatomic


【解决方案1】:

这称为seqlock;仅仅因为对memsetmemcpy 的调用冲突,它就有了数据竞争。有人提议提供类似memcpy 的工具来使此类代码正确; most recent 不太可能出现在 C++26 之前(即使获得批准)。

【讨论】:

  • 非常有趣,+1。所以,现在,我猜,只有具有原子数据加载和存储的 seqlock 是无竞争条件的。
  • 考虑更多,即使 memcpy 在 P1478R2 示例中是原子的 atomic_thread_fence(memory_order_acquire); 也不会做正确的事情,因为它不会阻止 preceding 操作被重新排序过去。应该是atomic_thread_fence(memory_order_acq_rel);
  • @MaximEgorushkin:是的,实现 seqlock 需要一些 2-way fences,而不仅仅是获取加载和释放存储。例如我在Implementing 64 bit atomic counter with 32 bit atomics 中的尝试,另请参阅Optimal way to pass a few variables between 2 threads pinning different CPUs。您想要的是让编译器发出有效的宽负载,而不管原子性如何。如果没有 UB,C++ 就不可能做到这一点,但至少 C 允许 struct foo = volatile struct foo 让编译器选择一种有效的方式来执行 volatile
  • @MaximEgorushkin:您当然可以通过使用relaxed 加载/存储atomic&lt;long&gt; 的块作为共享数据来避免UB,但这会阻止当前编译器执行多个原子long 加载/使用单个 SIMD 加载/存储进行存储。 (特别是因为像英特尔这样的硬件供应商甚至没有记录对齐的 SIMD 加载/存储具有每个元素的原子性:Per-element atomicity of vector load/store and gather/scatter? 尽管我认为每个人都这么认为。)
【解决方案2】:

这称为 seqlock。这是一种已知的模式,它适用于偶尔发布,经常阅读。如果您重新发布过于频繁(尤其是对于 5000 字节大的缓冲区),您可能会面临读者重试次数过多的风险,因为他们会不断检测到可能的撕裂。它通常用于例如将 64 位或 128 位时间戳从定时器中断处理程序发布到所有内核,其中写入器不必获取锁这一事实很好,而读取器是只读的并且可以忽略不计的事实也是如此快速路径中的开销。


Acq 和 Rel are one-way barriers.

您需要atomic_thread_fence(mo_acquire) 在阅读器中第二次加载序列号之前,以确保它不会在 memcpy 完成之前发生。写入器中的 atomic_thread_fence(mo_release) 也是如此,在写入数据之前的第一次存储之后。请注意acquire / release fences are 2-way barriers,确实会影响非原子变量1。 (尽管有相反的误解,但与获取或释放操作不同,栅栏确实是双向屏障。Jeff Preshing explains and debunks the confusion

另请参阅Implementing 64 bit atomic counter with 32 bit atomics,了解我对模板化 SeqLock 类的尝试。我要求模板class T 提供一个赋值运算符来复制自己,但使用memcpy 可能会更好。我使用volatile 来提高我们所包含的 C++ UB 的安全性。这对于uint64_t 来说很容易工作,但对于更广泛的内容来说,C++ 是一个巨大的痛苦,不像在 C 中,你可以让编译器有效地发出代码以从 volatile 结构加载到非volatile 临时结构中。

无论哪种方式,您都将拥有 C++ 数据竞争 UB(因为 C++ 在没有 UB 的情况下无法实现最佳效率:SeqLock 的全部意义在于让撕裂可能发生在 data[] 上,但检测到这一点并且从不实际查看在撕裂的数据)。您可以通过将数据复制为atomic&lt;unsigned long&gt; 或其他内容的数组来避免UB,但是当前的编译器还不够聪明,无法为此使用SIMD,因此对共享数据的访问效率会很低。 (而且硬件供应商没有记录Per-element atomicity of vector load/store and gather/scatter?,尽管我们都知道当前的 CPU 确实提供了这一点,未来的 CPU 几乎肯定也会提供。)

内存屏障可能就足够了,但是最好做一些事情来“清洗”该值以确保编译器不会再次重新加载非原子数据之后第二次加载。喜欢What is the purpose of glibc's atomic_forced_read function?。但正如我所说,我认为atomic_thread_fence() 就足够了。至少在实践中,像 GCC 这样的编译器会将 thread_fence 视为 asm("":::"memory"),告诉编译器内存中的所有值都可能已更改。


脚注 1:Maxim 指出 atomic_thread_fence 可能是一种 hack,因为 ISO C++ 仅根据障碍和释放序列来指定事物,与看到存储值的负载同步。

但众所周知,fences 和 acq/rel 加载/存储如何映射到任何给定目标平台的 asm。编译器会做足够多的整个程序的线程间分析来证明它可以破坏你的代码,这是难以置信的。

关于在tmp+1 的存储区和至少一些假设的读者之间建立发生前发生关系的 C++ 标准中使用的语言可能存在争议。实际上,这足以阻止编译器破坏编写器:它不知道什么代码将读取它正在写入的数据,因此它必须尊重障碍。并且标准中的语言可能足够强大,以至于看到奇数序列号(并避免阅读data[])的读者可以避免数据竞争UB,因此在具有保持领先一些非原子商店。所以我不相信恶意的全能编译器有任何空间不尊重atomic_thread_fence(),更不用说任何真正的编译器了。

无论如何,您肯定想要 x86 上的_mm_lfence()您想要编译器屏障防止运行时重新排序,但您肯定不 想要lfence的主要作用:阻塞乱序执行。Understanding the impact of lfence on a loop with two long dependency chains, for increasing lengthsAre loads and stores the only instructions that gets reordered?

即你只想要 GNU C asm("":::"memory"),又名 atomic_signal_fence(mo_seq_cst)。也相当于 x86 上的atomic_thread_fence(mo_acq_rel),它只需要阻止编译时重新排序来控制运行时排序,因为 x86 的强内存模型允许的唯一运行时重新排序是 StoreLoad(NT 存储除外)。 x86 的内存模型是 seq_cst + 带有存储转发的存储缓冲区(这会将 seq_cst 弱化为 acq/rel,并且偶尔会产生其他奇怪的效果,尤其是对于与存储部分重叠的负载)。

有关_mm_lfence() 等与asm 指令的更多信息,请参阅When should I use _mm_sfence _mm_lfence and _mm_mfence


其他调整

您的序列号过宽,并且 64 位原子在某些 32 位平台上效率较低,在少数平台上效率非常低。 32 位序列号不会包含在任何合理的线程睡眠时间中。 (例如,一个 4GHz CPU 将需要大约一秒钟的时间以每个时钟 1 次存储进行 2^32 次存储,并且写入缓存行的竞争为零。并且没有周期花费执行实际数据的存储。实际使用 -在某些情况下,编写者不会在一个紧密的循环中不断发布新值:这可能会导致类似于活锁的情况,读者不断重试并且没有任何进展。)

unsigned long 永远不会 (AFAIK) 太宽而无法有效处理,除非 CPU 小于 32 位。所以atomic&lt;long&gt;atomic&lt;unsigned long&gt; 会在 CPU 上使用 64 位计数器,这很好,但绝对要避免在 32 位代码中使用 64 位原子的风险。并且long 至少需要 32 位宽。

另外,您不需要两份写入序列号。只需让编写器对 tmp var 进行原子加载,然后分离 tmp+1 和 tmp+2 的原子存储。

(你想避免sequence_number++ 是正确的;要求编译器执行两个原子 RMW 是个坏主意)。写入器私有序列号的单独非原子 var 的唯一优点是,它是否可以内联到写入循环并将其保存在寄存器中,这样写入器就不会重新加载值。

【讨论】:

  • Presing 说:获取栅栏可防止按程序顺序在其前面的任何读取与按程序顺序在其后面的任何读取或写入进行内存重新排序。 但是,@ 987654330@:当前线程中的读取或写入在此加载之前不能重新排序。请问 C++ 标准在哪里要求 preceding 加载和存储在获取操作之后不被重新排序?
  • @MaximEgorushkin:获取fence不是获取操作。所以 C++ 标准没有这么说。这正是许多人(包括 Herb Sutter 和其他 C++ 专家)有一段时间的误解preshing.com/20131125/… 是我打算在这个答案中包含的链接。我忘记了 Jeff Preshing 有 2 篇博客文章,标题中有 acq/rel 围栏。
  • 关于栅栏的文章很有见地,谢谢分享。我现在看到栅栏比具有相同内存顺序的加载和存储更强大。
猜你喜欢
  • 2011-03-21
  • 1970-01-01
  • 1970-01-01
  • 2017-02-03
  • 1970-01-01
  • 2015-07-07
  • 1970-01-01
  • 2023-03-16
  • 2012-06-28
相关资源
最近更新 更多