69、STL与原子操作:atomic类型、内存序、无锁编程

多线程编程,说白了就是几个工人同时干活。但问题来了——他们抢工具怎么办?

我早年做后台服务时,就吃过这个亏。一个全局计数器,多个线程同时++,结果跑着跑着数值对不上了。查了半天,原来是CPU缓存捣的鬼。嗯,从那天起,我就认真研究起了原子操作。

一、为什么需要atomic?

先看一个经典问题:

int counter = 0;
// 线程A
counter++;
// 线程B
counter++;

你以为结果是2?不一定。counter++ 在底层是三步:读、改、写。两个线程可能同时读到0,各自加1,最后写回1。这就是数据竞争。

解决办法?加锁可以,但锁太重了。对于简单的整数操作,atomic 是更好的选择。

核心观点:atomic 类型提供了无锁的线程安全操作,性能远优于互斥锁。

二、atomic 类型怎么用?

C++11 开始,STL 提供了 std::atomic 模板。我最常用的几个:

#include <atomic>

std::atomic<int> counter(0);
std::atomic<bool> flag(false);
std::atomic<long long> total(0);

// 原子自增
counter.fetch_add(1);
// 等价于 counter++,但线程安全

// 原子交换
int old = counter.exchange(10);

// CAS操作——无锁编程的核心
int expected = 5;
bool success = counter.compare_exchange_weak(expected, 10);

我在项目中遇到过这样一个场景:多个生产者线程往队列里写数据,一个消费者线程读。用 atomic 做计数器,比用 mutex 快了将近三倍。

个人习惯:能用 atomic 就别用锁。但注意,atomic 只保证单个变量的原子性,多个 atomic 操作之间仍然需要同步。

三、内存序——这个坑我踩过

atomic 操作还有一个隐藏参数:内存序(memory order)。很多人忽略它,直接用默认值。但如果你做无锁数据结构,内存序就是命根子。

先看有哪些内存序:

内存序 含义 性能
memory_order_relaxed 只保证原子性,不保证顺序 最快
memory_order_consume 依赖关系排序(很少用) 较快
memory_order_acquire 读操作,后续读写不能重排到前面 中等
memory_order_release 写操作,前面的读写不能重排到后面 中等
memory_order_acq_rel 同时具有 acquire 和 release 语义 较慢
memory_order_seq_cst 全局顺序一致(默认) 最慢

为什么会这样?因为现代CPU为了性能,会乱序执行指令。内存序就是告诉CPU:这里不能乱。

我曾经写过一个无锁队列,用了 memory_order_relaxed,结果在ARM架构上跑出来数据全乱套了。查了两天才发现,x86上 relaxed 没问题,但ARM的内存模型更弱,必须用 acquire/release。

避坑指南:如果你不确定用哪个内存序,就用默认的 memory_order_seq_cst。虽然慢一点,但至少不会出错。等性能瓶颈出现了,再针对性优化。

四、无锁编程实战——一个简单的无锁栈

理论说完了,来点实际的。下面是一个基于CAS的无锁栈:

template<typename T>
class LockFreeStack {
private:
    struct Node {
        T data;
        Node* next;
        Node(const T& d) : data(d), next(nullptr) {}
    };
    
    std::atomic<Node*> head{nullptr};

public:
    void push(const T& value) {
        Node* new_node = new Node(value);
        new_node->next = head.load(std::memory_order_relaxed);
        
        // CAS循环:如果head没变,就替换;否则重试
        while (!head.compare_exchange_weak(
            new_node->next,
            new_node,
            std::memory_order_release,
            std::memory_order_relaxed
        )) {
            // 循环体为空,CAS失败会自动更新new_node->next
        }
    }

    bool pop(T& result) {
        Node* old_head = head.load(std::memory_order_relaxed);
        
        while (old_head && !head.compare_exchange_weak(
            old_head,
            old_head->next,
            std::memory_order_acquire,
            std::memory_order_relaxed
        )) {
            // 循环体为空
        }
        
        if (old_head) {
            result = old_head->data;
            delete old_head;  // 注意:这里存在ABA问题,实际生产需处理
            return true;
        }
        return false;
    }
};

这段代码里,我用了 release 和 acquire 配对。push 时 release,保证新节点数据对其他线程可见;pop 时 acquire,保证读到的是最新的 head。

注意:上面的代码为了演示简化了内存管理。实际无锁编程中,内存回收是个大难题(ABA问题、内存泄漏等)。工业级实现通常用 hazard pointer 或 RCU。

五、知识体系总览

下面这张图,是我自己总结的原子操作知识结构:

std::atomic atomic 类型 atomic<int> / atomic<bool> atomic<T*> 指针类型 atomic_flag 自旋锁 原子操作 load() / store() fetch_add() / exchange() compare_exchange_weak/strong 内存序 relaxed(最快) acquire / release seq_cst(最安全) 无锁编程 = 原子类型 + 正确操作 + 合适内存序 性能与正确性的平衡艺术

六、总结与建议

原子操作这东西,说难不难,说简单也不简单。我给你的建议是:

  • 日常开发:用默认内存序,别瞎折腾。计数器、标志位这些场景,atomic 比锁好使多了。
  • 性能敏感:可以试试 relaxed,但一定要在目标平台上做充分测试。x86 上跑得欢,ARM 上可能就崩了。
  • 无锁数据结构:这是高手领域。如果你不是写底层库,建议直接用现成的(比如 boost.lockfree)。

我记得有一次,团队里一个小伙子非要用无锁队列实现一个高频交易系统。我劝他先用 mutex 版本跑通,再优化。他不听,结果 ABA 问题搞了整整一周。嗯,有时候「先做对,再做好」才是最快的路。

最后说一句:atomic 不是银弹。如果你的逻辑涉及多个变量的协同修改,老老实实用 mutex。无锁编程的正确性,比性能重要得多。


公众号:蓝海资料掘金营,微信deep3321