先举个栗子

下面的C++代码编译执行后,v的输出是多少呢?每个线程都执行了10000次v++,总共2个线程,那么v的最终结果应该是20000,结果是这样吗?

#include <iostream>       // std::cout
#include <thread>         // std::thread
int v = 0;
void plus()
{
    for(int i=0; i<10000; i++)
    {
        ++v;
    }
}
int main()
{
    std::thread t1(plus);
    std::thread t2(plus);
    t1.join();
    t2.join();
    std::cout << "v: " << v << std::endl;
}

//编译执行指令:g++ -o test test.cpp -lpthread -std=c++11

把代码保存为test.cpp,编译并执行

g++ test.cpp -o test -lpthread -std=c++11

如果使用的是多核机器,每次执行上面的代码,v的值基本都是不一样的,并且是小于20000的。如果使用的是单核机器,不管执行多少次,v的值最终都会等于20000。

为什么在多核机器上,执行结果会不对呢?

CPU缓存(cache)导致数据不一致

CPU的主要任务是从内存中读取指令执行,从内存中读取数据,把数据写入到内存中。随着技术的演进,CPU执行指令的速度越来越快,内存越来越跟不上CPU的脚步了。CPU每次访问内存时都需要浪费很多时间来等待内存响应。工程师们想要避免这种等待内存造成的时间浪费。所以,他们给CPU配置了一个访问速度要远快于内存的小容量存储。这就是我们常说CPU缓存(cache)。CPU每次需要访问数据时,先检查缓存中是否有副本,如果有的话,就直接读写缓存,不再和缓慢的内存打交道。

现代机器使用的基本都是多核CPU,每个核心都有自己专属的缓存,每个缓存分别和内存交互。这就需要保证缓存之间数据的一致性,不能出现一个核心修改了数据A,另外核心却从自己的缓存中读到了A的旧值。否则就会出现上面的例子中v的输出结果错误的问题。

如果每个核心的缓存之间都维护严格的数据一致性,那么每次修改缓存中的某个数据时,其他核心都不能读取或者修改缓存中该数据的值,这样做显然代价是非常巨大的,而实际场景中并不是每个数据都有这样的需求,所以工程师们肯定不会这样去做。

原子操作

原子操作指的是不可分割的操作。

在单核机器上,一个CPU指令能够完成的操作天然就是原子操作,比如MOV、INC等。比如++v是一个INC指令可以完成的操作,这就是一个原子操作。如果一个操作需要多个CPU指令才能够完成,这个操作就不是原子操作,可能会被中断,中断过程中外部可能修改这个操作的数据。

在多核机器上,一个CPU指令能够完成的操作不再是原子操作,因为指令读写的数据在缓存中,无法保证读到的数据是最新的,并且也无法保证指令完成后写入缓存的数据能够被其他核心“看见”。

x86架构中提供了LOCK指令前缀,LOCK前缀保证了指令不会受到其他核心的影响,保持原子性。这个是通过在指令执行时,限制其他核心访问相关数据,保证指令读到的是最新数据,写入数据后能够立刻被其他核心”看见”。

C++中如何实现原子操作

C++提供了std::atomic来实现原子操作。我们修改一下上面的例子。

#include <iostream>       // std::cout
#include <thread>         // std::thread
#include <atomic>         // std::atomic
std::atomic<int> v(0);
void plus()
{
    for(int i=0; i<10000; i++)
    {
        ++v;
    }
}
int main()
{
    std::thread t1(plus);
    std::thread t2(plus);
    t1.join();
    t2.join();
    std::cout << "v: " << v << std::endl;
}

//编译执行指令:g++ -o test test.cpp -lpthread -std=c++11

这里我们把v定义成了原子变量,++v实际上执行的是原子操作v.fetch_add(1),这里保证了任意一个线程对v的修改,对其他线程是立刻可见的。保证了任意一个线程每次读到的v一定是当前的最新值。

编译执行上面的代码,可以发现每次执行,v的输出结果总是20000,没有再出现数据不一致的情况。

如果有同学去查看fetch_add的参数,会发现fetch_add还有一个std::memory_order参数,对大部分人来说,使用默认的值就够了。但是如果追求极致的性能,还需要继续学习一下内存顺序的概念。感兴趣的读者可以看一下这篇文章《内存乱序与C++内存模型详解》

参考文献

原子操作是如何实现的? understanding memory ordering