C++ 从 std::shared_ptr 引用计数的实现浅析 std::memory_order

Posted by Ciel's Paperplane on September 9, 2024

图片

本文前置知识见 [C++] 一点点 std::shared_ptr 的实现细节

1. 开篇

文档可见:std::memory_order - cppreference.com

首先我们需要知道,当我们写下如下的代码时,最后编译成汇编并不一样会是我们想象的那样:

void f() {
    int a = 1;
    double b = 1.0;
}

以此例来说,编译器完全可以把两条指令的执行顺序倒转。当然不止如此,在 RISC 平台上,由于在内存写入一个变量需要 load 入寄存器,修改值,再 store 回内存,所以这里可以拆成六条指令。只要它们在单线程下的执行结果不变,编译器可以随意调整它们的执行顺序,以追求更高效的指令执行。

但是到了多线程,这个指令重排行为就会带来很多麻烦。如果大家学过设计模式里单例模式的历史,可以知道单例模式里面的懒汉模式,在过去几十年一直是有 bug 的。在 C++ 里直到 C++11 标准引入 std::memory_order 才真正可以进行多线程编程。

2. 六种 memory_order

当我们使用 std::atomic<size_t> 作为 std::shared_ptr 引用计数时,很多人可以会像操作普通整型值一样进行 ++ += 等操作,这样虽然没有任何安全性问题,但是会比较浪费性能,因为 ++ 实际上是 fetch_add(1, std::memory_order_seq_cst) 的重载(其它操作同理),而 std::memory_order_seq_cst 是六种 memory_order 中最强也是性能损失最大的一种,多数情况下并没有必要。

而最弱的为 std::memory_order_relaxed,只保证原子性,没有同步或定序约束。引用计数中的增加只需要使用它即可。

std::memory_order_acquire 指的是从此语句往下的指令不允许重排到此语句之前,而 std::memory_order_release 则是从此语句往上的指令不允许重排到此语句之后。std::memory_order_acq_rel 是它们的加和。这是本篇要探讨的核心。

std::memory_order_consume 则是一个非常特殊且少用的东西,大部分人并不需要了解。注意到文档中有讲到 2015 年 2 月为止没有任何已知产品级编译器跟踪依赖链:consume 均被实现为 acquire。并且 C++17 中提到释放消费定序的规范正在修订中,暂时不鼓励使用 std::memory_order_consume

3. 释放-获取定序

先以 cppreference 的小例子讲解一下

std::atomic<std::string*> ptr;
int data;
 
void producer() {
    std::string* p = new std::string("Hello");
    data = 42;
    ptr.store(p, std::memory_order_release);
}
 
void consumer() {
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_acquire))) {}
    assert(*p2 == "Hello");
    assert(data == 42);
}

这个例子中,从人类视角设想的场景应该是:消费者线程一直卡在 while 循环,直到生产者执行完更新 ptr 那一行。此时 pdata 已经赋值过了,assert 语句不会有问题。得益于这一对 std::memory_order_release std::memory_order_acquire,执行确实会如预期所示。

但如果我们将生产者的 std::memory_order_release 替换为 std::memory_order_relaxed,情况就可能不一样了。失去了 release “从此语句往上的指令不允许重排到此语句之后”的保证,对 pdata 的赋值指令都有可能在 store 后才执行。

你可能会有疑问,data 重排没问题,因为在单线程下确实在哪里执行都不影响正确结果,但 p 会被 storeptr,它不应该一定要被执行完吗?并不一定,因为 new 也分为:用 ::operator new(sizeof(std::string)) 分配内存返回一根指针,在这根指针上执行 std::string 的构造函数(如果构造函数抛出异常还会 ::operator delete 这块内存),然后再把指针赋值给 p。这个过程也可以随意被重排,所以这个指针先赋值给 p 后再执行构造函数也完全可以,而 ptr 拿到 p 以后消费者查看 *p2 的时候这个构造函数可能还没有完成呢。这也是之前提到的懒汉单例模式的远古 bug。

那我们将消费者的 std::memory_order_acquire 替换为 std::memory_order_relaxed 又会如何呢。那它完全可以在 while 之前就取得 data 的值存入寄存器,只不过延迟到 while 之后才 assert,那使用的仍是未初始化值。

std::shared_ptr 的引用计数

void shared_count_release() noexcept {
    // A decrement-release + an acquire fence is recommended by Boost's documentation:
    // https://www.boost.org/doc/libs/1_57_0/doc/html/atomic/usage_examples.html
    // Alternatively, an acquire-release decrement would work, but might be less efficient
    // since the acquire is only relevant if the decrement zeros the counter.
    if (shared_count_.fetch_sub(1, std::memory_order_release) == 1) {
        std::atomic_thread_fence(std::memory_order_acquire);

        delete_pointer();
        weak_count_release(); // weak_count_ == weak_ref + (shared_count_ != 0)
    }
}

std::weak_ptrweak_count_release() 实现同理。

这里还是举两个反例来分别说明 fetch_sub(1, std::memory_order_release)std::atomic_thread_fence(std::memory_order_acquire) 的必要性。

这里的场景是当引用计数减为 0 时,最后那个线程要执行 if 块里的清理收尾过程。但是注意这里 fetch_sub 如果没有释放定序,之前对于管理对象的各种操作就可以重排到 if 块之后。只要引用计数不减到 0,那么对于当前线程来说,只是对一个数做了一次自减而已,自减在什么时候做都是一样的。而只要与此同时有另一个线程把引用计数减到 0 同时进行析构释放操作,之前这个线程就崩了。

说到 std::atomic_thread_fence(std::memory_order_acquire) 需要举一个稍微刁钻的例子:

假设我们的 std::shared_ptr 的管理对象为 std::unique_ptr<int>,我们知道它的析构会是把内部的指针 delete

std::shared_ptr<std::unique_ptr<int>> sp{new std::unique_ptr<int>{new int{1}}}; // 初始构造了一个 up 值为 1,sp 被两个线程持有

// 某个线程做了这样的事
sp.get()->reset(new int{2}); // reset 会删除之前管理的 up,并且塞入一个新的 up 值为 2

// 两个线程都调用了 shared_count_release()
if (shared_count_.fetch_sub(1, std::memory_order_release) == 1) {
    // std::atomic_thread_fence(std::memory_order_acquire);
    ...
}

所以在 if 块中会执行析构,并且把 sp 里面的 up 删除。而没有这个 acquire,编译器可以选择在 if 语句之前就把要删的 up 的指针提前存入寄存器,之后直接删除寄存器里的指针值。所以可以出现这样的场景:线程 1 向寄存器中存入了值为 1 的 up 值等待删除,而线程 2 中途又把 up 换掉了。那最后的结果就变成了 up1 被 double free 而 up2 泄漏。

libc++ 优化

LLVM 对于 weak_count_release() 有进一步的优化:

void weak_count_release() noexcept {
    // Avoid expensive atomic stores inspired by LLVM:
    // https://github.com/llvm/llvm-project/commit/ac9eec8602786b13a2bea685257d4f25b36030ff
    if (weak_count_.load(std::memory_order_acquire) == 1) {
        delete_control_block();

    } else if (weak_count_.fetch_sub(1, std::memory_order_release) == 1) {
        std::atomic_thread_fence(std::memory_order_acquire);
        delete_control_block();
    }
}

也就是在之前的基础上先 acquire load 查看值是否为 1,如果是 1 就代表它已经是最后一个持有资源的 std::weak_ptr,我们就可以直接删除资源,省下将引用计数更新为 0 写回内存的操作,因为 atomic store 开销还是比较大的。注意这里 acquire load 跟其它线程潜在的 fetch_sub(1, std::memory_order_release) 依旧构成释放-获取定序,所以不会有指令重排的风险。

但是,当最后一个持有资源的线程准备释放它时,这段时间依旧可以有其它线程尝试访问它并且增加引用计数。这句话是没错,问题在于,即使没有这个优化,这个访问过程也依旧可以发生在资源释放过程中的任何时刻,所以无论如何这都是无法避免的问题。

实际上这也确实不是 spwp 的责任,而是 std::atomic<std::shared_ptr<T>>std::atomic<std::weak_ptr<T>> 的责任,因为 spwp 本来就不是线程安全的。

再说回这个优化,它不能用于 shared_count_release() 是因为对于 std::shared_ptr,还存在一个特殊的函数 lock() 会原子性地检查 shared_count_ 的值,如果为 0 就认为其已被释放而不做任何事,不为 0 则从中增加一个引用共享,所以更新为 0 是必要的。而 std::weak_ptr 则没有。



Ciel's Paperplane

作诗中...