Skip to content

C++ SPSC:槽位所有权与发布内存序 ​

有界环、角色与槽位生存期 ​

先理解线程 join、数组索引和对象生存期。本节用完整队列头与测试推演单生产者/单消费者的槽位归属,示例未执行。

专题入口:C++ SPSC 模型:有界环、角色与槽位生存期 · C++ SPSC 内存序:发布消息与归还槽位 · C++ 完整 SPSC 实践:固定载荷、回绕与双线程测试 · C++ SPSC 排错:数据竞争、角色越界与退出协议

C++的SPSC教学适用于哪些并发场景 ​

C++的SPSC教学适用于哪些并发场景:本例只适用于单生产者向单消费者传递固定 uint64_t 值,使用 C++20 标准库。生产者独占调用 try_push,消费者独占调用 try_pop;主线程在启动前构造队列,在双方 join 后读取最终结果和析构。接口不支持两个线程同时承担同一角色。

教学以 WG21 N4861 的 acquire/release 规则为依据,2026-10-17核对;它是固定 C++20 时期草案,不作最新标准声明。std::uint64_t 和 std::jthread 需要实现支持;std::atomic<size_t> 的底层是否无锁取决于平台。Windows/Linux/macOS 工具链均未运行,不据此宣称可移植性已实测。

合适场景是已有明确线程归属的点对点消息管道,不能直接用于线程池多 worker 共同写队列。若业务需要动态数量生产者,先用互斥队列建立正确性,再选专门的多方算法;不要用此示例冒充 MPMC。

SPSC环形队列怎样区分满空和容量 ​

SPSC环形队列怎样区分满空和容量:物理数组有 N 个槽,可用容量为 N-1,留一个空槽区分状态。read==write 表示空;next(write)==read 表示满。写入前计算 next,确认不满后写当前槽再发布新写索引;读取前确认不空后拷贝当前槽再发布新读索引。

next(index) 在 index==N-1 时返回0,否则加1;每个索引始终处于[0,N),加法不会无限累加溢出,也不依赖2的幂或按位掩码。N>=2 由 static_assert 固定,N=2 是可用容量1的合法边界。数组本身仍受对象大小、栈空间和实现限制,不应拿极大模板参数当容量规划。

例如 N=4,初态r=w=0;push三项后w=3,next(w)=0等于r,因此第4项失败。pop一项使r=1,下次push可以写槽3并让w回到0。可用容量与物理存储的区分必须贯穿测试与文档,不能展示“存4项”的错误规格。

SPSC为什么可以让槽位不是原子变量 ​

SPSC为什么可以让槽位不是原子变量:生产者唯一修改写索引,消费者唯一修改读索引,槽位写入与读取由两条发布关系分隔。对同一槽,写入必须先于消费者读取,读取又必须先于下一次生产者覆盖;满足这个所有权交替,uint64_t 普通对象才不发生数据竞争。

原子索引不是自动保护整片数组的锁。安全来自角色唯一、保留空槽、不越过对方已发布边界以及 acquire/release;若两个生产者都读到同一写索引,再写同一槽,即使索引 store 原子也已竞争。返回值 true 表示本次成功提交,false 表示当前观察不足以继续,可以由上层重试。

接口不返回槽位引用;try_pop 把值复制进调用者的输出变量,成功后消费者不得再借用队列内部槽位。不同线程也不能同时操作同一个外部 out 变量;队列的同步不能代替调用者对象的独立所有权。

SPSC槽位对象和线程应怎样安全析构 ​

SPSC槽位对象和线程应怎样安全析构:std::array<uint64_t,N> 在队列构造时已全部初始化,操作只是值赋值;uint64_t 的复制和析构不抛异常,所以 try_push/try_pop 可为 noexcept。没有未初始化存储、placement-new 或手动析构,也没有泛型类型抛错后的提交回滚问题。

队列不拥有工作线程,调用方必须先停止并 join,再让队列析构。实现禁止复制与移动,不提供并发 clear/reset。std::jthread 的作用域收拢可以帮助建立生存期,但它不会自动使自写循环响应 stop_token;示例用独立原子 stop 与期限检查保证循环有退出路径。

若换成 std::string 或任意 T,赋值可能分配并抛异常,槽位对象生命周期、失败后索引是否发布、资源何时销毁都需重审。本教学选择固定值载荷,不能通过删除 uint64_t 后简单改为模板 T 就宣称支持任意对象。

练习与自检 ​

用N=3的纸上状态依次演算push10、push11、push12、pop、push12、pop、pop,给出成功标记与索引。

参考答案与判断依据

初态r=w=0。push10成功w=1;push11成功w=2;push12失败。pop10成功r=1;push12成功w=0;pop11成功r=2;pop12成功r=0,最终空。物理3槽只容纳2项,满时不得修改数据或索引。

一手资料与验证边界 ​

2026-10-17核对 WG21 N4861草案、N4861 atomics.order可读镜像、N4861 intro.races可读镜像。固定草案用于说明C++20内存模型;未运行编译器、线程示例、CTest、TSan或性能基准。旧泛型与并发基础保留,与本次逐槽发布证明互补。

发布消息与归还槽位 ​

先掌握前节空满判定和角色唯一规则,再沿队列头的索引 load/store推导双向发布;未运行线程测试或性能测量。

专题入口:C++ SPSC 模型:有界环、角色与槽位生存期 · C++ SPSC 内存序:发布消息与归还槽位 · C++ 完整 SPSC 实践:固定载荷、回绕与双线程测试 · C++ SPSC 排错:数据竞争、角色越界与退出协议

SPSC生产者为什么用release发布写索引 ​

SPSC生产者为什么用release发布写索引:先对 values[write] 普通赋值,再以 release store 更新 write_。消费者通过 acquire load 观察到该写索引的发布值时,发布之前的槽位写入 happens-before 消费者之后的读取;这条链让普通 uint64_t 值可见,避免读到未完成的载荷。

按事件写为:P写槽 → P.store(write,release) → C.load(write,acquire) → C读槽。中间同步要求 acquire 观察到对应发布或足以覆盖该槽的后续发布,不能把“两个原子操作都发生了”误写成无条件同步。消费者只读观察到的已发布区间;它不会先读槽再检查索引。

若把 write_ 的发布或对方读取降为 relaxed,原子索引本身仍不会撕裂,但不再提供旁边槽位的跨线程可见性。即使某硬件上的试跑结果正确,也不能建立 C++ happens-before,因此不能用压力测试替代推导。

SPSC消费者为什么也要release归还读索引 ​

SPSC消费者为什么也要release归还读索引:消费者先把槽位值读进自己的输出,再以 release store 推进 read_。生产者以 acquire load 观察到归还后的读索引,才允许复用已经读完的槽位;这使上次读取 happens-before 下次覆盖。

完整第二条链为:C读槽 → C.store(read,release) → P.load(read,acquire) → P覆盖槽。它与写索引的发布方向相反,但同样必要。仅写端 release、读端推进 relaxed,不能证明生产者覆盖时消费者已完成非原子读取;“消费者不会写槽”不代表读写冲突安全。

保留空槽与读索引检查限制生产者不能追上未消费区间。在多次回绕中,唯一写者、原子修改顺序与同线程观察一致性共同保证不能把索引当作任意旧历史值回退使用;有空才写,有值才读。不要将此证明扩大到索引被多个线程修改的算法。

SPSC为什么读取自己的索引可以用relaxed ​

SPSC为什么读取自己的索引可以用relaxed:每个索引的唯一写者只需读取自己先前写下的位置,不需要从这个读取获得对方槽位访问的同步;原子的同线程一致性保留这个角色的位置推进。真正跨角色的读取仍是 acquire,跨角色发布仍是 release,不能把四个访问统一降级。

生产者 relaxed load write_ 后检查 acquire load read_;消费者 relaxed load read_ 后检查 acquire load write_。该实现每次检查都读取对方索引,没有缓存局部索引的性能优化,便于逐行核对。atomic<size_t> 用于让另一角色合法读取索引,不意味着必须使用 seq_cst 才安全。

stop 旗标的 relaxed 是另一个独立理由:它只用于通知放弃循环,不携带 sum/received 等数据。主线程通过 join 读取结果,不依赖 stop 发布结果。若未来把“完成=true”当成读取结果的唯一条件,则必须重新定义相应同步,不能照抄本例 stop。

SPSC失败重试和lock-free应该怎样表述 ​

SPSC失败重试和lock-free应该怎样表述:对方索引是并发快照,可能保守地看到空或满;false 不保证函数返回那一瞬间仍为空/满。调用者可以重试、退避或放弃,不能把一次失败当成永久无数据。本例没有线性化的 size 查询,也不暴露跨两个原子变量拼出的精确队列长度。

每次 try 操作是有限步骤且无自写循环,但 atomic<size_t> 是否在机器上无锁由实现决定,indices_lock_free() 只是对两索引调用 is_lock_free。即使返回true,也不能推出操作系统公平调度、端到端固定延迟或整个进程不使用锁。外部 while 重试可能一直等待对方,属于调用者协议的进展问题。

发生超时时应该区分工作量、调度、对方退出和算法错误。yield 是调度提示,既不是同步屏障,也不是正确性修复。本文没有性能测量,不使用“比锁快”或“wait-free跨平台”结论。

练习与自检 ​

有人把read_的store从release改relaxed,理由是消费者只读数组。写出缺失的事件关系。

参考答案与判断依据

缺少消费者本次读槽 happens-before 生产者下一次覆盖同槽。read_的relaxed store不与生产者acquire建立所需同步;读写普通槽位依然是冲突访问。应保留release/acquire并沿每个槽位生命周期检查两条链,而不是只看写端发布。

一手资料与验证边界 ​

2026-10-17核对 WG21 N4861草案、N4861 atomics.order可读镜像、N4861 intro.races可读镜像。固定草案用于说明C++20内存模型;未运行编译器、线程示例、CTest、TSan或性能基准。旧泛型与并发基础保留,与本次逐槽发布证明互补。

继续阅读