低延迟系统开发#5

共享内存里的 SPMC 广播环

一个生产者、多个读者、每个读者都要看到每一条、生产者永远不等:每个槽位自带序号戳,读者各持私有游标,一次回绕安全的减法有三种含义——代价改由读者承担:无声的套圈、撕裂读、追上来的读者抢走生产者要写的那一行,以及共享内存里地址、第一圈缺页和重启的坑。

约 16 分钟
本页目录

交易所的 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 和自己想要的比。用一张图看最直观:同一时刻,三个读者落后的程度不同,看到的正好是三种情形。

CNT = 8 的环,生产者写到第 11 条;读者 A 还没等到、读者 B 正好拿到、读者 C 被套圈跳过第 2–9 条

把图里的三种情形写成规则:

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,7043,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() 的 ns0 个读者12345
512 KiB 的环,读者只看戳2.818.524.025.326.428.0
512 KiB 的环,读者还读内容2.822.825.828.532.537.0
64 MiB 的环,读者只看戳12.615.317.419.922.526.0
64 MiB 的环,读者还读内容12.620.126.829.833.838.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 ns8.6 ns / 10 ns
开始前对每个 4 KiB 页写一个字节9.0 ns / 10 ns8.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 期间什么都读不到)。重建队列时一起重启读者,或者检测“戳倒退了”。