题目与背景
你有一个高并发无锁栈,节点通过原子指针链接。线程从头部读取节点并 CAS 摘除,但另一个线程可能在此期间释放该节点。请用 C++26 hazard_pointer 思路设计安全回收,允许多个读线程并发访问,不能用全局大锁包住整个栈。
面试官考察什么
重点是理解 hazard pointer 保护的是“正在读取的地址”,而不是让节点永久存活。读线程先发布 hazard,再重新确认原子头仍指向该节点,成功摘除后把节点放入退休列表;扫描所有 hazard 后只回收不再受保护的节点。答案还应说明 acquire/release、线程注册与退出、扫描成本及 ABA 仍需独立处理。
先问清楚的澄清问题
数据结构与进度保证
确认是 Treiber 栈、链表还是哈希桶,要求 lock-free 还是 wait-free,以及是否允许线程本地退休列表。不同结构决定 hazard 数量和扫描频率。
线程生命周期
确认线程如何获得 hazard pointer slot,线程退出时如何撤销保护并移交退休节点。线程崩溃不能留下永远不可回收的保护记录。
ABA 与标记策略
确认节点地址是否可能重用、是否有版本计数或 tagged pointer。Hazard pointer 防止受保护节点被释放,但不自动阻止 ABA 导致 CAS 错误成功。
30 秒回答框架
“读线程先原子读取 head,把地址发布到自己的 hazard slot,再重新读取 head;两次一致才允许解引用。CAS 摘除成功后节点进入退休列表,不立即 delete。定期收集所有 hazard 地址,只回收不在集合中的退休节点。发布和扫描使用匹配的 acquire/release 语义,线程退出前清除 hazard 并交接退休列表。ABA 需要版本计数或其他方案独立解决。”
深入解答步骤
第一步:定义 hazard slot 与退休列表
每个可能解引用共享节点的线程拥有一个 hazard slot。退休列表保存已从数据结构摘除但尚未安全回收的节点。slot 生命周期和线程注册必须可追踪,不能让临时指针绕过保护路径。
第二步:建立发布—确认保护窗口
读 head 后,把它以 release 或等价顺序写入 hazard slot,再以 acquire 重新读取 head。只有两次读取一致才可访问节点字段;如果变化,清除 slot 并重试。该双重确认阻止节点在发布前被回收。
第三步:执行 CAS 并延迟回收
读取 next 后用 compare_exchange 摘除 head。CAS 失败时清除 hazard 并重试;成功时把旧节点加入退休列表,并在仍可能使用其内容的代码结束后清除 slot。任何路径都不能直接 delete 共享节点。
第四步:扫描并回收
扫描所有线程的 hazard slot,建立受保护地址集合。遍历退休列表,只回收不在集合中的节点;扫描期间新发布的保护不会让已经开始读取的节点失去保护,因为读线程遵循发布—确认协议。扫描阈值可按 slot 数量和退休列表长度调整。
第五步:处理 ABA 与内存序
节点被延迟回收可降低地址重用,但不等于消除 ABA。若栈允许快速摘除和重新入栈,使用版本计数、tagged pointer 或其他 ABA 防护。原子 head、hazard slot 和节点字段的读写必须有清晰的 happens-before 关系,避免用 relaxed 误判保护已生效。
第六步:处理线程退出和异常
线程停止读取前清除 hazard,退休节点交给仍存活的回收者或全局域。线程异常退出不能遗留 slot;注册表需要可检测的 owner 状态。析构函数只能在确认没有读者后运行,不能把普通对象生命周期规则套到并发节点上。
第七步:测试安全性和性能
用 ThreadSanitizer、随机调度和压力测试覆盖 CAS 失败、扫描并发、线程退出、节点重用和异常路径。加入延迟释放的哨兵,验证没有 use-after-free;同时测量扫描耗时、退休列表峰值、吞吐和尾延迟,调整扫描批量而不是直接加锁。
高质量示例回答
我会为每个读线程分配 hazard slot。pop 先读取 head、发布 hazard、再次读取 head,只有一致才读取 next 并 CAS;失败就清除并重试。摘除成功后节点进入退休列表,扫描全部 hazard 地址后只回收未受保护节点。线程退出要清除 slot 并移交退休列表,ABA 通过版本计数或 tagged pointer 另行解决。测试覆盖高竞争、CAS 失败、重用、退出和异常路径,并检查 use-after-free、扫描成本和尾延迟。
常见错误
- 错误: 读取 head 后直接访问节点。→ 原因: 保护发布之前节点可能被回收。→ 改进: 发布 hazard 后重新确认 head。
- 错误: CAS 成功后立即 delete 节点。→ 原因: 其他读者仍可能持有保护窗口。→ 改进: 先退休,扫描后再回收。
- 错误: 认为 hazard pointer 自动解决 ABA。→ 原因: 延迟回收不保证逻辑版本不变。→ 改进: 使用版本计数或 tagged pointer。
- 错误: 用 relaxed 原子就能完成保护。→ 原因: 可能缺少发布和确认之间的可见性。→ 改进: 明确 acquire/release 与 happens-before。
追问与回答
追问 1:为什么要重新读取 head?
第一次读取到发布 hazard 之间存在窗口,其他线程可能摘除并回收节点。重新确认保证发布保护时节点仍是当前 head,否则必须重试。
追问 2:扫描时新发布的 hazard 会不会漏掉?
正确协议要求读线程先发布再确认,并在确认失败时重试。扫描只回收已退休且当前不在保护集合的节点;不遵守协议的裸指针访问不受保护。
追问 3:退休列表会无限增长吗?
在读线程长期持有 hazard、线程停止或扫描频率过低时可能增长。设置阈值、监控峰值,并确保线程退出清理;必要时让回收线程主动扫描。
追问 4:hazard pointer 与 epoch-based reclamation 如何选?
Hazard pointer 精确保护少量地址,适合读路径需要动态保护的结构,但扫描 slot 成本明显。Epoch 方案批量回收效率高,却可能被停顿线程拖住。根据读者数量、停顿容忍度和内存上界选择。