linux网络编程8——原子操作CAS与锁实现

原子操作CAS与锁实现

本文讲述了原子操作的原理、缓存一致性协议MESI、和原子操作的6种内存序,最后利用原子变量和内存序实现了一个自旋锁。

1. 原子性及其实现

原子性(Atomicity) 是计算机科学中的一个重要概念,指的是一组操作要么完全执行,要么完全不执行,不会在中间状态被中断或干扰。在多线程编程中,原子性通常是指对共享资源的操作,在并发环境中,保证该操作在执行过程中不会被其他线程打断。

如何保证原子性?

  • 单处理器单核。只需要屏蔽中断,就能够避免操作被打断,保证原子性。

  • 多处理器多核心。在多处理器多核情况下,除了保证原子操作不被打断之外,还需要避免其他核心操作相关的内存空间。

    • 过去:lock指令锁总线,禁止其他处理器所有的内存访问,开销较大
    • 现在:lock指令利用缓存一致性协议可以只阻止其它核心对相关内存空间的访问

由于现代CPU复杂的执行环境:

  • 多核CPU并行,多线程并发
  • 本地缓存+全局缓存
  • 指令重排

原子操作在不同cpu或者核心中的顺序可见性问题变得突出,给线程同步带来了更多困难。但是基于原子操作和内存屏障,我们可以创建各种用于线程同步或互斥的锁,从而更容易编写线程安全的代码。

如何保证线程安全:

  • 锁的类型

    互斥锁、自旋锁、自适应锁、递归锁、读写锁、分布式锁。

  • 线程同步设施

    锁、原子变量、信号量、管道。

互斥锁与自旋锁的区别

互斥锁也分为普通互斥锁和自适应互斥锁,再linux中,自适应互斥锁在检测到锁已经被占用时会在用户态自旋一会,如果锁依然没有被释放,当前线程会进入内核并转为阻塞态。

自适应自旋锁在无法进入临界区时会在用户态自旋一会,如果锁依然没有被释放,当前线程会进入就绪态。

互斥锁是一种粗粒度锁,自旋锁是一种细粒度锁

2. 缓存一致性

在这里插入图片描述

2.1 Cache Line缓存行

缓存行(Cache Line)是 CPU 缓存中数据存储的基本单位,通过加载连续的内存块,利用了空间局部性提升了访问速度。

缓存行通常是 64 字节大小(根据硬件可能有所不同)(加上一些控制信息后会略大于64B),它代表缓存和主存之间传输数据的最小粒度。在 CPU 缓存中,数据并不是单独存储的,而是按固定大小的块(即缓存行)来存储和管理的。缓存行的使用大幅提高了数据访问速度,因为它通过一次性加载一整行的数据,减少了每次访存的开销。

CPU每次访问内存,都会将其这个地址所在的数据块加载到缓存行,即使只需要一个字节的数据,整个缓存行都会被加载。

在这里插入图片描述

2.2 CPU数据读写

写回策略(write-back)

Write-Back 策略下,CPU 并不会立即将写入的数据同步到主存,而是保存在缓存中,等待后续需要替换或同步时再写回主存。

写数据时,首先根据内存地址定位缓存块,如果缓存命中,则直接写入,并标记脏位。

如果缓存未命中,则需要将其从内存中加载进缓存,这时如果缓存中没有空闲的缓存行,则需要执行缓存替换:首先根据LRU算法定位一个缓存块,如果该缓存换设置脏位,则刷入内存。然后将要被写入的数据加载到该缓存行。

最后,写入数据并标记脏位。

缓存替换的方法取决于cpu采用的缓存映射方案,包括直接缓存映射、组相联缓存映射和全相联缓存映射。

其中组相联和全相联可以采用LRU算法来淘汰缓存块。

读取数据

读取数据时,如果命中缓存则直接读取,否则需要将数据所在的数据块加载进缓存。如果缓存中有空闲的缓存行,则直接加载并读取。

否则需要进行缓存替换,根据LRU算法定位一个缓存块,如果设置了脏位,则需要将其写回内存。

最后,将数据加载进缓存并读取。

2.3 缓存一致性问题

通过前面讲述的CPU读写数据的策略可知,当多个核心同时缓存同一块内存数据的副本并进行读写操作时,可能出现数据不一致的情况。

缓存一致性问题的核心在于:确保各个核心访问的共享数据是一致的,不会出现不同核心看到不同版本的数据

2.4 缓存一致性问题的解决

a) 总线嗅探机制(Bus Snooping)

写传播/写同步

当一个核心写入数据时,会通过总线广播给其它核心,收到消息的核心会修改相应的缓存行(例如标记为invalid)。

但是如果每次写数据都通知所有核心的话,显然是非常低效的,因为不是所有数据都被每个核心所共享。因此有了缓存一致性协议来确保只有共享了该变量的核心被通知,从而减少写传播给总线带宽带来的压力。

b) MESI一致性协议

通过缓存行的状态管理和状态转换来确保多个核心共享相同的缓存数据时,只存在一份有效的缓存副本。

MESI协议减少了对总线的广播需求,有效提高了单核访问独占数据的效率。

状态机

缓存行的四种状态

  • Modified 已修改
  • Exclusive 独占,并与内存数据一致
  • Shared 共享,并与内存数据一致
  • Invalid 已失效,缓存数据已不是最新版本

事件

  • PrRd: 核心请求从缓存块读数据
  • PrWr: 核心请求向缓存块写数据
  • BusRd: 总线嗅探器收到来自其它核心的读取缓存请求
  • BusRdX: 总线嗅探器收到另一个核心写一个其不拥有的缓存块的请求。
  • BusUpgr: 总线嗅探器收到另一个核心写一个其拥有的缓存块的请求。
  • Flush: 总线嗅探器收到一个另一个核心把一个缓存块写回主存的请求
  • FlushOpt:总线嗅探器收到一个缓存块被放置在总线以提供给另一核心的请求。

状态转换

以下是MESI协议中常见的状态转换规则:

  • 写操作时:
    • 如果缓存行处于 Exclusive 状态,写操作会将缓存行的状态转换为 Modified
    • 如果缓存行处于 Shared 状态,写操作会将所有其他缓存中的副本标记为 Invalid,并将缓存行的状态转换为 Modified
    • 如果缓存行处于Invalid状态,则会向其他核心发出“无效化”请求,将其他缓存中的副本标记为 Invalid,然后将当前缓存行状态更新为 Modified
    • 如果缓存行处于Modified状态,则直接写入。
  • 读取操作时:
    • 如果缓存行处于 Modified 状态,直接从缓存读取数据。
    • 如果缓存行处于 ExclusiveShared 状态,则读取数据并保持相应的状态。
    • 如果缓存行处于Invalid状态,则从主存或其它缓存中读取。如果其它缓存中有该数据则进入Shared状态,否则进入Exclusive状态。

缓存行独占访问

现代 CPU 常用缓存一致性协议(如 MESI 协议)在缓存层面实现原子操作,而不依赖总线锁

cpu原子操作指令配合缓存一致性协议能够确保对缓存行的独占访问,防止其他 CPU 修改该缓存行,直到操作完成。这是通过将缓存行设置为独占(Exclusive)或已修改(Modified)状态来保证独占访问的。这个状态转换过程会通知其他处理器,使其它处理器中相应的缓存行无效。并且在原子操作期间,推迟其他处理器对该缓存行的无效化请求,从而确保了操作期间的独占性。

在x86处理器中,带有LOCK前缀的指令会要求缓存行独占访问,防止其他处理器修改。

3. 原子操作

3.1 原子变量及其操作

  1. std::atomic
  2. is_lock_free
  3. store(T desired, std::memory_order order)
  4. load
  5. exchange
  6. compare_exchange_weak
  7. compare_exchange_strong
  8. fetch_add
  9. fetch_sub
  10. fetch_and
  11. fetch_or
  12. fetch_xor

3.2 内存序

原子操作通常需要配合内存屏障才能真正确保多线程下内存操作的一致性和可见性。

3.2.1 多线程环境下指令重排导致的问题

cpu运行时指令重排

现代处理器为了提高指令流水线的效率,会对指令进行重排。例如,在一条指令等待内存加载数据时,CPU可以提前执行其他不依赖该数据的指令,从而减少停顿。

编译器指令重排

编译器在生成目标代码时,会通过指令重排优化程序性能。

指令重排的存在使得原子操作对于其它线程的可见性变得不确定。

3.2.2 内存操作依赖关系

下面这个实例演示了多线程场景下,程序的正确性需要内存操作的依赖关系来保证。在这个例子中,我们使用了原子变量data_ready,默认情况下其使用最严格的内存序。关于内存序的问题在下一小节详述。

#include <vector> 
#include <atomic>
#include <iostream> 

std::vector<int> data;
std::atomic<bool> data_ready{false};

void reader_thread()
{
	while(!data_ready.load())	// 操作A
	{
		std::this_thread::sleep(std::chrono::milliseconds(1));
    }
    std::cout<<”The answer=<<data[0]<<”\n”;	// 操作B
}

void writer_thread()
{
    data.push_back(42);	// 操作C
    data_ready = true;	// 操作D
}

synchronizes-with relationship 同步关系

同步关系简单来说就是在一系列关于同一个变量的原子操作中,如果一个写操作写入的值被一个读操作读到,或者经由读-修改-写操作之后被一个读操作读到,那么就说这个写操作于这个读操作同步。也可以理解为该写操作对该读操作可见

举个例子,在上面的代码中,writer_thread中操作D执行了写操作,然后在reader_thread中操作A读取到了这个true值,那么这两个操作就具有同步关系。

在之后介绍的6种内存序中,memeory_order_relaxed不具备同步性。

happens-before relationship 先序关系

如果一个线程的操作A于另一个线程的操作B具有同步关系,那么同时A于B也具有先序关系,即A happens before B。先序关系是可传递的,即如果A先序于B,B先序于C,则有A先序于C。

举个例子,在上面的代码中,writer_thread操作C先序于操作D,而后者与另一个线程的操作A同步,同时操作A又先序于B,因此可退出先序关系: C --> D --> A -->B。

3.2.3 内存序

指令重排可能会带来一些并发问题,尤其在多线程环境下,会导致不可预测的行为。为解决这些问题,现代CPU和编译器实现了内存模型(Memory Model),为多线程访问提供了顺序保证和可见性约束。

内存屏障是用于控制指令重排的一种指令,分为读屏障写屏障。内存屏障会强制保证在它之前的读/写操作在它之后的操作前完成,避免重排导致的数据不一致问题。

使用内存序来限制指令重排

  • memory_order_relaxed:松散内存序,只保证操作的原子性,不保证执行顺序。可用于读或者写操作。宽松内存序下,在同一线程内,对不同变量的操作可以自由重新排序,只要它们遵循所依赖的先序关系。

    松散内存序只能保证每个线程看到的对同一个原子变量的修改顺序是相同的,原子变量的修改顺序是线程间的唯一共识。

    除此之外,松散内存序也保证在同一线程内对同一个原子变量的访问顺序不能改变。

    注意

    但是需要明白的是,内存序是一个软件层面的语义,他告诉编译器和cpu是否对内存操作执行顺序做额外要求。但是这并不妨碍硬件上的缓存一致性协议在硬件层面通知最新值。而缓存一致性协议会保证单一变量的一致性。

    #include <atomic> 
    #include <thread>
    #include <assert.h> 
    
    std::atomic<bool> x,y; 
    std::atomic<int> z;
    
    void write_x_then_y()
    {
    	x.store(true,std::memory_order_relaxed);	// 操作1
        y.store(true,std::memory_order_relaxed); 	// 操作2
    }
    void read_y_then_x()
    {
    	while(!y.load(std::memory_order_relaxed));	// 操作3
        if(x.load(std::memory_order_relaxed))		// 操作4
    		++z;
    }
    
    int main()
    {
    	x = false; y = f alse; z = 0;
        std::thread a(write_x_then_y); 
        std::thread b(read_y_then_x); 
        a.join();
        b.join(); 
        assert(z.load()!=0);	// 操作5
    }
    

    在上面的例子中,操作5断言可能失败。因为在宽松内存序下,x和y是不同的原子变量,所以操作1和操作2的执行顺序是不确定的,如果read_y_then_x线程看到y先被改变然后x被改变,那么操作4可能判断失败。

  • memory_order_consume消费语义,类似获取语义,区别是仅规定了依赖于该原子变量操作涉及的对象的内存操作顺序。该内存序很少被用到。

  • memory_order_acquire获取语义,对应读操作。具备同步性。当前线程中读或写不能被重排到此加载前。其他释放同一原子变量的线程的所有写入,能为当前线程所见。

  • memory_order_release释放语义,对应写操作。具备同步性。当前线程中的读或写不能被重排到此存储后。当前线程的所有写入,可见于获得该同一原子变量的其他线程。

  • memory_order_acq_rel获取释放语义,对应读-修改-写操作。具备同步性。当前线程的读或写内存不能被重排到此存储前或后。

    获取释放语义的组合用于建立“先行发生”(happens-before)关系,使该变量**写入操作及其之前的内存操作 happens-before 读取操作及其之后的操作 **。

    #include <atomic> 
    #include <thread>
    #include <assert.h> 
    
    std::atomic<bool> x,y; 
    std::atomic<int> z;
    
    void write_x_then_y()
    {
        x.store(true, std::memory_order_relaxed);	// 操作1
        y.store(true, std::memory_order_release);	// 操作2
    }
    
    void read_y_then_x()
    {
    	while (!y.load(std::memory_order_acquire));	// 操作3
        if (x.load(std::memory_order_relaxed))		// 操作4
        	++z;
    }
    
    int main()
    {
        x=false; y=false; z=0;
        std::thread a(write_x_then_y); 
        std::thread b(read_y_then_x); 
        a.join();
        b.join(); 
        assert(z.load() != 0);	// 操作5
    }
    

    在上面的例子中操作2与操作3同步,操作2 happens-before 操作3,而操作1 happens-before 操作2, 操作3 happens-before 操作4,因此有操作1 happens-before 操作4,因此z一定会自增。

    但是,该内存序也有局限性,就是对于不同线程中不同变量的原子操作的顺序的可见性没有规范。下面看一个例子。

    #include <atomic> 
    #include <thread>
    #include <assert.h> 
    
    std::atomic<bool> x,y; 
    std::atomic<int> z;
    
    void write_x()
    {
    	x.store(true,std::memory_order_release); 	// 操作1
    }
    void write_y()
    {
    	y.store(true,std::memory_order_release);	// 操作2
    }
    void read_x_then_y()
    {
    	while(!x.load(std::memory_order_acquire));
        if(y.load(std::memory_order_acquire))		// 操作3
    		++z;
    }
    void read_y_then_x()
    {
    	while(!y.load(std::memory_order_acquire));
        if(x.load(std::memory_order_acquire))		// 操作4
    		++z;
    }
    
    int main()
    {
        x=false; y=false; z=0;
    	std::thread a(write_x); 
        std::thread b(write_y);
    	std::thread c(read_x_then_y); 
        std::thread d(read_y_then_x); 
        a.join(); b.join(); c.join(); d.join();
    	assert(z.load()!=0);	// 操作5
    }
    

    在上面的例子中,操作5断言不一定成功。因为操作1和操作2分属不同的线程,其操作顺序在不同线程看来可能是不同的,例如read_x_then_y中可能看到x == true, y == false,而read_y_then_x中可能看到x == false, y == true。

    为什么会出现这种情况?这是因为操作1和操作2的发生顺序是未知的,即使有MESI缓存一致性协议,也不能保证这写操作发生时能够同时被其它核心看见,而由于异步更新的时间差,很可能一个核心看到x先被写入、y后被写入,另一个核心看到y先被写入,x后被写入。

  • memory_order_seq_cst顺序一致语义,默认情况下采用的内存序。可读可写。对于读操作相当于获得,对于写操作相当于释放,对于读-修改-写操作相当于获取释放。保证各个线程中所有原子操作观察到的内存操作顺序一致。

    #include <atomic> 
    #include <thread>
    #include <assert.h> 
    
    std::atomic<bool> x,y; 
    std::atomic<int> z;
    
    void write_x()
    {
    	x.store(true,std::memory_order_seq_cst); 	// 操作1
    }
    void write_y()
    {
    	y.store(true,std::memory_order_seq_cst);	// 操作2
    }
    void read_x_then_y()
    {
    	while(!x.load(std::memory_order_seq_cst));
        if(y.load(std::memory_order_seq_cst))		// 操作3
    		++z;
    }
    void read_y_then_x()
    {
    	while(!y.load(std::memory_order_seq_cst));
        if(x.load(std::memory_order_seq_cst))		// 操作4
    		++z;
    }
    
    int main()
    {
        x=false; y=false; z=0;
    	std::thread a(write_x); 
        std::thread b(write_y);
    	std::thread c(read_x_then_y); 
        std::thread d(read_y_then_x); 
        a.join(); b.join(); c.join(); d.join();
    	assert(z.load()!=0);	// 操作5
    }
    

    在顺序一致内存序下,操作3一定发生在操作1之后,操作4一定发生在操作2之后。

    如果操作3判断为假,说明操作3发生在操作2之前,此时一定有操作4发生在操作1之后,于是操作4判断为真,++z。同理如果操作4判断为假,此时一定有操作3发生在操作2之后,于是操作3判断为真,++z。因此操作5总是正确的。要么x先被写入、y后被写入,则操作4一定执行++z,要么y先被写入、x后被写入,则操作3一定执行++z。

    这就是顺序一致性的体现,对于每个线程来说,其看到的对于同一原子变量的所有操作的顺序是相同的。

了解了这6中内存序,下面我们可以对3.2.2小节的例子进行改进,使其更为高效。

#include <vector> 
#include <atomic>
#include <iostream> 

std::vector<int> data;
std::atomic<bool> data_ready{false};

void reader_thread()
{
	while(!data_ready.load(std::memory_order_acquire))	// 操作A
	{
		std::this_thread::sleep_for(std::chrono::milliseconds(1));
    }
    std::cout << "The answer=" << data[0] << "\n";	// 操作B
}

void writer_thread()
{
    data.push_back(42);	// 操作C
    data_ready.store(true, std::memory_order_release);	// 操作D
}

其中操作D使用std::memory_order_release保证操作C不会被重排到操作D后面,而操作A使用std::memory_order_acquire保证操作B不会被重拍到操作A前面。

4. 利用原子操作实现自旋锁

4.1 自旋锁的使用场景

  • 临界区较短
  • 多核或多处理器
  • 锁争用不频繁

4.2 代码实现

#include <vector> 
#include <atomic>
#include <iostream> 
#include <thread>
#include <chrono>
// 
// #define USE_SPINLOCK

class SpinLock
{
public:
    SpinLock() : flag{ATOMIC_FLAG_INIT} {}
    SpinLock(const SpinLock &) = delete;
    SpinLock &operator=(const SpinLock &) = delete;

    void lock()
    {
        while (flag.test_and_set(std::memory_order_acquire))
        {
            std::this_thread::yield();
        }
    }

    void unlock()
    {
        flag.clear(std::memory_order_release);
    }

private:
    std::atomic_flag flag;
};

SpinLock lock;
int num;
std::atomic_bool fence;  // 保证所有线程几乎同时运行

void increase()
{
    while (!fence.load(std::memory_order_acquire))
        std::this_thread::yield();

    for (int i = 0; i < 10000; ++i)
    {
#ifdef USE_SPINLOCK
        lock.lock();
#endif
        ++num;

#ifdef USE_SPINLOCK    
        lock.unlock();
#endif
        std::this_thread::yield();
    }
}


int main(int argc, char const *argv[])
{
    std::thread t1(increase);
    std::thread t2(increase);
    std::thread t3(increase);

    fence.store(true, std::memory_order_release);

    t3.join();
    t2.join();
    t1.join();

    std::cout << "Expected: 30000\n" << "Output: " <<
        num << std::endl;
    return 0;
}

学习参考

学习更多相关知识请参考零声 github

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

HilariousDog

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值