交易所的 Level-2 行情是从一个回调线程里进来的:逐笔成交、逐笔委托,行情一忙起来每秒几万条。下游的消费者不止一个——一个把每条都落盘,一个转发到 MQTT,一个做实时分析——而且每个都要收到每一条。
怎么把一条消息交给三个人?最先想到的是 #4里的 SPSC 队列:开三个,生产者每条写三遍。SPSC 的生产者要读消费者的位置,才知道队列满没满。读这一下是有回报的:它不会盖掉读者还没读的数据,真要丢,生产者也当场知道丢的是谁的哪一条。代价是任何一个读者慢下来、队列一满,生产者就得当场选:丢消息,还是等。而这里的生产者是行情热路径,一刻都不能等任何一个读者。
这篇讲一个五十行左右的答案:SPMC 广播环。它的取舍是生产者干脆不看读者,写满一圈就盖掉最老的槽位。思路一句话就能说完——把“这条消息已经发布”的信号,从一个所有人共享的下标,挪进每个槽位里。但这一挪带来的后果,要一节一节讲:第 1 部分讲环本身、它不保证什么、读者怎么让生产者变贵,以及什么时候不该用它;第 2 部分讲把它放进进程之间的共享内存,会多出哪些坑。
1. 广播环:一个生产者,多个读者
1.1 数据结构:一圈带戳的槽位
这个设计是一个小巧的开源环形队列(MengRao 的 SPMC_Queue),全部大约五十行:
#include <atomic>
#include <cstdint>
template<class T, uint32_t CNT>
class SPMCQueue {
public:
static_assert(CNT && !(CNT & (CNT - 1)), "CNT must be a power of 2");
struct Reader {
operator bool() const { return q; }
T* read() {
auto& blk = q->blks[next_idx % CNT];
uint32_t new_idx = ((std::atomic<uint32_t>*)&blk.idx)->load(std::memory_order_acquire);
if (int(new_idx - next_idx) < 0) return nullptr; // 还没有新消息
next_idx = new_idx + 1;
return &blk.data;
}
T* readLast() { // 读到没有为止,只留最新一条
T* ret = nullptr;
while (T* cur = read()) ret = cur;
return ret;
}
SPMCQueue<T, CNT>* q = nullptr;
uint32_t next_idx; // 这个读者私有
};
Reader getReader() { // 从"下一条"开始
Reader reader;
reader.q = this;
reader.next_idx = write_idx + 1;
return reader;
}
template<typename Writer>
void write(Writer writer) {
auto& blk = blks[++write_idx % CNT];
writer(blk.data); // 原地填槽位
((std::atomic<uint32_t>*)&blk.idx)->store(write_idx, std::memory_order_release); // 最后盖戳
}
private:
struct alignas(64) Block {
uint32_t idx = 0; // 这个槽位里是第几条消息
T data;
} blks[CNT];
alignas(128) uint32_t write_idx = 0;
};
读任何一个函数之前,先注意三件事:
- 戳
idx在每个槽位里。 每个块自己记着“我现在装的是第几条消息”。发布信号不再是一个所有人都读的共享下标,而是跟着数据走。 write_idx是普通的uint32_t,不是原子量。 只有生产者写它;getReader()建读者时读一次。CNT是 2 的幂,所以% CNT编译成掩码(#4 的技巧)。戳是 32 位的,写到约 42.9 亿条会回绕;read()里的比较就是为此写的。
用它原本服务的 Level-2 逐笔成交记录(88 字节)算:一个 Block 是 4 字节戳 + 4 字节填充 + 88 字节数据 = 96,alignas(64) 再补到 128 字节,正好两条缓存行。CNT = 524288 时,一个队列 64 MiB。
戳和数据挨着放还有一个好处:块的第一条缓存行里,是戳加上数据的前 56 字节。读者为了看戳把这一行搬过来时,这 56 字节已经跟着到了,落在里面的字段马上能用;戳要是单独放在别处,就得先搬戳那一行,看清了再去搬数据那一行,多等一趟。
alignas(64) 让相邻的槽位不共用缓存行,生产者写第 i 块时,不会拽走读者正在用的第 i − 1 块所在的行(伪共享,#2)。write_idx 对齐到 128 而不是 64,常见的解释是有些 CPU 会成对预取相邻的两条缓存行;设计本身没说明,当成“可能的原因”而不是“写明的原因”。
1.2 生产者:先写数据,最后盖戳
auto& blk = blks[++write_idx % CNT];
writer(blk.data); // ① 调用者的 lambda 填槽位
((std::atomic<uint32_t>*)&blk.idx)->store(write_idx, std::memory_order_release); // ② 再给这个槽位盖上新序号
这就是 #4 的两步交接——先原地写,再发布——只是“发布”从推进一个共享下标,变成了给这个槽位盖戳。
而且生产者从不读任何读者写的东西。没有“满了”这回事:写满一圈就覆盖最老的槽位。所以它从不等待,走的步数也和有多少读者、读者多慢无关。
1.3 读者:一次减法,三种含义
Reader 里只有一个指向队列的指针和它自己的 next_idx——“我下一条想要第几条”。它放在读者自己的内存里,读者从不往队列里写。读者之间没有竞争,多一个或少一个读者,生产者的执行路径一步都不变。
read() 去看第 next_idx 条消息应该在的那个槽位,拿槽位里的戳 new_idx 和自己想要的比。用一张图看最直观:同一时刻,三个读者落后的程度不同,看到的正好是三种情形。
把图里的三种情形写成规则:
int(new_idx - next_idx) | 意思 | read() 做什么 |
|---|---|---|
| < 0 | 槽位里还是上一圈的消息,生产者还没写到我要的那条 | 返回 nullptr |
| == 0 | 正好是我在等的那一条 | 返回它,next_idx = new_idx + 1 |
| > 0 | 生产者已经套了我一圈,这个槽位装的是更新的消息 | 返回这条更新的,next_idx = new_idx + 1——中间的全部跳过 |
两个细节撑起了大部分分量。
为什么写 int(new_idx - next_idx) < 0,而不是 new_idx < next_idx。 戳在 2³² 回绕。按无符号相减、再把差当有符号数看,只要两者相差不到 2³¹,回绕前后大小关系都对。直接比较在回绕点会判反:next_idx = 0xFFFFFFFE,回绕后新戳是 1,有符号差值是 +3——有新数据,对;而 1 < 0xFFFFFFFE 会说“还没写”。
正的差值一定是 CNT 的整数倍。 槽位里的戳是消息序号,槽位的位置是序号 mod CNT,所以读者去看的那个槽位,戳只可能是 next_idx、next_idx ± CNT、± 2·CNT……实测 CNT = 8:读者存着 next_idx = 6,生产者已经写到 30,读者拿到的第一条是 30——差值 24 = 3 × 8。
另外两个入口出自同一条规则:getReader() 从 write_idx + 1 开始,所以从不回放历史(写了五条之后才建的读者,先拿到 nullptr,下一次写入后拿到第 6 条);readLast() 一直读到 nullptr,只留最新一条——给只要最新快照的消费者用。
1.4 内存序:戳就是发布信号
内存序是 #2 的 release/acquire 配对,搬进了槽位里:生产者用普通写填数据,再对戳做 release 存储;读者对戳做 acquire 读取,之后才读数据。读者一旦看到新戳,就一定看得到在它之前写好的数据。因为信号是每个槽位一份,任意多个读者都能各自判断,彼此之间没有任何可写的共享状态。
1.5 这个设计不保证什么
这笔交易现在看清楚了:生产者谁也不等,后果由读者承担。 后果有两个:套圈是无声的(1.5.1),复查抓不住写了一半(1.5.2)。
1.5.1 套圈是无声的
第一个后果是套圈。CNT = 8,读者一条没读,生产者已经写了 20 条,读者依次拿到 17、18、19、20。第 1–16 条从没送到——而队列不会告诉你。read() 只返回指针、直接改写 next_idx,调用方从返回值看不出跳过了什么。
信息其实现成:正的差值恰好就是跳过的条数。要它可见只需一行——让 read() 把差值报出来,或者在 Reader 里累加 skipped += diff。否则就得靠消息里自带的序号。
生产环境 CNT = 524288,读者要落后五十多万条才会被套圈。但每条消息都做慢事的读者——拼字符串、同步发 MQTT、fprintf——只要比行情慢,就在一点点靠近这条边界。
1.5.2 复查看不见写了一半
read() 返回的是 &blk.data,一个指向共享槽位的指针,读者随后逐个字段去读。这期间生产者要是绕回来重写这个槽位,读者看到的就是新旧字段的混合:撕裂读。
直觉的修法是:复制完数据,再读一次戳,变了就丢掉这份拷贝。它不管用,原因值得记住。压力测试:CNT = 4,生产者全速写,每组三秒——故意最恶劣的设置,只为证明这件事存在:
| 情形 | 被接受的读取 | 其中撕裂 |
|---|---|---|
| 原样:拿到指针就读 | 59,869,704 | 3,269,273(约 5.5%) |
复制后再读一次 idx,变了就丢 | 43,942,557(另有 8,861,343 次被丢弃) | 2,084,572(约 4.7%) |
| 生产者先作废戳、再写数据、最后盖戳;读者复查 | 38,607,078(另有 6,436,383 次被丢弃) | 0 |
第二行就是教训。这个生产者先写数据、最后才改戳。重写进行到一半时,戳还是旧的,读者复查看到“没变”,就放行了一份写了一半的记录。只有生产者在动数据之前先把旧戳毁掉,复查才有用:
// 生产者:两步改成三步
((std::atomic<uint32_t>*)&blk.idx)->store(0, std::memory_order_relaxed); // ① 先作废戳
std::atomic_thread_fence(std::memory_order_release);
writer(blk.data); // ② 再写数据
((std::atomic<uint32_t>*)&blk.idx)->store(write_idx, std::memory_order_release); // ③ 最后盖新序号
// 读者:复制数据 → atomic_thread_fence(acquire) → 再读一次 idx;
// 只要不是复制前看到的那个序号,就丢掉这份拷贝
这样推理就闭合了:戳还是旧的 ⇒ 数据一个字节都没被动过。 复制和重写只要有任何重叠,戳要么是 0(正在重写),要么是新序号(已经写完),两种情况都丢弃。两道栅栏保证“戳先变、数据后动”和“先复制数据、后复查戳”在编译器和 CPU 面前都成立。(在 x86 上测的;严格按 C++ 内存模型,数据字段本身也要是原子量。)
这些是压力测试划出的边界,不是说原来的环在生产上天天撕裂。环够大、读者够快,它就很少发生。很少不等于没有。
1.6 硬件上,读者会不会拖慢生产者
1.5 说生产者谁也不等,这是从代码上看:write() 里没有一行碰读者。可代码终究跑在 CPU 上,读者也在别的核上跑着。行情接入是热路径,多挂一个读者之前,得知道它在硬件上让生产者多花多少时间。这一节算的就是这笔账。
先说结论:读者不会让生产者等,但跟得上的读者会让生产者每写一条都多一趟跨核通信。平时这一趟在后台办完,看不出来;生产者全速写时才显出来,挂一个读者,每条从 3–13 ns 涨到 15–23 ns(1.6.3)。落在后面的读者,不让生产者多花。
1.6.1 为什么会变慢
CPU 的缓存按“行”管理,一行 64 字节。同一行可以在几个核里各有一份副本,大家都能读;但一个核要写这一行,得先通知别的核把副本作废,等它们回话,才能写。这一来一回要跨核走一趟,约 20–100 ns(速查 2.1),规矩就是 MESI 那一套。别的核手里没有副本,就不用问谁,直接写。
读者读完最新一条,没有新消息,就会一遍遍去看下一格写了没有。它看的正是生产者下一条要写的那一格,所以这一格所在的行,读者手里总有一份副本。生产者要写这一格,就得先让读者作废副本、等它回话;写完,读者马上又读回去一份,接着盯再下一格。每条消息都这样来一回;挂几个读者,就有几份副本要作废。
生产者要写的行,总是先被读者拿走了一份,每次都得先要回来——下面管这叫“抢”。
1.6.2 平时:看不出来
平时每一条都会被抢一次。行情再忙,两条之间也隔着几十微秒,热路径上的读者早处理完上一条,回来盯着下一格了。
但生产者感觉不到,因为它写的时候不停下来等回话:要写的内容先放进核里的一个小队列(store buffer),核接着往下执行,write() 马上返回;作废副本、等回话、真正写进缓存,都是硬件在后台办的。两条之间隔着几十微秒,后台这点事早就办完了。
实测:512 KiB 的环,每 2 µs 写一条,读者也读消息内容。0 到 5 个读者,write() 的中位数都是 10 ns,这已经是计时能分辨的最小值;变的只有最慢的千分之一,从 20 ns 涨到 70 ns。换成生产环境大小的 64 MiB 环、每 1 µs 写一条(另一个程序,计时本身就要 30 ns),0 到 4 个读者,中位数都是 30 ns,和没有读者一样。
1.6.3 全速写,读者跟得上:每条变贵
开盘时行情一阵阵涌进来,生产者会一口气连着写。这时后台就来不及了:小队列只有几十个位置,前面的写还在等回话,后面的又进来,很快塞满,核只能停下来等。每条都得实打实付这一趟。
下面的实验测的就是这种情况:生产者全速写;读者在别的物理核上,除了读什么都不干,追上了就盯着下一格(Ryzen 5 5600GT,Windows,三次 1 秒运行的中位数)。环有两种:512 KiB 的放得进缓存;64 MiB 是生产环境的大小,放不进。读者也有两种:只看戳的,和拿到消息后把内容也读一遍的。
每次 write() 的 ns | 0 个读者 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 512 KiB 的环,读者只看戳 | 2.8 | 18.5 | 24.0 | 25.3 | 26.4 | 28.0 |
| 512 KiB 的环,读者还读内容 | 2.8 | 22.8 | 25.8 | 28.5 | 32.5 | 37.0 |
| 64 MiB 的环,读者只看戳 | 12.6 | 15.3 | 17.4 | 19.9 | 22.5 | 26.0 |
| 64 MiB 的环,读者还读内容 | 12.6 | 20.1 | 26.8 | 29.8 | 33.8 | 38.7 |
没有读者时,只有生产者自己碰这些行,一次都不用问别的核。小环的行一直在生产者自己的缓存里,2.8 ns 一条;大环转一圈回来,行早被挤回了内存,得先拉回来,12.6 ns 一条。
挂上读者,小环涨得最陡:第一个读者就让它从 2.8 涨到 18.5,从“没人抢”变成了“每条都要先要回来”。再多的读者,只是同一次通知多发几份、一起回话,所以每多一个只加 1–7 ns。大环的行本来就得从远处拿:没有读者时从内存拉,有读者时从读者的核里要回来,所以第一个读者加得少一些。
1.6.4 全速写,读者跟不上:不抢
真实的读者每条都要算东西,生产者全速写时不一定跟得上,就会落在后面。落在后面的读者,读的是生产者早写完的格子,碰不到生产者正要写的那一行,也就抢不了。
这件事用另一个程序专门测过:64 MiB 的环,生产者全速写 2000 万条,每种情况跑 3 次,看每条平均多久(Ryzen 5 5600GT,Windows)。程序和 1.6.3 不同,数只在这一组里比。
- 没有读者:12.6–14.0 ns
- 1–4 个读者,只读落后生产者 4096–8191 条的格子:13.0–13.9 ns,和没有读者一样
- 1–4 个核只在自己身上空转,不碰环:12.7–15.2 ns。这是对照:多几个忙着的核,本身不拖慢生产者
- 1 个正常读的读者,追上了就盯着下一格:25.6–35.4 ns;2–4 个:49.9–55.0 ns。这也是对照:这个程序量得出“抢”
它读过的那些格子也不碍事:生产者要转完一整圈才会再写到那里,64 MiB 的环全速写一圈也要 6–7 ms;这期间读者又读了一整圈 64 MiB,旧副本早被挤出了它的缓存。生产者回来写时,别的核手里没有这一行,不用问谁。环小到读者的缓存装得下时,旧副本可能还在,这种情况没测。
不抢,代价就落到读者自己身上:消息在环里排队,落后 k 条,新来的一条就要等它先处理完前面 k 条;落后超过一整圈就被套圈,余量怎么算见 2.3.2。
1.6.5 读者该怎么等
read() 没有新消息就直接返回,接下来怎么等,由每个读者自己的循环决定。
热路径上的读者要每条一到就拿走,只能盯着下一格,这笔钱得付:平时藏在后台,只在全速写时让每条变贵。
落盘、监控这类不急的读者,不该盯着下一格,而是隔一会儿醒一次,把攒下的一口气读完。每秒 5 万条、每 1 ms 醒一次,一批也就 50 条,离 2.3.2 算出的套圈余量(约 10 秒)远得很。一批里读的大多是生产者早写完的格子,只在读到最新那一格时碰一次生产者正要写的行:每批抢一次,不是每条一次。
1.7 什么时候不用广播环
回到开头那个选择:开几个 SPSC,还是一个广播环。两者只差一处——生产者看不看读者读到哪了。
开几个 SPSC 是“看”。看的回报是 1.5.1、1.5.2 都不会发生:生产者不会盖掉读者没读完的槽位,所以没有无声的套圈,也读不到写了一半的数据;每个队列还可以只装这个读者要的那部分。看的代价是:生产者的活跟读者个数成正比,几个读者就写几份;有读者慢到队列满,生产者得当场选等还是丢;读者有哪几个得事先定好,临时挂一个上来要动生产者。
广播环是“不看”。只写一份,读者随时挂上来,生产者一步都不变;代价就是整个 1.5 和 1.6:套圈和撕裂读归读者自己扛,每个读者都得把全量过一遍,盯着最新槽位的读者还会在全速写时让生产者每条变贵。
1.6.3 的表也能拿来算两边的硬件账:第二个读者起,每多一个只加 1–7 ns。如果每个读者都要全量,开 N 个 SPSC 就是每条写 N 份,每份都有一个读者盯着,都要作废一次副本;广播环只写一份,第二个读者起每个只加几纳秒。如果每个读者只要一部分(比如按股票分给几个计算线程),每条只写进一个队列、只有一个读者盯着,生产者的代价大致停在“1 个读者”那一档,再加上查读者位置的那一下,不随读者变多而涨;读者也不用把别人的消息挨个跳过。
所以:读者都要全量、会增会减、在别的进程里(落盘、转发、监控),用广播环;读者各要一部分,或者丢了必须当场知道,用按读者分开的 SPSC。两者也能叠着用:接入线程把每条写进广播环,给不赶时间的读者;同时按股票写进各计算线程自己的 SPSC。要是改成另起一个线程从环里读出来再分,热路径上就多了一次跨核转手。
2. 放进共享内存
2.1 shmmap:四个调用
部署时生产者和读者是不同的进程。环放进 POSIX 共享内存:
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
template <class Q> Q* shmmap(const char* name) {
int fd = shm_open(name, O_CREAT | O_RDWR, 0666); // ① 按名字打开(没有就创建)
if (fd == -1) return nullptr;
if (ftruncate(fd, sizeof(Q))) { close(fd); return nullptr; } // ② 设成队列那么大
Q* ret = (Q*)mmap(nullptr, sizeof(Q), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); // ③ 映射进来
close(fd); // ④ 映射不受影响
return ret == MAP_FAILED ? nullptr : ret;
}
- 名字相同,就是同一块内存。 Linux 上它在
/dev/shm下(tmpfs,不落盘)。生产者和读者都用同一个名字调shmmap。 - 没有构造函数会运行。
mmap返回的只是一段被当成Q来看的字节。队列靠“新内存全是 0”当空队列——idx = 0、write_idx = 0就是空队列——而ftruncate保证新扩出来的部分是全 0。 - 它比进程活得久,直到
shm_unlink或重启。 - 一个队列,一个写者。
++write_idx是普通自增,两个写者会互相踩。每路行情各一个队列。
2.2 同样的字节,不同的地址
最要紧的一条规则来自实际运行。两个进程映射同一个命名区域(用 Windows 上的对应物 CreateFileMapping + MapViewOfFile 实测):
[producer] view at 0000017987cc0000
[reader ] view at 000001ae3be70000
[reader ] m->self = 0000017987cc0048 vs my address of this block = 000001ae3be70048 (DIFFERENT)
消息全部送到,而且同一块物理内存在两个进程里位于不同的虚拟地址。存在区域里的指针——哪怕是指向区域自己的——到了另一边就是错的。指向字符串字面量的指针这次碰巧对上了,因为两个进程是同一个可执行文件、加载在同一个基址;换成不同的程序或开了地址随机化就不成立。别依赖它。
所以共享内存里的元素必须自包含:整数、浮点、定长数组。不能有任何指针——指向堆的、指向区域内部的、指向字面量的都不行,虚函数表指针也不行,所以不能有虚函数。这就是证券代码写成 char SecurityID[31] 而不是 std::string 的原因,而 std::string 是双重不行:长串的字节在写者进程的堆上;短串(小字符串优化)存着一个指向自己内部缓冲的指针——libstdc++ 实测 &s 结尾是 …880、s.data() 是 …890——区域映射到别的地址后它就指错了。
同样的道理还带出双方必须一致的其他“合同”:同一份结构体定义、同样的编译方式(改了布局,旧的共享对象就对不上——换名字,或先 shm_unlink);alignas 照样有效,因为 mmap 返回页对齐的地址,队列又放在开头。
2.3 部署时的三个坑
2.3.1 第一圈会缺页
共享内存按需分配:一个 4 KiB 页第一次被碰到时才分到物理页。一个槽位 128 字节,所以每写 32 条就踩进一个新页。实测(Windows 上 64 MiB 的映射;Linux tmpfs 机制相同,数字不同):
| 第 1 圈:平均 / p99 | 第 2 圈:平均 / p99 | |
|---|---|---|
| 新映射 | 56.1 ns / 1,302 ns | 8.6 ns / 10 ns |
| 开始前对每个 4 KiB 页写一个字节 | 9.0 ns / 10 ns | 8.8 ns / 10 ns |
每次缺页约 1.5 µs((56.1 − 9.0) × 32)。开盘前把每一页碰一遍,第一圈就和第二圈一样——和内存池(#3)同一条规矩:别让“第一次”发生在热路径上。
2.3.2 慢读者会被套圈:余量 = 容量 ÷ 速率差
读者每条花的时间比生产者的间隔长,就在稳定地落后;落后满 CNT 条就被套圈、无声丢数据。能撑的秒数 = 524,288 ÷(行情速率 − 处理速率);读者完全停住时是 524,288 ÷ 行情速率——每秒 5 万条的话约 10.5 秒。把慢活移出 read() 循环(交给别的队列或线程),并监控落后量 write_idx − next_idx。
2.3.3 读者重启会全丢,除非存了游标
getReader() 从下一条开始,所以 10:00:00 崩溃、10:00:03 重启的记录进程会无声地跳过三秒——每秒 5 万条就是 15 万条。next_idx 是公开的、读者私有的,可以存下来再恢复。实测 CNT = 8:
reader read 1..5, "crashed" with next_idx = 6; producer wrote 6..10
rebuilt with getReader() -> 10 (6..9 lost)
resumed from saved next_idx -> 6 7 8 9 10 (nothing lost)
saved cursor more than a lap behind (producer at 30)
first read() returns 30, skipping 24 (= 3 laps); 23..29 were still in the ring but are never revisited
只要落后不到一圈,续读就一条不丢;游标要存在进程外面,否则跟着进程一起没了。对称的坑:队列被重建了(比如每个交易日前 shm_unlink),老读者却还在跑。 新戳从 0 开始,读者的游标是几百万,int(new_idx - next_idx) < 0 永远成立,read() 一直返回 nullptr——看上去是“没有新消息”,实际是卡死,直到新戳追上来(实测:游标 1000,新写者写 1..999 期间什么都读不到)。重建队列时一起重启读者,或者检测“戳倒退了”。