第四章:锁与并发——无锁队列设计、原子操作与内存序、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机制:读多写少的终极方案
- 伪共享:看不见的性能杀手,必须用对齐解决
这些技术,说白了就是「用空间换时间,用复杂度换性能」。在交易系统里,每一纳秒都很关键。希望这些经验能帮你在实际项目中少走弯路。