摘要
多线程并发需要我们有一个代码执行粒度的考虑,比如一个简单的 int * pCount = new int(0);
程序运行时的汇编语句可能有三句,先new内存空间,再调用int的构造,最后把地址返回pCount。这个顺序随编译器的优化可能会变,也就是先返回了地址,后进行的构造。
当其他线程依据指针为非空判定为对象已经创建完成,而实际上对象的构造还没有被执行,这就为我们的程序埋下了重磅隐患。
由此引出了一个需要重点学习的知识点,就是计算机cpu与内存相关的代码是按什么顺序执行的?因为CPU读写内存是跑的固定代码,所以这个执行顺序是编译器指定好的。也就是我们要站在编译器的角度进行思考为啥执行顺序是这样的!
。
直接看下面这个代码
// EvaluationOrder_Atomic.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//
#include <vector>
#include <iostream>
#include <thread>
#include <atomic>
int cnt_2 = 0;
void f()
{
for (int n = 0; n < 1000; ++n) {
cnt_2++;
}
}
int main()
{
std::vector<std::thread> v;
for (int n = 0; n < 10; ++n) {
v.emplace_back(f);
}
for (auto& t : v) {
t.join();
}
std::cout << "Final counter value cnt_2 is " << cnt_2 << '\n';
}
启动十个线程,每个线程对一个全局变量进行++操作1000次,最后的执行结果应该是1000*10等于10000吧?然鹅实际的输出如下图9856。产生这个结果的原因是多线程下,++操作不是原子的,有道面试题是:
“
两个线程对全局变量i,跑i++100次,结果最大值是多少?最小值是多少
”
,搞懂这个++操作的理解就到位了。
再看下图,我把++操作的汇编语言截图了,
- 第一步是先mov完成从内存到寄存器的工作
- 第二步是寄存器加1
- 第三步是寄存器回写内存
So,如何保证i++操作多线程下的原子性呢?
答案是对内存加“临界区”,如下的汇编代码就是对加加操作的变量加了锁,别的线程访问这个内存会被挂起。这样就保证了在写这个内存的时候,其他线程不会读到脏数据(无效数据,为啥无效?因为读的时候正在写)。
//这个代码是++操作的线程安全版本!
int* pcnt_2 = NULL;
pcnt_2 = &cnt_2;
__asm
{
mov ecx, pcnt_2;
mov eax, 1;
lock xadd[ecx], eax; //加
inc eax;
}
那么诸如此简单的指令我们可以通过汇编很容易实现,那复杂一些的怎么办?这就引出了本文的主角,C++新支持的特性原子操作<atomic>
与内存序"memory_order"
。
正文
紧接着上面汇编实现多线程++i操作的安全方案,这里给出c++保证多线程操作安全的方案。
typedef enum memory_order {
memory_order_relaxed, // 不对执行顺序做保证,只保证此操作是原子的
memory_order_acquire, // 本线程中,所有后续的读操作必须在本条原子操作完成后执行
memory_order_release, // 本线程中,所有之前的写操作完成后才能执行本条原子操作
memory_order_acq_rel, // 同时包含 memory_order_acquire 和 memory_order_release
memory_order_consume, // 本线程中,所有后续的有关本原子类型的操作,必须在本条原子操作完成之后执行
memory_order_seq_cst // 全部存取都按顺序执行
} memory_order;
c++ 保证++操作的原子性
std::atomic<int> cnt = { 0 };
void f()
{
for (int n = 0; n < 1000; ++n)
{
cnt.fetch_add(1, std::memory_order_relaxed);//保证操作的原子性,即相关的汇编指令不会被打断
}
}