`volatile` 是否允许使用联合进行类型双关?

Does `volatile` permits type punning with unions?

我们都知道这样的双关语

union U {float a; int b;};

U u;
std::memset(u, 0, sizeof u);
u.a = 1.0f;
std::cout << u.b;

在 C++ 中是未定义的行为。

它是未定义的,因为在 u.a = 1.0f; 赋值后 .a 成为活动字段并且 .b 成为非活动字段,并且从非活动字段读取是未定义的行为。我们都知道这一点。


现在,考虑以下代码

union U {float a; int b;};

U u;
std::memset(u, 0, sizeof u);
u.a = 1.0f;

char *ptr = new char[std::max(sizeof (int),sizeof (float))];
std::memcpy(ptr, &u.a, sizeof (float));
std::memcpy(&u.b, ptr, sizeof (int));

std::cout << u.b;

现在它变得定义明确了,因为这种类型的双关语是允许的。 此外,如您所见,u 内存在 memcpy() 调用后保持不变。


现在让我们添加线程和 volatile 关键字。

union U {float a; int b;};

volatile U u;
std::memset(u, 0, sizeof u);
u.a = 1.0f;

std::thread th([&]
{
    char *ptr = new char[sizeof u];
    std::memcpy(ptr, &u.a, sizeof u);
    std::memcpy(&u.b, ptr, sizeof u);
});
th.join();

std::cout << u.b;

逻辑保持不变,但我们只有第二个线程。由于 volatile 关键字代码保持明确定义。

在实际代码中,第二个线程可以通过任何蹩脚的线程库实现,编译器可能不知道第二个线程。但由于 volatile 关键字,它仍然定义明确。


但是如果没有其他线程呢?

union U {float a; int b;};

volatile U u;
std::memset(u, 0, sizeof u);
u.a = 1.0f;
std::cout << u.b;

没有其他线程。 但是编译器不知道没有其他线程!

从编译器的角度来看,没有任何改变!如果第三个示例定义明确,那么最后一个示例也必须定义明确!

我们不需要第二个线程,因为它不会改变 u 内存。


如果使用 volatile,编译器假定 u 可以在任何时候静默修改。在这样的修改中,任何字段都可以变为活动状态。

因此,编译器永远无法跟踪 volatile 联合的哪个字段处于活动状态。 它不能假设一个字段在分配给它之后仍然处于活动状态(并且其他字段保持不活动状态),即使没有真正修改该联合。

因此,在最后两个示例中,编译器应给我转换为 int1.0f 的精确位表示。


问题是:我的推理是否正确?第三和第四个例子真的很好吗?标准是怎么说的?

不 - 你的推理是错误的。 volatile 部分是一个普遍的误解 - volatile 并不像您所说的那样工作。

并集部分也是错误的。阅读此 Accessing inactive union member and undefined behavior?

使用 c++ (11) 时,您只能期望 correct/well 已定义的行为,即最后一次写入对应于下一次读取。

In real code this second thread can be implemented through any crappy threading library and compiler can be unaware of that second thread. But because of the volatile keyword it's still well-defined.

该陈述是错误的,因此您得出结论所依据的其余逻辑是不可靠的。

假设您有这样的代码:

int* currentBuf = bufferStart;
while(currentBuf < bufferEnd)
{
    *currentBuf = foobar;    
    currentBuf++;
}

如果 foobar 不是易失性的,则允许编译器进行如下推理:"I know that foobar is never aliased by currentBuf and therefore does not change within the loop, therefore I may optimize the code as"

int* currentBuf = bufferStart;
int temp = foobar;
while(currentBuf < bufferEnd)
{
    *currentBuf = temp;    
    currentBuf++;
}

如果 foobarvolatile 那么这个和许多其他 代码生成 优化被禁用。请注意,我说的是 代码生成 CPU 完全在其权利范围内,但是只要 CPU 的内存模型 没有违规。

特别是,编译器不需要在每次读取和写入 foobar 时强制 CPU 返回主内存。 所有它需要做的是避开某些优化。 (这不完全正确;编译器也有义务确保保留涉及长跳转的某些属性,以及其他一些与线程无关的次要细节。)如果有两个线程,每个线程都在不同的处理器,并且每个处理器都有不同的缓存,volatile 如果它们都包含 foobar.

的内存副本,则不需要使缓存保持一致

一些编译器可能会为了您的方便而选择实现这些语义,但这不是必须的;请查阅您的编译器文档。

我注意到 C# 和 Java do 需要在 volatiles 上获取和释放语义,但这些要求可能出奇地薄弱。特别是,x86 不会对两个易失性写入或两个易失性读取进行重新排序,但允许在对另一个变量进行易失性写入之前对一个变量的易失性读取进行重新排序,事实上 x86 处理器在极少数情况下可以这样做。 (请参阅 http://blog.coverity.com/2014/03/26/reordering-optimizations/ 用 C# 编写的谜题,该谜题说明即使所有内容都是易变的并且具有获取-释放语义,低锁代码也可能出错。)

道理是:即使您的编译器很有帮助并且确实对 C# 或 Java 等可变变量施加了额外的语义,仍然可能没有一致观察到的序列所有线程的读写次数;许多内存模型不强加此要求。这可能会导致奇怪的运行时行为。同样,如果您想了解 volatile 对您意味着什么,请查阅您的编译器文档