现今的C和C++编译器线程保证是什么?

4
我想知道编译器为确保多线程写入内存在其他线程中可见所做的保证。我知道有无数情况会出现问题,如果您有兴趣回答,我相信您也知道,但请关注我将要提出的情况。更准确地说,我担心可能导致线程错过其他线程执行的内存更新的情况。我在这一点上并不关心更新是否是非原子性的或同步不良的:只要相关线程注意到变化,我就会很高兴。我希望编译器能区分两种变量访问方式:
- 访问必须具有地址的变量; - 访问不一定具有地址的变量。
例如,如果您看下面这段代码片段:
void sleepingbeauty()
{
    int i = 1;
    while (i) sleep(1);
}

自从i是一个局部变量后,我认为编译器可以将其优化掉,让沉睡的美人永远安眠。
void onedaymyprincewillcome(int* i);

void sleepingbeauty()
{
    int i = 1;
    onedaymyprincewillcome(&i);
    while (i) sleep(1);
}

由于i是一个本地变量,但其地址被获取并传递给另一个函数,我认为我的编译器现在会知道它是一个“可寻址”的变量,并生成内存读取以确保也许有一天王子会来。

int i = 1;
void sleepingbeauty()
{
    while (i) sleep(1);
}

由于 i 是全局变量,我假设我的编译器知道该变量有一个地址,并会生成对它的读取而不是缓存该值。

void sleepingbeauty(int* ptr)
{
    *ptr = 1;
    while (*ptr) sleep(1);
}

我希望解引用运算符足够明确,以便我的编译器在每次循环迭代中生成内存读取。

我相当确定这是当前所有C和C++编译器使用的内存访问模型,但我认为没有任何保证。事实上,C++03甚至对线程的存在都视而不见,因此如果考虑标准,这个问题甚至没有意义。不过我不确定C是否也是如此。

是否有一些文件可以指定我是正确还是错误的?我知道这些可能不是基于标准的理由,但这对我来说似乎是一个重要的问题。

除了编译器生成读取之外,我还担心CPU缓存可能会保留过时的值,即使我的编译器尽力产生读写操作,值也从未在线程之间同步。这种情况可能发生吗?


你必须选择C或C++,它们在这方面有所不同。 - Puppy
@DeadMG,如果我没有选择一个,那是因为我不知道有什么区别。如果你能解释一下,那会很有帮助。 - zneak
7个回答

6

访问不一定有地址的变量。

所有变量都必须有地址(从语言的角度来看——编译器可以避免给某些变量分配地址,但这在语言内部是不可见的)。即使空类通常至少有一个 char 的大小以便于创建指向它的指针,这也是一种副作用,使得所有东西都必须是“可指针化”的。

由于 i 是局部变量,但其地址被取出并传递到另一个函数中,我认为我的编译器现在会知道它是一个“可寻址”的变量,并生成对其进行内存读取以确保也许有一天王子会来。

这取决于 onedaymyprincewillcome 的内容。如果编译器希望,它可能会将该函数内联,并且仍然不进行任何内存读取。

由于 i 是全局变量,我认为我的编译器知道该变量具有地址,并会生成对其进行读取的代码。

是的,但实际上是否对其进行读取并不重要。这些读取可能只是在当前本地 CPU 核心的缓存中进行,而实际上并没有返回主内存。你需要像内存屏障这样的东西才能实现此功能,而且没有 C++ 编译器会为你做到这一点。

我希望解引用运算符足够明确,以便我的编译器在每次循环迭代中生成内存读取。

不是必需的。如果该函数被内联,编译器可以完全删除这些内容,如果它愿意的话。

标准中唯一允许您控制线程相关事项的语言特性是 volatile,它只要求编译器生成读取操作。但由于 CPU 缓存问题,这并不意味着值将是一致的——您需要内存屏障来实现这一点。

如果您需要真正的多线程正确性,则将使用某些平台特定的库来生成内存屏障等内容,或者您需要支持 std::atomic 的 C++0x 编译器,后者对变量进行了明确的要求。


1
所有变量都必须有地址吗?不!首先,允许使用取地址符号并不意味着您确实使用了它,而没有被取地址的变量很可能仅存在于CPU寄存器中。其次,并非所有内容都是“可指针化”的(特别是位域不属于此范畴)。 - Ben Voigt
1
@Billy:有一个“好像规则”。编译器只有在地址被使用时才需要为变量分配地址。如果没有被使用,语言允许它不具有地址,只要满足“好像规则”即可。 - Ben Voigt
2
@Billy:所以你真正想说的不是“所有变量都有地址”,而是“如果一个变量没有地址,你永远也不会知道它的存在”? - Ben Voigt
@Ben:是的,没错。(利用这一点的另一个优化示例是空基类优化) - Billy ONeal
如果函数可以内联并且不通知任何外部代理指针,则我们回到了睡美人从未醒来的情况1。为了回答我的问题,我希望我们假设它不能被内联,或将指针传递给全局位置,或启动另一个线程并将其作为参数传递。 - zneak
显示剩余17条评论

2
你的假设是错误的。
void onedaymyprincewillcome(int* i);

void sleepingbeauty()
{
    int i = 1;
    onedaymyprincewillcome(&i);
    while (i) sleep(1);
}

在这段代码中,每次循环时编译器都会从内存中加载i。为什么?并不是因为它认为另一个线程可能会更改它的值,而是因为它认为sleep可能会修改它的值。这与此线程执行的操作有关,与i是否具有地址或必须具有地址无关。
特别地,即使在所有我们使用的平台上都是正确的,也不能保证将int分配给原子性。
如果您的线程程序没有使用适当的同步原语,则会出现太多问题。例如:
char *str = 0;
asynch_get_string(&str);
while (!str)
    sleep(1);
puts(str);

这种做法可能会(在某些平台上)有时打印出一些无用的垃圾并导致程序崩溃。它看起来是安全的,但由于您没有使用适当的同步原语,因此对ptr的更改可能会被您的线程在引用到该指针所指向的内存位置之前看到,即使另一个线程在设置指针之前初始化了字符串。

所以,请不要这样做。而且,volatile也不能解决问题。

总结:基本问题在于编译器仅更改指令的顺序以及加载和存储操作的位置。这还不足以保证线程安全性,因为处理器可以自由地更改加载和存储的顺序,并且加载和存储的顺序在处理器之间不得保留。为确保正确顺序发生,您需要内存屏障。您可以编写汇编代码,或者使用互斥锁/信号量/临界区等,这些方法可以为您完成正确的操作。


我不担心同步问题,我只想确保线程能够收到有关更改的通知。 - zneak
“sleep” 无法修改第一个示例中的本地变量“i”。编译器不会为此生成读取操作。也许对于全局示例,你有一点道理... - Billy ONeal
1
@zneak:没错。"你不用担心同步"本质上就是你所面临的问题,我想要解决那个问题。 - Dietrich Epp
2
@Dietrich:啊...我错过了那个漏洞(读错了)。然而,如果onedaymyprincewillcome被内联,所有的赌注都将失效——没有任何要求编译器在那里生成读取操作,这是我的主要观点。(即使它确实生成了读取操作,由于CPU缓存,这仍然无法解决问题) - Billy ONeal
1
@zneak:我正在处理这个特定的循环。 这里的一个问题是编译器假设没有异步访问,但不会在任意函数(如pthread_mutex_lock)中重新排序逃逸的加载和存储。 另一个问题是处理器可以在函数调用之间重新排序加载和存储,但不能跨越内存屏障(同步原语使用,必要时)。 - Dietrich Epp
显示剩余5条评论

2
虽然 C++98 和 C++03 标准并没有规定编译器必须使用的标准内存模型,但是 C++0x 规定了,你可以在这里阅读相关内容:http://www.hpl.hp.com/personal/Hans_Boehm/misc_slides/c++mm.pdf 对于 C++98 和 C++03,最终取决于编译器和硬件平台。通常情况下,编译器不会为常规写入的代码发出任何内存屏障或围栏操作,除非你使用编译器内置函数或从操作系统的标准库中使用同步功能。大多数互斥锁/信号量实现也包括一个内置的内存屏障操作,以防止 CPU 在互斥锁的锁定和解锁操作之间进行推测读写,并防止编译器跨同一读或写调用重新排序操作。
最后,正如 Billy 在评论中指出的,在 Intel x86 和 x86_64 平台上,任何单字节增量的读或写操作都是原子的,以及将寄存器值读写到 x86 上的任何 4 字节对齐内存位置和 x86_64 上的任何 4 或 8 字节对齐内存位置。在其他平台上,可能不是这种情况,您需要查阅平台的文档。

简短版:读写比指针小的变量是原子操作。其他所有操作都是未定义行为。如果您想要定义某些内容,必须使用std::atomic - Billy ONeal
请参阅您引用的论文第5页,其中解释了即使使用C++0x的内存模型,也不能保证能够完成海报所请求的操作。具体来说,海报希望创建一种操作,其中一个线程读取 i,另一个线程写入它,可能同时进行,这是根据幻灯片中定义的 "数据竞争" 定义而言的。下一张幻灯片解释了仅当您没有数据竞争(按此定义)时,才会有关于交错的实现承诺。它可能在您的结构上工作,也可能不工作,每个人都被x86并发技术宠坏了。 - Dietrich Epp
1
没错,C++0x内存模型并不是为了避免数据竞争而创建的,如果你有意决定创建它们的话。我想我试图表达的观点是,目前线程之间没有关于内存可见性的标准,而使用C++0x后将会有一个标准,但这并不意味着定义的内存模型会防止数据竞争,如果你选择放弃规范中的内存屏障构造的使用,仍然可能发生数据竞争。 - Jason

1

你唯一能控制的优化方式是使用volatile

编译器不能保证并发线程同时访问同一位置。你需要使用某种类型的锁机制。


5
你确定吗?英特尔的线程构建块(Threading Building Blocks)架构师Arch Robinson表示,“volatile”在线程编程中几乎没有用处。 - zneak
1
如果你想编写可移植的 C++ 代码,那么根据规范,volatile 对于线程基本上是无用的。但这取决于编译器;例如,Visual C++ 为带有 volatile 限定符的变量提供获取/释放语义(它超出了规范要求的范围)。 - James McNellis
1
@zneak:我说volatile可以控制优化,而不是帮助线程。Volatile将确保编译器不会将变量缓存到寄存器中。 - Richard Schneider
1
@zneak:仅仅因为它不是很有用,并不会以任何方式削弱@Richard所说的话。他并没有说volatile对于线程是有益的,只是你没有其他选择。在C++0x之前,除了volatile之外,没有任何东西具有任何影响。 - Ben Voigt
1
@zneak:因为这些锁定函数具有每个平台的实现,其中包括必要的内存屏障。 - Ben Voigt
显示剩余9条评论

0

我只能就C语言发表意见,由于同步是CPU实现的功能,因此C程序员需要调用操作系统中包含锁访问的库函数(在Windows NT引擎中为CriticalSection函数),或者实现一些更简单的东西(例如自旋锁)并自己访问该功能。

volatile是在模块级别使用的好属性。有时非静态(公共)变量也可以起作用。

  • 本地(堆栈)变量将无法从其他线程访问,也不应该这样做。
  • 模块级别的变量是多个线程访问的好选择,但需要同步函数才能可预测地工作。

锁是不可避免的,但它们可以更明智地使用,从而导致可以忽略或相当大的性能损失。

我在这里回答了一个类似的问题关于未同步的线程,但我认为您最好浏览类似的主题以获得高质量的答案。


你为什么说"volatile在模块级别使用是一个好的属性"?volatile解决了哪个问题? - zneak
在模块级别意味着它至少可以被模块中的代码访问(如果声明为静态)或者所有代码都可以访问(如果没有声明为静态)。Volatile 让编译器知道变量中的值可能随时发生变化。也就是说,如果一个代码序列从函数中的两个或更多不同位置读取值,编译器不应该(暂时)存储第一次读取的值以替换后续的读取,它必须在每次引用时读取该值。另一种选择是编译器可能会(取决于很多因素)优化除了第一次读取之外的所有读取。 - Olof Forshell
有许多因素会导致编译器无法优化读取(请参见其他答案); Intel Threading Building Blocks的Arch Robisnon表示这也不是必要的 - zneak
然而,如果变量中的值可能随时更改,为什么要冒险通过将变量声明为非volatile来删除读取操作呢? - Olof Forshell
确保读取发生当然是首要任务,但还有其他方法可以实现这一点。这个问题就是关于它们的。volatile的问题在于它不仅确保我们需要的读取存在,而且还确保根本没有读取被删除;对于写入也是如此。 - zneak

0
我写这篇答案是因为大部分的帮助都来自于问题的评论,而不是答案的作者。我已经给那些最有帮助的答案点了赞,并且将这个回答变成了社区维基,以免滥用他人的知识。(如果你想给这个答案点赞,请考虑同时给Billy和Dietrich的答案点赞:他们对我最有帮助。)
当需要从一个线程中写入的值在另一个线程中可见时,有两个问题需要解决:
1. 缓存(从一个CPU写入的值可能永远无法到达另一个CPU); 2. 优化(编译器可能会优化掉对一个变量的读取,如果它觉得它不会被改变)。
第一个问题相当容易解决。在现代英特尔处理器上,有一个缓存一致性的概念,这意味着对缓存的更改会传播到其他CPU缓存中。
原来优化部分也不是太难。只要编译器无法保证函数调用不会改变变量的内容,即使在单线程模型中,它也不会优化读取操作。在我的示例中,编译器不知道 sleep 不能改变 i,这就是为什么每次操作都会发出读取操作。不过不一定非得是 sleep,任何编译器没有实现细节的函数都可以。我想一个特别适合使用的函数是发出内存屏障的函数。
未来,编译器可能会更好地了解当前无法穿透的函数。然而,当那个时候到来时,我期望会有标准的方法来确保更改被正确传播。(这将随着 C++11 和 std::atomic<T> 类一起到来。我不知道 C1x。)

0

我不确定你是否理解你所声称讨论的主题的基础知识。两个线程,每个线程在完全相同的时间开始,并循环一百万次,每次对同一个变量执行一次递增操作,最终值将不会是两百万(两百万次递增)。该值最终会落在一百万到两百万之间。

第一次递增会导致该值从 RAM 读取到访问线程/核心的 L1 缓存中(通过先进入 L3 然后进入 L2)。该增量被执行并将新值首先写入 L1 以进行向下传播。当它到达 L3(两个核心共同的最高缓存)时,内存位置将被废止到另一个核心的缓存中。这似乎是安全的,但与此同时,另一个核心已经根据变量中相同的初始值执行了增量。第一个值的写入无效化将被第二个核心的写入所取代,从而使第一个核心的高速缓存中的数据无效。

听起来是一团糟?确实如此!核心速度非常快,缓存中发生的事情严重落后:核心才是行动的地方。这就是为什么您需要显式锁定:以确保新值在内存层次结构中低到足以使其他核心读取新值并且不会有其他东西。或者换句话说:放慢速度,以便缓存能够跟上核心。

编译器没有“感觉”。编译器基于规则,如果构建正确,将根据规则进行优化,编译器编写者可以构建优化器。如果变量是易失性的,并且代码是多线程的,则规则不会让编译器跳过读取。即使表面上看起来可能相当棘手,其实很简单。

我不得不再次重申,锁不能在编译器中实现,因为它们特定于操作系统。生成的代码将调用所有函数,而不知道它们是否为空、包含锁代码还是将触发核爆炸。同样,代码不会意识到锁正在进行中,因为核心将插入等待状态,直到锁请求导致锁处于就位状态。锁存在于核心和程序员的思想中。代码不应该(也不会!)关心。


我从未提到过两个线程在同一时刻同时写入。问题仅涉及一个线程写入和一个线程读取。我知道你需要同步才能从两个线程中写入。 - zneak
好的,所以让核心编号为二的读取:核心编号为一写入的值仍需要一定时间才能达到使核心编号为二的缓存失效的水平。如果您没有锁定,核心编号为二将假定它具有正确的值,直到其数据失效。这种延迟问题的严重程度决定了您是否需要锁定。 - Olof Forshell
我非常确定它也不是“即时”的。那么是什么原因导致锁定无效缓存数据呢? - zneak
锁定不会使缓存数据失效,写入/更新才会。如果两个核心正在使用相同的数据位置,并且其中一个写入它,另一个核心缓存的位置内容副本将在写线程通过缓存时被标记为无效。来自总线主设备(磁盘控制器、NIC等)的RAM更新也将使涉及的RAM位置失效,前提是核心正在使用它们。 - Olof Forshell

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