当我在《C++实战:核心技术与最佳实践》中写下下面的句子时:
在 C++17 里,我们可以用
atomic_load
、atomic_store
等独立函数来对shared_ptr
进行操作(这些函数在这一场景之外主要用于跟 C 代码兼容)。而到了 C++20 之后,更可以直接对atomic<shared_ptr>
对象使用load
、store
等方法,这样可以实现一些允许并发访问的操作,如可并发访问的树形结构。在这样的树形结构里,修改者能够像提交数据库事务一样来提交自己的修改,而在提交之前的读者会看到修改前的一致状态。
我并没有多想 C++20 和之前的方式之间的区别。毕竟,两者的区别是很容易屏蔽的。我早就写了下面这样的代码:
class TreeNodeBase;
using TreePtr = std::shared_ptr<TreeNodeBase>;
#if __cpp_lib_atomic_shared_ptr >= 201711L
using TreeAtomicPtr = std::atomic<TreePtr>;
inline TreePtr loadTreeAtomicPtr(TreeAtomicPtr& ptr)
{
return ptr.load(std::memory_order_acquire);
}
inline void storeTreeAtomicPtr(TreeAtomicPtr& ptr, TreePtr desired)
{
ptr.store(std::move(desired), std::memory_order_release);
}
#else
using TreeAtomicPtr = TreePtr;
inline TreePtr loadTreeAtomicPtr(TreeAtomicPtr& ptr)
{
return std::atomic_load_explicit(&ptr, std::memory_order_acquire);
}
inline void storeTreeAtomicPtr(TreeAtomicPtr& ptr, TreePtr desired)
{
std::atomic_store_explicit(&ptr, std::move(desired),
std::memory_order_release);
}
#endif
不过,当某技术群正好聊起了相关的问题时,我也就去细看了一下标准库实现里的源代码。不看不知道,一看吓一条——这两种方式的差异还真不小。
首先,在使用非 C++20 的方式时,你访问的对象仍然是 shared_ptr
,这就意味着如果有人使用非原子的方式访问这个对象的话,仍可能发生数据竞争。编译器没法帮你自动检查这个问题。
其次,因为对象本身仍然是 shared_ptr
,锁不能跟对象放在一起。而在 C++20 的 atomic<shared_ptr>
里,实现可以使用技巧,把锁跟智能指针放在一起。事实上,目前已有的实现里,atomic<shared_ptr>
的大小跟 shared_ptr
都没有变化,直接利用了指针里一般不会用到的那些比特。
那问题就来了,在 C++20 之前,对 shared_ptr
的原子操作使用的是什么锁?是怎么对不同的 shared_ptr
分配锁的?
这实际上是标准库的实现可以自行决定的。我们可以看一下最常见的 libstdc++ 里的实现逻辑。
从 include/bits/shared_ptr_atomic.h 里我们可以看到下面这样的代码:
template<typename _Tp>
_GLIBCXX20_DEPRECATED_SUGGEST("std::atomic<std::shared_ptr<T>>")
inline shared_ptr<_Tp>
atomic_load_explicit(const shared_ptr<_Tp>* __p, memory_order)
{
_Sp_locker __lock{__p};
return *__p;
}
…
template<typename _Tp>
_GLIBCXX20_DEPRECATED_SUGGEST("std::atomic<std::shared_ptr<T>>")
inline void
atomic_store_explicit(shared_ptr<_Tp>* __p, shared_ptr<_Tp> __r,
memory_order)
{
_Sp_locker __lock{__p};
__p->swap(__r); // use swap so that **__p not destroyed while lock held
}
显然,这里是加了一把锁。查找 _Sp_locker
的使用,我们就可以在 src/c++11/shared_ptr.cc 里看到:
namespace
{
inline unsigned char key(const void* addr)
{ return _Hash_impl::hash(addr) & __gnu_internal::mask; }
}
_Sp_locker::_Sp_locker(const void* p) noexcept
{
if (__gthread_active_p())
{
_M_key1 = _M_key2 = key(p);
__gnu_internal::get_mutex(_M_key1).lock();
}
else
_M_key1 = _M_key2 = __gnu_internal::invalid;
}
在找到 __gnu_internal::mask
的定义是 0xf
之后,我们就清楚了:哦,这里 libstdc++ 是准备了 16 个全局的 mutex,通过对指针进行哈希、取低位来获得锁的索引,然后使用指定的全局锁来进行锁定……
(群友柴郡猫正确提出了猜想:“如果全局锁的话,可能多个 atomic 共有,有一组锁池,按照某种 hash 算法,获取一个锁对象去加锁”。跟 libstdc++ 的作者完全是不谋而合啊。)
显然,如果你需要原子访问的 shared_ptr
不多,这样的实现基本没有问题。但如果很多的话,那问题就大了。
这实际上也是 Herb Sutter 在 N4058 里提出 atomic<shared_ptr>
的原因了:
… the free functions inherently less efficient; for example, the implementation could require every
shared_ptr
to carry the overhead of an internal spinlock variable (better concurrency, but significant overhead pershared_ptr
), or else the library must maintain a lookaside data structure to store the extra information forshared_ptr
s that are actually used atomically, or (worst and apparently common in practice) the library must use a global spinlock.
atomic<shared_ptr>
的实现就完全不同了。从 shared_ptr_atomic.h 里可以看到,atomic<shared_ptr>
底层是对 _Sp_atomic<weak_ptr<_Tp>>
进行操作,而 _Sp_atomic
里的数据(_M_val
)则是 __atomic_base<uintptr_t>
。_Sp_atomic
的 lock
操作大致说明了锁相关的逻辑(略有简化):
// Precondition: Caller does not hold lock!
// Returns the raw pointer value without the lock bit set.
pointer
lock(memory_order __o) const noexcept
{
// To acquire the lock we flip the LSB from 0 to 1.
auto __current = _M_val.load(memory_order_relaxed);
while (__current & _S_lock_bit)
{
__detail::__thread_relax();
__current = _M_val.load(memory_order_relaxed);
}
while (!_M_val.compare_exchange_strong(__current,
__current | _S_lock_bit,
__o,
memory_order_relaxed))
{
__detail::__thread_relax();
__current = __current & ~_S_lock_bit;
}
return reinterpret_cast<pointer>(__current);
}
也就是说,指针的最低位(LSB)被认为是不会使用的,这里被当作锁定标识使用。这里就是一个典型的使用 CAS 来加锁的场景了。
不过,历史上没有更早提出 atomic<shared_ptr>
也是有原因的。一般而言,atomic<T>
里的 T 应当是平凡的(trivial)。shared_ptr
显然不平凡——比如它的拷贝构造函数需要做一些工作,绝不能简单复制其中的数据了事。这是更早的 N2674 提案里明确不采用 atomic<shared_ptr>
这一特化形式的原因。Sutter 在 N4058 里也只是对智能指针这个特殊情况开了绿灯。 目前,在标准库里我也只看到 atomic<shared_ptr>
和 atomic<weak_ptr>
这两种非平凡类型的原子量。
因此,我们清楚地看到了,C++ 在演进过程中既有很多革命性变化(如模块、概念等),也有很多小改进,而 atomic<shared_ptr>
就是无数不起眼的小改进中的一个。但它确实让代码的可读性、可维护性和性能,都比之前更早的方式有了很大的提高。