导读:本期聚焦于林小满创作的《C++如何使用std::atomic实现无锁编程?(原子操作详解)》,敬请观看详情。多线程程序中,多个线程同时读写同一个变量时,用互斥锁虽然安全却带来上下文切换和死锁的隐患。std::atomic通过CPU底层原子指令,让单个变量的读写、加减、比较交换等操作一气呵成,不会被其他线程打断,从而省去锁的开销。本文从原子操作的底层原理讲起,介绍内存顺序(memory_order)的含义与选择,配合自增计数器、自旋锁、无锁栈等典型示例,展示std::atomic的常用API和实战写法,同时分析无锁编程的适用场景、常见坑点以及与互斥锁的性能对比,帮助你在高并发场景下写出正确又高效的无锁代码。

两个线程同时对一个int变量做一万次自增,最后结果往往不是两万,而是一个小于两万的数字。原因很简单:i++实际上分三步执行——读取、加一、写回。线程A刚读完值,线程B也读了同一个值,两个线程各自加一写回,两次自增就丢了一次。传统解法是加互斥锁,但锁的开销和死锁风险让人头疼。C++11引入的std::atomic正是为此而生:它让这些操作在硬件层面变得不可分割,也就是所谓的原子操作,从而实现不依赖锁的线程安全,即无锁编程。

C++如何使用std::atomic实现无锁编程?(原子操作详解)

原子操作的底层原理:CPU如何保证不可分割

要理解std::atomic,得先明白编译器和CPU会对普通代码做哪些“破坏”。编译器为了优化性能,可能把变量缓存在寄存器里,多个循环内的读写合并成一次;CPU为了提速,又会乱序执行指令、让每个核心持有独立的缓存行。这些手段在单线程下天衣无缝,到了多线程环境下就会导致一个核心的修改迟迟不被另一个核心看见。

原子操作的本质,是借助CPU提供的特殊指令在硬件层面“锁定”执行路径。x86平台上的LOCK前缀指令(如lock addlock cmpxchg)会在执行期间锁定缓存行或总线,保证同一时刻只有一个核心能操作该内存位置。ARM平台则采用LL/SC(Load-Linked/Store-Conditional)机制,通过标记监视地址来实现条件写入。std::atomic就是对这些底层指令的C++封装,同时告诉编译器:这个变量不许优化、不许重排相关指令。

一个关键概念是std::atomic<T>是否“无锁”(lock-free)。可以用is_lock_free()成员函数检测:如果类型大小不超过CPU原生支持的字长(通常8字节),基本都能映射到单条硬件指令,属于真正的无锁;如果塞进一个很大的结构体,标准库可能退化为内部加锁实现,此时性能优势荡然无存。std::atomic<int>std::atomic<pointer>是最常用也最放心的选择。

std::atomic常用API与基本用法

先看最基本的用法。把共享变量声明为atomic类型后,普通的读写、自增自减、加减赋值都能自动获得原子性,不需要手动调用任何函数:

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

std::atomic<int> counter{0};

void worker() {
    for (int i = 0; i < 10000; ++i) {
        counter++;          // 原子自增,等价于 fetch_add(1)
        counter += 2;       // 原子加法
    }
}

int main() {
    std::vector<std::thread> ts;
    for (int i = 0; i < 4; ++i) ts.emplace_back(worker);
    for (auto& t : ts) t.join();
    std::cout << counter.load() << std::endl;  // 稳定输出 120000
    return 0;
}

除了运算符重载,几个核心成员函数必须掌握:load()原子地读取值;store(v)原子地写入值;exchange(v)原子地换成新值并返回旧值;compare_exchange_weak/strong(expected, desired)是比较交换(CAS)操作,如果当前值等于expected就写入desired并返回true,否则把当前值写回expected并返回false。CAS是无锁算法的基石,后面会详细讲。

注意一个细节:std::atomic<bool>可以用作开关标志,std::atomic<T*>可以原子地修改指针,但atomic类型不支持拷贝构造和拷贝赋值,因为它内部可能被锁保护或需要特殊指令,复制语义没有意义。如果要把值传出来,用load();要传进去,用store()

内存顺序:无锁编程最容易踩的坑

原子操作解决的是“单个操作不可分割”,但多线程正确性还依赖另一个维度——内存顺序,即一个线程的写操作何时对其他线程可见。C++11定义了六种内存顺序,从最宽松到最严格分别是memory_order_relaxedconsumeacquirereleaseacq_relseq_cst

默认的seq_cst(顺序一致性)最安全也最慢,它保证所有线程看到的全局操作顺序一致。而relaxed只保证操作本身的原子性,不提供任何同步语义,适合纯粹的计数器——比如统计打点次数,不关心它与其他数据的先后关系,此时用relaxed能拿到最好的性能:

std::atomic<int> hits{0};

void on_request() {
    hits.fetch_add(1, std::memory_order_relaxed);  // 只需要计数,不需要同步
}

releaseacquire是一对配合使用的顺序:写线程在store时用memory_order_release,读线程在load时用memory_order_acquire,就能建立“同步于”关系——写线程在store之前的所有写操作,对读线程load之后的代码全部可见。这是实现自旋锁、发布数据的标准手法。典型例子如下:

std::atomic<bool> ready{false};
int payload = 0;   // 普通变量,靠atomic的内存顺序保护

void producer() {
    payload = 42;  // 先写数据
    ready.store(true, std::memory_order_release);  // 再发布标志
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) {  // 自旋等待
        std::this_thread::yield();
    }
    // 此处读 payload 一定能看到 42
    assert(payload == 42);
}

如果把上面的release和acquire都换成relaxed,断言就可能失败,因为CPU和编译器都可能把payload = 42重排到store之后。实践经验是:除非明确知道自己在做什么(比如纯计数),否则老老实实用默认的seq_cst;性能敏感且逻辑清晰时,再精确选release/acquire组合,这是性能与安全的最佳平衡点。

实战:用CAS实现自旋锁和无锁栈

有了CAS,就能构造更复杂的无锁结构。先看一个简单的自旋锁——忙等待形式的锁,适合临界区极短的场景:

class SpinLock {
    std::atomic<bool> locked_{false};
public:
    void lock() {
        // exchange 返回旧值,false 说明之前没被锁,成功拿到锁
        while (locked_.exchange(true, std::memory_order_acquire)) {
            // 拿锁失败就继续自旋,真实项目中可加 pause 指令降低功耗
        }
    }
    void unlock() {
        locked_.store(false, std::memory_order_release);
    }
};

SpinLock mtx;
int shared_data = 0;

void add_one() {
    mtx.lock();
    ++shared_data;
    mtx.unlock();
}

再进一步,实现一个无锁栈。核心思路是用compare_exchange_weak在头节点上做“乐观并发”:读出当前头指针,基于它构造新状态,然后尝试CAS替换,失败说明别的线程抢先修改了,就重读重试。

template <typename T>
class LockFreeStack {
    struct Node {
        T value;
        Node* next;
        Node(const T& v) : value(v), next(nullptr) {}
    };
    std::atomic<Node*> head_{nullptr};

public:
    void push(const T& v) {
        Node* node = new Node(v);
        node->next = head_.load(std::memory_order_relaxed);
        // CAS 失败则 head 已被其他线程修改,重读 next 后重试
        while (!head_.compare_exchange_weak(node->next, node,
                 std::memory_order_release,
                 std::memory_order_relaxed)) {
            // 循环体内不需要额外代码,compare_exchange_weak 已更新 node->next
        }
    }

    T pop() {
        Node* old = head_.load(std::memory_order_acquire);
        while (old &&
               !head_.compare_exchange_weak(old, old->next,
                   std::memory_order_acquire,
                   std::memory_order_acquire)) {
        }
        if (old == nullptr) throw std::runtime_error("stack empty");
        T result = std::move(old->value);
        delete old;   // 注意:多线程下 delete 存在 ABA 与悬垂风险
        return result;
    }
};

这段代码在push上是正确的无锁实现,但pop里注释指出了真问题:无锁编程的深水区是内存回收。假设线程A刚读完old指针,线程B把old弹出并delete了,线程A再访问old->next就是未定义行为;即使改成延迟回收,还会遇到经典的ABA问题——指针地址被新节点复用,导致CAS误判“没变过”。工业级方案通常借助风险指针(hazard pointer)、.epoch回收(RCU思想)或直接换用带引用计数的std::shared_ptr配合std::atomic<std::shared_ptr>(C++20起支持)。这印证了一条铁律:无锁不等于简单,每一个“看起来没问题”的无锁结构,都需要经过严谨的正确性论证。

无锁编程的适用边界与选型建议

无锁不是银弹。互斥锁的代价主要是线程挂起唤醒的上下文切换(微秒级)以及可能的缓存污染,而atomic操作的代价是缓存行争用——多个核心频繁抢同一个缓存行时,硬件维护一致性的开销会急剧上升,甚至比加锁更慢。经验法则是:临界区只有一两条指令且争用极高时(如计数器、标志位、简单指针替换),atomic几乎总是赢;临界区涉及复杂数据结构修改时,std::mutex更简单也更不容易出错。

还有两个实用建议。第一,警惕“伪共享”:两个不相关的atomic变量如果落在同一缓存行(通常64字节),核心间会互相拖累,可以按缓存行对齐:alignas(64) std::atomic<int> a;。第二,compare_exchange_weak在循环里使用比strong更好,因为weak版本允许伪失败(spurious failure),某些平台上开销更低;而strong版本保证不伪失败,适合不用循环的一次性判断。掌握这些细节,std::atomic才能真正成为你高并发工具箱里那把锋利又安全的刀。

C++无锁编程std::atomic原子操作修改时间:2026-09-14 06:50:50

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260914/56523.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。