3

我有一个环形缓冲区,由一个生产者写入并由 N 个消费者读取。因为它是一个环形缓冲区,所以生产者写入的索引小于消费者当前的最小索引是可以的。生产者和消费者的位置由他们自己跟踪Cursor

class Cursor
{
public:
    inline int64_t Get() const { return iValue; }
    inline void Set(int64_4 aNewValue)
    {
        ::InterlockedExchange64(&iValue, aNewValue);
    }

private:
    int64_t iValue;
};

//
// Returns the ringbuffer position of the furthest-behind Consumer
//
int64_t GetMinimum(const std::vector<Cursor*>& aCursors, int64_t aMinimum = INT64_MAX)
{
    for (auto c : aCursors)
    {
        int64_t next = c->Get();
        if (next < aMinimum)
        {
            aMinimum = next;
        } 
    }

    return aMinimum;
}

查看生成的汇编代码,我看到:

    mov rax, 922337203685477580   // rax = INT64_MAX
    cmp rdx, rcx    // Is the vector empty?
    je  SHORT $LN36@GetMinimum
    npad    10
$LL21@GetMinimum:
    mov r8, QWORD PTR [rdx]    // r8 = c
    cmp QWORD PTR [r8+56], rax // compare result of c->Get() and aMinimum
    cmovl   rax, QWORD PTR [r8+56] // if it's less then aMinimum = result of c->Get()
    add rdx, 8                 // next vector element
    cmp rdx, rcx    // end of the vector?
    jne SHORT $LL21@GetMinimum
$LN36@GetMinimum:
    fatret  0   // beautiful friend, the end

我看不出编译器如何认为可以读取 的值c->Get(),将其与 进行比较aMinimum,然后有条件地将 RE-READ 的值移动c->Get()aMinimum。在我看来,这个值可能在cmpandcmovl指令之间发生了变化。如果我是正确的,那么以下情况是可能的:

  • aMinimum当前设置为 2

  • c->Get()返回 1

  • 完成cmp并设置less-than标志

  • 另一个线程将当前持有的值更新c为 3

  • cmovl设置aMinimum为 3

  • Producer 看到 3 并覆盖 ringbuffer 的位置 2 中的数据,即使它还没有被处理。

我是不是看得太久了?不应该是这样的:

mov rbx, QWORD PTR [r8+56]
cmp rbx, rax 
cmovl rax, rbx 
4

1 回答 1

3

您没有使用原子或任何类型的线程间排序操作来访问您的访问权限iValue(可能对于另一个线程上可能正在修改的任何内容都是如此iValue,但我们会看到这并不重要),所以编译器是可以自由假设它在两条代码汇编行之间保持不变。如果另一个线程修改iValue您有未定义的行为。

如果您的代码旨在是线程安全的,那么您将需要使用原子、锁或一些排序操作。

C++11 标准在第 1.10 节“多线程执行和数据竞争”中对此进行了形式化,这并不是特别容易阅读的内容。我认为与此示例相关的部分是:

第 10 段:

评估 A在评估 B之前是依存排序的,如果

  • A 对原子对象 M 执行释放操作,并且在另一个线程中,B 对 M 执行消耗操作并读取以 A 为首的释放序列中的任何副作用写入的值,或
  • 对于某些评估 X,A 在 X 之前是依赖排序的,并且 X 携带对 B 的依赖。

如果我们说评估 A 对应于Cursor::Get()函数,而评估 B 将对应于一些看不见的修改iValue. 求值 A ( Cursor::Get()) 不对原子对象执行任何操作,并且依赖关系不排在其他任何东西之前(因此这里不涉及“X”)。

如果我们说评估 A 对应于修改的代码,iValueB 对应于Cursor::Get(),则可以得出相同的结论。Cursor::Get()所以和 的修饰符之间没有“依赖排序之前”的关系iValue

因此,Cursor::Get()在任何可能修改iValue.

第 11 段:

评估 A 线程间发生在评估 B 之前,如果

  • A 与 B 同步,或
  • A 在 B 之前是依赖排序的,或者
  • 对于一些评估 X
    • A 与 X 同步并且 X 在 B 之前排序,或者
    • A 在 X 之前排序,并且 X 线程间发生在 B 之前,或者
    • 线程间发生在 X 之前,而 X 线程间发生在 B 之前。

同样,这些条件都不满足,因此之前没有发生线程间。

第 12 段

如果满足以下条件,则评估 A 在评估 B 之前发生:

  • A 在 B 之前排序,或
  • 线程间发生在 B 之前。

我们已经表明,两个操作都不是“线程间发生在”另一个。并且术语“sequenced before”在 1.9/13“程序执行”中定义为仅适用于在单个线程上发生的评估(“sequenced before”是 C++11 对旧“sequence point”术语的替代)。由于我们讨论的是单独线程上的操作,因此 A 不能在 B 之前排序。

所以在这一点上,我们发现Cursor::Get()不会“发生在”iValue另一个线程上发生的修改之前(反之亦然)。最后,我们在第 21 段中得出了结论:

如果程序的执行在不同的线程中包含两个相互冲突的操作,则该程序的执行包含数据竞争,其中至少一个不是原子的,并且两者都不会在另一个之前发生。任何此类数据竞争都会导致未定义的行为。

因此,如果您想在一个线程上使用Cursor::Get()并在另一个线程上进行修改iValue,则需要使用原子或其他一些排序操作(互斥锁等)来避免未定义的行为。

请注意,根据标准,volatile不足以提供线程之间的排序。微软的编译器可能会提供一些额外的承诺来volatile支持定义良好的线程间行为,但这种支持是可配置的,所以我的建议是避免依赖volatile新代码。以下是 MSDN 对此的一些看法 ( http://msdn.microsoft.com/en-us/library/vstudio/12a04hfd.aspx ):

符合 ISO 标准

如果您熟悉 C# volatile 关键字,或者熟悉 Visual C++ 早期版本中 volatile 的行为,请注意 C++11 ISO 标准 volatile 关键字是不同的,并且在 Visual Studio 中支持 /volatile:iso指定了编译器选项。(对于 ARM,它是默认指定的)。C++11 ISO 标准代码中的 volatile 关键字仅用于硬件访问;不要将其用于线程间通信。对于线程间通信,请使用 C++ 标准模板库中的 std::atomic 等机制。

微软特定

当使用 /volatile:ms 编译器选项时(默认情况下,当针对 ARM 以外的体系结构时)编译器生成额外的代码来维护对 volatile 对象的引用之间的顺序,以及维护对其他全局对象的引用的顺序。尤其:

  • 对 volatile 对象的写入(也称为 volatile 写入)具有 Release 语义;也就是说,在指令序列中写入易失性对象之前发生的对全局或静态对象的引用将发生在已编译二进制文件中的易失性写入之前。

  • 对 volatile 对象的读取(也称为 volatile 读取)具有 Acquire 语义;也就是说,在指令序列中读取易失性存储器之后发生的对全局或静态对象的引用将发生在编译二进制文件中的易失性读取之后。

这使得 volatile 对象可以用于多线程应用程序中的内存锁定和释放。

于 2013-02-06T02:14:56.847 回答