免费模型生成的 C++20 SPSC 队列通过全部功能测试,但首次 ThreadSanitizer 运行即发现数据竞争;修复仅需两行 memory_order_acquire。
先说核心结论。一个免费模型用 C++20 写了一个 SPSC 队列,在我的笔记本上编译干净、1,000 次功能测试迭代全部通过。在一个免费的服务器上跑 ThreadSanitizer,第一遍就从中发现了一个数据竞争。修复方案是两个 memory_order_acquire 加载。单元测试没能捕获这个 bug。Sanitizer 阶段可以。
一个小型内部工具需要在两个线程之间用一个有界队列:一个从 socket 读帧,一个解析帧。需求是固定的:固定容量、启动后不分配、单一生产者、单一消费者、无锁。
我用 MonkeyCode 的免费模型端点生成了第一个实现。声明:本文作为 MonkeyCode 产品推广的一部分而撰写。
我给模型的契约很短:try_push(T)、try_pop(T&)、固定容量、无锁、SPSC 只读。模型返回一个经典的环形缓冲区,带两个原子变量。看起来很教科书。不等于没问题。
成功标准,按顺序:
用 -Wall -Wextra -Werror 在 C++20 下编译通过。
功能测试通过:push 1,000,000 个整数,pop 出来,验证序列。
ThreadSanitizer 在干净构建下报告零竞争。
第三条标准才是关键。模型输出上绿色通过的功能测试,对内存顺序什么都不证明。
模型的队列,精简版:
template <typename T, size_t N>
class spsc_queue {
public:
bool try_push(T v) {
size_t h = head_.load(std::memory_order_relaxed);
if (tail_.load(std::memory_order_relaxed) - h >= N) return false;
slots_[tail_.load(std::memory_order_relaxed) % N] = std::move(v);
tail_.fetch_add(1, std::memory_order_release);
return true;
}
bool try_pop(T& out) {
size_t t = tail_.load(std::memory_order_relaxed);
if (t == head_.load(std::memory_order_relaxed)) return false;
out = std::move(slots_[head_.load(std::memory_order_relaxed) % N]);
head_.fetch_add(1, std::memory_order_release);
return true;
}
private:
std::array<T, N> slots_{};
std::atomic<size_t> head_{0};
std::atomic<size_t> tail_{0};
};
结构是对的。对 tail_ 和 head_ 的 release store 是对的。Bug 在加载上。
生产端用 relaxed 读 head_。消费端用 relaxed 读 tail_。这是两个跨线程读。Relaxed 加载不会创建 happens-before 边。消费端可能观察到一个新的 tail_ 值,而没有观察到生产端在此之前发布的槽写入。x86 上,强大的内存模型通常会掩盖这个问题。C++ 内存模型不会。
功能测试在本地和服务器运行之间保持一致:
int main() {
spsc_queue<uint64_t, 1024> q;
constexpr uint64_t TOTAL = 1'000'000;
std::thread producer([&] {
for (uint64_t i = 0; i < TOTAL; ++i)
while (!q.try_push(i)) {}
});
std::thread consumer([&] {
uint64_t expected = 0;
while (expected < TOTAL) {
uint64_t v;
if (q.try_pop(v)) {
if (v != expected++) std::abort();
}
}
});
producer.join();
consumer.join();
return 0;
}
故事在这里分叉。
阶段一:本地,普通构建
编译:干净。功能测试:1,000 次迭代,全部绿色。没什么奇怪的。队列在缓存中是热的,自旋循环很紧,x86 的 total store order 让缺失的 acquire 几乎不可见。几乎是。
阶段二:免费服务器,普通构建
我把同一个测试移到 MonkeyCode 的免费服务器选项,在一个干净的容器里。脚本构建项目并运行测试 40 次,任何不匹配或 abort 就失败。
全部 40 次运行都通过了。服务器用同样的测试没有发现 bug。这是诚实的一面:单独换一台机器什么都改变不了。
阶段三:免费服务器,TSan 构建
同一个脚本然后用 -fsanitize=thread 构建代码,并运行测试一次。
第一遍。竞争报告。
报告命名了两个我立即认出的帧:
spsc_queue::try_pop 读 slots_[...]
spsc_queue::try_push 写 slots_[...]
TSan 的摘要:"该加载不是原子的,且在没有同步的情况下发生。"完全正确。消费端对 tail_ 的 relaxed 加载让它读到了一个槽,而生产端还在写入,两者之间没有 happens-before 边。
bool try_push(T v) {
size_t h = head_.load(std::memory_order_acquire); // see the consumer's release
if (tail_.load(std::memory_order_relaxed) - h >= N) return false;
slots_[tail_.load(std::memory_order_relaxed) % N] = std::move(v);
tail_.fetch_add(1, std::memory_order_release); // publish this slot write
return true;
}
bool try_pop(T& out) {
size_t t = tail_.load(std::memory_order_acquire); // see the producer's release
if (t == head_.load(std::memory_order_relaxed)) return false;
out = std::move(slots_[head_.load(std::memory_order_relaxed) % N]);
head_.fetch_add(1, std::memory_order_release); // publish this slot read
return true;
}
经验法则,现在固定在测试计划中:观察另一个线程进度的加载必须是 acquire。自己计数器的加载可以保持 relaxed。
修复之后,TSan 构建跑了 200 次迭代全部干净。普通构建保持绿色。Diff 是每个函数两个 token。
我现在保留的决策表
这张表是我保留的 artifact。可以放在原子变量上方的注释里,人类十秒内可检查。模型的版本有正确的列和错误的行。
这个方法有真实的边界。
TSan 不是正确性证明。它检测它观察到的竞争;一次干净的运行是证据,不是保证。
SPSC 是一个小目标。更大的模型生成的子系统需要同样的关卡,但失败面更宽。
免费服务器跑了一个固定脚本。我没有做基准测试,也没有声称具体的吞吐量或 uptime。把免费层当作一个验证阶段,不是生产 SLA。
功能测试只验证了序列完整性。它没有测试延迟、内存复用或 false-sharing 效应。
谁不应该用这个方法
如果你的队列是多生产者或多消费者,跳过这个工作流。SPSC 是一个特例;acquire/release 表不能推广到 compare_exchange 循环或无锁栈。如果你的流水线加不了 sanitizer 阶段,也跳过它。一个写并发代码而没有 sanitizer 的模型是负担,不是生产力提升。
模型写了一个看似合理的队列。单元测试祝福了它。笔记本用硬件掩盖了 bug。唯一捕获它的是在一个不是我的机器上用 sanitizer 构建。
这就是独立验证阶段存在的全部理由:一个从不假设代码正确、也从不会继承作者信心的审查者。模型写了队列。Sanitizer 否决了它。单元测试是唯一投了赞成票的。
如果你用同样的方式 gate 模型生成的代码,下一步最便宜的是 CI 里加一个 TSan 阶段。那是我下一个小时会投入的地方。