一个标准如何将C++从多线程混乱中拯救出来
想象一下:你编写了一个C++多线程程序,它在你的笔记本电脑上运行完美。你将代码部署到服务器——程序崩溃了。或者更糟,它偶尔会给出错误结果,但一个月只发生一次。熟悉吗?在2011年之前,这是常态。C++没有统一的内存模型——一套描述不同线程如何看到数据变化的规则。每个编译器和处理器都各行其是。结果是:混乱、数据竞争以及对多线程的诅咒。
C++11改变了一切。它引入了标准化的内存模型。听起来很枯燥?其实不然。这个模型是安全高效多线程编程的真正基石。在本文中,我们将深入底层:内存模型如何工作,什么是原子操作和内存屏障,以及它们如何影响你的代码。如果你正在使用C++编程,并希望你的程序是可预测的,请继续阅读。
问题:没有内存模型的世界
在C++11之前,C++中的多线程就像狂野西部。你有线程(通过pthreads、Windows API或Boost),但语言标准没有说明它们如何通过内存进行交互。编译器可以为了优化而重新排序指令,处理器可以改变写入缓存的顺序。在单线程代码中,这是安全的:结果不会改变。但在多线程中,这就是灾难。
考虑一个最简单的例子:
// 就绪标志和数据int data = 0;bool ready = false;
// 线程1(生产者)void producer() { data = 42; // (1) ready = true; // (2)}
// 线程2(消费者)void consumer() { while (!ready); // 等待标志 std::cout << data; // 期望输出42}直觉上似乎:如果ready == true,那么data应该已经是42。但如果没有内存模型,编译器可能会交换(1)和(2)的顺序!或者处理器可能会使ready的写入比data的写入更早地对另一个核心可见。结果,消费者会看到ready == true,但读取到data == 0。这是一个调试器无法捕捉到的bug。
程序员们手动与之斗争:插入内存屏障(asm volatile),使用volatile变量(这并不能解决问题),祈祷。C++11终于给出了明确的规则。
什么是C++11内存模型?
内存模型是一套形式化的规则,定义了在什么条件下一个线程保证能看到另一个线程所做的更改。它回答了以下问题:
- 对变量的写入何时对另一个线程可见?
- 来自不同线程的操作可以以什么顺序被观察到?
- 什么是数据竞争(data race)以及如何避免它?
C++11的关键创新是原子操作(std::atomic<T>)和六种内存排序模型(memory_order)。它们让程序员能够控制编译器和处理器如何重新排序操作。
现在,让我们深入探讨其底层工作原理。
原子性和顺序:模型的两大支柱
原子操作
原子操作是一种要么完全执行,要么完全不执行的操作。对于其他线程来说,它是不可分割的。例如,在x86上,读取或写入一个int通常是原子的(如果对齐的话),但++count不是(这是一个读-修改-写操作)。C++11提供了std::atomic<T>,它保证了基本类型的原子性。
#include <atomic>
std::atomic<int> counter(0);
// 原子递增void increment() { counter.fetch_add(1); // 等同于counter++,但是原子的}但原子性只是成功的一半。另一半是
博客
书籍推荐
推荐阅读