首先,你必须学会像语言律师一样思考。
C++ 规范没有提及任何特定的编译器、操作系统或 CPU。它引用了一个抽象机器,它是实际系统的概括。在语言律师的世界里,程序员的工作是为抽象机器编写代码;编译器的工作是在具体机器上实现该代码。通过严格按照规范进行编码,您可以确定您的代码无需修改即可在任何具有兼容 C++ 编译器的系统上编译和运行,无论是现在还是 50 年后。
C++98/C++03规范中的抽象机基本上是单线程的。因此,就规范而言,不可能编写“完全可移植”的多线程 C++ 代码。该规范甚至没有说明内存加载和存储的原子性或加载和存储可能发生的顺序,更不用说诸如互斥锁之类的事情了。
当然,您可以在实践中为特定的具体系统(如 pthread 或 Windows)编写多线程代码。但是没有为 C++98/C++03 编写多线程代码的标准方法。
C++11 中的抽象机在设计上是多线程的。它还有一个定义明确的内存模型;也就是说,它说明了编译器在访问内存时可以做什么和不可以做什么。
考虑以下示例,其中两个线程同时访问一对全局变量:
Global
int x, y;
Thread 1 Thread 2
x = 17; cout << y << " ";
y = 37; cout << x << endl;
线程 2 可能输出什么?
在 C++98/C++03 下,这甚至不是 Undefined Behavior;这个问题本身是没有意义的,因为该标准没有考虑任何称为“线程”的东西。
在 C++11 下,结果是未定义行为,因为加载和存储通常不需要是原子的。这可能看起来并没有太大的改进......而且就其本身而言,它不是。
但是使用 C++11,你可以这样写:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17); cout << y.load() << " ";
y.store(37); cout << x.load() << endl;
现在事情变得更有趣了。首先,定义了这里的行为。线程 2 现在可以打印0 0
(如果它在线程 1 之前运行),37 17
(如果它在线程 1 之后运行),或者0 17
(如果它在线程 1 分配给 x 之后但在它分配给 y 之前运行)。
它不能打印的是37 0
,因为 C++11 中原子加载/存储的默认模式是强制执行顺序一致性。这只是意味着所有加载和存储必须“好像”它们按照您在每个线程中编写它们的顺序发生,而线程之间的操作可以交错,但系统喜欢。因此,原子的默认行为为加载和存储提供了原子性和排序。
现在,在现代 CPU 上,确保顺序一致性可能代价高昂。特别是,编译器可能会在此处的每次访问之间发出完整的内存屏障。但是如果你的算法可以容忍乱序的加载和存储;即,如果它需要原子性但不需要排序;即,如果它可以容忍37 0
这个程序的输出,那么你可以这样写:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_relaxed); cout << y.load(memory_order_relaxed) << " ";
y.store(37,memory_order_relaxed); cout << x.load(memory_order_relaxed) << endl;
CPU 越现代,它就越有可能比前面的示例更快。
最后,如果您只需要按顺序保持特定的加载和存储,您可以编写:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_release); cout << y.load(memory_order_acquire) << " ";
y.store(37,memory_order_release); cout << x.load(memory_order_acquire) << endl;
这将我们带回到有序的加载和存储——因此37 0
不再是可能的输出——但它以最小的开销这样做。(在这个简单的例子中,结果与完整的顺序一致性相同;在更大的程序中,它不会。)
当然,如果您想看到的唯一输出是0 0
or 37 17
,您可以在原始代码周围包裹一个互斥锁。但是如果你已经读到这里,我敢打赌你已经知道它是如何工作的,而且这个答案已经比我预期的要长:-)。
所以,底线。互斥体很棒,C++11 将它们标准化。但有时出于性能原因,您需要较低级别的原语(例如,经典的双重检查锁定模式)。新标准提供了诸如互斥锁和条件变量之类的高级工具,它还提供了诸如原子类型和各种形式的内存屏障之类的低级工具。因此,现在您可以完全使用标准指定的语言编写复杂、高性能的并发例程,并且您可以确定您的代码将在今天和明天的系统上编译和运行不变。
虽然坦率地说,除非您是专家并且正在处理一些严肃的低级代码,否则您可能应该坚持使用互斥锁和条件变量。这就是我打算做的。
有关这些内容的更多信息,请参阅此博客文章。