参考
为什么要写这篇文章
项目需求,需要实现 lock-free 的并行写文件。
深入理解内存模型是实现高性能并行程序的基本,因此需要将 C++11 的 atomic 相关内容细细研读。
网上看了不少相关资料,大部分给我的感觉是,偏深偏难不实用。不适合我这种菜鸡看。于是只好自己动手写一篇。
为什么需要内存模型
先进一段代码
#include <thread>
#include <vector>
int main(int argc, char const *argv[]) {
int a = 1;
int b = 100;
std::vector<std::thread> threads;
threads.emplace_back([&](){a = 2; printf("b = %d\n", b);});
threads.emplace_back([&](){b = 200; printf("a = %d\n", a);});
for (auto& t : threads) {
t.join();
}
return 0;
}
猜猜这段代码的输出是什么?有可能存在 a = 1, b = 100
的输出吗?
乱序执行
首先需要推翻的一个观点是,单个 CPU 只能串行执行指令。
现代cpu都采用流水线结构,流水线的各级可以同时执行不同的指令,也只有用多条指令将流水线填满以后,cpu的能力才能得到充分发挥。
编译器和 CPU 都会对你的代码进行优化,为了实现更好的性能。例如,有如下操作:
B = func(3) // 1
A = B+1 // 2
C = 7 // 3
注意到,语句 2 依赖于语句 1 的结果,而语句 3 是独立的。
假设目前有 1 个 CPU 正在处理这段代码,那么它在等待获取 B 的值的时候其实空闲流水线可以先处理语句 3 的代码。这就是为什么需要乱序优化。
乱序优化也是有原则的,那就是保证在单核情况下运行的效果是不变的。
乱序导致的问题
但是,现在已经进入了多核时代,于是乱序就会导致问题。例如:
伪代码如下所示,能否预测 Q 和 D 最终结果是多少?
A := 1
B := 2
C := 3
P := &A
Q := &C
// CPU 1
B = 4
P = &B
// CPU 2
Q = P
D = *Q
这个例子中,CPU2 要执行的指令有明显的依赖关系,所以顺序不会改变。因为 D=*Q
依赖于 Q 指针。所以需要先执行 Q=P
。
CPU1 要执行的指令看起来似乎也有依赖关系,但实际是没有。因为改变 B 的值不会改变 B 的地址。也就是说,倒序执行,最终 B 和 P 中的值是一样的。所以 CPU1 在这里不一定会按照顺序执行。
可能出现以下情况,第三种情况比较特殊。
- CPU1 还未执行
P=&B
,CPU 2 执行结束
此时应该有 Q = &A, D = 1 - CPU1 执行了
P=&B
,CPU2 执行结束
这时候必定已经执行了B=4
,因此有 Q = &B, D = 4 - CPU1 乱序执行,先执行了
P=&B
,CPU2 执行结束,但是还未执行B=4
此时会有 Q=&B, D = 2
如何解决
如上面例子所述,在多核系统上,如果不施加任何限制,当多个线程同时读写共享的变量的时候,一个线程可能观察到值的变化于另一个线程写的顺序不同。
要解决这个问题,我们需要定义内存的访问顺序。
四种常用内存模型
我们将重点介绍 release-acquire 模型,它是实现 lock-free 编程的重点。
std::memory_order_relaxed
最宽松的内存模型,效率也最高。实际上它不属于同步操作,因为它不对内存访问做出任何顺序限制。仅仅保证操作的原子性。
一般用于多线程计数器。例如
std::shared_ptr
的引用计数就是利用这个实现的。
std::memory_order_release 和 std::memory_order_acquire
这两者是需要搭配使用的。构成一种 release-acquire 模型。
std::memory_order_acquire
这是读操作 (load) 时可以指定的内存顺序。对作用的内存区域产生效果:
- 在这次 load 之前当前线程的读写不允许乱序。
- release 同一原子量的线程中的写操作在当前线程可见。
std::memory_order_release
这是写操作 (store) 时可以指定的内存顺序。对作用的内存区域产生如下效果:
- 在这次 store 之后当前线程的读写不允许乱序。
- acquire 同一原子量的线程可以看到当前线程的所有写操作。
用人话来讲就是,在两个线程中建立了同步关系(synchronize-with),在 release 之前发生的所有事,在 acquire 之后都是可见的。
release-acquire下面是一个利用原子操作来解决 Double-Checked Locking 线程不安全问题的例子。
std::atomic<Singleton*> Singleton::m_instance;
std::mutex Singleton::m_mutex;
Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance.load(std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard<std::mutex> lock(m_mutex);
tmp = m_instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton;
m_instance.store(tmp, std::memory_order_release);
}
}
return tmp;
}
如果不增加这个原子操作,会出现以下问题:
- A 线程调用,发现还未构造,于是获取锁开始进行构造。
-
tmp = new Singleton
其实是分为两步,第一步分配内存,第二步构造对象。分配内存后 tmp 指针就已经不是空了。但是还没执行构造函数。 - B 线程刚好此时插入,检查 tmp 非空,于是直接返回了一个没有构造完成的对象。
因此必须要使用原子操作来同步。在新建实例成功后再 release,则 acquire 的操作时必然可见的是一个构造好了的对象。
mutex 和 spinlock 都是它的典型应用。
典型的 spinlock 实现:
std::atomic_flag spinlock = ATOMIC_FLAG_INIT;
// lock
while (spinlock.test_and_set(std::memory_order_acquire)) {
}
// critical area
// unlock
spinlock.clear(std::memory_order_release);
由于在上锁之前, acquire 特性保证了不可乱序,而解锁之后,release 特性又保证了不可乱序,中间则只能有一个线程执行,因为只有一个线程能获得锁,因此乱序也无妨。所以一定是安全的。
std::memory_order_release 和 std:: memory_order_consume
不建议使用。
std::memory_order_seq_cst
顺序一致模型(sequence-consistent)。任何操作都同时是 acquire 操作和 release 操作。在所有线程上都观察到改变是同一顺序的(与作出修改的线程一致)。这是默认的模型,如果不为原子操作指定参数,则就采用这个模型。性能最差,但是符合逻辑。
网友评论