【问题标题】:Multiple fast readers single slow writer: is using shadow-data with atomic index safe?多个快速阅读器单个慢速编写器:使用带有原子索引的影子数据安全吗?
【发布时间】:2018-03-23 10:12:28
【问题描述】:

我不确定我能否在标题中正确地压缩这个概念,但我的意思是:

假设我有“慢”,从非常不频繁的意义上说,单个线程对数据结构进行更新,而有多个线程连续读取相同的数据结构。

为了避免锁定,并被卡在 C++14(因此没有可用的 std::shared_mutex)并且没有提升,我想到了一种方法,我保留结构的 2 个副本并使用原子整数来索引当前的。

假设 2 的深度就足够了,我们不用担心,因为更新非常罕见,以至于在新的更新到来之前,有足够的时间让所有读者“看到”数据结构的新版本.

这是一个 sn-p,它显示了我正在做的事情的简化版本:

 /*
 * includes...
 */

struct datastructure_t {
    /*...*/
};


class StructSwapper
{
    std::atomic<unsigned int> current_index_;

    datastructure_t structures_[2];

public:
    StructSwapper (datastructure_t s)
        : current_index_(0)
        , structures_{std::move(s), {}}
    {}

    //Guaranteed to be called _infrequently_ by the same single thread
    void update (datastructure_t newdata)
    {
        auto const next_index = !current_index_.load();

        structures_[next_index] = std::move(newdata);

        current_index_.store(next_index);
    }


    //Called _frequently_ by multiple threads
    datastructure_t const & current_data() const
    {
        return structures_[current_index_.load()];
    }
};

所以基本上当编写器线程执行更新时,它首先修改数据结构的“影子”副本,然后原子地更新指向它的索引。

每个阅读器线程都会执行以下操作:

void reader_thread(StructSwapper const &sw)
{
    auto const &current_data  = sw.current_data();


    if (current_data->find(...))                        //1
    {
        do_something (current_data->val1);              //2

        if (current_data->property2)                    //3
            do_something_else (current_data->val2);     //4
        /*...*/

    }
}

但后来我开始思考:是什么保证编译器不会在任何标记为 1、2、3、4 的行中重新读取 current_data 的值,然后在执行过程中可能会得到它的两个不同版本这个函数是否同时被写入线程执行了更新?

也许如果 StructSwapper::current_data() 是内联的,它可能会查看它并将原子变量用作索引,但我怀疑这是否足够。

那么,两个问题:

  1. 我是否认为这种方法不能保证有效,因为编译器不知道 current_data 的“快照”实际上只能拍摄一次?
  2. 我认为,如果我改为返回对数据结构当前版本的原子引用,这可能会有所不同,因为在这种情况下,编译器会理解它可以从两个不同的读取中获得两个不同的值,对吧?

编辑:在看到与更宽松的内存排序相关的优化建议后,我想补充一下,我没有在上面的 sn-p 中报告它们,但它们确实已经在实际代码中到位。

【问题讨论】:

    标签: c++ multithreading c++11 atomic


    【解决方案1】:

    这是一种常见的方法,而且很有效。

    您需要确保没有其他线程仍在读取您的编写器更新的旧数据。为了解决这个问题,读者通常只是复制数据并在必要时使用它。

    但后来我开始思考:什么保证编译器不会重新读取current_data的值。

    编译器可能会将变量current_data的值存储在堆栈中,稍后重新读取它,但它不会为您调用sw.current_data()

    我是否认为这种方法不能保证有效,因为编译器不知道 current_data 的“快照”实际上只需要拍摄一次?

    这是不正确的。

    我认为,如果我改为返回对数据结构当前版本的原子引用,可能会有所不同,因为在这种情况下,编译器会理解它可以从两个不同的读取中获取两个不同的值,对吧?

    这是不必要的。


    一些优化:

    void update (datastructure_t newdata)
    {
        auto const next_index = !current_index_.load(std::memory_order_relaxed); 
        structures_[next_index] = std::move(newdata);
        current_index_.store(next_index, std::memory_order_release); // (1) 
    }
    
    
    //Called _frequently_ by multiple threads
    datastructure_t const & current_data() const
    {
        return structures_[current_index_.load(std::memory_order_acquire)]; // Synchronises with (1).
    }
    

    【讨论】:

    • 另一种选择是在每个缓冲区中保留一个读取器锁定计数,并且写入器被阻止写入影子缓冲区,直到所有读取器都从那个(即 2 次写入)移动到最后一个缓冲区。这取决于读者每次阅读的速度,以及作者是否可以等待那么久。
    • @GemTaylor 这将要求每个读者进行额外的原子增量和减量。而通过这种机制,每个阅读器只执行一个原子加载。
    • @MaximEgorushkin,谢谢,但我想说服自己,假设编译器可以多次调用 sw.current_data() 是不正确的,你的“它不会调用”背后的原因是什么sw.current_data() 给你”声明?我认为不会违反任何假设规则,但我希望看到一个确凿的证据证明我错了。也许编译器无法证明该函数没有副作用,然后不能多次调用它?。
    • @abigagli 我对您的想法感到惊讶,C++ 是一种命令式语言,其中的事情按程序顺序发生(带有多线程警告)。是什么让你不这么认为?
    • @Maxim,但我正在为每个读者交易该锁,不必快速获取数据副本,而且获取副本可能需要超过 2 个作者的可能性仍然很小... 秋千和环形交叉路口... 为复制付费或为增量和信号量付费。我知道这些都是以前解决的问题,尽管使用 unix pthread 库可能不是所有人都能接受的。
    【解决方案2】:

    如果您在任何阅读器仍在处理原始数据时进行第二次更新,您将遇到数据竞争,从而导致未定义行为。

    因此,不频繁是不够的,您需要保证任何两次更新之间的距离足够远,以便在您获得第二次更新之前,读者都将使用最新数据。

    可以通过提供更多可用位置、跟踪有多少读者在使用每个副本并推迟更新、或通过根据需要动态分配尽可能多的副本并使用共享指针管理它们来缓解这种情况。无论你做什么,都要认真审视它是否足够正确和有效。

    【讨论】:

    • 是的,我知道,sn-p 是一个非常简化的缩减,真正的代码还有其他几件事已经解决了这个问题,因为它不是我问题的重点,我不想添加太多“噪音”...
    • 另一个选项是返回一个共享指针给读者,如果作者可以调用new
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2013-09-06
    • 1970-01-01
    • 2022-01-25
    • 1970-01-01
    • 2023-03-22
    • 1970-01-01
    相关资源
    最近更新 更多