“若允许释放旧哨兵,必须使用安全回收协议。出队至少要覆盖h的字段访问与Head比较,以及n.value的读取。只保护h不够:别的线程可以先把h摘为旧哨兵,再把n摘为下一轮旧哨兵并释放n,虽然h…”
形式陈述
Hazard Pointer以每线程发布的保护槽,决定已从并发结构摘除的节点何时可以释放。它工作于共享内存系统;本页固定p个已注册线程,每线程K个单写者、多读者hazard槽,总数
所有共享指针与hazard槽读写都是SC 原子操作。移除节点后先retire,放入退休列表;每次回收先固定一批已经退休的候选节点,再扫描全部hazard槽,只释放该批中未被任何槽命中的节点。扫描开始后新退休的节点必须留给后续批次,不能拿旧扫描结果决定其释放。退休日程保证一个节点只退休一次,且退休后不再从共享结构重新发布同一个节点实例。
保护一个共享根指针src的基本协议为:
protect(src, slot):
repeat:
p = load(src)
store(slot, p)
until load(src) == p
return p
use:
p = protect(src, mySlot)
if p != null: read p.fields
store(mySlot, null) // 最后一次危险使用之后
发布前不能解引用,发布后必须复查。 比较地址值本身在本抽象模型中合法;语言级悬空指针表示、对象生存期和弱内存屏障需要另外处理,不能把这里的SC伪代码直接译成普通C++读写。
从内部next取得节点时,还需先保护承载next的前驱,并验证该结构所要求的可达性条件。protect不是可以对任意已悬空字段地址调用的魔法函数。
直觉
摘链说明“新的访问者不应再从结构入口拿到这个节点”,却不说明“先前的访问者已经不用它”。Hazard槽就是访问者公开的一张暂借单。节点摘链后进入退休区,只有所有暂借单都不再列出它,才可考虑释放。
但暂借单必须在读者确认仍能合法取得节点时已经生效。先拿一个裸指针,过很久再补登记,可能遇到别人早已检查过名单并释放内存的情况。复查源指针把这个竞态变成重试,而不是一次悬空读取。
例子与边界
读到地址后暂停的窗口
初始Head=a,T1的hazard为空。T2用CAS摘除旧头,之后再判断何时可以释放。
| 步骤 | T1 | T2 |
|---|---|---|
| 1 | 读取Head,局部p=a,随后暂停 | |
| 2 | 用CAS把Head改为b,退休a | |
| 3 | 扫描未见a的hazard,可以释放a | |
| 4 | 恢复,发布hazard=a | |
| 5 | 再读Head得到b,与p不同 | |
| 6 | 不读取a的字段,重新保护Head |
第4步发布的地址值不会令已释放内存复活。安全来自第5步失败后没有发生解引用。若删掉复查,T1就可能在第6步读取已释放的a。
另一种交错是T1先发布a、再复查成功,随后T2摘除并扫描。只要T1还未完成所有危险使用,它的槽一直保存a,扫描就必须保留a。T1清空槽后,下一次扫描才可能释放。
退休批次必须先于扫描固定
设回收者先读到读者的slot=null;随后读者发布a并复查共享入口仍为a,开始合法使用;另一个线程这时才摘除并退休a。若把这个新退休节点塞进已经开始的候选批次,旧扫描没有a,便可能在读者仍使用时将它释放。正确协议只评估扫描开始前就已退休的候选,新增退休项等下一次扫描。
为什么扫描不必是全体槽的原子快照
关键不变量是:每个将要危险使用节点a的读者,必须从a仍可合法取得的某一时刻起,到最后使用结束,连续保持同一个实际hazard槽指向a。
候选节点在扫描前已经退休,此后新的读者无法重新取得同一个已退休实例。若一个旧读者在扫描结束时仍可能危险使用a,保护它的那个固定实际槽必覆盖整个相关扫描期间,扫描该槽时就应看到a。逐槽读可能来自不同时间,但不能把一个连续存在的保护全都漏掉。这个论证依赖完整的发布、可达性验证和持续保护条件,而不是“最终大概会看见写入”。
“任何时刻总有某个槽保护a”仍然太弱,单遍扫描可能在槽之间追丢一个移动的保护。设初始槽1=a、槽2=null,发生以下交错:
- 回收者先扫描槽2,读到null
- 读者把a写入槽2,此刻两个槽都保护a
- 读者清空槽1,此刻槽2仍保护a
- 回收者再扫描槽1,读到null,形成的集合却完全没有a
读者始终保留至少一份保护,扫描仍可能释放a。因此本页协议要求整个危险使用期间持续保留同一个实际槽,不能仅靠“先填新槽、再清旧槽”的重叠迁移。交换线程私有的槽角色名称不改变实际槽内容,可以保持这条纪律;真正迁移保护则需要额外验证或扫描协议。
地址复用与逻辑ABA
在首次读取到发布复查之间,地址a可能已经释放并重新分配给另一个节点。若复查最终又读到a,本协议尚未读取旧内容,后续保护针对的是当前可达节点;算法必须在验证成功之后才收集依赖该节点的字段。
成功保护期间,受保护节点不能被释放并换成另一实例,从而阻止很多由回收引起的地址ABA。但如果某个结构允许一个仍然存活的同一节点被摘下又插回,或逻辑状态自行回到原值,hazard并不自动证明所有旧快照仍有效。操作自身的线性化和状态不变量仍要另证。
推论与应用
一个保护尝试有常数次原子读写,源指针若持续改变,重试次数可能无界。因此不能把回收扫描可在有限步完成,误写为“每个调用protect的对象操作都是wait-free”。对象进展还依赖其自己的重试、帮助与分配协议。
设退休批次含r个节点。扫描H个槽建立地址哈希集合,再检查r个退休地址,期望时间为
暂停线程可以长期保护至多K个地址,但这些地址仍不能释放。各线程私有批次和正在扫描的列表也占空间;在固定线程数、固定阈值和规定扫描策略下才有相应缓存上界,不能把“单线程只有K个槽”直接当成整个程序只多占K个节点。
Michael–Scott队列的出队通常需要同时保护旧Head和下一个节点:保护旧哨兵并不自动保护将要读取data的后继。本单元终点给出具体双槽发布/复查轨迹,将结构线性化与安全释放分开审核。
参考资料
- Maged M. Michael, “Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects”, IEEE TPDS 15(6), 2004, 491–504,§§3–4:扫描、持续保护条件及队列双槽应用