3、软件架构设计:低延迟消息队列、无锁数据结构、内存池与对象复用、CPU亲和性与隔离
好,咱们进入软件架构这一块。说实话,很多做高频交易的朋友,一开始都把精力花在策略上,觉得算法牛逼就赢了。但我在项目里踩过最大的坑,往往不是策略逻辑错了,而是数据还没到策略手里,就已经被延迟吃掉了。你想想看,一个订单从网卡进来,经过内核协议栈、系统调度、锁竞争、内存分配……等策略拿到数据,黄花菜都凉了。
所以这一章,我重点讲四个核心武器:低延迟消息队列、无锁数据结构、内存池与对象复用、CPU亲和性与隔离。这四个东西,说白了就是给数据铺一条高速公路,中间没有任何红绿灯和收费站。
核心思想:软件架构的目标不是“功能正确”,而是“在正确的时间把正确的数据交给正确的线程”。延迟每多1微秒,可能就是几百万的盈亏。
3.1 低延迟消息队列
消息队列,大家都不陌生。但传统的消息队列,比如RabbitMQ、Kafka,那是给分布式系统用的,延迟在毫秒级。做市系统里,我们需要的是微秒甚至纳秒级的队列。
我个人习惯用无锁环形缓冲区(Lock-Free Ring Buffer)。它的核心思想很简单:一个固定大小的数组,一个写指针,一个读指针。写线程只管往写指针位置写,读线程只管从读指针位置读。没有锁,没有互斥量,全靠原子操作来同步。
这里有个关键点:内存屏障。我在项目中遇到过,一开始写了个无锁队列,测试时跑得飞快,一上生产环境就出乱序。为什么?因为CPU和编译器会重排指令。写数据这个操作,可能被重排到更新写指针之后,导致读线程读到脏数据。
避坑指南:我曾经在某个项目中,因为少加了一个std::atomic_thread_fence(std::memory_order_release),导致队列在压力测试下偶尔读到全零数据。排查了整整两天,最后发现是内存屏障的问题。所以,无锁队列不是“不加锁”,而是“用更精细的同步原语代替锁”。
一个典型的无锁环形缓冲区实现,大概长这样:
template<typename T, size_t Size>
class LockFreeRingBuffer {
static_assert((Size & (Size - 1)) == 0, "Size must be power of 2");
T buffer_[Size];
std::atomic<size_t> head_{0}; // 写指针
std::atomic<size_t> tail_{0}; // 读指针
public:
bool push(const T& item) {
size_t h = head_.load(std::memory_order_relaxed);
size_t t = tail_.load(std::memory_order_acquire);
if ((h - t) >= Size) return false; // 队列满
buffer_[h & (Size - 1)] = item;
head_.store(h + 1, std::memory_order_release);
return true;
}
bool pop(T& item) {
size_t t = tail_.load(std::memory_order_relaxed);
size_t h = head_.load(std::memory_order_acquire);
if (t == h) return false; // 队列空
item = buffer_[t & (Size - 1)];
tail_.store(t + 1, std::memory_order_release);
return true;
}
};
你看,代码其实不长。但每一行都有讲究。比如Size必须是2的幂,这样可以用位运算代替取模。再比如内存序的选择,acquire和release配对使用,保证写线程的数据对读线程可见。
3.2 无锁数据结构
除了队列,做市系统里还有很多地方需要无锁化。比如订单簿(Order Book),它需要支持高频的插入、删除、查询操作。如果用带锁的红黑树,那延迟会惨不忍睹。
我常用的方案是无锁跳表(Lock-Free Skip List)。跳表本质上是一个多层链表,通过概率平衡来保证O(log n)的查找效率。无锁版本则通过CAS(Compare-And-Swap)操作来实现并发安全。
嗯,这里要注意:无锁数据结构不是银弹。它的实现复杂度远高于有锁版本,而且调试起来非常痛苦。我个人建议,只在真正的热点路径上使用无锁结构。比如订单簿的增删改查,那是每微秒都在执行的操作,值得花功夫优化。而一些低频的管理操作,比如配置更新、日志写入,用锁完全没问题。
警告:无锁数据结构最常见的坑是ABA问题。简单说,就是线程A读到指针P指向节点X,然后被挂起。线程B把X删了,又新建了一个节点Y,恰好复用了X的内存地址。线程A醒来后,CAS比较发现指针还是P,以为没变化,实际上内容已经变了。解决方案是使用带标签的指针(Tagged Pointer),或者用Hazard Pointer、RCU等机制。
3.3 内存池与对象复用
做高频交易的人,对new和delete应该深恶痛绝。为什么?因为动态内存分配是不确定的。你永远不知道一次malloc要花多少时间,可能几纳秒,也可能几十微秒(比如触发系统调用或内存碎片整理)。
所以,我们的原则是:在初始化阶段一次性分配好所有内存,运行期间绝不动态分配。
具体做法就是内存池(Memory Pool)。我习惯用固定大小的对象池,比如为订单、报价、行情快照分别创建独立的池子。每个池子预分配一大块连续内存,然后用一个无锁空闲链表来管理。
对象复用也很关键。比如一个订单处理完了,我们不销毁它,而是把它放回池子里,下次直接用。这样既避免了内存分配,又利用了CPU缓存——因为同一块内存很可能还在L1缓存里。
template<typename T>
class ObjectPool {
static_assert(sizeof(T) >= sizeof(void*), "T must be at least pointer size");
union Slot {
T object;
Slot* next; // 空闲链表指针
};
Slot* pool_; // 预分配的内存块
std::atomic<Slot*> free_list_;
public:
ObjectPool(size_t count) {
pool_ = new Slot[count];
free_list_.store(pool_, std::memory_order_relaxed);
// 初始化空闲链表
for (size_t i = 0; i < count - 1; ++i) {
pool_[i].next = &pool_[i + 1];
}
pool_[count - 1].next = nullptr;
}
T* acquire() {
Slot* s = free_list_.load(std::memory_order_acquire);
while (s && !free_list_.compare_exchange_weak(s, s->next,
std::memory_order_acquire, std::memory_order_relaxed));
return s ? &s->object : nullptr;
}
void release(T* obj) {
Slot* s = reinterpret_cast<Slot*>(obj);
s->next = free_list_.load(std::memory_order_relaxed);
while (!free_list_.compare_exchange_weak(s->next, s,
std::memory_order_release, std::memory_order_relaxed));
}
};
你看,这个池子的acquire和release都是无锁的,只有几个原子操作。在实际项目中,一次分配/释放的开销可以控制在10纳秒以内。
3.4 CPU亲和性与隔离
最后这个,很多人容易忽略。你想想看,一个做市线程正在处理关键订单,结果操作系统把它调度到了另一个CPU核心上,导致L1/L2缓存全部失效。这个代价有多大?少说几百纳秒,甚至几微秒。
所以,我们要做两件事:CPU亲和性(Affinity)和CPU隔离(Isolation)。
CPU亲和性,就是把关键线程绑定到固定的CPU核心上。比如,行情处理线程绑定到核心0,策略计算线程绑定到核心1,订单发送线程绑定到核心2。这样每个线程都独占一个核心,不会被打断。
CPU隔离更进一步。在Linux上,我们可以通过isolcpus内核启动参数,把某些核心从系统的调度器中隔离出来。这样普通进程就不会跑到这些核心上,只有我们指定的线程才能使用。
我的经验:我曾经在一个项目中,把行情处理和策略计算放在同一个核心上。结果行情爆发时,策略计算被行情处理打断,导致订单发送延迟飙升。后来我把它们分到两个核心,延迟直接降了40%。所以,核心隔离不是越多越好,而是要按数据流来划分。通常的做法是:一个核心专门收行情,一个核心专门做策略,一个核心专门发订单,再加一个核心做监控和日志。
设置CPU亲和性的代码很简单:
#include <sched.h>
#include <pthread.h>
void pin_thread_to_cpu(pthread_t thread, int cpu_id) {
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(cpu_id, &cpuset);
int ret = pthread_setaffinity_np(thread, sizeof(cpu_set_t), &cpuset);
if (ret != 0) {
// 处理错误
}
}
除了线程绑定,还要注意中断亲和性。网卡的中断处理,默认可能落在任意核心上。我们可以通过/proc/irq/下的文件,把网卡中断绑定到专门的核心上。这样,行情数据从网卡进来,直接由指定核心处理,不需要跨核心传递。
知识体系总览
下面这张图,我把这四块内容的关系画出来了。你可以看到,它们共同构成了一个低延迟的数据处理管道:
这四个组件,环环相扣。消息队列负责线程间通信,无锁数据结构负责核心业务逻辑,内存池保证分配确定性,CPU隔离确保执行环境稳定。少了任何一个,整个系统的延迟都会出现毛刺。
好了,这一章的内容就到这里。下一章我们会深入具体的实现细节,看看这些组件在实际项目中是如何组合使用的。