C 11引入了标准化的内存模型,但究竟是什么意思呢?它将如何影响C编程?
This article(由引用 Herb Sutter 的 Gavin Clarke )说,
内存模型意味着C代码现在有一个标准化的库可以调用,无论是谁编译器以及它运行的是什么平台 . 有一种标准方法可以控制不同线程与处理器内存的对话方式 . “当你谈论在标准中的不同内核之间分割[代码]时,我们讨论的是内存模型 . 我们将优化它,而不会破坏人们将在代码中做出的以下假设,”Sutter说 .
好吧,我可以在网上记住这个和类似的段落(因为我完全理解这一点 .
C程序员以前用于开发多线程应用程序,那么如果它是POSIX线程,Windows线程或C 11线程,它又如何重要?有什么好处?我想了解低级细节 .
我也觉得C 11内存模型与C 11多线程支持有某种关系,因为我经常将这两者结合在一起 . 如果是,究竟是怎么回事?他们为什么要相关?
由于我不知道多线程的内部工作方式以及内存模型的含义,请帮助我理解这些概念 . :-)
6 回答
首先,你必须学会像语言律师那样思考 .
C规范不引用任何特定的编译器,操作系统或CPU . 它引用了一个抽象机器,它是实际系统的概括 . 在语言律师的世界里,程序员的工作就是为抽象机器编写代码;编译器的工作是在具体机器上实现该代码 . 通过严格按照规范进行编码,无论是今天还是50年后,您都可以确定您的代码无需在任何具有兼容C编译器的系统上进行编译和运行 .
C 98 / C 03规范中的抽象机器基本上是单线程的 . 因此,编写与规范相关的"fully portable"的多线程C代码是不可能的 . 该规范甚至没有说明内存加载和存储的原子性或加载和存储可能发生的顺序,更不用说像互斥体这样的东西了 .
当然,您可以在实践中为特定的具体系统编写多线程代码 - 例如pthreads或Windows . 但是没有标准的方法来为C 98 / C 03编写多线程代码 .
C 11中的抽象机器在设计上是多线程的 . 它还有一个明确定义的内存模型;也就是说,它说明了在访问内存时编译器可能会做什么,也可能不会做什么 .
请考虑以下示例,其中两个线程同时访问一对全局变量:
Thread 2的输出可能是什么?
在C 98 / C 03下,这甚至不是未定义的行为;问题本身毫无意义,因为标准没有考虑任何称为"thread"的东西 .
在C 11下,结果是Undefined Behavior,因为加载和存储通常不需要是原子的 . 这可能看起来不是很大的改善......而且它本身并不是 .
但是使用C 11,你可以这样写:
现在事情变得更有趣了 . 首先,定义了此处的行为 . 线程2现在可以打印
0 0
(如果它在线程1之前运行),37 17
(如果它在线程1之后运行),或0 17
(如果它在线程1分配给x但在它分配给y之前运行) .它无法打印的是
37 0
,因为C 11中原子加载/存储的默认模式是强制执行顺序一致性 . 这只是意味着所有加载和存储必须按照您在每个线程中编写它们的顺序发生,而线程之间的操作可以交错,但系统喜欢 . 因此,atomics的默认行为为加载和存储提供了原子性和排序 .现在,在现代CPU上,确保顺序一致性可能很昂贵 . 特别是,编译器可能会在每次访问之间发出完整的内存屏障 . 但是,如果您的算法可以容忍无序的加载和存储;即,如果它需要原子性而不是订购;即,如果它可以容忍
37 0
作为该程序的输出,那么你可以这样写:CPU越现代,就越有可能比前一个例子更快 .
最后,如果您只需要按顺序保持特定的加载和存储,您可以编写:
这将我们带回有序的加载和存储 - 因此
37 0
不再是可能的输出 - 但它以最小的开销实现了这一点 . (在这个简单的例子中,结果与完整的顺序一致性相同;在较大的程序中,它不会 . )当然,如果你想看到的唯一输出是
0 0
或37 17
,你可以只用一个互斥量包裹原始代码 . 但是如果你已经阅读过这篇文章,我打赌你已经知道它是如何工作的,而且这个答案已经超出了我的预期:-) .所以,底线 . 互斥体很棒,C 11标准化它们 . 但有时出于性能原因,您需要较低级别的原语(例如,经典double-checked locking pattern) . 新标准提供了高级小工具,如互斥锁和条件变量,还提供了原子类型和各种内存屏障等低级小工具 . 因此,现在您可以完全使用标准指定的语言编写复杂的高性能并发例程,并且您可以确定您的代码将在今天's systems and tomorrow'上编译并运行不变 .
虽然坦率地说,除非您是专家并且正在处理一些严重的低级代码,否则您应该坚持使用互斥锁和条件变量 . 这就是我打算做的事情 .
有关这些内容的更多信息,请参阅this blog post .
我将简单地给出我理解的内存一致性模型(或简称内存模型)的类比 . 它的灵感来自Leslie Lamport的开创性论文"Time, Clocks, and the Ordering of Events in a Distributed System" . 这个比喻很贴切,具有根本意义,但对许多人来说可能有些过分 . 但是,我希望它提供一个心理图像(图形表示),便于推理内存一致性模型 .
让我们在时空图中查看所有存储器位置的历史,其中横轴表示地址空间(即,每个存储器位置由该轴上的点表示),纵轴表示时间(我们将看到,一般来说,没有一个普遍的时间概念) . 因此,每个存储器位置所保持的值的历史由该存储器地址处的垂直列表示 . 每个值更改都是由于其中一个线程将新值写入该位置 . 通过 memory image ,我们将表示 a particular thread 可观察到的所有内存位置的值的聚合/组合 at a particular time .
引自"A Primer on Memory Consistency and Cache Coherence"
该全局存储器顺序可以从程序的一次运行到另一次运行而变化,并且可能事先不知道 . SC的特征是地址空间 - 时间图中的水平切片集合,表示 planes of simultaneity (即,存储器图像) . 在给定的平面上,其所有事件(或内存值)都是同时的 . 有一个绝对时间的概念,其中所有线程都同意哪些内存值是同时的 . 在SC中,在每个时刻,所有线程只共享一个内存映像 . 也就是说,在每个时刻,所有处理器都同意存储器图像(即,存储器的聚合内容) . 这不仅意味着所有线程都查看所有内存位置的相同值序列,而且所有处理器都观察到所有变量的值的相同组合 . 这与所有线程以相同的总顺序观察所有内存操作(在所有内存位置上)相同 .
在宽松的内存模型中,每个线程都会以自己的方式切换地址空间时间,唯一的限制是每个线程的切片不会相互交叉,因为所有线程必须就每个内存位置的历史达成一致(当然,不同线程的切片可以并且将会彼此交叉) . 没有通用的方法来对其进行分割(没有特权的地址空间时间) . 切片不必是平面的(或线性的) . 它们可以是弯曲的,这可以使线程读取由另一个线程写入的值而不是它们被写入的顺序 . 不同存储器位置的历史可以相对于彼此任意地滑动(或拉伸) when viewed by any particular thread . 每个线程将具有不同的感知,即哪些事件(或等效地,存储器值)是同时的 . 与一个线程同时发生的事件(或内存值)集与另一个线程不同时发生 . 因此,在放松的存储器模型中,所有线程仍然观察每个存储器位置的相同历史(即,值序列) . 但是它们可以观察到不同的存储器图像(即,所有存储器位置的值的组合) . 即使两个不同的存储器位置按顺序由相同的线程写入,也可以由其他线程以不同的顺序观察到两个新写入的值 .
[来自维基百科的图片]
熟悉爱因斯坦 Special Theory of Relativity 的读者会注意到我所指的是什么 . 将Minkowski的单词翻译成内存模型领域:地址空间和时间是地址空间时间的阴影 . 在这种情况下,每个观察者(即线程)会将事件的阴影(即内存存储/加载)投影到他自己的世界线(即他的时间轴)和他自己的同时平面(他的地址空间轴)上 . . C 11中的主题内存模型对应于在狭义相对论中相对移动的 observers . 顺序一致性对应于 Galilean space-time (即,所有观察者都同意事件的一个绝对顺序和全局同时性意义) .
记忆模型和狭义相对论之间的相似性源于两者都定义了一组部分有序的事件,通常称为因果集 . 某些事件(即内存存储)可能会影响(但不会受到其他事件的影响) . C 11线程(或物理学中的观察者)不过是一个链(即,完全有序的集合)事件(例如,存储器加载并存储到可能不同的地址) .
在相对论中,一些秩序被恢复到看似混乱的部分有序事件的图像,因为所有观察者都同意的唯一时间顺序是“时间”事件之间的排序(即,那些原则上可由任何粒子变得更慢的事件可以连接的事件而不是真空中的光速) . 只有时间相关的事件才是不变的 . Time in Physics, Craig Callender .
在C 11内存模型中,使用类似的机制(获取 - 释放一致性模型)来 Build 这些 local causality relations .
为了提供内存一致性的定义和放弃SC的动机,我将引用"A Primer on Memory Consistency and Cache Coherence"
因为缓存一致性和内存一致性有时会混淆,所以引用这个引用也是有益的:
继续我们的心理图像,SWMR不变量对应于物理要求,即任何一个位置最多只有一个粒子,但任何位置都可以有无限数量的观察者 .
这意味着标准现在定义了多线程,它定义了多线程上下文中发生的事情 . 当然,人们使用不同的实现,但这就像问我们为什么我们应该有
std::string
时我们都可以使用自制的string
类 .当你're talking about POSIX threads or Windows threads, then this is a bit of an illusion as actually you'谈论x86线程时,因为's a hardware function to run concurrently. The C++0x memory model makes guarantees, whether you'在x86,或ARM,或MIPS,或者其他任何你可以提出的 .
如果您使用互斥锁来保护所有数据,那么您真的不必担心 . 互斥锁始终提供足够的订购和可视性保证 .
现在,如果你使用原子或无锁算法,你需要考虑内存模型 . 存储器模型精确地描述了原子提供排序和可见性保证的时间,并为手动编码保证提供了便携式栅栏 .
以前,原子会使用编译器内在函数或一些更高级别的库来完成 . Fences将使用CPU特定指令(内存屏障)完成 .
对于未指定内存模型的语言,您将编写语言代码和处理器体系结构指定的内存模型 . 处理器可以选择重新排序存储器访问以获得性能 . 所以, if your program has data races (数据竞争就是多个核心/超线程可能同时访问同一个内存),那么你的程序不是跨平台的,因为它依赖于处理器内存模型 . 您可以参考英特尔或AMD软件手册,了解处理器如何重新排序内存访问 .
非常重要的是,锁(以及带锁定的并发语义)通常以跨平台的方式实现......因此,如果您在没有数据竞争的多线程程序中使用标准锁,那么您 don't have to worry about cross platform memory models .
有趣的是,用于C的Microsoft编译器具有针对volatile的获取/释放语义,这是C扩展来处理C http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs.80).aspx中缺少的内存模型 . 但是,鉴于Windows仅在x86 / x64上运行,这并不是很多(英特尔和AMD内存模型使得在一种语言中实现获取/发布语义变得简单而有效) .
这是一个多年前的问题,但是非常受欢迎,值得一提的是了解C 11内存模型的绝佳资源 . 我认为总结他的演讲是没有意义的,以便使这又一个完整的答案,但鉴于这是实际编写标准的人,我认为值得观看谈话 .
Herb Sutter有一个长达3个小时的关于C11内存模型的讨论,名为"atomic<> Weapons",可在Channel9网站上找到 - part 1和part 2 . 这个讲座非常技术性,涵盖以下主题:
优化,种族和记忆模型
订购 - 内容:获取和发布
订购 - 如何:互斥锁,原子和/或栅栏
编译器和硬件的其他限制
代码生成和性能:x86 / x64,IA64,POWER,ARM
轻松的原子学
谈话没有详细说明API,而是关于推理,背景,幕后和幕后(您是否知道轻松的语义被添加到标准中只是因为POWER和ARM不能有效地支持同步加载?) .