题干与适用场景
实现一个固定容量队列:只有一个线程写入,只有一个线程读取;push 在满时失败,pop 在空时失败。面试官希望看到你先利用 SPSC 约束,再说明索引、可见性、内存回收和测试边界,而不是直接搬用多生产者队列。
面试官考察什么
核心是并发不变量与实现取舍。你需要保证生产者只写自己的 tail,消费者只写自己的 head;跨线程只读取对方索引,并用原子操作建立“数据先写、索引后发布”的 happens-before。cppreference 对 release store 与 acquire load 的同步关系有明确说明;Java VarHandle 也区分 acquire、release 和 volatile 访问模式。
回答前要澄清的问题
先确认元素是否可复制或需要移动、队列是否允许覆盖旧数据、容量是否运行时指定、是否要求阻塞接口、是否只有一个生产者和一个消费者,以及元素析构或异常如何处理。若约束放宽为 MPSC 或 MPMC,当前算法不能直接复用。
30 秒回答框架
可以这样说:“我维护单调递增的 head 和 tail,数组位置用索引取模。生产者先读取本地 tail 和消费者已发布的 head,确认未满后写入槽位,再用 release 发布新 tail;消费者 acquire 读取 tail,确认非空后移动元素,再用 release 发布新 head。容量不要求是 2 的幂时使用取模,测试重点放在环绕、满空和并发可见性。”
分步骤深入分析
第一步:写出不变量
使用 tail - head 表示已占用数量,前提是索引使用足够宽的无符号整数并允许自然回绕。生产者不能让差值超过容量;消费者不能读取 head == tail 之外的槽位。每个槽位在被消费前只由生产者写入一次。
第二步:区分本地索引与共享索引
生产者频繁修改 tail,消费者频繁修改 head;各自的本地副本可用普通变量保存。跨线程读取对方索引时使用 acquire,发布自己的新索引时使用 release,避免两个线程对同一索引进行写竞争。
第三步:实现 push 的发布顺序
生产者先读取消费者的 head,判断 tail - head < capacity。随后把元素写入 buffer[tail % capacity],最后以 release store 写入新 tail。消费者只有在 acquire 读到新 tail 后才能读取该槽位。
第四步:实现 pop 的回收顺序
消费者先读取生产者发布的 tail,判断 head != tail。读取并移动槽位元素后,再以 release store 发布新 head。生产者 acquire 读到新 head 后,才能复用刚释放的槽位。
第五步:处理容量与回绕
容量为 2 的幂可以用位掩码替代取模,但实现必须明确检查溢出和类型宽度。普通容量直接使用 % capacity 更易验证。若长期运行,选择足够宽的无符号索引并用差值判断,不要把索引截断成小整数。
第六步:定义失败、生命周期与测试
满时 push 返回 false,空时 pop 返回 false,不自旋等待。元素写入失败时不能发布 tail;元素移动或析构异常需要明确类型约束或恢复策略。测试单元素容量、容量加一、反复环绕、生产消费者不同速度、满空边界和线程结束前的剩余元素。
高质量示范回答
push(x):
t = tail.load(relaxed)
h = head.load(acquire)
if t - h == capacity: return false
buffer[t % capacity] = x
tail.store(t + 1, release)
return true
pop():
h = head.load(relaxed)
t = tail.load(acquire)
if h == t: return empty
x = move(buffer[h % capacity])
head.store(h + 1, release)
return xhead 与 tail 都是原子计数器,生产者只写 tail,消费者只写 head。buffer 的普通写入发生在 release 发布 tail 之前,消费者 acquire 读取到该值后才能读取元素;反向的 release head 让生产者安全复用槽位。容量不是二的幂时使用取模,容量为二的幂才可在额外溢出约束下使用掩码。这个版本只适用于单生产者、单消费者,不声称支持多写者或多读者。
常见错误与改进
- 两个线程都写同一个索引: 明确 SPSC 所有权,扩展到 MPSC/MPMC 时改用专门算法。
- 先发布索引再写元素: 先写槽位,最后 release 发布索引。
- 所有操作都用 relaxed: relaxed 只保证原子性,不能发布普通元素写入;跨线程索引使用 acquire/release。
- 把容量当成二的幂: 普通容量用取模,不要用未验证的位掩码。
追问及应对
为什么本地索引可以用 relaxed?
生产者只修改自己的 tail,消费者只修改自己的 head,本地读取不需要与另一线程同步。读取对方索引时仍需 acquire,因为它同时承担可见性职责。
如果元素是引用,什么时候可以复用槽位?
消费者完成移动或析构后才 release 发布新的 head。生产者 acquire 读到该值,才可以覆盖对应槽位;不能只看到消费者开始读取就复用。
如何扩展为多生产者?
不能让多个生产者直接写同一个 tail。需要 CAS 预留序号、每槽位序列号或带锁方案,并重新证明预留、发布和回收的顺序;当前 SPSC 代码不应伪装成通用队列。
如何判断性能是否真的更好?
在相同元素大小、线程亲和性、批量大小和负载下比较吞吐、p99 延迟、上下文切换与缓存未命中。若生产者经常追上消费者,容量、批量提交或背压策略比更弱的内存序更值得先验证。