第四章:锁与并发——无锁队列设计、原子操作与内存序、RCU机制、避免伪共享

各位同学,欢迎来到第四章。这一章,我们聊聊并发编程里最硬核的部分——锁与并发。说实话,在交易系统里,锁就是性能的敌人。我见过太多系统因为一把锁没用好,延迟直接飙到微秒级,这在高频交易里是不可接受的。

今天,我们不讲理论空话。直接上干货:无锁队列怎么设计?原子操作和内存序怎么用?RCU机制到底解决了什么问题?还有那个让人头疼的伪共享,怎么避免?

4.1 无锁队列设计:SPSC与MPMC

先问一个问题:为什么需要无锁队列?

很简单。锁会导致线程阻塞、上下文切换、缓存行失效。在交易网关里,一个订单从接收到撮合,中间可能经过多个线程。如果每个环节都加锁,延迟会成倍增加。

我个人习惯,能不用锁就不用。实在要用,优先考虑无锁队列。

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

SPSC是最简单的无锁队列。只有一个线程写,一个线程读。没有竞争,所以不需要锁。

核心思路:用环形缓冲区(Ring Buffer),生产者写头指针,消费者读尾指针。两者互不干扰。

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

注意看,这里用了 alignas(64)。为什么?

因为head和tail如果在一个缓存行里,生产者和消费者会互相干扰。这就是伪共享。我们后面会细讲。

关键点:SPSC队列的容量必须是2的幂。这样可以用位运算代替取模,性能提升明显。

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

MPMC就复杂多了。多个线程同时写,多个线程同时读。这时候必须用原子操作来保证一致性。

我推荐一个经典实现:基于Dmitry Vyukov的Bounded MPMC队列。核心思想是用一个数组存储节点,每个节点包含一个原子变量表示状态。

template<typename T, size_t Capacity>
class MPMCQueue {
    struct Node {
        std::atomic<size_t> sequence;
        T data;
    };
    
    alignas(64) Node buffer_[Capacity];
    alignas(64) std::atomic<size_t> enqueue_pos_{0};
    alignas(64) std::atomic<size_t> dequeue_pos_{0};
    
public:
    MPMCQueue() {
        for (size_t i = 0; i < Capacity; ++i) {
            buffer_[i].sequence.store(i, std::memory_order_relaxed);
        }
    }
    
    bool push(const T& data) {
        size_t pos = enqueue_pos_.fetch_add(1, std::memory_order_relaxed);
        Node* node = &buffer_[pos & (Capacity - 1)];
        
        while (node->sequence.load(std::memory_order_acquire) != pos) {
            // 自旋等待,直到该位置可用
        }
        
        node->data = data;
        node->sequence.store(pos + 1, std::memory_order_release);
        return true;
    }
    
    bool pop(T& data) {
        size_t pos = dequeue_pos_.fetch_add(1, std::memory_order_relaxed);
        Node* node = &buffer_[pos & (Capacity - 1)];
        
        while (node->sequence.load(std::memory_order_acquire) != pos + 1) {
            // 自旋等待,直到数据就绪
        }
        
        data = node->data;
        node->sequence.store(pos + Capacity, std::memory_order_release);
        return true;
    }
};

这个实现里,每个节点有一个sequence字段。生产者写入后,sequence变成pos+1。消费者读取后,sequence变成pos+Capacity。这样就能区分空和满的状态。

我的经验:MPMC队列在竞争激烈时,性能会下降。因为多个线程同时fetch_add,会导致缓存行 bouncing。如果可能,尽量用SPSC。

4.2 原子操作与内存序

原子操作是无锁编程的基础。C++11提供了std::atomic,让我们可以无锁地操作共享变量。

但光有原子操作还不够。你还需要理解内存序(Memory Order)。

说白了,内存序就是告诉编译器和CPU:这个原子操作前后,其他线程能看到什么顺序的修改。

4.2.1 六种内存序

内存序 含义 性能
memory_order_relaxed 只保证原子性,不保证顺序 最快
memory_order_consume 依赖关系排序(基本不用) 较快
memory_order_acquire 之后的读操作不能重排到之前 中等
memory_order_release 之前的写操作不能重排到之后 中等
memory_order_acq_rel acquire + release 较慢
memory_order_seq_cst 全局顺序一致 最慢

我在项目中遇到过一个问题:用memory_order_relaxed做计数器,结果两个线程看到的计数顺序不一致。后来改成memory_order_seq_cst才解决。但性能下降了30%。

所以,我的建议是:

  • 计数器、统计信息:用relaxed
  • 生产者-消费者同步:用acquire/release
  • 锁的实现:用acq_rel或seq_cst

4.2.2 一个常见的坑

看这段代码:

std::atomic<bool> ready{false};
int data = 0;

// 线程1
data = 42;
ready.store(true, std::memory_order_release);

// 线程2
while (!ready.load(std::memory_order_acquire));
assert(data == 42); // 这个断言会成功吗?

答案是:会成功。因为release-acquire语义保证了:线程1在release之前的所有写操作,在线程2的acquire之后都是可见的。

但如果你把release改成relaxed,那就不能保证了。data可能还是0。

警告:不要滥用memory_order_seq_cst。它虽然最安全,但性能最差。在交易系统里,每一纳秒都很宝贵。

4.3 RCU机制:读-拷贝-更新

RCU(Read-Copy-Update)是一种非常优雅的同步机制。它让读操作完全无锁,写操作通过拷贝和延迟回收来实现。

为什么需要RCU?

想想看,在交易网关里,配置信息、路由表这些数据,读的频率远高于写。如果用读写锁,读操作也要加锁,性能损失很大。

RCU的思路是:读操作直接访问指针,不加锁。写操作先拷贝一份数据,修改,然后原子地替换指针。旧数据等所有读操作完成后,再回收。

4.3.1 核心原理

struct Config {
    int timeout;
    int max_orders;
};

std::atomic<Config*> g_config{nullptr};

// 读操作
int read_timeout() {
    Config* cfg = g_config.load(std::memory_order_acquire);
    return cfg->timeout; // 无锁!
}

// 写操作
void update_config(int timeout, int max_orders) {
    Config* new_cfg = new Config{timeout, max_orders};
    Config* old_cfg = g_config.exchange(new_cfg, std::memory_order_release);
    
    // 等待所有读操作完成
    synchronize_rcu();
    
    delete old_cfg; // 安全回收
}

这里的关键是synchronize_rcu()。它要等待所有正在进行的读操作完成。在Linux内核里,这通过synchronize_rcu()实现。在用户态,可以用epoch-based reclamation(EBR)或hazard pointers。

核心思想:RCU把读和写解耦。读操作永远不阻塞,写操作通过延迟回收来保证安全。

4.3.2 用户态RCU实现

我曾在项目中实现过一个简单的用户态RCU。核心是用一个全局的epoch计数器:

class RCU {
    static constexpr int NUM_THREADS = 64;
    alignas(64) std::atomic<int> global_epoch_{0};
    alignas(64) int per_thread_epoch_[NUM_THREADS]{0};
    
public:
    void read_lock(int tid) {
        per_thread_epoch_[tid] = global_epoch_.load(std::memory_order_relaxed);
        std::atomic_thread_fence(std::memory_order_acquire);
    }
    
    void read_unlock(int tid) {
        per_thread_epoch_[tid] = 0;
    }
    
    void synchronize() {
        int epoch = global_epoch_.fetch_add(1, std::memory_order_release);
        for (int i = 0; i < NUM_THREADS; ++i) {
            while (per_thread_epoch_[i] == epoch) {
                // 自旋等待
            }
        }
    }
};

这个实现虽然简单,但已经能工作了。每个读操作记录当前epoch,写操作递增epoch并等待所有读操作离开旧epoch。

我的经验:RCU最适合读多写少的场景。如果写操作频繁,RCU的开销会很大。因为每次写都要等待所有读操作完成。

4.4 避免伪共享

伪共享(False Sharing)是并发编程里最隐蔽的性能杀手。它不报错,不崩溃,但让你的程序慢10倍。

什么是伪共享?

CPU缓存是以缓存行(Cache Line)为单位的,通常是64字节。如果两个变量在同一个缓存行里,一个线程修改变量A,会导致另一个线程的变量B的缓存行失效。即使这两个变量毫无关系。

说白了,就是硬件层面的「误伤」。

4.4.1 如何检测伪共享

我曾在项目中遇到一个诡异的问题:两个线程各自操作自己的计数器,但性能就是上不去。用perf一看,L1缓存缺失率高达30%。

检查代码发现:

struct Counter {
    int a; // 线程1操作
    int b; // 线程2操作
};

a和b在同一个缓存行里。线程1修改a,线程2的b缓存行失效。线程2修改b,线程1的a缓存行失效。这就是典型的伪共享。

4.4.2 解决方案

最简单的办法:用alignas(64)把变量对齐到缓存行边界。

struct alignas(64) Counter {
    int a;
};

struct alignas(64) Counter2 {
    int b;
};

Counter c1;
Counter2 c2;

这样a和b就在不同的缓存行里,互不干扰。

另一个办法:用padding填充。

struct Counter {
    int a;
    char padding[60]; // 填充到64字节
    int b;
};

但这样比较丑陋。我更喜欢用alignas

注意:伪共享不仅发生在不同变量之间,也可能发生在同一个变量的不同字段之间。比如一个结构体里有两个原子变量,它们也可能互相干扰。

4.4.3 一个实战案例

我记得有一次优化交易网关的订单处理模块。原本每个线程处理自己的订单队列,但性能始终上不去。

用perf分析发现,每个线程的队列头指针都在同一个缓存行里。因为队列是连续分配的。

解决方案:给每个队列分配独立的内存页,用mmap分配,确保它们不在同一个缓存行里。

// 每个线程的队列独立分配
for (int i = 0; i < num_threads; ++i) {
    void* mem = mmap(nullptr, 4096, PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    queues[i] = new (mem) SPSCQueue<Order>();
}

优化后,吞吐量提升了3倍。这就是伪共享的威力。

4.5 本章小结

这一章我们聊了:

  • 无锁队列:SPSC简单高效,MPMC复杂但必要
  • 原子操作与内存序:选对内存序,性能翻倍
  • RCU机制:读多写少的终极方案
  • 伪共享:看不见的性能杀手,必须用对齐解决

这些技术,说白了就是「用空间换时间,用复杂度换性能」。在交易系统里,每一纳秒都很关键。希望这些经验能帮你在实际项目中少走弯路。

锁与并发:知识体系结构图 锁与并发 无锁队列 SPSC MPMC 原子操作与内存序 6种内存序 acquire/release RCU机制 读-拷贝-更新 延迟回收 避免伪共享 alignas(64) 缓存行对齐