【问题标题】:Does `volatile` permits type punning with unions?`volatile` 是否允许使用工会进行类型双关语?
【发布时间】:2015-10-17 11:34:31
【问题描述】:

我们都知道这样的双关语

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 联合的哪个字段处于活动状态。 它不能假设一个字段在分配给它之后仍然处于活动状态(并且其他字段仍然处于非活动状态),即使没有真正修改该联合。

因此,在最后两个示例中,编译器将给出1.0f 转换为int 的精确位表示。


问题是:我的推理是否正确?第 3 和第 4 个例子真的很好辩解吗?标准对此有何规定?

【问题讨论】:

  • volatile 与线程无关。见Why does volatile exist?
  • 首先,在您的第四个示例中,您在没有同步的情况下读取了一个变量,因此编译器可以假设没有其他线程写入它。 volatile 仅表示对变量的每个操作都是可观察到的副作用;这与多线程执行无关。所以这个例子肯定是错误的。第二:你为什么要做这样的事情?
  • @BaummitAugen 1. 我这样做是因为它看起来像是在 C++ 中用于简单类型双关语的 hack。 2. 你的意思是当compiler 看到th.join() 时,它会假设 voltaile 变量有可能被修改?但是,如果我使用任何非标准线程库,例如 SDL 线程,该怎么办?编译器不知道SDL_WaitThread() 加入了一个线程。 3.volatile only means that every operation on the variable is a observable side effect你能解释一下吗?我不明白你的意思。
  • @HolyBlackCat 它不需要知道,因为它知道你通过引用或它的地址将变量传递给某个函数,否则新线程无法修改它。所以它可以推断它可能已经改变了。
  • 此外,第二个示例似乎也是错误的,因为如果 sizeof(float) &lt; sizeof(int) 正在读取未初始化的字节。

标签: c++ volatile unions type-punning


【解决方案1】:

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

该陈述是错误的,因此您得出结论的其余逻辑是不合理的。

假设你有这样的代码:

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

如果foobar 不是易失性的,则允许编译器进行如下推理:“我知道 foobar 永远不会被 currentBuf 别名,因此不会在循环内更改,因此我可以将代码优化为”

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

如果foobarvolatile,则此优化和许多其他代码生成 优化将被禁用。注意我说的是代码生成CPU 完全在其权利范围内,但只要不违反 CPU 的内存模型,就可以将读写移动到其核心内容。

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

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

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

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

【讨论】:

    【解决方案2】:

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

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

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

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2022-01-14
      • 2020-02-13
      • 2016-07-03
      • 2020-10-02
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2012-11-04
      相关资源
      最近更新 更多