这个信封实现是否正确使用了C++11原子类型?

7
我写了一个简单的“envelope”类,以确保我正确理解C++11原子语义。该类有一个头部和一个负载,写入者会清除头部,填充负载,然后用递增整数填充头部。读取者可以读取头部,memcpy出负载,再次读取头部,并且如果头部相同,则可以假定成功地复制了负载。读取者可能会错过一些更新,但获取到混合不同更新字节的损坏更新是不可接受的。只有单个读取者和单个写入者。
写入者使用release内存顺序,读取者使用acquire内存顺序。
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 很好的发现,已经修复了。但是它仍然不会中止 ;) - Joseph Garvin
我不明白有什么会阻止在 try_copy 中重新排序这些代码: ::memcpy(copy, payload, 5000); auto after = sequence_number.load(std::memory_order::memory_order_acquire); 我觉得应该使用 release? 同样地,start_writing 看起来应该使用 acquire - JesseC
={"ABCDEFGHIJKLMNOPQRSTUVWXYZ"}是一种奇怪的初始化方式。 - curiousguy
@JosephGarvin 我对字符串的内容感到满意,但我觉得语法选择 str = { "..." } 相当奇怪:花括号通常用于结构体或数组,而不是字符串。 - curiousguy
这个问题应该发布在 https://codereview.stackexchange.com/ 上。 - Doctor Jones
显示剩余2条评论
2个回答

5

这叫做序列锁(seqlock);由于 memsetmemcpy 的冲突调用,它存在数据竞争。已经有提议提供类似 memcpy 的功能使得这种代码正确;最近的提案即使被批准,也不太可能在C++26之前出现。


非常有趣,+1。所以,目前来看,只有使用原子数据加载和存储的seqlocks是无竞争条件的。 - Maxim Egorushkin
1
更深入地思考,即使memcpy是原子的,在P1478R2示例中使用atomic_thread_fence(memory_order_acquire);也不会做正确的事情,因为它不能防止之前的操作被重新排序。它应该是atomic_thread_fence(memory_order_acq_rel); - Maxim Egorushkin
@MaximEgorushkin:是的,实现序列锁需要一些双向栅栏,而不仅仅是获取加载和释放存储。例如,我的尝试在使用32位原子实现64位原子计数器,还可以参见在两个固定不同CPU的线程之间传递几个变量的最佳方法。你想要的是让编译器发出高效的宽加载,而不管原子性如何。C++使这成为不可能,但至少C允许struct foo = volatile struct foo,以便让编译器选择一种有效的处理volatile的方式。 - Peter Cordes
1
@MaximEgorushkin:当然,您可以通过使用atomic<long>的“relaxed”加载/存储来避免UB,但这会防止当前编译器使用单个SIMD加载/存储执行多个原子long加载/存储。(特别是因为像英特尔这样的硬件供应商甚至未能记录对齐的SIMD加载/存储具有每个元素的原子性:向量加载/存储和聚集/散射的每个元素的原子性?,尽管我认为每个人都会假设。) - Peter Cordes

3
这被称为序列锁(seqlock)。它是一种已知的模式,对于发布偶尔、读取频繁的情况非常有效。如果您过于频繁地重新发布(特别是对于像5000字节这样大的缓冲区),则读者会不断检测可能的撕裂,并且会有太多的重试风险。通常用于例如从定时器中断处理程序发布64位或128位时间戳到所有核心,其中写入者无需获取锁的事实很好,读者是只读的,在快速路径中具有可忽略的开销。

Acq和Rel 是单向屏障

在读者的第二次加载序列号之前,您需要使用atomic_thread_fence(mo_acquire),以确保它不会在memcpy完成之前发生。对于写入器,在第一次存储后,写入数据之前需要使用atomic_thread_fence(mo_release)。请注意,acquire / release fences是双向屏障,并且会影响非原子变量1。(尽管有误解,但fences确实是双向屏障,与acquire或release操作不同。Jeff Preshing解释并揭穿了混淆

另外,查看使用32位原子实现64位原子计数器获取我的模板化SeqLock类尝试。 我要求模板class T提供分配运算符以复制自身,但使用memcpy可能更好。我使用volatile来提高对包含C++ UB的额外安全性。这对于uint64_t很容易,但对于任何更宽的东西,在C++中是一个巨大的烦恼,不像在C中,您可以让编译器高效地生成代码从volatile结构中加载到非volatile临时变量中。
您无论如何都会遇到C++数据竞争UB(因为C++在不使用UB的情况下无法实现最佳效率:SeqLock的整个目的是让撕裂可能发生在上,但检测到后从未真正查看撕裂的数据)。您可以通过将数据复制为数组或其他形式来避免UB,但当前的编译器还不够智能,无法使用SIMD来提高对共享数据的访问效率。 (而HW供应商未能记录向量装载/存储和聚合/散布的每个元素的原子性?,尽管我们都知道当前的CPU确实具备该功能,未来的CPU几乎肯定也会具备该功能。)

内存屏障可能就足够了,但最好做一些"洗涤"值的事情,以确保编译器不会在第二次加载后放置另一个非原子数据的重新加载。就像 glibc的atomic_forced_read函数有什么用?。但是,我认为atomic_thread_fence()已经足够了。至少在使用像GCC这样的编译器时,在线程栅栏中处理asm("":::"memory")的方式告诉编译器所有内存中的值可能已经改变。


注1:Maxim指出atomic_thread_fence可能有点像一个hack,因为ISO C++仅以障碍和释放序列与看到存储值的负载同步来规定事物。

但是众所周知,对于任何给定的目标平台,障碍和acq/rel加载/存储如何映射到asm。编译器很难进行足够的整个程序的跨线程分析来证明它可以破坏您的代码。

关于在C++标准中使用的语言来建立tmp+1存储和至少一些假想读取器之间的happens-before关系可能存在争议。实际上,这足以阻止编译器破坏写入者:它无法知道将读取其正在写入的数据的代码,因此必须尊重屏障。而且,标准中的语言可能足够强大,以至于看到奇数序列号(并避免读取data[])的读取器可以避免数据竞争UB,因此在原子存储之间会有一个有效的happens-before关系,该存储必须保持领先于一些非原子存储。因此,我不相信有任何恶意全视编译器不尊重atomic_thread_fence(),更不用说任何真正的编译器了。

无论如何,在x86上绝对不要使用_mm_lfence() 您需要编译器屏障来防止运行时重排序,但是您绝对不需要lfence的主要效果:阻塞乱序执行。了解lfence对具有两个长依赖链的循环的影响,以增加长度被重新排序的指令是否只有加载和存储指令? 即你只需要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 在除了小于32位的CPU之外永远不会太宽,因此atomic<long>atomic<unsigned long>会在可以使用64位计数器的CPU上使用,但绝对避免在32位代码中使用64位原子的风险。而且long至少需要32位宽。

此外,您不需要两份写序列号的副本。只需让编写器对一个临时变量进行原子加载,然后分开进行tmp + 1和tmp + 2的原子存储即可。
(您正确地想要避免sequence_number++;要求编译器执行两个原子RMW操作是不明智的)。将编写者的私有序列号分离为一个非原子变量的唯一优点是,如果它可以内联到写循环中并保持在寄存器中,那么编写者永远不会重新加载该值。

Presing说:获取屏障可以防止在程序顺序中位于其之前的任何读取与在程序顺序中位于其之后的任何读取或写入之间进行内存重排序。 然而,C++参考文献表示:在此加载操作之前,当前线程中的任何读取或写入都不能被重新排序。 请问C++标准要求不对先前的加载和存储进行重新排序以避免超过获取操作的位置? - Maxim Egorushkin
4
一个 acquire fence 并不是一个 acquire 操作。因此,C++ 标准并没有这样说。这正是许多人长期以来存在的误解(包括 Herb Sutter 和其他 C++ 专家)。我想包含在这个答案中的链接是 https://preshing.com/20131125/acquire-and-release-fences-dont-work-the-way-youd-expect/。我忘记 Jeff Preshing 写了 2 篇标题带有 acq/rel fences 的博客文章。 - Peter Cordes
这是一篇关于栅栏的深入文章,非常有见地,感谢分享。我现在明白了,相同内存顺序的栅栏比加载和存储更强大。 - Maxim Egorushkin

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接