理解原子操作与CAS锁

简介: 理解原子操作与CAS锁

@TOC

线程间内存访问同步的问题

先看一小段程序

#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服务期高级架构系统教程学习:链接

相关文章
|
6天前
|
应用服务中间件 Linux 调度
锁和原子操作CAS的底层实现
锁和原子操作CAS的底层实现
21 0
|
6天前
【原子操作】顺序操作
【原子操作】顺序操作
|
6月前
|
存储 编译器 API
锁与原子操作CAS
锁与原子操作CAS
100 0
|
5天前
|
算法 调度 数据安全/隐私保护
什么是CAS锁
什么是CAS锁
20 0
|
6天前
|
算法
原子操作CAS
原子操作CAS
21 0
|
6天前
基于CAS实现自旋锁
基于CAS实现自旋锁
20 0
|
6天前
|
缓存 Linux API
原子操作CAS与锁实现
原子操作CAS与锁实现
|
6天前
|
存储 安全 中间件
锁与原子操作CAS的底层实现
锁与原子操作CAS的底层实现
|
6月前
互斥锁、自旋锁、原子操作
互斥锁、自旋锁、原子操作
|
9月前
|
Go
Gomutex的原子操作
要对一个 int32 类型的变量执行原子递增操作,可以使用 sync/atomic 包中的 AddInt32 函数
46 0