返回专辑
·Johan·4 分钟阅读

原子与内存序:传感器标志位怎么同步

memory_order 在传感器标志位与 SPSC 队列里的用法,以及 acquire/release 用错时的隐蔽代价。

原子与内存序:传感器标志位怎么同步

1. 千帧里偶发一帧全零

乱序执行下,普通 bool ready = true 不够:写线程对 SensorFrame 的赋值可能对读线程仍不可见,或者读到「半更新」状态。我曾在解码线程里漏 release,表现为千帧里偶发一帧点云坐标全零——TSan 不一定抓得到,stress test 才复现。

std::atomic 提供的是可见性契约,不只是「不可分割」。C++17 没有 Java 式 volatile 语义;别用 volatile bool 做线程同步。先写对再写快:seq_cst 理解透了,再按需降到 acquire/release。IMU 序号与 payload 不同步是另一类隐蔽 bug——读者看到新 seq 配旧 gyro,field 上表现为姿态跳变。

2. 默认 seq_cst 何时够用

memory_order_seq_cst 全局顺序一致,最省心。传感器「最新帧就绪」标志、配置热更新完成标志,先用 seq_cst 写对,profiler 确认不是热点再放宽到 acquire/release。

我曾在 IMU 中断里用 seq_cst 更新 sample_count,占整条 ISR 不到 0.2%——不必过早优化,但文档里要写清「若改 relaxed 需证明无同步需求」。新 sensor flag 先用 seq_cst 写对逻辑,再在 profiler 证明 flag 是热点时改成 acquire/release。

3. 发布-消费:SPSC 队列

单生产者单消费者(lidar 解码 → 融合)典型模式:生产者写完 payload 后对序号 store(release),消费者 load(acquire) 读序号再读数据。IMU slot 曾 seq 用 relaxed、payload 普通写——读者偶见新 seq 配旧 gyro。

alignas(64) 避免 seq 与 data 同 cache line 伪共享——高频 publish 时消费者 load acquire 会拖慢生产者。改成 producer data=...; seq.store(s, release); consumer if (seq.load(acquire)>last) 再读 data。

cpp
struct FrameSlot {
  alignas(64) SensorFrame data;
  std::atomic<uint64_t> seq{0};
};

void publish(FrameSlot& slot, SensorFrame f) {
  slot.data = std::move(f);
  slot.seq.fetch_add(1, std::memory_order_release);
}

bool try_read(const FrameSlot& slot, uint64_t& last_seq, SensorFrame& out) {
  const auto s = slot.seq.load(std::memory_order_acquire);
  if (s == last_seq) return false;
  out = slot.data;
  last_seq = s;
  return true;
}

4. relaxed 的边界

memory_order_relaxed 只保证原子性,不提供同步。计数器统计、单调序号(不关联其他数据)可用 relaxed;保护普通指针或复合不变量不行——那需要 mutex 或 paired release/acquire。

fetch_add(1, memory_order_relaxed) 适合统计丢帧次数——不需要同步别的内存。但若 relaxed 写 frame_id、普通读 payload,读者仍可能看到旧 payload 配新 id。计数与 payload 要么同一 release/acquire 边保护,要么别拆。relaxed 散落到 payload 字段是 IMU 姿态跳变的常见根因。

5. 别用原子替代 mutex

多字段状态机转移、先写 A 再写 B 必须同时可见——用 std::mutex。配置快照「整包替换」可用 atomic<std::shared_ptr<const Config>>(C++20);高频 IMU 环形缓冲仍用 mutex 或成熟 SPSC 库。

atomic<double> 在 x86 上可能锁总线,控制环里测过比 mutex 还慢的情况。对「只写一次」的 shutdown flag 可用 release store + acquire load;别在 shutdown 路径改用 relaxed 图省事,否则 worker 可能看不到队列里最后一帧。atomic 不是免费午餐——profiler 证明是热点再优化,别在理解 happens-before 之前就改 memory order。

6. 验收

  • acquire/release 配对错误:极低概率 torn read,field 测试难抓。
  • seq 与 data 同 cache line:profiler 见 decode 线程 cache miss 飙高。
  • 改 order 后 TSan 仍要通过,再加 litmus test 线程交错。
  • README 里画 happens-before 示意图:producer release 序号、consumer acquire 序号,再读 payload。
bash
cmake -DCMAKE_CXX_FLAGS="-fsanitize=thread" ..
./stress_spsc --iterations 1000000

7. 案例:IMU 序号与 payload 不同步

IMU slot 曾 seq 用 relaxed、payload 普通写——读者偶见新 seq 配旧 gyro,千帧里出现一次,field 上表现为姿态跳变。改成 release/acquire 配对后 TSan 绿,stress test 百万次无 torn read。IMU ISR 里改成 release 写 payload 后 ISR 时间降 15µs。这类 bug 极低概率,靠 stress test 和 litmus test 验证,不能仅靠 lab 功能测试。

8. 与 shutdown 路径

shutdown flag 用 release store + acquire load,别在 shutdown 路径改用 relaxed 图省事,否则 worker 可能看不到队列里最后一帧。consumer spin 等 seq 时加 pause/yield 防 pipeline 占满。文档里画 happens-before 示意图,新人改代码时不至于把 relaxed 散落到 payload 字段。

9. 决策:atomic 还是 mutex

高频单字标志(最新帧就绪、shutdown)→ atomic + acquire/release。多字段状态机、复合不变量 → mutex。配置快照整包替换 → atomic<shared_ptr<const Config>>(C++20)。高频 IMU 环形缓冲 → mutex 或成熟 SPSC 库,别自己发明 lock-free。改 memory order 前先在 README 画 happens-before 图,TSan 和 stress test 都过再合入。先写对再写快,seq_cst 是默认起点。README 里画 happens-before 示意图,新人改代码时不至于把 relaxed 散落到 payload 字段,TSan 和 stress test 都过再合入。atomic 不是 mutex 的免费替代品。改 memory order 前先在 README 画 happens-before 图,profiler 证明是热点再优化。payload 与序号同一 release/acquire 边,别拆 relaxed 计数。

← 全部文章

johan's blog