原子操作 CAS 与锁实现

小捏哩 2026-03-16 14:10:43

原子操作 CAS 与锁实现

目录

  • 原子操作 CAS 与锁实现
  • 1. CPU 缓存架构与缓存一致性
  • 1.1 为什么需要 CPU 缓存?
  • 1.2 写回策略与缓存不一致问题
  • 1.3 缓存一致性协议:MESI 与总线嗅探
  • 2. 原子操作:不可分割的执行单元
  • 2.1 什么是原子操作?
  • 2.2 为什么需要原子操作?
  • 2.3 硬件如何支持原子操作?
  • 3. Compare-And-Swap(CAS)原理与应用
  • 3.1 CAS 定义
  • 3.2 C++ 中的 CAS 接口
  • 3.3 CAS 的 ABA 问题及解决
  • 4. 内存序:从指令重排到可见性
  • 4.1 为什么会有内存序问题?
  • 4.2 内存序解决什么问题?
  • 4.3 C++ 内存模型与六种内存序
  • 5. 锁的实现:从自旋锁到互斥锁
  • 5.1 自旋锁(Spinlock)
  • 5.2 互斥锁(Mutex)
  • 5.3 锁的代价与无锁编程
  • 5.4 自旋锁和互斤锁的区别
  • 6. 案例:手撕线程安全的 shared_ptr
  • 关键点解析:
  • 7. 总结

在多线程编程中,数据竞争和同步问题是开发者必须面对的挑战。为了解决这些问题,我们需要深入理解硬件层面的缓存架构、原子操作、内存序以及锁的实现原理。本文将从底层硬件开始,逐步剖析原子操作 CAS(Compare-And-Swap)的工作机制,并通过实现一个线程安全的 shared_ptr 来展示原子操作的实际应用。


1. CPU 缓存架构与缓存一致性

1.1 为什么需要 CPU 缓存?

CPU 的运算速度远快于内存的访问速度(内存访问延迟通常是几十纳秒,而 CPU 周期仅零点几纳秒)。为了弥合这一差距,现代 CPU 引入了多级缓存(L1/L2/L3)。CPU 优先从缓存中读取数据,只有当缓存缺失时才访问内存,从而大幅提升性能。

请添加图片描述

1.2 写回策略与缓存不一致问题

当 CPU 修改缓存中的数据时,并不会立即写回内存,而是采用写回(Write-back)策略:仅当缓存行被替换或显式刷新时才写回内存。这种策略提高了性能,但也带来了缓存不一致问题——多个 CPU 核心各自持有同一内存地址的副本,一个核心的修改可能对其他核心不可见。

1.3 缓存一致性协议:MESI 与总线嗅探

为了解决缓存不一致,硬件实现了缓存一致性协议,最常见的是 MESI 协议。每个缓存行被标记为四种状态之一:

  • Modified:数据已被修改,与内存不一致,且仅存在于当前缓存中。
  • Exclusive:数据与内存一致,且仅存在于当前缓存中。
  • Shared:数据与内存一致,且可能存在于多个缓存中。
  • Invalid:数据无效,不可使用。

总线嗅探机制(Bus Snooping)是 MESI 的核心:每个核心监听总线上的事务,当检测到其他核心读取或写入某个缓存行时,根据当前状态进行响应。例如,当核心 A 修改了一个处于 Shared 状态的缓存行,它会通过总线发出“读并独占”的信号,其他核心的对应缓存行被标记为 Invalid,从而保证数据一致性。这种机制实现了事务串行化,并通过状态机降低了总线带宽的压力。


2. 原子操作:不可分割的执行单元

2.1 什么是原子操作?

原子操作(Atomic Operation)是指一个或多个指令的执行过程不可中断,要么全部完成,要么全部不执行,且中间状态对其他线程不可见。常见的原子操作包括读-改-写(RMW)操作,例如 ++CAS 等。

2.2 为什么需要原子操作?

在多线程环境中,多个线程同时访问同一变量可能导致数据竞争(Data Race)。例如,两个线程同时对 count 执行 count++,在汇编层面可能对应多条指令(读取、加1、写回),线程切换可能破坏操作的完整性,导致最终结果错误。原子操作保证了这些指令作为一个整体执行,避免了数据竞争。

2.3 硬件如何支持原子操作?

CPU 提供了特定的指令来实现原子性,例如 x86 的 LOCK 前缀可以锁定总线或缓存行,确保多处理器环境中指令的原子执行。现代 CPU 还支持如 cmpxchg(Compare-And-Swap)等原子指令。


3. Compare-And-Swap(CAS)原理与应用

3.1 CAS 定义

CAS(Compare-And-Swap)是一种经典的原子操作,它接受三个参数:内存地址 p、期望值 expected、新值 desired。操作逻辑如下:

if (*p == expected) {
    *p = desired;
    return true;
} else {
    return false;
}

整个过程是原子的,不可被其他线程打断。

3.2 C++ 中的 CAS 接口

C++11 的 <atomic> 库提供了 compare_exchange_weakcompare_exchange_strong 方法:

std::atomic<int> value(0);
int expected = 0;
int desired = 1;
bool success = value.compare_exchange_strong(expected, desired);
  • compare_exchange_strong 保证在 *p == expected 时成功更新,否则返回 false 并将 expected 更新为当前值。
  • compare_exchange_weak 可能因虚假失败而返回 false(某些平台上性能更好),通常用于循环中。

3.3 CAS 的 ABA 问题及解决

ABA 问题是指:线程 1 读取变量值为 A,随后线程 2 将 A 改为 B 又改回 A,此时线程 1 执行 CAS 发现值仍为 A,误认为变量未被修改,从而导致逻辑错误。

常见解决方案:

  • 使用带版本的原子指针:例如 std::atomic<std::shared_ptr<T>> 或为数据附加一个标记(tag),每次修改递增标记,通过双字 CAS 同时比较值和标记。
  • 避免复用地址:在无锁数据结构中谨慎设计。

4. 内存序:从指令重排到可见性

4.1 为什么会有内存序问题?

为了提高性能,编译器和 CPU 可能对指令进行重排(Reordering),只要不改变单线程的语义。但在多线程环境中,重排可能导致其他线程看到“奇怪”的执行顺序,破坏同步逻辑。此外,由于缓存的存在,一个线程的修改何时对其他线程可见也是不确定的。

4.2 内存序解决什么问题?

内存序约束了编译器和 CPU 的重排行为,并规定了多线程间的可见性。它主要解决两个问题:

  • 顺序性:保证某些操作在另一个操作之前发生(happens-before 关系)。
  • 可见性:保证一个线程对共享变量的修改能被其他线程及时看到。

4.3 C++ 内存模型与六种内存序

C++11 定义了六种内存序(std::memory_order),从松到严:

  1. **memory_order_relaxed**:只保证原子性,无顺序和可见性保证。用于计数器等不依赖同步的场景。

    请添加图片描述

  1. **memory_order_consume**(已弃用,不建议使用):针对数据依赖的优化。
  2. **memory_order_acquire**:用于读操作,保证之后的所有读写操作不会被重排到 acquire 之前,且能看到之前 release 操作写入的数据。

    请添加图片描述

  1. **memory_order_release**:用于写操作,保证之前的所有读写操作不会被重排到 release 之后,且对 acquire 操作可见。

请添加图片描述

  1. **memory_order_acq_rel**:同时具有 acquire 和 release 语义,用于读-改-写操作。
  2. **memory_order_seq_cst**:最强约束,全局顺序一致,所有线程看到相同的操作顺序,但性能开销最大。

典型的使用模式是 Release-Acquire 屏障:一个线程用 release 写入,另一个线程用 acquire 读取,从而建立 happens-before 关系,保证数据同步。


5. 锁的实现:从自旋锁到互斥锁

5.1 自旋锁(Spinlock)

自旋锁是最简单的锁,它通过忙等待(循环检查)实现。使用 CAS 可以轻松实现:

class Spinlock {
    std::atomic<bool> flag{false};
public:
    void lock() {
        while (flag.exchange(true, std::memory_order_acquire)) {
            // 自旋等待
        }
    }
    void unlock() {
        flag.store(false, std::memory_order_release);
    }
};

这里使用 exchange 原子地设置 flag 并返回旧值,若旧值为 true 表示锁已被占用,循环继续。解锁时用 store 释放锁,配合 acquire/release 语义确保同步。

5.2 互斥锁(Mutex)

操作系统提供的互斥锁(如 std::mutex)通常比自旋锁更复杂:当锁被占用时,线程会被挂起(阻塞),避免浪费 CPU。其底层实现依赖于系统调用(如 Linux 的 futex)和原子操作。一个简化的 futex 互斥锁可描述为:

  • 使用原子变量 state 表示锁状态:0 未锁,1 加锁,2 有等待者。
  • lock():尝试用 CAS 将 0 改为 1,若成功则获得锁;否则原子地增加等待者计数并调用 futex_wait 阻塞。
  • unlock():将状态改回 0,若有等待者则调用 futex_wake 唤醒。

虽然实现细节复杂,但其核心仍然是基于原子操作和 CAS。

5.3 锁的代价与无锁编程

锁会导致线程阻塞、上下文切换,还可能引发死锁、优先级反转等问题。因此,在高性能场景下,人们倾向于使用无锁数据结构(Lock-Free),它们直接利用原子操作(如 CAS)来避免锁。但无锁编程难度更高,需要仔细处理 ABA 问题和内存序。

5.4 自旋锁和互斤锁的区别

1.等待策略:自旋锁用户态忙等待,互斥锁内核态休眠;
2.上下文切换:自选锁无上下文切换,互斥锁有;
3.场景:自旋锁适用于持锁时间短,互斥锁适用于持锁时间长;


6. 案例:手撕线程安全的 shared_ptr

std::shared_ptr 的核心是引用计数,它必须保证多线程环境下计数的增减是安全的。通常,引用计数用 std::atomic<size_t> 实现,并且需要注意内存序的选择。下面我们手写一个简化的 Shared_ptr,并分析其中原子操作的使用。

#pragma once
#include <atomic>

template <typename T>
class Shared_ptr {
public:
    Shared_ptr() : ptr_(nullptr), ref_count_(nullptr) {}

    explicit Shared_ptr(T *ptr) : ptr_(ptr), 
        ref_count_(ptr ? new std::atomic<size_t>(1) : nullptr) {}

    // 拷贝构造:增加引用计数
    Shared_ptr(const Shared_ptr<T>& other) 
        : ptr_(other.ptr_), ref_count_(other.ref_count_) {
        if (ref_count_) {
            // 使用 relaxed 即可,因为不需要同步其他内存
            ref_count_->fetch_add(1, std::memory_order_relaxed);
        }
    }

    // 拷贝赋值
    Shared_ptr<T>& operator=(const Shared_ptr<T>& other) {
        if (this != &other) {
            release();                    // 释放旧资源
            ptr_ = other.ptr_;
            ref_count_ = other.ref_count_;
            if (ref_count_) {
                ref_count_->fetch_add(1, std::memory_order_relaxed);
            }
        }
        return *this;
    }

    // 移动构造:直接转移所有权
    Shared_ptr(Shared_ptr<T>&& other) noexcept 
        : ptr_(other.ptr_), ref_count_(other.ref_count_) {
        other.ptr_ = nullptr;
        other.ref_count_ = nullptr;
    }

    // 移动赋值
    Shared_ptr<T>& operator=(Shared_ptr<T>&& other) noexcept {
        if (this != &other) {
            release();
            ptr_ = other.ptr_;
            ref_count_ = other.ref_count_;
            other.ptr_ = nullptr;
            other.ref_count_ = nullptr;
        }
        return *this;
    }

    ~Shared_ptr() { release(); }

    T& operator*() const { return *ptr_; }
    T* operator->() const { return ptr_; }
    T* get() const { return ptr_; }

    size_t use_count() const {
        return ref_count_ ? ref_count_->load(std::memory_order_acquire) : 0;
    }

    void reset(T *ptr = nullptr) {
        if (ptr != ptr_) {
            release();
            ptr_ = ptr;
            ref_count_ = ptr ? new std::atomic<size_t>(1) : nullptr;
        }
    }

private:
    void release() {
        if (ref_count_) {
            // 减 1,并检查是否变为 0
            if (ref_count_->fetch_sub(1, std::memory_order_acq_rel) == 1) {
                delete ptr_;
                delete ref_count_;
            }
        }
    }

    T *ptr_;
    std::atomic<size_t> *ref_count_;   // 指向堆上的原子计数器
};

关键点解析:

  • 拷贝构造函数:使用 memory_order_relaxed 增加计数。因为只需要保证计数本身原子递增,不涉及其他内存的同步。即使线程 A 在递增后立即释放原指针,由于 ref_count_ 是指向共享内存的指针,其他线程看到的是同一计数器,所以 relaxed 足够安全。
  • release() 函数:使用 fetch_sub(1, memory_order_acq_rel)。为什么需要 acq_rel
    • 获取(acquire)语义保证:在 fetch_sub 之前的所有操作(如对 ptr_ 的访问)不会被重排到 fetch_sub 之后,确保递减时对象仍然有效。
    • 释放(release)语义保证:递减操作本身对其他线程可见,并且如果当前线程是最后一个(返回 1),它将负责删除资源。acq_rel 确保了在这之前对 ptr_ 的读写对其他线程(如果有)是可见的,同时后续删除操作不会被提前。
    • 如果使用 relaxed,可能出现:一个线程刚刚把计数减到 0,但另一个线程还没来得及看到新值,就同时尝试减少,导致重复释放。所以必须用更强的内存序保证正确性。
  • use_count() 使用 memory_order_acquire:因为读取计数需要看到其他线程对计数的修改(即前面 release 写的结果)。acquire 确保读取到的是最新值。
  • 为什么析构函数不需要显式内存序? 因为 release() 内部已经处理了内存序。

7. 总结

原子操作和内存序是多线程编程的基石,本文从 CPU 缓存架构出发,解释了缓存一致性问题如何催生原子操作,然后深入 CAS 的原理和 ABA 问题的解决,接着讨论了内存序的必要性和 C++ 中的内存序选项,最后通过自旋锁和 shared_ptr 的实现展示了原子操作的实际应用。

https://blog.csdn.net/qq_57951250/article/details/159044302?spm=1011.2124.3001.6209

https://github.com/0voice

...全文
26 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

545

社区成员

发帖
与我相关
我的任务
社区描述
零声学院,目前拥有上千名C/C++开发者,我们致力将我们的学员组织起来,打造一个开发者学习交流技术的社区圈子。
nginx中间件后端 企业社区
社区管理员
  • Linux技术狂
  • Yttsam
  • 零声教育-晚晚
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告

请新加入的VIP学员,先将自己参加活动的【所有文章】,同步至社区:

【内容管理】-【同步至社区-【零声开发者社区】

试试用AI创作助手写篇文章吧