4. 锁与并发:无锁队列、读写锁、CAS操作,如何设计高并发订单簿
订单簿这东西,说白了就是整个交易系统的核心战场。所有买卖指令都在这里碰撞,每秒几万笔甚至几十万笔的读写操作,稍有不慎就是数据不一致或者性能雪崩。我这些年调过的订单簿系统,十有八九的瓶颈都出在锁上。
今天咱们就聊聊,怎么在保证数据正确的前提下,把并发做到极致。
4.1 为什么锁会成为瓶颈?
先想一个问题:订单簿里最频繁的操作是什么?
- 挂单(插入买/卖单)
- 撤单(删除订单)
- 撮合(修改订单状态)
这些操作都需要修改共享数据——价格队列、订单链表、成交量统计。如果每个操作都加一把大锁,那系统就退化成单线程了。你想想看,CPU 8核16核在那闲着,就一个线程在干活,多浪费。
核心矛盾:订单簿的读写比例大约是 7:3,读多写少。但写操作往往需要修改多个数据结构,锁的粒度如果太粗,读操作也得排队等。
我记得有一次线上事故,某个期货品种的订单簿延迟从 50 微秒飙到了 5 毫秒。查了半天,发现是某个撤单操作持有了全局锁,导致所有查询都得等着。嗯,这就是典型的锁粒度过粗问题。
4.2 读写锁:读多写少的首选
读写锁(Read-Write Lock)的思路很简单:读操作可以并发,写操作独占。这正好匹配订单簿的场景——大部分时间是查询盘口深度,只有少数时间在修改。
在 C++ 里,我习惯用 std::shared_mutex(C++17 引入):
#include <shared_mutex>
class OrderBook {
private:
std::shared_mutex mtx_;
std::map<double, OrderList> bids_; // 买单队列
std::map<double, OrderList> asks_; // 卖单队列
public:
// 读操作:多个线程可以同时调用
double getBestBid() {
std::shared_lock<std::shared_mutex> lock(mtx_);
if (bids_.empty()) return 0.0;
return bids_.rbegin()->first; // 最高买价
}
// 写操作:独占
void addOrder(const Order& order) {
std::unique_lock<std::shared_mutex> lock(mtx_);
// 插入订单到对应价格队列
if (order.side == BUY) {
bids_[order.price].push_back(order);
} else {
asks_[order.price].push_back(order);
}
}
};
我的经验:读写锁在订单簿的查询接口上效果很好。但要注意,如果写操作太频繁(比如高频交易场景),写线程可能会被读线程饿死。这时候可以考虑「写优先」策略,或者用 std::shared_mutex 的 try_lock 做超时处理。
4.3 CAS 操作:无锁编程的基石
读写锁虽然好,但毕竟还有锁的开销。在高频交易领域,微秒级别的延迟都嫌多。这时候就得请出 CAS(Compare-And-Swap)了。
CAS 是 CPU 提供的一条原子指令,大概逻辑是:
// 伪代码:如果 *ptr == old_value,就把 *ptr 改成 new_value
bool CAS(int* ptr, int old_value, int new_value) {
if (*ptr == old_value) {
*ptr = new_value;
return true;
}
return false;
}
在 C++ 里,我们用 std::atomic 来封装 CAS 操作:
#include <atomic>
class LockFreeOrderQueue {
private:
struct Node {
Order data;
Node* next;
};
std::atomic<Node*> head_;
public:
void push(const Order& order) {
Node* new_node = new Node{order, nullptr};
Node* old_head = head_.load();
do {
new_node->next = old_head;
// CAS:如果 head_ 还是 old_head,就换成 new_node
} while (!head_.compare_exchange_weak(old_head, new_node));
}
bool pop(Order& result) {
Node* old_head = head_.load();
while (old_head != nullptr) {
if (head_.compare_exchange_weak(old_head, old_head->next)) {
result = old_head->data;
delete old_head;
return true;
}
}
return false;
}
};
注意:CAS 有个经典问题叫 ABA 问题。简单说就是:线程 A 读到值是 X,然后被挂起;线程 B 把 X 改成 Y 又改回 X;线程 A 醒来后 CAS 发现还是 X,就以为没变过。这在订单簿里可能导致重复处理同一笔订单。解决方案是加一个版本号(比如用 64 位指针,低 16 位存版本号)。
我曾经在一个期权做市商项目里,用 CAS 实现了一个无锁的订单队列,把单笔订单的入队延迟从 200 纳秒降到了 50 纳秒。嗯,这个优化效果还是很明显的。
4.4 无锁队列:真正的并发利器
如果只是简单的入队出队,CAS 就够了。但订单簿需要更复杂的数据结构——比如按价格排序的队列。这时候就得设计真正的无锁数据结构了。
我推荐一个经典方案:Michael-Scott 无锁队列。它用两个 CAS 操作分别控制头指针和尾指针:
template<typename T>
class MSQueue {
private:
struct Node {
T data;
std::atomic<Node*> next;
Node(const T& val) : data(val), next(nullptr) {}
};
std::atomic<Node*> head_;
std::atomic<Node*> tail_;
public:
MSQueue() {
Node* dummy = new Node(T());
head_.store(dummy);
tail_.store(dummy);
}
void enqueue(const T& value) {
Node* node = new Node(value);
Node* tail;
while (true) {
tail = tail_.load();
Node* next = tail->next.load();
if (tail == tail_.load()) { // 检查 tail 是否被修改
if (next == nullptr) {
// 尝试把新节点挂到 tail 后面
if (tail->next.compare_exchange_weak(next, node)) {
break; // 成功
}
} else {
// tail 落后了,帮它前进
tail_.compare_exchange_weak(tail, next);
}
}
}
// 更新 tail 指针
tail_.compare_exchange_weak(tail, node);
}
};
关键点:无锁队列的核心思想是「帮助」——如果一个线程发现另一个线程的操作没完成,就帮它完成。这样就不会有线程被阻塞,所有线程都在干活。
4.5 订单簿的并发设计实战
说了这么多理论,咱们看看实际怎么用。下面是我在一个项目中用过的订单簿并发架构:
这个架构的核心思路是分层:
- 输入层:多个订单输入线程通过无锁队列提交订单,避免锁竞争。
- 处理层:订单簿核心用读写锁保护,写操作独占,读操作共享。
- 输出层:行情发布线程定期读取订单簿快照,生成增量行情。
避坑指南:我曾经在一个项目中,把订单簿的写操作也放到了无锁队列里处理。结果发现,虽然入队很快,但出队后的处理逻辑(比如更新价格队列)还是需要锁。后来我改成「无锁入队 + 读写锁处理」的混合模式,性能反而更好。说白了,无锁不是万能的,要结合具体场景。
4.6 性能对比:哪种方案适合你?
| 方案 | 延迟(微秒) | 吞吐量(万笔/秒) | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 全局互斥锁 | 5-10 | 10-20 | 低 | 低并发、原型验证 |
| 读写锁 | 2-5 | 30-50 | 中 | 读多写少、常规交易 |
| 无锁队列 + 读写锁 | 1-2 | 50-100 | 高 | 高频交易、低延迟 |
| 完全无锁 | 0.5-1 | 100+ | 极高 | 极高频、FPGA 加速 |
从表里能看出来,读写锁在大多数场景下已经够用了。只有当你真的需要微秒级延迟时,才值得去搞无锁方案。毕竟,无锁代码的调试难度是普通代码的 3 倍以上——我吃过这个亏。
4.7 总结
订单簿的并发设计,说白了就是平衡「正确性」和「性能」。我的建议是:
- 先用读写锁把系统跑起来,保证功能正确。
- 用性能分析工具(比如 perf、火焰图)找到真正的瓶颈。
- 只在热点路径上用无锁优化,其他地方保持简单。
记住一句话:过早优化是万恶之源。但该优化的时候,也别手软。
最后提醒:无锁编程的 ABA 问题、内存序问题(memory order)、伪共享问题(false sharing),每一个都能让你调试到怀疑人生。如果你不是团队里最懂并发的那个人,建议先用读写锁,等性能瓶颈真的出现了再考虑升级。
公众号:蓝海资料掘金营,微信deep3321