并发数据结构:无锁队列、无锁栈、读写分离、RCU

并发数据结构这块,说实话是很多C++后端工程师的噩梦。我早年接手过一个高并发网关项目,锁竞争导致CPU利用率上不去,吞吐量卡在瓶颈上。后来把核心队列换成无锁实现,性能直接翻倍。今天我就把这几类常用的并发数据结构掰开揉碎了讲清楚。

为什么需要无锁数据结构?

传统的互斥锁(mutex)在高并发场景下有个致命问题:线程阻塞和上下文切换。你想想看,一个线程持有锁,其他线程只能干等着。如果临界区代码很短,锁的开销甚至比实际工作还大。

无锁数据结构利用原子操作(CAS、FAA等)来保证线程安全,避免了操作系统介入的锁机制。说白了,就是让多个线程同时操作数据结构,但通过硬件级别的原子指令来保证一致性。

核心思想:无锁 ≠ 没有同步,而是将同步粒度降到CPU指令级别。

无锁队列(Lock-Free Queue)

无锁队列是最常见的无锁数据结构。我项目中用得最多的是基于链表的多生产者多消费者(MPMC)队列。

单生产者单消费者(SPSC)队列

这种最简单,甚至不需要CAS。只需要两个原子变量:head和tail。生产者只写tail,消费者只读head。我在做音频流处理时就用过这种,延迟极低。

template<typename T, size_t Capacity>
class SPSCQueue {
    std::atomic<size_t> head_{0};
    std::atomic<size_t> tail_{0};
    T buffer_[Capacity];
    
public:
    bool push(const T& item) {
        size_t tail = tail_.load(std::memory_order_relaxed);
        size_t next = (tail + 1) % Capacity;
        if (next == head_.load(std::memory_order_acquire))
            return false;  // 队列满
        buffer_[tail] = item;
        tail_.store(next, std::memory_order_release);
        return true;
    }
    
    bool pop(T& item) {
        size_t head = head_.load(std::memory_order_relaxed);
        if (head == tail_.load(std::memory_order_acquire))
            return false;  // 队列空
        item = buffer_[head];
        head_.store((head + 1) % Capacity, std::memory_order_release);
        return true;
    }
};

经验之谈:SPSC队列的memory_order选择很关键。release-acquire语义保证了生产者写入的数据对消费者可见,同时不会引入不必要的同步开销。

多生产者多消费者(MPMC)队列

MPMC队列就复杂多了。我常用的方案是基于链表的Hazard Pointer实现,或者用epoch-based reclamation。这里给一个简化版的CAS实现思路:

// 伪代码:无锁链表队列的push操作
void push(Node* node) {
    while (true) {
        Node* tail = tail_.load();
        Node* next = tail->next.load();
        if (tail == tail_.load()) {  // 检查一致性
            if (next == nullptr) {
                if (tail->next.compare_exchange_weak(next, node)) {
                    tail_.compare_exchange_strong(tail, node);
                    return;
                }
            } else {
                tail_.compare_exchange_strong(tail, next);  // 帮助推进tail
            }
        }
    }
}

注意:无锁队列的ABA问题是个大坑。我曾经在生产环境遇到过,排查了整整两天。解决方案是用带标记的指针(tagged pointer)或Hazard Pointer。

无锁栈(Lock-Free Stack)

无锁栈比队列简单,本质就是一个单向链表的头插法。核心操作是CAS更新栈顶指针。

template<typename T>
class LockFreeStack {
    struct Node {
        T data;
        Node* next;
    };
    std::atomic<Node*> head_{nullptr};
    
public:
    void push(const T& value) {
        Node* node = new Node{value, nullptr};
        node->next = head_.load();
        while (!head_.compare_exchange_weak(node->next, node)) {
            // CAS失败则重试,node->next会被更新为最新的head
        }
    }
    
    bool pop(T& value) {
        Node* old_head = head_.load();
        while (old_head && 
               !head_.compare_exchange_weak(old_head, old_head->next)) {
            // 重试直到成功或栈空
        }
        if (!old_head) return false;
        value = old_head->data;
        // 注意:这里需要处理内存回收!
        return true;
    }
};

嗯,这里要注意内存回收问题。直接delete old_head会导致其他线程还在访问它。我一般用Hazard Pointer或引用计数来解决。

读写分离(Read-Write Separation)

读写分离的思路很简单:读操作不加锁,写操作通过拷贝替换来实现。适用于读多写少的场景,比如配置中心、路由表。

我做过一个实时风控系统,规则表每秒更新一次,但被查询几万次。用读写分离后,读性能几乎和单线程一样快。

template<typename T>
class ReadWriteSeparated {
    std::atomic<T*> data_{nullptr};
    std::mutex write_mutex_;
    
public:
    const T* read() const {
        return data_.load(std::memory_order_acquire);
    }
    
    void write(const T& new_data) {
        T* copy = new T(*data_.load());  // 拷贝当前数据
        *copy = new_data;                // 修改拷贝
        T* old = data_.exchange(copy, std::memory_order_release);
        // 注意:旧数据的回收需要延迟处理
    }
};

关键点:写操作必须保证在读操作完成之前,旧数据不会被释放。否则读线程可能访问到野指针。

RCU(Read-Copy-Update)

RCU是Linux内核里广泛使用的同步机制,在用户态C++中也有实现。它的核心思想是:读操作完全无锁,写操作通过发布-订阅机制来更新

RCU的工作流程:

  1. 读端:进入临界区时标记自己正在读,退出时取消标记
  2. 写端:拷贝数据,修改拷贝,然后原子替换指针
  3. 回收:等待所有在读的线程退出临界区后,再释放旧数据
class RCUProtectedData {
    std::atomic<int*> data_{nullptr};
    // 每个线程的读端状态(简化示意)
    thread_local static bool in_read_ = false;
    
public:
    int read() {
        in_read_ = true;
        int* ptr = data_.load(std::memory_order_acquire);
        int val = *ptr;
        in_read_ = false;
        return val;
    }
    
    void update(int new_val) {
        int* old = data_.load();
        int* new_ptr = new int(*old);
        *new_ptr = new_val;
        data_.store(new_ptr, std::memory_order_release);
        // 等待所有读线程退出
        synchronize_rcu();
        delete old;
    }
};

我的建议:RCU适合读操作远多于写操作的场景。如果写操作频繁,RCU的等待开销会很大。我一般在配置更新、路由表这类场景用RCU。

知识体系总览

下面这张图总结了四种并发数据结构的核心逻辑和适用场景:

并发数据结构知识体系 无锁队列 • SPSC:单生产者单消费者,无CAS • MPMC:多生产者多消费者,CAS+ABA处理 • 适用:消息传递、任务调度 • 注意:内存回收(Hazard Pointer) 无锁栈 • 单向链表头插法 • 核心操作:CAS更新栈顶 • 适用:LIFO场景、对象池 • 注意:ABA问题、内存回收 读写分离 • 读操作无锁,写操作拷贝替换 • 适用:读多写少(配置、路由表) • 优势:读性能接近单线程 • 注意:旧数据延迟回收 RCU(读-复制-更新) • 读端标记,写端拷贝+替换 • 适用:读极多写极少 • 优势:读端完全无锁 • 注意:同步等待开销 选择原则:根据读写比例、并发度、延迟要求权衡

避坑指南

我曾经在一个金融交易系统中使用无锁队列,上线后偶尔出现数据丢失。排查后发现是ABA问题导致的节点被错误回收。从那以后,我养成了几个习惯:

  • 永远不要假设CAS一定成功:一定要在循环中重试
  • 内存回收比数据操作更难:Hazard Pointer、Epoch-Based Reclamation、引用计数,选一个用熟
  • 测试要覆盖极端并发:单线程测试通过不代表并发没问题
  • 性能分析要量化:不要凭感觉优化,用perf、火焰图说话

重要提醒:无锁数据结构不是银弹。如果临界区代码较长,或者写操作频繁,传统锁可能更合适。我见过太多人为了炫技而用无锁,结果性能反而更差。

好了,并发数据结构这块就讲到这里。每种方案都有其适用场景,关键是要理解它们的原理和权衡。实际项目中,我建议先从简单的读写分离或SPSC队列开始,等真正遇到性能瓶颈时再考虑更复杂的无锁实现。


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