编译器是否可以从全局变量中读取两次,而不是存储一个局部变量

Can a compiler read twice from a global variable, instead of storing a local one?

本文关键字:存储 局部变量 一个 两次 是否 全局变量 读取 编译器      更新时间:2023-10-16

我最近一直在尝试重新熟悉多线程,并找到了这篇论文。其中一个例子说,当使用这样的代码时要小心:

int my_counter = counter; // Read global
int (* my_func) (int);
if (my_counter > my_old_counter) {
... // Consume data
my_func = ...;
... // Do some more consumer work
}
... // Do some other work
if (my_counter > my_old_counter) {
... my_func(...) ...
}

声明:

如果编译器决定需要溢出寄存器在两次测试之间包含我的计数器,它很可能会决定避免存储值(毕竟它只是计数器的副本),并且而是简单地重新读取计数器的值比较涉及我的计数器〔…〕

这样做会将代码变成:

int my_counter = counter; // Read global
int (* my_func) (int);
if (my_counter > my_old_counter) {
... // Consume data
my_func = ...;
... // Do some more consumer work
}
... // Do some other work
my_counter = counter; // Reread global!
if (my_counter > my_old_counter) {
... my_func(...) ...
}

I、 然而,我对此持怀疑态度。我不明白为什么编译器被允许这样做,因为据我所知,只有当试图用任意数量的读取和至少一次写入访问同一内存区时,才会发生数据竞争。作者继续激励:

核心问题源于编译器利用假设变量值不能异步更改显式分配

在我看来,在这种情况下,条件得到了尊重,因为本地变量my_counter从未被访问过两次,其他线程也无法访问。编译器如何知道全局变量不能由另一个线程在另一个翻译单元的其他地方设置?它不能,事实上,我认为第二个if情况实际上会被优化掉。

是作者错了,还是我遗漏了什么?

除非counter明确为volatile,否则如果当前执行范围中没有任何内容可以更改它,编译器可能会认为它永远不会更改。这意味着,如果变量上没有别名,或者其间没有函数调用,编译器无法知道其效果,则任何外部修改都是未定义的行为。使用volatile,您将尽可能地声明外部更改,即使编译器不知道如何声明。

因此,优化是完全有效的。事实上,即使它确实执行了复制,它仍然不会是线程安全的,因为该值可能在读取过程中部分更改,甚至可能完全过时,因为如果没有同步原语或原子,就无法保证缓存一致性。

实际上,在x86上,你不会得到一个整数的中间值,至少只要它是对齐的。这是体系结构的保证之一。过期缓存仍然适用,该值可能已被另一个线程修改。

如果需要此行为,请使用互斥对象或原子。

编译器可以在假设任何"未定义行为"都不可能发生的情况下进行优化:程序员将阻止代码以调用未定义行为的方式执行。

这可能导致相当愚蠢的执行,例如,下面的循环永远不会终止!

int vals[10];
for(int i = 0; i < 11; i++) {
vals[i] = i;
}

这是因为编译器知道vals[10]将是未定义的行为,因此它假设它不可能发生,并且由于它不可能出现,i永远不会超过或等于11,因此这个循环永远不会终止。并不是所有的编译器都会以这种方式积极地优化这样的循环,尽管我知道GCC会这样做。

在您正在处理的特定情况下,以这种方式读取全局变量可能是未定义的行为,前提是另一个线程有可能在此期间对其进行修改。因此,编译器假设跨线程修改永远不会发生(因为这是未定义的行为,编译器可以在假设UB没有发生的情况下进行优化),因此重读该值是完全安全的(它知道该值在自己的代码中不会被修改)。

解决方案是使counter成为原子(std::atomic<int>),这迫使编译器承认可能存在对变量的某种跨线程操作。