我有一个环形缓冲区,由一个生产者写入,由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
进行比较,然后有条件地将c->Get()
的RE-read值移动到aMinimum
中是可以的。在我看来,这个值可能在cmp
和cmovl
指令之间发生了更改。如果我是正确的,那么以下情况是可能的:
aMinimum
当前设置为2c->Get()
返回1个CCD_ 10完成并且CCD_
另一个线程将当前
c
当前保持的值更新为3cmovl
将aMinimum
设置为3生产者看到3并且覆盖环形缓冲器的位置2中的数据,即使它还没有被处理。
我看它太久了吗?难道不应该是这样的吗:
mov rbx, QWORD PTR [r8+56]
cmp rbx, rax
cmovl rax, rbx
您在访问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对应于修改iValue
的代码,而B对应于Cursor::Get()
,则可以得出相同的结论。因此CCD_ 23与CCD_。
因此,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"程序执行"中被定义为仅适用于在单个线程上发生的求值("sequence before"是C++11对旧的"序列点"术语的替代)。由于我们谈论的是在单独线程上的操作,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 Standard volatile关键词是不同的,并且在指定/voile:ISO编译器选项时,Visual Studio支持该关键字。(对于ARM,默认情况下指定)。C++11 ISO标准代码中的volatile关键字仅用于硬件访问;不要将其用于线程间通信。对于线程间通信,请使用C++标准模板库中的std::atomic等机制。
Microsoft特定
当使用/voile:ms编译器选项时(默认情况下,当针对ARM以外的体系结构时),编译器会生成额外的代码,以维护对易失性对象的引用之间的顺序,以及对其他全局对象的引用的顺序。特别是:
对易失性对象的写入(也称为易失性写入)具有Release语义;也就是说,在写入指令序列中的易失性对象之前发生的对全局或静态对象的引用将在编译的二进制文件中的该易失性写入之前发生。
对易失性对象的读取(也称为易失性读取)具有Acquire语义;也就是说,在读取指令序列中的易失性存储器之后发生的对全局或静态对象的引用将在编译的二进制文件中的该易失性读取之后发生。
这使易失性对象能够用于多线程应用程序中的内存锁定和释放。