线程间内存访问同步的问题
先看一小段程序
#include <chrono>
#include <iostream>
#include <thread>
#define USE_ATOMIC 1
const int test_times = 100;
#if USE_ATOMIC
#include <atomic>
std::atomic<int> count{0};
#else
int count = 0;
#endif
void increase(int num) {
for (int i = 0; i < num; i++) {
#if USE_ATOMIC
std::this_thread::sleep_for(std::chrono::milliseconds(1));
count.fetch_add(1);
#else
++count;
#endif
}
}
int main() {
for(int i = 0; i < test_times ; i++) {
#if USE_ATOMIC
count.store(0);
#else
count = 0;
#endif
std::thread t1(increase, 500);
std::thread t2(increase, 500);
std::thread t3(increase, 500);
std::thread t4(increase, 500);
t1.join();
t2.join();
t3.join();
t4.join();
#if USE_ATOMIC
std::cout << "count.load() = " << count.load() << std::endl;
if (count.load() != 2000) {
#else
if (count != 2000) {
#endif
std::cout << "i: " << i << " count :" << count << std::endl;
break;
}
}
return 0;
}
USE_ATOMIC 1 程序运行完后,直接退出:说明每次创建的t1,t2,t3,t4四个线程都能把原子变量std::atomic count,增加到2000,程序执行100次,一次失误都没有
USE_ATOMIC 0 程序输出 i: 0 count :1298:说明没有使用原子变量的情况下,i的值是0说明程序只跑了一次,就出了问题,count并没有通过t1,t2,t3,t4四个线程增加到2000。
通过这个例子只想说明,在多线程的程序,对变量的访问存在内存同步的问题(t1,t2,t3,t4四个线程对普通变量 int count 的修改,并没有很好的同步给彼此)。
怎么理解这种现象呢?---- 需要理解多核cpu的存储体系结构。
理解cpu的存储体系结构
cpu存储架构
cpu的速度非常快,内存较慢,为了解决cpu与内存之间速度不匹配的问题,在cpu 与内存之前加了很多级缓存。
类似于写磁盘的操作,写之前也会先写到cache buffer,然后再由cache buffer写入磁盘。
上图是一个双核的cpu
锁读内存相关的总线: cache line (缓存行,大小为64字节,在不同架构和系统中可能会有所变化)
cache L1,L2 是cpu每个核心独享的, L3是所有核心共享的cache
cpu访问内存的顺序如下:
L1---> L2 ---> L3 ----> 主存(内存)
先在L1中找,没有找到,去L2找,没有找到去L3找,没有找到去主存找,读到数据之后,
又会反过来写入到缓冲中去,等一下次再需要读数据的时候,就可以直接从缓存中取了。
但是缓存的都是比较小的,里面会不停的有数据的进入与淘汰,谁负责这些呢?MESI协议,LRU策略等。
cache line
cache line, cpu从缓存中读数据的基本单元
flag| tag | data: flag判断缓存是否失效 (存MESI 的状态);tag:数据存在哪个地方; data是数据48byte
了解一下写回策略 write-back
cpu要写一个数据,先根据tag, 判断缓存是否命中
1 命中直接写,标记脏数据(直接写缓存)
2 没有命中,缓存里没有值:先在缓存中定位一个缓存块cache line
a 脏数据(数据还没有写到内存里), 把数据写回内存(通过LRU策略淘汰掉的数据才写回内存)
b ==数据尽量停留在缓存里==,数据在缓存里被淘汰了,才写回内存
c 内存与缓存里的数据一样,不标记脏数据
==数据尽量停留在缓存里== 这是核心思想,只有这样cpu取数据、指令会快一些
多线程运行在cpu的多核之中,数据怎么共享,怎么同步?
通过事件串行化
假如有t1,t2两个线程对i进行写,t3对i进行读,t1,t2写完的顺序不一样,就会导致t3读到数据可能与最终的结果不一致。
这时可以通过锁指令,分别对t1,t2上锁,确保t3读到正确的值。
但是如果每一次,都广播给其它的核心,代价较大,有可能浪费带宽(不是所有的核心都需要i),这里引入MESI来解决问题。
通过MESI
MESI是一种缓存一致性协议,用于在多处理器系统中保持数据的一致性。它定义了四种状态:Modified(修改),Exclusive(独占),Shared(共享)和Invalid(无效)。当一个处理器访问某个内存位置时,它将读取该位置的值,并将其保存在缓存中。此时,该缓存行的状态被标记为“独占”,表示该处理器是唯一拥有该数据的。如果另一个处理器需要访问相同的内存位置,则必须通过总线请求缓存行。如果该缓存行的状态为“共享”,则该处理器可以直接从缓存中读取数据,而不需要访问主内存。如果该缓存行被标记为“修改”状态,则表示当前处理器已经对其进行了修改,并且需要向其他处理器发送通知,以便它们更新自己的副本或者使其失效。
使用MESI协议可以确保多个处理器之间共享数据时,各自拥有最新版本,并且避免了数据冲突和不一致性问题。
原子操作
1 对于单处理,单核心的机器,一个操作(三条指令)要保持它的原子性,就是确保这三条指令执行的时候不被打断就行。
在执行这三条指令之前屏蔽掉中断,执行完,恢复中断即可。
2 对于多处理器多核心的机器(不同处理间有高速互联总线HSIB ),确保1的同时还需要做到:
以往的处理方式是锁住HSIB,确保原子操作的时候,两个处理器之前不交换数据
现在是通过lock指令,阻止其他核心对相关内存空间的访问
CAS锁
CAS锁(Compare-and-Swap Lock)是一种乐观锁,也被称为无阻塞算法。它基于CPU提供的原子指令实现,用于解决并发环境下对共享资源的竞争访问问题。
CAS锁操作包括三个参数:内存位置V、期望值A和新值B。当V中的值等于A时,将V中的值修改为B,并返回true;否则不修改V中的值,并返回false。通过多次调用CAS操作,可以实现并发环境下对某个共享变量进行安全地读写。
使用CAS锁需要注意以下几点:
1 CAS操作是原子性的,但不能保证在高并发环境下绝对安全;
2 在使用CAS锁时,需要确保所有线程都使用相同的期望值A;
3 如果期望值A与当前内存位置V中的值不匹配,则需要重新尝试操作直到成功。
常用到的方法
compare_exchange_strong ------------ 会阻塞cpu, 会慢一些
compare_exchange_weak --------------- 有可能失败,性能高, 可以加while直到它成功
文章参考与<零声教育>的C/C++linux服务期高级架构系统教程学习:链接