Skip to the content.

harzard pointer

https://en.cppreference.com/w/cpp/thread 这里介绍的,还没有实现 https://melodiessim.netlify.app/intro-hazard-ptrs/ https://ckf104.github.io/posts/Thread-in-UE/ harzard pointer 是实现 lockfree 的基础 游戏引擎这么复杂的吗?

https://mp.weixin.qq.com/s/is1XID2rSWy3vnd0rmhEJA https://mp.weixin.qq.com/s/MpJHAM_9tVlUTpBXGCgd5Q

0. 核心

它解决的是 lock-free 数据结构里一个非常核心的问题:

一个线程把节点从数据结构里删除之后,什么时候才能真的 free()

这个问题不是“并发修改”本身,而是 并发内存回收(safe memory reclamation)

1. 最典型的问题:lock-free stack

比如一个 Treiber stack:

struct node {
    struct node *next;
    int value;
};

_Atomic(struct node *) head;

pop 大致写成:

node *p = atomic_load(&head);
node *next = p->next;

if (CAS(&head, p, next))
    free(p);

乍看没问题,但两个线程同时运行:

CPU0                            CPU1

p = head;   // p = A

                                CAS(head, A, B)
                                free(A);

next = p->next;
       ^^^^^^^
       use-after-free

也就是说:

CPU0 只是“读到了 A”,并不代表 A 还活着。

这就是 lock-free 数据结构中最麻烦的地方之一。


2. Hazard Pointer 的核心思想

Hazard Pointer 的思路非常直接:

在解引用一个共享指针之前,先公开声明:

“我现在可能要访问这个对象,你不能 free 它。”

每个线程都有一个或几个 hazard pointer slot。

例如:

_Atomic(void *) hazards[MAX_THREADS];

CPU0 想访问节点 A

CPU0:

1. p = head
2. hazards[0] = p
3. 再检查 head 是否还是 p
4. 如果还是,就可以安全使用 p

CPU1 删除 A 后,不能马上:

free(A);

而是先把它放进:

retired list

以后检查所有 hazard pointers:

hazard[0]
hazard[1]
hazard[2]
...

如果没有任何 hazard pointer 指向 A

A ∉ hazard set

才能:

free(A);

所以可以把 hazard pointer 理解成:

“这个对象目前有人可能正在摸,暂时别回收。”

3. 最关键的地方:为什么需要“读两次”?

这是理解 Hazard Pointer 最重要的一点。

很多人第一次会写成:

p = atomic_load(&head);
atomic_store(&my_hazard, p);

use(p);

这是 错误的

因为中间存在窗口:

CPU0                            CPU1

p = head;      // A

                                head = B
                                scan hazards
                                没看到 A
                                free(A)

hazard = A;

use(A);   // UAF

你虽然“发布 hazard pointer”了,但发布得太晚。

因此正确方式是:

do {
    p = atomic_load(&head);

    atomic_store(&my_hazard, p);

} while (p != atomic_load(&head));

逻辑是:

read pointer
    ↓
publish hazard
    ↓
re-read pointer

只有确认:

共享位置仍然指向同一个对象

之后,才允许 dereference。


4. 为什么第二次检查可以解决?

考虑两种情况。

情况 A:删除发生在 hazard 发布之后

CPU0                      CPU1

p = head(A)
hazard = A
                          remove A
                          scan hazard
                          看见 A
                          不 free
check head
use A

安全。


情况 B:删除发生在 hazard 发布之前

CPU0                      CPU1

p = head(A)

                          remove A
                          scan hazards
                          free A

hazard = A

check head

此时:

head != A

所以 CPU0 不允许解引用 A,而是重新开始。

注意一个很微妙的地方:

hazard = A;

即便这里 A 已经 free 了,本身通常还没问题。

因为你只是保存了一个 指针值,没有 dereference。

真正关键是:

p->next

必须发生在验证成功之后。


5. 一个典型 HP pop

大致如下:

node *pop(void)
{
    node *p;
    node *next;

retry:
    p = atomic_load_explicit(&head, memory_order_acquire);

    if (!p)
        return NULL;

    atomic_store_explicit(&my_hazard, p,
                          memory_order_seq_cst);

    if (p != atomic_load_explicit(&head,
                                  memory_order_acquire))
        goto retry;

    /* 到这里之后 p 被 hazard 保护 */
    next = p->next;

    if (!atomic_compare_exchange_weak(&head,
                                      &p,
                                      next))
        goto retry;

    atomic_store(&my_hazard, NULL);

    retire(p);

    return p;
}

实际代码在 retry 时还要认真清 hazard,以及 memory ordering 往往可以优化,先看逻辑即可。


6. “删除”和“回收”必须分开

Hazard Pointer 强迫你区分两个概念:

logical removal

从数据结构里移除:

A -> B -> C

变成

A    B -> C

例如:

CAS(&head, A, B);

此时:

A 已经 unreachable

但它未必能 free。


physical reclamation

真正:

free(A);

必须等到:

没有 hazard pointer 指向 A

所以通常有一个:

retired list

例如:

remove A
  ↓
retire(A)
  ↓
retired = [A, ...]
  ↓
数量达到阈值
  ↓
scan all hazard pointers
  ↓
free 没被保护的 retired nodes

这是 HP 的基本生命周期:

reachable
   ↓
removed
   ↓
retired
   ↓
no hazard references
   ↓
free

7. 为什么不每删除一个节点就扫描一次?

因为扫描通常是:

O(number_of_threads × hazard_slots)

假设:

100 threads
2 HP/thread

每次删除都扫 200 个 atomic pointer,会很贵。

所以一般:

retire(node)
{
    retired_list.push(node);

    if (retired_list.size >= threshold)
        scan();
}

批量做:

retired:
A B C D E F G

hazard set:

{B, F}

那么:

A free
B keep
C free
D free
E free
F keep
G free

8. HP 真正保证的是什么?

Hazard Pointer 不是保护:

对象不会从数据结构删除

对象完全可以被别人 remove。

它保护的是:

只要我的 hazard pointer 仍然指向这个对象,它就不能被释放。

例如:

CPU0:

hazard = A

CPU1 可以:

unlink(A)

但是不能:

free(A)

直到 CPU0:

hazard = NULL

9. 和 reference count 有什么区别?

直觉上它们很像,但获取引用时存在本质区别。

refcount 想做:

p = head;
refcount_inc(&p->refcnt);

问题是:

CPU0                     CPU1

p = head                 remove p
                         refcount -> 0
                         free(p)

refcount_inc(&p->refcnt)
              ^
              UAF

也就是说:

你要增加 refcount,本身就必须首先保证对象还活着。

这就是一个 chicken-and-egg 问题。

HP 则避免修改对象本身:

HP 放在线程自己的独立 storage 里。

所以:

hazard = p;

不需要访问:

p->xxx

这非常关键。


10. 和 RCU 很像,但思路完全不同

你最近在看 RCU,所以可以直接这么对比。

RCU 的思路是:

不问“谁在使用这个对象”,而是等一个 grace period,保证旧 reader 都退出了。

remove A
    ↓
call_rcu(A)
    ↓
grace period
    ↓
free A

Hazard Pointer:

每个 reader 明确告诉 writer:

“我当前正在保护 A。”

reader:
hazard = A

writer:
remove A
scan hazards
A 被保护 → 暂时不能 free

可以理解成:

RCU:
    reader 表示“我在 read-side critical section”

HP:
    reader 表示“我具体正在用这几个对象”

这是非常大的区别。


11. HP vs RCU

简单对比:

  Hazard Pointer RCU
reader 要做什么 publish pointer enter/exit RCU read section
精确度 精确到 object 精确到 grace period
reader overhead atomic store + validation 通常极低
reclamation scan hazard pointers 等 grace period
stalled reader 只挡住自己保护的对象 可能拖延整个 grace period
常见领域 用户态 lock-free Linux kernel

例如一个线程挂死:

hazard = A
线程永久 sleep

结果只是:

A 永远无法 free

而其它对象仍然可以回收。

这也是 HP 一个很有意思的性质:

一个 stalled reader 对 reclamation 的影响是局部的。


12. 和 Epoch Based Reclamation(EBR)区别

EBR 更像轻量版 RCU。

reader:

enter epoch
...
exit epoch

writer:

retire A at epoch N

只有所有线程都已经越过 epoch N:

free A

因此:

EBR:
    谁还处在旧 epoch?

HP:
    谁具体还拿着 A?

假设:

thread0 卡住

EBR 可能导致:

所有 epoch N retired objects 都不能释放

HP 只会导致:

thread0 hazard 指向的那些对象不能释放

13. 为什么叫 Hazard Pointer?

因为它不是:

protected pointer

这个名称强调的是对 reclaimer 来说:

hazard = A

意味着:

A 是危险的,目前不能回收。

所以:

hazard set = 当前不能 reclaim 的对象集合

14. 一个链表 traversal 为什么可能需要两个 HP?

比如:

A -> B -> C

你现在保护 A:

HP0 = A;

准备读:

B = A->next;

但如果你准备从 A 移动到 B,不能简单:

HP0 = B;

因为需要先确保 B 在你 publish hazard 之前没被删除/reclaim。

常见模式是:

HP0 = current
HP1 = next

过程:

protect A

read B = A->next

protect B

revalidate A->next == B

clear HP0

current = B

所以:

一个算法需要几个 hazard slots,往往由“一次同时必须安全持有几个节点”决定。

这也是 HP API 经常提供:

HP[0]
HP[1]
HP[2]

的原因。


15. Hazard Pointer 和 ABA

HP 对 ABA 也很有帮助,但要小心一句话:

Hazard Pointer 主要解决 reclamation,它可以消除很多“由内存重用造成的 ABA”,但不是一个通用 ABA 解法。

比如:

head = A

CPU0 保存 A。

CPU1:

pop A
free A

malloc()

碰巧新节点又分配到地址 A:

head = A

CPU0 看:

head == A

以为没变。

HP 如果正确保护了旧 A,那么:

旧 A 不能被 free

自然不能被 allocator 立即复用成另一个 A。

因此这种:

reclamation-induced ABA

就避免了。

但是数据结构如果自己可以:

A -> B -> A

逻辑状态真正发生 ABA,HP 本身不一定解决。

这种往往需要:

tagged pointer
version counter

等机制。


16. Memory ordering 是 HP 最容易写错的地方之一

从 abstract algorithm 来看:

reader:

P = load(shared)
store(hazard, P)
if (P != load(shared))
    retry
dereference P

reclaimer:

remove(P)
scan hazards
if P not found
    free(P)

关键要求是:

“publish hazard”和“reclaimer scan”之间必须有足够强的排序关系,否则双方可能都看不到对方。

危险执行类似:

reader                        writer

P = shared

                              shared = NULL

hazard = P

                              scan hazard -> NULL

shared check -> P ?           free(P)

在弱内存模型上,证明正确 ordering 并不简单。

因此经典 Hazard Pointer 算法对:

hazard publish

通常要求相对较强的原子语义。

这里不能简单认为:

store_release(hazard, p)

一定就够了。

原因是 release 本质上主要约束:

之前操作
   ↓
release

而 HP 最关键的关系里还涉及:

publish hazard
   ↓
随后重新 load shared pointer

以及和 reclaimer scan 形成跨线程同步。

所以 HP 的 C/C++ memory model 实现经常比算法伪代码难不少。


17. 从“所有权”角度理解 HP

我觉得最容易建立直觉的方法是区分:

shared reachability

和:

temporary ownership

数据结构:

head → A

表示:

A 由于数据结构可达,因此活着

但当你:

p = head;

并不能自动把这个生命期转移给你。

HP 做的事情其实是:

shared ownership:
    head → A

            ↓

temporary reader claim:
    HP → A

然后 writer 可以断开:

head -X-> A

但因为:

HP → A

对象仍然不能释放。

最后:

HP = NULL

它才真正失去所有保护。


18. 一个非常简化的完整模型

假设每个线程只有一个 HP:

_Atomic(void *) hp[NTHREADS];

struct retired {
    void *ptr;
    struct retired *next;
};

保护:

void *protect(_Atomic(void *) *src)
{
    void *p;

    do {
        p = atomic_load(src);
        atomic_store(&hp[tid], p);
    } while (p != atomic_load(src));

    return p;
}

释放保护:

void unprotect(void)
{
    atomic_store(&hp[tid], NULL);
}

删除:

p = ...
unlink(p);
retire(p);

scan:

for each retired node r:
    found = false;

    for each thread t:
        if (hp[t] == r) {
            found = true;
            break;
        }

    if (!found)
        free(r);

核心就是这么简单。

工程实现的复杂性主要在:

1. memory ordering
2. retired list batching
3. thread registration
4. HP slots 管理
5. scan 的性能
6. thread exit 清理

19. 为什么 Hazard Pointer 很漂亮,但内核里 RCU 更常见?

因为 HP 的 reader fast path 并不算便宜。

一次 pointer dereference 可能变成:

load shared pointer
store hazard pointer
load shared pointer again
branch/retry
load object data

尤其:

store hazard pointer

是一个共享可见的 atomic store。

而 RCU reader 典型场景下可能接近:

rcu_read_lock();
p = rcu_dereference(ptr);
...read...
rcu_read_unlock();

在某些内核配置/架构上,read-side overhead 极低。

如果是:

read-mostly

RCU 非常有优势。

HP 更常见于:

portable userspace lock-free library
C/C++ concurrent data structure

因为没有内核帮你提供:

scheduler-assisted grace period

而 HP 可以纯用户态实现。


20. 最后抓住这三个核心点

如果只记 Hazard Pointer 的三个东西,我建议记:

1. load → publish → recheck

不能:

load → dereference

也不能:

load → publish → 直接 dereference

必须:

load pointer
publish hazard
revalidate pointer
dereference

2. removal != reclamation

unlink(node)

不等于:

free(node)

必须先:

retire(node)

等待:

node ∉ hazard set

3. HP 保护的是“对象生命周期”

它不是:

locking

也不是:

阻止别人修改节点

而只是:

阻止对象在你使用期间被 reclaim

Hazard Pointer 是 reader 把“我准备访问这个具体对象”公开出来,reclaimer 只有确认没有任何 reader 宣称该对象为 hazard 时,才能真正释放它。

本站所有文章转发 CSDN 将按侵权追究法律责任,其它情况随意。