一文看懂C++原子操作(上)

 

原子操作到底是什么?为什么会有原子操作这个概念?C++中怎么去使用原子操作?本文将会一一解答这些问题。

什么是原子操作

   我们知道原子通常是指化学反应中不可分割的最小单位,顾名思义,原子操作 是指在执行过程中不会被中断的操作,它要么执行成功,要么执行失败,不会出现执行一半的情况。

   现在让我们思考一下++--+=-=这些操作是不是原子操作?

   显然这些操作都是要么执行成功,要么执行失败的,所以这些操作都是原子操作。(然而真的是这样吗 :raising_hand:)。 这些操作确实也只需要一个 CPU 指令就可以完成,但绝对不能简单的就把他们认为是原子操作:exclamation:

   这些操作在单核 CPU 上确实是原子操作,但是在多核 CPU 上就不是了,由于每个 CPU 上的每个核都有自己的缓存,当多个核同时对同一个变量进行操作的时候,就会出现数据不一致的情况,这就是所谓的 缓存一致性 问题。下面我们就来聊聊缓存一致性问题吧。

缓存一致性问题

缓存

   对于 CPU 而言,它从寄存器文件中读数据比内存中读取几乎要快 100 倍。随着这些年半导体技术的进步,这种处理器内存之间的差距还在持续增大。当 CPU 试图从内存中读取数据时,CPU 不得不等待几百个时钟周期,这个等待时间对于 CPU 而言是非常漫长的。

  针对这种处理器内存之间的差异,系统设计者采用了更小更快的存储设备,称为 高速缓存存储器(cache memory,简称为 cache 或高速缓存),作为暂时的集结区域,用来存放处理器近期可能会需要的信息。根据程序的空间局部性和时间局部性原理, 缓存命中率 可以达到 70~90% 。因此, 缓存作为CPU内存之间的缓冲,可以大大提高系统的性能

   CPU Cache 通常分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核心越近,访问速度也快,但是存储容量相对就会越小。其中,在多核心的 CPU 里,每个核心都有各自的 L1/L2 Cache,而 L3 Cache 是所有核心共享使用的。

  缓存中的最小存储单元是 缓存行,一般为 64 字节,也就是 8 个 8 字节的数据。当 CPU 从内存中读取数据是按照缓存行为单位进行读取的。

缓存一致性

  缓存一致性问题是指当多个 CPU 同时对同一个变量进行操作的时候,就会出现数据不一致的情况。这里的数据不一致指的是同一个变量在不同的 CPU 中的值不一样。为了保证数据的一致性,CPU 使用了 缓存一致性协议。如 MESI 协议、MSI 协议、MOESI 协议等。由于这些协议的存在,使得原子操作的实现成为可能。

  下面我们看一个例子,我们用两个线程对同一个变量进行自增操作,代码如下:

#include <iostream>
int main() {
	int a = 0;
	std::thread t1([&a]() {
	for (int i = 0; i < 1000000; ++i) {
		++a;
		}
	});
	std::thread t2([&a]() {
	for (int i = 0; i < 1000000; ++i) {
		++a;
		}
	});
	t1.join();
	t2.join();
	std::cout << a << std::endl;
	return 0;
}

  我们在这里使用了 C++11 中的线程库,这里的 std::thread 是一个线程类,它的构造函数接受一个函数对象作为参数,这个函数对象就是我们要在线程中执行的函数。我们在这里使用了一个 lambda 表达式作为函数对象,这个 lambda 表达式中使用 &a 捕获外部变量 a,然后对a自增 100000 次。

  也许你会想到这个程序的输出应该是 2000000,但是实际上这个程序的输出是不确定的,每次运行的结果都不一样,这是因为这里的自增操作不是原子操作,所以会出现数据不一致的情况。下面就该原子操作登场了。

C++中的原子操作

  C++11 中提供了一个 std::atomic 模板类,可以用来包装任意类型的数据,使其操作变为原子操作。我们可以使用 std::atomic 来包装 a,使得 a 的自增操作变为原子操作,代码如下:


#include <iostream>
#include <atomic>
int main() {
	std::atomic<int> a(0);
	std::thread t1([&a]() {
	for (int i = 0; i < 1000000; ++i) {
		++a;
		}
	});
	std::thread t2([&a]() {
	for (int i = 0; i < 1000000; ++i) {
		++a;
		}
	});
	t1.join();
	t2.join();
	std::cout << a << std::endl;
	return 0;
}

  这里我们使用 std::atomic<int> 来包装 a,使得 a 的自增操作变为原子操作。这样我们就可以保证 a 的自增操作是原子的了,这样就不会出现数据不一致的情况了。

  除了 std::atomic 之外,C++11 还提供了一些原子操作的函数,如 std::atomic_loadstd::atomic_storestd::atomic_exchangestd::atomic_compare_exchange 等。这些函数可以用来对 std::atomic 类型的数据进行操作,这些操作在下一章节中会详细介绍。

参考文献

https://hansimov.gitbook.io/csapp/ch01-a-tour-of-computer-systems/1.5 https://www.xiaolincoding.com/os/1_hardware/cpu_mesi.html https://en.wikipedia.org/wiki/Cache_coherence