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

时间:2011-06-22 02:45:30

标签: c++ c multithreading

我想知道编译器有什么保证可以确保对内存的线程写入在其他线程中具有可见效果。

我知道无数的案例存在问题,我相信如果你有兴趣回答你也知道,但请关注我将要呈现的案例。

更确切地说,我担心可能导致线程丢失其他线程完成的内存更新的情况。我不关心(此时)更新是非原子的还是非常同步的:只要相关的线程注意到更改,我就会很高兴。

我希望编译器区分两种变量访问:

  • 访问必须具有地址的变量;
  • 访问不一定有地址的变量。

例如,如果您使用此代码段:

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);
}

我希望dereference运算符足够明确,让我的编译器在每次循环迭代时生成一个内存读取。

我很确定这是生产中每个C和C ++编译器使用的内存访问模型,但我认为没有任何保证。事实上,C ++ 03甚至对线程的存在视而不见,所以这个问题在标准时甚至都没有意义。不过我对C不太确定。

那里有一些文件说明我是对还是错?我知道这些都是泥泞的水域,因为这些可能不符合标准,对我来说这似乎是一个重要的问题。

除了编译器生成读取之外,我还担心CPU缓存在技术上可能会保留过时的值,即使我的编译器尽力实现读写操作,这些值也不会在线程之间同步。这会发生吗?

7 个答案:

答案 0 :(得分:6)

  

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

所有变量都必须具有地址(来自语言的预期 - 允许编译器在可能的情况下避免给出地址,但是在语言内部不可见)。这是一个副作用,一切都必须“可以”,所有东西都有一个地址 - 即使空类的大小通常至少为char,这样就可以为它创建一个指针。

  

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

这取决于onedaymyprincewillcome的内容。编译器可以根据需要内联该函数,但仍然不进行内存读取。

  

由于我是全局的,我假设我的编译器知道该变量有一个地址并将生成读取。

是的,但是如果有读取它真的没关系。这些读取可能只是缓存在当前的本地CPU核心上,而不是实际上一直回到主内存。你需要这样的内存屏障,没有C ++编译器会为你做这件事。

  

我希望dereference运算符足够明确,让我的编译器在每次循环迭代时生成一个内存读取。

不 - 不是必需的。该函数可以内联,这将允许编译器在需要时完全删除这些内容。

标准中唯一允许您控制此类内容的语言功能w.r.t. threading是volatile,它只需要编译器生成读取。这并不意味着价值将是一致的,因为CPU缓存问题 - 你需要内存障碍。

如果你需要真正的多线程正确性,你将会使用一些特定于平台的库来生成内存障碍等等,或者你需要一个支持std::atomic的C ++ 0x编译器, 明确地对变量提出这些要求。

答案 1 :(得分:2)

你认为错了。

void onedaymyprincewillcome(int* i);

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

在此代码中,每次循环时,编译器都会从内存中加载i。为什么? NOT 因为它认为另一个线程可能会改变其值,但因为它认为sleep可以修改其值。它与i是否具有地址或必须具有地址无关,而与此线程执行的可能修改代码的操作有关。

特别是,不能保证分配给int甚至是原子的,尽管在我们现在使用的所有平台上都是如此。

如果您没有为线程程序使用正确的同步原语,则会出现太多问题。例如,

char *str = 0;
asynch_get_string(&str);
while (!str)
    sleep(1);
puts(str);

这可能(甚至会在某些平台上)有时打印出完全垃圾并导致程序崩溃。 看起来是安全的,但由于您没有使用正确的同步原语,因此在更改其引用的内存位置之前,您的线程可能会看到对ptr的更改,即使其他线程在设置指针之前初始化字符串。

所以只是不要,不要,不要做这种事情。不,volatile不是解决办法。

摘要:基本问题是编译器只会更改指令的顺序,以及加载和存储操作的位置。这通常不足以保证线程安全,因为处理器可以自由地改变加载和存储的顺序,并且处理器之间不保留加载和存储的顺序。为了确保以正确的顺序发生事情,您需要内存障碍。您可以自己编写程序集,也可以使用互斥/信号量/临界区/等,这对您来说是正确的。

答案 2 :(得分:2)

虽然C ++ 98和C ++ 03标准没有规定编译器必须使用的标准内存模型,但是C ++ 0x确实如此,你可以在这里阅读它:http://www.hpl.hp.com/personal/Hans_Boehm/misc_slides/c++mm.pdf

最后,对于C ++ 98和C ++ 03,它完全取决于编译器和硬件平台。通常,编译器不会为正常编写的代码发出任何内存屏障或围栏操作,除非您使用编译器内在函数或OS的标准库中的某些内容进行同步。大多数互斥/信号量实现还包括内置的内存屏障操作,以防止CPU对互斥锁上的锁定和解锁操作进行推测性读取和写入,并防止在相同的读取或写入调用中对操作进行任何重新排序由编译器。

最后,正如Billy在评论中指出的那样,在Intel x86和x86_64平台上,单字节增量中的任何读或写操作都是原子的,以及对任何4字节对齐的寄存器值的读或写操作x86上的内存位置以及x86_64上的4或8字节对齐的内存位置。在其他平台上,情况可能并非如此,您必须查阅平台的文档。

答案 3 :(得分:1)

您对优化的唯一控制是volatile

编译器对同时访问同一位置的并发线程进行 NO 保证。您将需要某种类型的锁定机制。

答案 4 :(得分:0)

我只能说C语言,因为同步是一个CPU实现的功能,C程序员需要为操作系统调用一个库函数来访问锁定(Windows NT引擎中的CriticalSection函数)或实现更简单的操作(例如自旋锁)并自己访问功能。

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

  • 本地(堆栈)变量将无法从其他线程访问,不应该是。
  • 模块级别的变量是多线程访问的良好候选者,但需要同步功能才能以可预测的方式工作。

锁是不可避免的,但它们可以或多或少地使用,导致可忽略不计或相当大的性能损失。

我在这里回答了类似的问题concerning unsynchronized threads,但我认为您最好浏览类似的主题以获得高质量的答案。

答案 5 :(得分:0)

我正在写这个答案,因为大多数帮助来自对问题的评论,而不是总是来自答案的作者。我已经提出了最能帮助我的答案,而且我正在制作一个社区维基,不要滥用他人的知识。 (如果你想提出这个答案,也可以考虑提高比利和迪特里希的答案:他们对我来说是最有帮助的作者。)

当从一个线程写入的值需要从另一个线程可见时,有两个问题需要解决:

  • 缓存(从CPU写入的值永远不能进入另一个CPU);
  • 优化(如果感觉无法更改,编译器可以优化读取变量)。

第一个很容易。在现代英特尔处理器上,存在缓存一致性的概念,这意味着对缓存的更改会传播到其他CPU缓存。

原来优化部分也不太难。一旦编译器无法保证函数调用不能改变变量的内容,即使在单线程模型中,它也不会优化读取。在我的示例中,编译器不知道sleep不能更改i,这就是为什么在每次操作时都会发出读取的原因。它不需要是sleep,但编译器没有实现细节的任何函数都可以。我认为一个特别适合使用的功能将是一个发出内存障碍的功能。

将来,编译器可能会更好地了解当前不可穿透的功能。但是,当这个时间到来时,我希望有一些标准方法可以确保正确传播更改。 (这是C ++ 11和std::atomic<T>类的结果。我不知道C1x。)

答案 6 :(得分:0)

我不确定您是否理解您声称要讨论的主题的基础知识。两个线程,每个线程在完全相同的时间开始并循环一百万次,每个线程在同一个变量上执行一个inc将不会导致最终值为200万(两个*一百万个增量)。价值最终将介于一百万到两百万之间。

第一个增量将使值从RAM读入到访问线程/核心的L1(通过L3,然后是L2)缓存中。执行增量并将新值最初写入L1以传播到较低的高速缓存。当它达到L3(两个核心共用的最高缓存)时,内存位置将无效到其他核心的缓存。这似乎是安全的,但与此同时,另一个核心同时根据变量中的相同初始值执行增量。第一个值的写入失效将被第二个核心的写入取代,使第一个核心的高速缓存中的数据无效。

听起来像一团糟?它是!核心速度非常快,以至于缓存中发生的事情落在后面:核心就是行动所在。这就是您需要显式锁定的原因:确保新值在内存层次结构中变得足够低,以便其他内核将读取新值而不是其他内容。或者换一种方式:放慢速度,这样缓存就可以赶上内核。

编译器没有“感觉”。编译器是基于规则的,如果构造正确,将优化到规则允许的范围,编译器编写者能够构造优化器。如果变量是易失性的并且代码是多线程的,则规则将不允许编译器跳过读取。很简单,即使面对它,它也可能看起来非常棘手。

我将不得不重复自己并说锁定无法在编译器中实现,因为它们特定于操作系统。生成的代码将调用所有函数,而不知道它们是否为空,包含锁定代码或将触发核爆炸。以同样的方式,代码将不会知道正在进行锁定,因为核心将插入等待状态,直到锁定请求导致锁定到位。锁是存在于程序员的核心和思想中的东西。代码不应该(也不要!)关心。