解引用指针是否总是会导致内存访问?

21
我想知道无论编译器优化程度如何,解引用指针是否总是会被翻译成机器级的Load/Store指令。
假设我们有两个线程,一个(我们称之为Tom)接收用户输入并写入一个bool变量。另一个线程(Jerry)读取该变量来决定是否继续循环。我们知道,优化的编译器在编译循环时可能会将变量存储在寄存器中。因此,在运行时,Jerry可能会读取一个过时的值,与Tom实际写入的值不同。因此,我们应该将bool变量声明为volatile。
然而,如果解引用指针总是会导致内存访问,那么这两个线程可以使用指针引用该变量。每次写操作时,Tom将通过解引用指针并写入新值来将新值存储到内存中。每次读取时,Jerry可以通过解引用相同的指针来真正读取Tom写入的值。这似乎比依赖于具体实现的volatile更好。
我对多线程编程还很陌生,所以这个想法可能看起来很琐碎和不必要。但我真的很好奇。

18
作为结果,我们应该将bool变量声明为volatile。 - 这完全是错误的。volatile并不能使变量具有原子性/线程安全性。您需要使用std::atomicstd::mutex或类似的方法。 - Jesper Juhl
18
因此,我们应该将bool变量声明为volatile是完全错误的。volatile并不能使变量具有原子性或线程安全性。你需要使用std::atomicstd::mutex或类似的方法。 - Jesper Juhl
6
简短回答:不,编译器可以自由地根据as-if规则进行优化,通常可以假设没有多线程。你使用volatile来规避这个问题也已经过时了。自从C++11和C11版本以后,这些语言都包含了支持多线程的内存模型。请使用适当的原语,比如std::atomic来处理这个问题。 - Homer512
6
简短回答:不,编译器可以根据as-if规则自由进行优化,通常可以假设没有多线程。你使用volatile来规避这一点也已经过时了。自从C++11和C11以后,这些语言都引入了包含多线程的内存模型。请使用适当的原语,比如std::atomic来处理这个问题。 - Homer512
4
除非这些操作明确地进行同步(例如通过std::atomicstd::mutex等机制),否则在两个不同的线程上解引用指针会导致未定义的行为。volatile并不引入同步。如果行为是未定义的,根据标准,编译器无论它做什么(省略操作、以某种形式进行同步执行、使主机崩溃),都是正确的。 - Peter
显示剩余8条评论
4个回答

30
指针解引用总会导致内存访问吗?
不,举个例子:
int five() {
    int x = 5;
    int *ptr = &x;
    return *ptr;
}

任何明智的优化编译器都不会在这里从堆栈内存中发出一个mov指令,而是会采取类似以下的操作:
five():
  mov eax, 5
  ret

这是允许的,因为有as-if rule的存在。
如何通过bool*进行线程间通信呢?
这就是std::atomic<bool>的用途。 在C++中,不应该使用非原子对象在线程之间进行通信,因为通过两个线程以冲突方式访问同一内存位置是未定义行为。而std::atomic可以确保线程安全,而volatile则不能。 例如:
void thread(std::atomic<bool> &stop_signal) {
    while (!stop_signal) {
        do_stuff();
    }
}

技术上来说,这并不意味着每次从stop_signal载入的负载都会实际发生。编译器可以进行部分循环展开,如下所示:
void thread(std::atomic<bool> &stop_signal) {
    // only possible if the compiler knows that do_stuff() doesn't modify stop_signal
    while (!stop_signal) {
        do_stuff();
        do_stuff();
        do_stuff();
        do_stuff();
    }
}

原子的 load() 允许观察到陈旧的值,所以编译器可以假设四个 load() 都会读取相同的值。 只有像 fetch_add() 这样的一些操作才需要观察到最新的值。 即便如此,这种优化也可能是可能的。

实际上,在任何编译器中都没有为 std::atomic 实现这样的优化,所以 std::atomic 是准-volatile 的。对于 C 的 atomic_bool_Atomic 类型也是如此。


1) 如果两个内存访问在同一位置发生冲突,那么至少其中一个是写操作,即两个读操作在同一位置不会发生冲突。参见[intro.races]

另请参阅


7
可能有用的观察是,并非每一个在线程之间共享的值都需要使用std::atomic,只要与之相关联的形式存在某种形式的同步。例如,如果你使用std::mutex来保护bool值(可能还包括其他一些值),那么在一个线程上解锁互斥量和在另一个线程上获取互斥量之间的同步将会导致编译器确保进行了所有必要的操作,例如确保处理器缓存是一致的,确保读写不会超过屏障重新排序等。 - Daniel Schepler
7
很可能有用的观察是,并不一定需要对每个在线程之间共享的值都使用std::atomic,只要与之相关的形式的同步存在即可。例如,如果你使用std::mutex来保护bool值(可能还有其他一些变量),那么在一个线程解锁互斥锁并在另一个线程获取互斥锁时进行的同步将使得编译器确保做必要的事情,比如确保处理器缓存是一致的,确保读写不会被重新排序越过屏障等。 - Daniel Schepler
7
可能有用的是观察到,并不一定需要对每个在线程之间共享的值使用std::atomic,只要与之相关的某种形式的同步存在即可。例如,如果您使用std::mutex来保护bool值(可能还有其他一些值),那么在一个线程上解锁互斥量并在另一个线程上获取互斥量之间的同步将导致编译器确保完成所有必要的操作,例如确保处理器缓存一致性,确保读/写不会在屏障之后重新排序等。 - undefined
1
值得一提的是,你可以在stop_signal上使用std::memory_order_relaxed进行加载和存储(参考:为什么使用memory_order_seq_cst设置停止标志,而使用memory_order_relaxed检查它?)。通常,当人们想要避免使用原子操作时,是因为他们认为原子操作速度较慢。但是使用relaxed时,并不会有额外的屏障指令,即使在像ARM这样的弱序ISA上也只是一个纯粹的加载操作(在x86上,acquire/release是“免费”的,seq_cst加载也是如此;不需要额外的屏障指令)。 - Peter Cordes
1
此外,由于问题涉及到volatile - 何时在多线程中使用volatile? - 从来没有必要使用,但在当前的编译器上确实有效,并且在历史上被广泛使用(与内联汇编一起用于内存排序),直到被std::atomic<>所取代。我在那里的回答解释了它在实际实现中如何以及为什么仍然有效,对于那些对于"魔法"std::atomic需要使用什么感到好奇的人来说是很有帮助的。(其实不需要太多,CPU硬件会负责维护缓存一致性) - Peter Cordes
显示剩余10条评论

5

使用指针解引用产生的lvalue的一些方式不会导致访问。例如,给定int arr[5][4]; int *p;语句p = *arr;不会对与arr关联的任何存储进行解引用,而只会使编译器识别赋值右半边的lvalue是一个将衰减为int*int[4]

除此之外,标准试图将所有情况分类为未定义行为,在这些情况下,标准允许实现根据自己的意愿来执行解引用操作,即访问或不访问,并且其决策会明显影响程序行为。

这种哲学在程序使用某些存储来保存类型为T的结构,然后是类型为U的结构,再然后是再次类型为T的结构,并且在没有写入所有字段的情况下复制了T之后,最终使用fwrite输出整个T的副本时,会导致一些相当模糊的边界情况。如果编译器知道原始T中的某个字段已经被写入了某个值,它可能会生成代码将相同的值存储到副本中,而不考虑底层存储是否发生了变化。如果宇宙中没有任何东西关心通过fwrite处理的数据中与该字段相关联的字节所包含的内容,那么这就不会造成问题,并且要求程序员确保在将其作为类型T复制之前,使用该类型写入与T相关联的所有存储,将使程序员和运行程序的计算机都需要额外的无用工作。标准无法描述允许实现在复制T时未能引用T的所有字段的程序行为,而不将程序表征为调用未定义行为。

4

*p 不会导致内存访问的另一个情况是使用 sizeof 运算符:sizeof(*p) 只是确定 p 所指的静态类型的大小。注意,在 C 中,对于可变长度参数,sizeof(*p) 实际上可能需要内存访问,但 VLAs 是 C++ 中的编译器扩展。


4

在处理多线程的时候,明确性是好的。所以我要把每一部分都分解开来。

"解引用指针总是会导致内存访问吗?"

不一定。考虑表达式语句(void)*p*p执行间接引用。根据[expr.unary.op]:

一元*运算符执行间接引用:应用于它的表达式必须是对象类型的指针,或者是函数类型的指针,并且结果是指向该表达式指向的对象或函数的左值引用。

因此,结果是一个左值引用。单独看这个结果并不足以导致对p所指向的数据进行“读取”。在上面的例子中,我明确地丢弃了结果,所以没有理由去读取内存。

当然,有人可能争辩说读取了p的内存。为了追求严谨,我要指出这是对这个词的一种解释。然而,优化编译器可以看出在这里不需要使用p指向的左值,因此实际上根本不需要读取/写入该指针。

现在考虑多线程环境下的情况怎么办呢?这里的关键是[intro.multithread]中的“happens-before”关系。虽然用的是非常干燥正式的语言,但基本思想是,如果事件A在事件B之前发生(在单个线程中),或者A在B之前发生于不同线程中,那么A就发生在B之前。后者是一种高级的法律术语,用于捕捉互斥锁和原子操作等同步原语的行为。
如果A不发生在B之前,且B不发生在A之前,那么这两个事件在彼此之间没有顺序。当你没有像互斥锁这样的东西来强制排序时,在两个线程上会出现这种情况。如果一个事件写入内存位置,而另一个事件读取或写入该地址,结果就是数据竞争。而数据竞争是未定义行为:你得到什么就是什么。规范对此并没有任何说明。它对于这种情况是否触发内存访问或其他行为完全没有提及。
由于在 [intro.multithread] 中规定的规则的影响,编译器可以有效地对其代码进行优化,就好像一个线程完全独立运行一样,除非使用了线程原语(比如互斥锁或原子操作),强制要求不同。这包括所有常见的省略,比如如果不需要,就不从内存中读取数据。

在C++的抽象机器中,(void)*p中的解引用仍然会发生,所以我认为如果其他线程同时写入相同的对象,仍然存在数据竞争的未定义行为。当然,你是正确的,根据as-if规则,可以为实际的机器生成不需要实际加载的汇编代码,但我想知道gcc或clang的-fsanitize=thread是否会生成用于检查这个问题的代码... https://godbolt.org/z/hvG6G1crY - 不,tsan不会检查(void)*p。你必须以某种方式使用该值,或者将其作为volatile解引用。(void)*volatile_ptr会被编译成一个加载操作。 - Peter Cordes
在C++的抽象机器中,(void)*p中的解引用仍然会发生,所以我认为如果其他线程同时写入相同的对象,仍然存在数据竞争的未定义行为。当然,你是正确的,根据as-if规则,可以生成针对实际机器的汇编代码,而不实际加载。但我想知道gcc或clang的-fsanitize=thread是否会生成代码来尝试检查这个问题... https://godbolt.org/z/hvG6G1crY - 不,tsan不会检查(void)*p。你必须以某种方式使用该值,或者将其作为volatile解引用。(void)*volatile_ptr会被编译成一个加载操作。 - Peter Cordes
所以很可能在tsan对剩下的加载/存储进行仪器化之前,会先进行未使用值删除优化。因此,这是一个实现细节,并不意味着(void)*p;中不存在技术上仍然可能存在未定义行为。 - Peter Cordes
@PeterCordes 我一直在考虑这个问题。据我所理解,*p 并不是直接访问或修改指针 p 所指向的内存位置。如果想要进行这样的访问,就需要执行类似复制操作的动作。当然,这会访问到 p 本身,如果指针在改变,那肯定会引发数据竞争。我认为仅仅创建一个引用对象的左值并不会访问该对象。而且 [expr] 表示 (void)X 是一个“废弃值”表达式,只有副作用才会被考虑,值本身会被丢弃。 - Cort Ammon
@PeterCordes 我一直在思考这个问题。根据我所理解,*p 并不会“访问或修改”指针 p 所指向的内存位置。只有在进行复制等操作时才会引起这样的访问。当然,这确实会访问到 p 本身,如果指针在变化,那肯定会形成数据竞争。我认为仅仅创建一个左值引用对象并不会访问它。而且 [expr] 表示 (void)X 是一个“被丢弃值”的表达式,只有副作用被考虑,而值本身被忽略。 - Cort Ammon
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接