Hazard Pointers:发布-验证协议、有界垃圾与栅栏的代价
内容提要
本文详解危险指针(HP)内存回收机制:读者先登记节点地址,再复读源指针,并配合StoreLoad栅栏防止use-after-free。实测去掉复读时ASan报33次错误,去掉栅栏后1000次运行捕获4次错误。未回收节点峰值等于理论上界2R,休眠线程不增加峰值。只读遍历每节点代价从1.65ns升至4.08ns,非对称栅栏降至2.66ns;缓存未命中时差异被淹没。HP不兼容乐观遍历,已进入C++26标准。
延伸解读
复读与栅栏:正确性的两个关键
HP协议中,发布hazard pointer后必须复读源指针,否则可能保护已释放的节点。实测去掉复读,ASan在40次运行中报告33次use-after-free。StoreLoad栅栏同样关键,去掉后1000次运行捕获4次错误。x86上栅栏编译为lock orq或xchg,开销小但不可省略。
未回收节点的有界性
HP保证未回收节点数有上界,每线程最多R个,全局PR个。实测峰值等于理论上界2R(P=2, K=1)。即使线程休眠,峰值也不变,因为休眠线程只扣住自己槽位和retired list中的有限节点。这解决了EBR中慢线程阻塞回收的问题。
栅栏的代价:何时可见
在只读遍历中,每个节点代价从1.65ns升至4.08ns,非对称栅栏降至2.66ns。但缓存未命中时差异被淹没。在Treiber栈上,栅栏代价被CAS本身盖住。非对称栅栏将读者栅栏移给回收者,但扫描时membarrier系统调用开销约209ns,需扫描足够稀少才划算。
HP的局限与演进
HP不兼容乐观遍历,因为复读无法确认跨多个节点的可达性。HP++和SCOT分别通过改回收方案和改数据结构来支持乐观遍历。C++26已纳入HP,但上界未指定,且省略了全局清理函数,依赖析构时机的代码需注意。
Q&A
危险指针(HP)为什么在登记节点地址后还要再读一次源指针?
只发布不复读,无法保证节点在发布时刻仍然安全。复读源指针可以确认节点在发布之后仍被结构引用,从而满足危险引用的安全条件。
在危险指针中,StoreLoad栅栏的作用是什么?去掉它会有什么后果?
StoreLoad栅栏防止读者和回收者的写后读操作被重排,避免读者读到已释放的节点。去掉栅栏后,在x86上实测1000次运行中捕获到4次use-after-free。
危险指针的未回收节点数量为什么有上界?上界由什么决定?
上界由线程数P、每线程危险指针数K和扫描阈值R决定。每个线程最多保留R个节点,全局上界为P·max(R, (P-1)K+1)。实测峰值与理论值2R吻合。
危险指针在只读遍历上的性能开销有多大?非对称栅栏能降低多少?
在相邻节点的只读遍历上,每个节点从1.65ns升至4.08ns;使用非对称栅栏(membarrier)降至2.66ns。但在缓存未命中时,差异被淹没。
为什么危险指针不兼容乐观遍历?有哪些解决方案?
乐观遍历会越过逻辑删除的节点,导致复读无法确认节点安全。解决方案包括:改遍历(如Harris-Michael链表)、改回收方案(如HP++)、改数据结构(如SCOT)。
C++26标准中的危险指针接口是怎样的?
C++26引入<hazard_pointer>头文件,类型需继承hazard_pointer_obj_base,使用make_hazard_pointer和protect方法。标准不规定具体上界,由实现决定栅栏策略。