现在CPU都是多核结构,每个核心都有自己的寄存器,一级缓存,二级缓存,以及共享的三级缓存。如下图,其中一级缓存分为指令缓存IL1和数据缓存DL1,二级缓存L2 256kB,三级缓存 L3 8MB。

从上图可以看出L3比L2大得多,但是L3离核心比较远,访问速度比较慢,L3后面则是与内存相连。当CPU核心要读取内存数据时,需要先从内存读取到L3,再到L2,再到L1,然后读取到寄存器中使用。
因CPU有多个核心,所以可以同时并行多个线程。CPU的每个核心可以有多个ALU(逻辑运算单元),也就是说单个核心内部也可以并行执行指令提高运行速度,而且CPU为进一步加快运行速度,还引入了乱序执行、分支预测等。
在多线程中并行执行就会同时读取或修改数据,这可能会破坏数据。
程序在CPU中执行的顺序是不可确定的,除了因为上面提到的乱序执行、分支预测,还有CPU会对执行指令进行重排,且编译器在编译时也会对指令进行重排,提高运行速度。
指令重排有一个要求:重排后的指令在单线程下执行的结果与重排前一致。 如下3行代码:

代码的执行顺序可以是:2、1、3,但是不能是 2、3、1。
Debug版本程序中不会进行指令重排,程序会按照代码逐行执行。程序在Release版本下才会进行指令重排,如果调试Release版本程序,并不一定完全按照代码顺序执行,特别是ARM处理器(手机上)。Debug版本因为缺少指令优化,执行速度会比Release版本慢很多。
指令重排对单线程没有影响,但多线程影响很大。例如以下代码:

如果writeThread中 1、2 两行代码进行指令重排,执行顺序位2、1,这会导致readThread读取到的data数据为0。
当某个线程修改数据,其首先修改的是线程运行所在核心的L1缓存,因为C++会保证缓存的一致性(cache coherence),数据会自动同步到L2缓存,然后同步到其他核心的L1缓存。但是数据并不会同步到核心的寄存器中,也就是说其他核心还会继续使用修改前的数据。例如以下代码:
- // 需要在Release模式下测试, 代码在VS2022 Release模式上测试readThread会死循环
- int data = 0;
- bool ready(false);
-
- std::thread readThread([&data, &ready]() {
- int n = 0;
- while (!ready) { ++n; /*++n 为防止空循环被过度优化*/ }
- std::cout << n << "Read Thread1: " + std::to_string(data) + "\n";
- });
- std::thread writeThread([&data, &ready]() {
- std::this_thread::sleep_for(std::chrono::microseconds(10)); // 延迟执行
- data = 5;
- std::atomic_thread_fence(std::memory_order_seq_cst); //防止指令重排
- ready = true;
- });
readThread中whlie会出现死循环,因为writeTread修改了ready变量,但readThread所在核心的寄存器变量并没用刷新,ready始终是false。
上面提到多线程的三个问题:数据竞争(Data Race),指令重排,寄存器刷新,内存模型可以用来解决这三个问题。
Data Race解决最简单的办法就是加锁,同mutex对象限制同时只有一个线程可以访问数据。C++ 20 还增加了Semaphores、barrier用于同步线程,这些对象在某些情况下也可也用于解决Data Race问题。
除了加锁还可以使用原子变量 atomic,原子变量可以保证每次只有一个线程操作数据。
当一个线程修改数据后,如果需要另一个线程读取使用最新的数据,可以使用以下方法:
1. 当线程获得锁时,此时会从新读取所有变量,刷新寄存器和缓存。
2. 使用原子操作,其他线程在使用原子变量每次都会重新从缓存中读取,从而刷新寄存器。原子变量有修改时,自动可以读取到最新数据。
3. 内存屏障,C++ 11中提供的函数std::atomic_thread_fence可以相当于设置内存屏障,通过调用这个函数,也可以刷新寄存器和缓存。
4. yield、sleep_for、sleep_until,相当于把线程切换回核心,此时会刷新寄存器和缓存。
mutex可以防止指令重排,mutex.lock可以阻止lock后面的指令重排到lock前面。mutex.unlock可以阻止unlock内部的指令重排到外面。 如下图:

默认情况下原子变量的读写都会阻止原子变量前面的指令重排到后面,也可以阻止后面的指令重排到前面。相当于原子变量读写就是一个屏障,阻止了指令重排时穿过屏障。
如过不使用原子变量可以使用函数std::atomic_thread_fence(std::memory_order_seq_cst),效果相同。
C++ 11 原子操作中引入了Memory Oreder,原子操作函数load、store、fetch_add等函数可以设置一个Memory Order参数,用于控制指令重排和缓存刷新。
Sequenced-before Sequenced代表代码的顺序,Sequenced-before相当于指令的代码在前面。Sequenced-before只在线程内,不可以超出线程。Sequenced-before确定需要考虑运算符优先级,等于号的左右,函数参数调用顺序等等。
Synchronizes with 是多个线程之间一条指令必须在另一条指令前执行。例如生成者线程必须产生数据,消费者线程才能使用数据。产生数据的指令Synchronizes with使用数据的指令。
Happens-before 一个指令在另一个指令前执行,Sequenced-before是Happens-before的单线程形式,Synchronizes with是Happens-before的多线程形式。

借助Atomic的Memory Order操作,可以实现Synchronizes with关系,在线程中实现同步,以及实现无锁编程(Lock Free)。
例如可以通过memory_order_release和memory_order_acquire实现同步生产者消费者,代码如下:
- // 来源:https://en.cppreference.com/w/cpp/atomic/memory_order
- #include
- #include
- #include
- #include
-
- std::atomic
ptr = nullptr; - int data = 0;
-
- void producer()
- {
- std::string* p = new std::string("Hello");
- data = 42;
- ptr.store(p, std::memory_order_release);
- }
-
- void consumer()
- {
- std::string* p2;
- while (!(p2 = ptr.load(std::memory_order_acquire)))
- ;
- assert(*p2 == "Hello"); // never fires
- assert(data == 42); // never fires
- }
-
- int main()
- {
- std::thread t1(producer);
- std::thread t2(consumer);
- t1.join(); t2.join();
- }
参考: