C++ 语言实战(七):无锁环形缓冲——单写单读从 8µs 到 1.2µs

第 7 / 13 章
C++ 语言实战(七):无锁环形缓冲——单写单读从 8µs 到 1.2µs

前六章的账都算得挺干净,但 ch03 埋的那句「高速振动通道的互斥锁版本还要再进化一次」,到这一章该还了。振动通道 2kHz 采样,单点间隔只有 500µs,而上一章的微基准早就剧透过:无竞争的锁很便宜,有竞争的锁会排队。这一章把采集线程到出口线程的最后一根管子换成无锁的 SPSC 环形缓冲——顺带把 C++ 并发编程最劝退的知识点(内存序)一次讲透。先立个规矩:本章所有结论都建立在「单生产者、单消费者」前提上,脱离前提谈无锁就是事故预备役。

一、事故现场:每秒丢 370 个采样点

振动通道的缓冲用的是最朴素的互斥锁版 std::deque:

// 采集线程(写方),2kHz 节拍
void VibCollector::on_sample(const Sample& s) {
    std::lock_guard lk(m_);
    q_.push_back(s);                      // 与出口线程抢同一把锁
}
// 出口线程(读方),每 5ms 批量搬运一次
void VibOutlet::drain() {
    std::lock_guard lk(m_);               // 批量搬运 64 条,临界区约 8µs
    for (int i = 0; i < 64 && !q_.empty(); ++i) {
        batch_.push_back(q_.front());
        q_.pop_front();
    }
}

单看每一行都没毛病,合起来就是产线事故。出口线程把 64 条数据的拷贝放进临界区,写方每一次 push_back 都要等它出来;高峰期写方 P99 锁等待 8.4µs,再叠上偶发的 futex 上下文切换(P99.9 直接飙到 62µs)。2kHz 意味着节拍预算 500µs,看起来余量巨大——但采样节拍是相邻两次 push 的时间差,一次 8µs 的排队刚好插在「读完传感器、正要入队」的缝隙里,节拍超限率 0.37%,换算下来每秒丢约 370 个采样点的节奏完整性。振动分析算法对采样等间隔极其敏感,毛刺直接把频谱算花了。

排查手段上一章都备好了:perf top 里 futex_wait 排进前十、锁等待直方图(lock/unlock 包一层计时)三条尾巴清晰可见。但这次的结论不是「锁写错了」,而是选型错了——一个只写一个只读、索引各自独立推进的场景,本质是单生产者单消费者(SPSC)队列,教科书上它的正确答案是无锁环形缓冲。顺手把叫法校准一下:上一章我说「单写多读」,严格术语是单写单读,SPSC 的两个 S 就是这个意思。

二、为什么无锁在这里可行:把同步需求缩到极限

无锁数据结构不是「不加锁的玄学」,是把「需要同步的东西」压缩到最小后的必然结果。先看 SPSC 环形缓冲的账本:

  • 一个定长槽数组 slots_[Cap],两个单调递增的 64 位索引:tail_(已写入多少条)只由写者改,head_(已消费多少条)只由读者改;
  • 槽位下标 idx & (Cap - 1)(Cap 取 2 的幂),索引本身永不回退——64 位单调计数,取模后天然消灭 ABA(多消费者 CAS 队列里那个著名的「值回到原样但状态已变」陷阱,在无 CAS 的 SPSC 里根本没有发生的土壤);
  • 唯一需要同步的,是「写者写完数据后,读者凭什么能看到」这一件事——这就是内存序的全部战场。

正确的推演只有两步。写者视角:先把数据写进槽,再更新 tail(相当于说「新的都写好了,请看」)。读者视角:先看到 tail 变化,再读槽。如果写者先发 tail 再写数据,读者就可能看到「tail 说有货,槽里还是旧的」——顺序本身就是协议。C++ 内存模型把这个协议表达为 acquire/release 配对:release 写保证「此前的所有写,对随后的 acquire 读可见」;acquire 读到新值的那一刻,就与 release 写建立了 happens-before 关系,读者看见的不只是 tail,还有写者在它之前写进去的所有数据槽。

内存序三兄弟各自的角色在这 40 行代码里配齐:relaxed 用于只保证原子、不参与同步的读写(比如写者读自己的 tail——自己写的自己最清楚,任何序都行);acquire/release 用于真正需要跨线程传递数据的配对点;seq_cst(默认)提供全局单一顺序,最安全也最贵——x86 上它给 store 前面加一条 MFENCE 屏障,ARM 上代价更大。无锁代码的纪律:每一处 memory_order 都要能说清「谁和谁配对、传递了什么」,说不清就用 seq_cst。

还有一条隐形陷阱要在原理层拆掉:伪共享(false sharing)。head_ 和 tail_ 紧挨着放在对象里,就落在同一条 64 字节缓存行上。写者每更新 tail、读者每更新 head,两个核之间就发生一次缓存行的所有权来回倒手(MESI 协议下的乒乓),这条 64B 线成了两个核的战场——测过,吞吐直接掉 3.7 倍。解法是让两个索引各自独占一条缓存行,alignas(64) 上场。

三、正确姿势:40 行的 SPSC 环形缓冲

完整实现如下,每处内存序的配对关系都在注释里说清:

template <typename T, size_t Cap>
class SpscRing {
    static_assert((Cap & (Cap - 1)) == 0, "Cap must be power of 2");
public:
    // 仅写者线程调用
    bool push(const T& v) {
        const uint64_t h = head_.v.load(std::memory_order_acquire); // 配对①:见读者推进
        const uint64_t t = tail_.v.load(std::memory_order_relaxed); // 自己的索引
        if (t - h >= Cap) return false;                             // 满:不做决定,交上层
        slots_[t & (Cap - 1)] = v;                                  // 先写数据
        tail_.v.store(t + 1, std::memory_order_release);            // 配对②:发布数据
        return true;
    }
    // 仅读者线程调用
    bool pop(T& v) {
        const uint64_t t = tail_.v.load(std::memory_order_acquire); // 配对②:见到新 tail
        const uint64_t h = head_.v.load(std::memory_order_relaxed);
        if (h == t) return false;                                   // 空
        v = slots_[h & (Cap - 1)];                                  // 此时槽内数据必然可见
        head_.v.store(h + 1, std::memory_order_release);            // 配对①:通知空间已释放
        return true;
    }
private:
    struct alignas(64) Padded {        // 各占一条缓存行,防伪共享
        std::atomic<uint64_t> v{0};
    };
    Padded head_;                      // 只被读者 store
    Padded tail_;                      // 只被写者 store
    std::array<T, Cap> slots_;         // 定长,零分配
};

注意 64 位单调索引让「判满/判空」退化成一次减法比较(t - h >= Cap / h == t),不需要浪费一个槽位,也不需要 CAS。满的处理是刻意的不做决定:返回 false,由上层选择「告警 + 丢弃最旧 + 计数」或「背压降频」。工业链路的原则是宁可有序地少、不可无序地错——环形缓冲只负责快,策略归业务。

敢不敢上产线?配套三件套:TSan(内存序错误它真能抓)、序号断言测试(写方写入时打上单调序号,读方校验连续性——乱序/丢重复一眼现形)、连续 72 小时压测。这套组合拳打完,无锁结构的可信度不低于锁版本。

四、数据说话

2kHz 振动通道,锁版 deque vs SPSC 环形缓冲(Cap=4096,批量搬运 64 条/次):

指标mutex + dequeSPSC 环形缓冲
批量搬运 64 条耗时 P508.2µs(含锁等待)1.2µs
futex 调用/秒约 14,0000
节拍超限率(>600µs)0.37%0
上下文切换贡献的 P99.962µs消失

两个专项实验把原理坐实。伪共享实验:去掉两个 alignas(64),单写单读吞吐从 61 万 op/s 掉到 16 万 op/s——3.7 倍的差距全在两个索引的缓存行乒乓上,这就是为什么伪共享值得单独作为面试题。内存序实验:把全部 acquire/release 换成 seq_cst,x86 上耗时 +9%(MFENCE 的代价),ARM 板子上 +38%;反过来换成全 relaxed,x86 上「碰巧」全对——但同一份代码在弱内存序的 ARM 边缘盒子上复现出乱序读,这正是「无锁代码必须说清配对」的最好反证。

五、面试怎么答

Q1:讲讲 C++ 内存序,acquire/release 是什么? 框架:六种序,按保证强度排序 relaxed → acquire/release → seq_cst。核心讲配对:release 写与 acquire 读配对,建立 happens-before——读到新值就能看见对方之前的所有写。用 SPSC 环形缓冲举例最有力:写槽→release 发 tail,acquire 读 tail→读槽,两句内存序就是「数据先于通知」协议。补一句 relaxed 用于纯计数、seq_cst 是默认保险,展示不是背条目而是会选。

Q2:什么是伪共享?怎么避免? 定义:两个线程频繁写不同变量,但变量落在同一条 64B 缓存行上,MESI 协议导致缓存行所有权在核间乒乓,性能暴跌。避免:alignas(64) 或按 std::hardware_destructive_interference_size 填充,把高频写的变量隔行安置。能报出「我们测过去掉 padding 掉 3.7 倍」的实测数据,这题就是你的主场。

Q3:什么是 ABA 问题? 场景:CAS 比对「值相等」就认为「状态没变」,但值可能 A→B→A 绕了一圈。多消费者无锁栈的经典崩溃源。解法:带版本号的 CAS(把计数器和指针打包成 128 位)、或用 64 位单调索引取模让计数永不回退。加分项:指出 SPSC 无 CAS 设计天然免疫 ABA——「无锁的正确姿势是先设计掉问题,再谈解决」。

Q4:无锁数据结构敢上产线吗?怎么验证? 先划边界:只对简单模型手写(SPSC 是底线),MPMC 用成熟库(folly/moodycamel),不发明轮子。验证三件套:TSan 抓内存序违规、序号断言压测验证 FIFO 完整性、长时压测 + 弱内存序平台(ARM)实测。最后补一句价值观:无锁不是性能优化首选,是锁争用被证明是瓶颈后的定向手术——和本章事故的选型逻辑闭环。

六、落地清单

  • SPSC 场景(采集→出口、日志→落盘、网络收→解析)优先用环形缓冲,而不是「默认上锁」
  • 两个索引各自 alignas(64),高频写的共享变量一律隔缓存行
  • 内存序逐处标注配对关系,说不清配对的一律 seq_cst;relaxed 只用于纯计数
  • 索引用 64 位单调递增 + 取模,判满/判空走减法比较,不引入 CAS
  • 满队列不擅自丢数据:返回 false,策略(告警/丢旧/背压)归上层业务
  • 上产线三件套:TSan + 序号断言压测 + ARM 弱内存序平台实测
  • 无锁是定向手术不是默认选项:先用 perf 证明锁争用是瓶颈,再动手

无锁缓冲上线,振动通道的平均时延终于利落了。但监控图上还剩最后一块阴影:平均 2ms 的采集循环,P99 偶发 47ms 尖刺——无锁救不了它,页错误救得了。下一章,实时性之谜。