C ++规范未引用任何特定的编译器,操作系统或CPU。它引用了 abstract machine 抽象机,它是对实际系统的概括。
在语言规范中,程序员的工作是为抽象机编写代码。编译器的工作是在具体机器上实现该代码。通过严格按照规范进行编码,可以确定您的代码可以在不使用兼容C ++编译器的任何系统上进行编译和运行,而无论是现在还是50年后。
C ++ 98 / C ++ 03规范中的抽象机基本上是单线程的。因此,不可能编写相对于规范“完全可移植”的多线程C ++代码。规范甚至没有说关于内存加载和存储的原子性或加载和存储可能发生的顺序的任何事情,不用管互斥锁之类的事情。
当然,您可以在实践中为特定的具体系统(例如pthread或Windows)编写多线程代码。但是没有标准的方法可以为C ++ 98 / C ++ 03编写多线程代码。
C ++ 11中的抽象机在设计上是多线程的。它还具有定义明确的内存模型;也就是说,它说明了在访问内存时编译器可能会做什么,可能不会做什么。
考虑以下示例,其中两个线程同时访问一对全局变量:
线程2可能输出什么?
在C ++ 98 / C ++ 03下,这甚至不是“未定义行为”;这个问题本身是没有意义的,因为该标准并未考虑任何被称为“线程”的东西。
在C ++ 11下,结果是未定义行为,因为加载和存储通常不需要是原子的。看起来似乎并没有太大的改善…就其本身而言,不是。
但是,使用C ++ 11,您可以编写以下代码:
现在事情变得更加有趣了。首先,在此定义行为。现在可以打印线程2 0 0(如果它在线程1之前运行),37 17(如果它在线程1之后运行)或0 17(如果在线程1分配给x之后但分配给y之前运行)。
它无法打印的是37 0,因为C ++ 11中原子加载/存储的默认模式是强制顺序一致性(sequential consistency)。这仅意味着所有加载和存储必须“好像”它们按照您在每个线程中写入它们的顺序发生,而线程之间的操作可以交错,但是系统喜欢。所以原子能的默认行为,同时提供了原子和排序的加载和存储。
现在,在现代CPU上,确保顺序一致性可能很昂贵。特别是,编译器很可能在每次访问之间发出完全成熟的内存屏障( memory barriers )。但是,如果您的算法可以容忍乱序的加载和存储;即,如果它需要原子性但不需要排序;即,如果它可以容忍37 0此程序的输出,则可以编写以下代码:
CPU越现代化,比上一个示例的运行速度越快。
最后,如果只需要按顺序保留特定的loads 和 stores ,则可以编写:
这将我们带回到有序的装载和存储位置。因此37 0不可能再是输出,但这样做的开销却很小。 (在这个简单的示例中,预期于结果的顺序一致性相同;在较大的程序中,结果则可能不是。)
当然,如果要查看的唯一输出是0 0或37 17,则只需在原始代码周围包裹一个互斥体Mutexes 即可。
互斥体很棒,并且C ++ 11对其进行了标准化。但有时出于性能原因,您需要较低级别的语言原语 lower-level Language primitive (例如,经典的双重检查锁定模式 double-checked locking pattern)。新标准提供了诸如互斥锁和条件变量之类的高级小工具,还提供了诸如原子类型和各种不同的内存屏障之类的低级小工具。因此,现在您可以完全使用标准指定的语言编写复杂的高性能并发例程,并且可以确定您的代码可以在当今和未来的系统上编译并保持不变。
坦率地说,除非您是专家并且致力于一些严肃的低级代码,否则您应该坚持使用互斥体和条件变量。那就是我打算做的。