本文前置知识见 [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
那一行。此时 p
和 data
已经赋值过了,assert
语句不会有问题。得益于这一对 std::memory_order_release
std::memory_order_acquire
,执行确实会如预期所示。
但如果我们将生产者的 std::memory_order_release
替换为 std::memory_order_relaxed
,情况就可能不一样了。失去了 release
“从此语句往上的指令不允许重排到此语句之后”的保证,对 p
和 data
的赋值指令都有可能在 store
后才执行。
你可能会有疑问,data
重排没问题,因为在单线程下确实在哪里执行都不影响正确结果,但 p
会被 store
进 ptr
,它不应该一定要被执行完吗?并不一定,因为 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_ptr
的 weak_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)
依旧构成释放-获取定序,所以不会有指令重排的风险。
但是,当最后一个持有资源的线程准备释放它时,这段时间依旧可以有其它线程尝试访问它并且增加引用计数。这句话是没错,问题在于,即使没有这个优化,这个访问过程也依旧可以发生在资源释放过程中的任何时刻,所以无论如何这都是无法避免的问题。
实际上这也确实不是 sp
和 wp
的责任,而是 std::atomic<std::shared_ptr<T>>
和 std::atomic<std::weak_ptr<T>>
的责任,因为 sp
和 wp
本来就不是线程安全的。
再说回这个优化,它不能用于 shared_count_release()
是因为对于 std::shared_ptr
,还存在一个特殊的函数 lock()
会原子性地检查 shared_count_
的值,如果为 0 就认为其已被释放而不做任何事,不为 0 则从中增加一个引用共享,所以更新为 0 是必要的。而 std::weak_ptr
则没有。