解决线程冲突 —— C++11 中的原子操作

在多线程编程中,经常会出现多个线程同时对某一全局变量试图进行修改的情况。比如我们创建了两个线程 $A$ 和 $B$,试图同时将某一全局变量 $n$ 递减 1。由于每个线程执行的语句都仅有一行 n--,如果 $n$ 原来的值为 10,那么最后期望得到的 $n$ 应该为 8。但事实可能并非如此,因为 n-- 这一语句本质上是由数条汇编代码组成的。实际中的执行顺序可能是这样:

  1. 线程 $A$ 将 n = 10 拷贝到 CPU 0 的寄存器;
  2. 线程 $B$ 将 n = 10 拷贝到 CPU 1 的寄存器;
  3. CPU 0 将寄存器中的值递减 1,得到 9;
  4. CPU 1 将寄存器中的值递减 1,得到 9;
  5. CPU 0 将寄存器中的值拷贝回内存中 $n$ 的位置,于是 n = 9
  6. CPU 1 将寄存器中的值拷贝回内存中 $n$ 的位置,于是 n = 9

可以看到,我们期望得到的 n = 8,但最终得到的 $n$ 却为 9。这就是多线程访问全局变量时产生的线程冲突。

例如下面一段代码,我们创建了 10000 个线程,每个线程都对全局变量 cnt 做 10000 次递增操作:

#include <iostream>
#include <stdio.h>
#include <ppl.h>
using namespace std;
using namespace concurrency;

int cnt;

int main() {
clock_t start_time = clock();
parallel_for(0, 10000, [&](int x) {
for (int i = 0; i < 10000; i++)
cnt++;
});
clock_t end_time = clock();
cout << "Result = " << cnt << endl;
cout << "Time usage = " << end_time - start_time << "ms\n";
system("pause");
return 0;
}

这段代码的运行结果为:

Result = 64871110
Time usage = 341ms
Press any key to continue . . .

可以看到,虽然程序执行的速度很快,但是由于线程冲突,计算结果显然是错误的。

老式的解决方案——互斥对象

关于互斥对象的用法请参见 http://www.cplusplus.com/reference/mutex/mutex/

在某一线程准备对 cnt 进行读写时,锁定互斥对象,读写完毕后再解锁互斥对象。

在以上代码的基础上进行修改,加入互斥锁,如下所示:

#include <iostream>
#include <stdio.h>
#include <ppl.h>
using namespace std;
using namespace concurrency;

int cnt;
mutex m;

int main() {
clock_t start_time = clock();
parallel_for(0, 10000, [&](int x) {
for (int i = 0; i < 10000; i++) {
m.lock();//Lock
cnt++;
m.unlock();//Unlock
}
});
clock_t end_time = clock();
cout << "Result = " << cnt << endl;
cout << "Time usage = " << end_time - start_time << "ms\n";
system("pause");
return 0;
}

这段代码的运行结果为:

Result = 100000000
Time usage = 29149ms
Press any key to continue . . .

可以看到,程序输出了正确的结果,但是耗时从原来的 341ms 增加到了现在的 29149ms,相差两个数量级,效率较低。

C++11 解决方案——原子操作

原子(atom)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为”不可被中断的一个或一系列操作” 。

原子操作的定义:

  • 如果这个操作所处的层(layer)的更高层不能发现其内部实现与结构,那么这个操作是一个原子(atomic)操作。
  • 原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分。
  • 将整个操作视作一个整体是原子性的核心特征。(摘自百度百科)

C++11 中的 atomic.h

在新标准 C++11,引入了原子操作的概念,并通过头文件 atomic.h 提供了多种原子操作数据类型,例如 atomic_int, atomic_float等等,如果在多线程中对这些类型的共享资源进行操作,编译器将保证这些操作都是原子性的,即确保任意时刻只有一个线程对这个资源进行访问。它的实现更接近底层,因而效率比互斥对象高。

更多关于 C++ 原子操作的资料请参阅cplusplus

于是我们对之前的代码进行修改,移除互斥对象,将 cnt 的类型修改为 atomic_int,引入 atomic.h 头文件:

#include <iostream>
#include <stdio.h>
#include <ppl.h>
#include <atomic.h>
using namespace std;
using namespace concurrency;

atomic_int cnt = 0;

int main() {
clock_t start_time = clock();
parallel_for(0, 10000, [&](int x) {
for (int i = 0; i < 10000; i++)
cnt++;
});
clock_t end_time = clock();
cout << "Result = " << cnt << endl;
cout << "Time usage = " << end_time - start_time << "ms\n";
system("pause");
return 0;
}

这段代码的运行结果是:

Result = 100000000
Time usage = 9028ms
Press any key to continue . . .

可以看到,这段代码同样得到了正确的结果,但耗时仅有使用互斥对象的代码的四分之一左右。