C++ 原子变量原子操作笔记

                     

贡献者: int256

   C++ 11 中引入的原子变量(atomic)是在多线程编程中的常用同步机制。它可以避免竟态条件(race condition)和死锁(dead lock)问题。它可以保证对共享变量的操作在执行时不会被其他线程的操作干扰。同时,性能也要高于使用锁的实现。

   举个例子,以下 C++ 代码:

// C++ 11
#include <iostream>
#include <thread>
using namespace std;
int n(0);

void cnt1e4() {
	for(int i(1); i <= 1e4; ++i) ++ n;
}

int main() {
	thread ths[100];
	for(thread &thr: ths) thr = thread(cnt1e4);
	for(thread &thr: ths) thr.join();
	cout << n << endl;
	return 0;
}
理论输出应为 $10^4 \times 100 = 1000000$,而实际输出总小一些,例如在某计算机上运行三次结果分别为:

图
图 1:第一次运行结果
图
图 2:第二次运行结果
图
图 3:第三次运行结果

   这类问题尤其在大量不同线程对于同一变量进行操作时出现,更多的线程数量可能导致更大的误差。以两个线程为例,从计算机对变量的操作的角度来考虑这个问题: 对于某一个线程,电脑此时要对 $n$ 执行 $+1$ 操作,顺序为:

  1. 从主内存中加载 $n$ 的值到线程工作内存;
  2. 执行 $+1$ 操作;
  3. 把第二步的执行结果从工作内存写入到主内存。

   而当有两个线程或是更多个线程的时候,可能会出现以下情况:

  1. 线程 1 从主内存中加载 $n$ 的值 $n_{old}$ 到线程 1 到工作内存;
  2. 线程 2 从主内存中加载 $n$ 的值 $n_{old}$ 到线程 2 到工作内存;
  3. 线程 1 执行 $+1$ 运算得到结果 $n_{old}+1$;
  4. 线程 2 执行 $+1$ 运算得到结果 $n_{old}+1$;
  5. 线程 1 把 $n_{old}+1$ 写入主内存中的 $n$ 变量;
  6. 线程 2 把 $n_{old}+1$ 写入主内存中的 $n$ 变量;

   此时 $n$ 的值本应为 $n_{old} + 2$,实际却是 $n_{old}+1$,当大量不同线程同时对 $n$ 进行类似操作时更容易出现这类问题,所以引入了原子变量与原子操作的方法。这个方法的实现性能要远高于使用锁的实现。

1. 原子变量

   atomic 是一个模板类,可以通过 atomic<_typName> varName; 来声明类型为 _typName 的、变量名为 varName 的原子变量,前提是 _typName 类型合法。

   对于所有的原子变量都有以下四个常用的公开成员函数:

  1. store:原子地以非原子对象替换原子对象的值;
  2. load:原子地获得原子对象的值;
  3. is_lock_free:检查原子对象是否免锁;
  4. compare_exchange_weakcompare_exchange_strong:原子地比较原子对象与非原子参数的值,若相等则进行交换,若不相等则进行加载。

   对于大部分情况下,要对原子变量操作,一般方法是先通过 load 加载原子变量目前存储的值,然后操作后执行 compare_exchange,特别注意,这里不使用 store 函数store 函数是不安全的。

   在一些平台,弱形式的 CAS(Compare And Set)函数,也就是 compare_exchange_weak 函数性能可能更高。两个 CAS 函数均建议使用 do-while 循环进行操作。但是弱形式的函数可能会有 “出乎意料(Unexpected)” 的返回,比如在字段值和期待值一样的时候却返回了 false

   下面通过介绍一些使用 atomic 类型的例子来介绍其用法,并给出一些例程。

2. 自定义类型的原子化

   对于合法的自定义类,例如下面的 counter,可以直接使用模板类声明原子类型。进行操作时使用 CAS 函数,也就是 compare_exchange_strongcompare_exchange_weak 进行保存。

// C++ 11
#include <iostream>
#include <thread>
#include <atomic>
using namespace std;

struct counter {
	int a{};
	int b{};
};

atomic<counter> cnt;

void cnt1e4() {
	for(int i(1); i <= 1e4; ++i) {
		counter desired, expected;
		desired = cnt.load();
		do {
			expected = desired;
			
			// then do some thing...
			expected.a ++;
			expected.b += 2;
			
		} while(! cnt.compare_exchange_strong(desired, expected)) ;
		// while(! cnt.compare_exchange_weak(desired expected)) ;
	}
}

int main() {
	thread ths[100];
	for(thread &thr: ths) thr = thread(cnt1e4);
	for(thread &thr: ths) thr.join();
	cout << cnt.load().a << ' ' << cnt.load().b << endl;
	return 0;
}

对整数类型的特化

   原子变量对整数类型进行了特化,具体的有:

  1. 字符类型 charchar8_t (C++20 起)、char16_tchar32_twchar_t
  2. 标准有符号整数类型:signed charshortintlonglong long
  3. 标准无符号整数类型:unsigned charunsigned shortunsigned intunsigned longunsigned long long

   这些类型可以直接使用 atomic_类型 的方式直接声明变量。 其中,unsigned 的类型加前缀 ulong long 缩写为 llong。 例如:atomic_ullong a;。 对于这些整数变量,提供了专门的 fetch_addfetch_subfetch_or 等函数可以直接进行原子操作。例如:a.fetch_add(3); 可以对原子变量 a 原子地加 $3$ 并赋值给它。

C++ 标准

   关于原子变量,有下列相关的 C++ 标准: C++ 标准在头文件(标头)中提供了以下关于 atomic 的定义:

  1. (C++11 起) template< class T > struct atomic;
  2. (C++11 起) 在标头 <memory> 定义:template< class U > struct atomic<U*>;
  3. (C++20 起) template<class U> struct atomic<std::shared_ptr<U>>;
  4. (C++20 起) 在标头 <stdatomic.h> 定义:template<class U> struct atomic<std::weak_ptr<U>>;

   原子类型要求是满足可复制构造 (CopyConstructible)、可复制赋值 (CopyAssignable) 的可平凡复制 (TriviallyCopyable) 类型。 其中,可复制构造是指该类型的实例可以从左值表达式进行复制构造,可复制赋值是指该类型的实例可以从左值表达式复制赋值,可平凡复制类型有下表的要求。

  1. 至少有一个合格的复制构造函数,移动构造函数,复制赋值运算符或移动赋值运算符;
  2. 每个合格的复制构造函数都是平凡的;
  3. 每个合格的移动构造函数都是平凡的;
  4. 每个合格的复制赋值运算符都是平凡的;
  5. 每个合格的移动赋值运算符都是平凡的;
  6. 有一个未被弃置的平凡析构函数。

   对于以下任意值为 false 的类型,

  1. std::is_trivially_copyable<T>::value
  2. std::is_copy_constructible<T>::value
  3. std::is_move_constructible<T>::value
  4. std::is_copy_assignable<T>::value
  5. std::is_move_assignable<T>::value

   都是不合法的。这些模板类定义在标头文件 <type_traits> 中。


致读者: 小时百科一直以来坚持所有内容免费,这导致我们处于严重的亏损状态。 长此以往很可能会最终导致我们不得不选择大量广告以及内容付费等。 因此,我们请求广大读者热心打赏 ,使网站得以健康发展。 如果看到这条信息的每位读者能慷慨打赏 10 元,我们一个星期内就能脱离亏损, 并保证在接下来的一整年里向所有读者继续免费提供优质内容。 但遗憾的是只有不到 1% 的读者愿意捐款, 他们的付出帮助了 99% 的读者免费获取知识, 我们在此表示感谢。

                     

友情链接: 超理论坛 | ©小时科技 保留一切权利