浅析 shared_ptr:MSVC STL 篇
序言请移步此处
因为这是系列第一篇,所以会带一些功能的 demo,以方便叙述。
How shared_ptr(new T()) differs from make_shared()
首先考虑 shared_ptr 对象的创建,对于给定类型 T,假设通过
1 | auto ptr = std::make_shared<T>(...); |
创建一个实例。
看一下函数代码:
1 | template<class _Ty, |
这里首先在 heap 上创建了一个 _Ref_count_obj<_Ty> 对象,通过 std::forward() 将 make_shared() 的参数转发作为构造函数;接着通过 default contructor 创建了一个 shared_ptr<_Ty>,并调用 _Set_ptr_rep_and_enable_shared() 设置相关数据。
因为创建 _Ty 实例需要的参数 _Args 被转发到了 _Ref_count_obj 的构造函数中,且 shared_ptr 的 default constructor 实质上是一个 constexpr function,因此猜测 shared_ptr 自身并不负责创建其管理的 object instance,而是将这部分操作“委托”给 _Ref_count_obj。
下面看一下 _Ref_count_obj 的结构
1 | class _Ref_count_base { |
引用计数的信息保存在了基类 _Ref_count_base 中,并且只是一个普通的 unsigned long,估计加减的相关原子操作是直接利用 Interlocked* 系列的 API 来完成。
计数相关的细节后面会分析,这里先略过。
_Ref_count_obj 自己只有一个成员,不过这个成员很有意思。
aligned_union_t<1, _Ty> 会在内部提供一个
- 地址按照
_Ty要求对齐 - 大小为
max(1, sizeof(_Ty))的 buffer
所以 _Storage 其实就是一个大小可以容纳一个 _Ty 实例,且地址经过对齐的 buffer,并且这个 buffer 就是用来保存 shared_ptr 要管理的 _Ty 的实例。
这可以结合 _Ref_count_obj 的构造函数看出:
1 | template<class... _Types> |
构造函数里通过 placement new 在前面说的 buffer 里构造了出了目标实例。
并且,实例和(计数)控制块实质上是一起存放在一块“大”内存上的。
至此,_Rx 的构造就结束了。
接下来看一下
1 | shared_ptr<_Ty> _Ret; |
是如何将 _Rx 和自己关联上的。
1 | // From class shared_ptr |
可以看出,shared_ptr 的基类 _Ptr_base 有且仅有两个成员,分别是 1) 指向 object instance 的指针 2) 指向 ref-counted 控制块的指针;这两个指针被分别设置为前面 _Ref_count_obj 有关的地址。
最后那个 _Enable_shared_from_this() 调用和 std::enable_shared_from_this 有关,后面会分析,这里先忽略。
到这里,函数 std::make_shared() 的整个流程我们已经清楚了,接下来就是研究并且对比一下,直接通过构造函数创建会有什么不同,亦即:
1 | std::shared_ptr<T> sp(new T(...)); |
对应调用的构造函数是:
1 | template<class _Ux, |
通过实现我们至少可以发现两点:
- 没有专门(dedicated)的通过
_Ty*创建对象的构造函数,而且凡是能够 implicit cast 到_Ty*的_Ux*都是被支持的 - C++ 17 开始
shared_ptr能够支持数组类型了。不知道这是个好消息还是坏消息……
由于 C++ 17 还没全面铺开,这里假设当作 C++ 11/14,不考虑类型是数组的 case。
接下来看一下 _Setp() 这个函数
1 | template <class _Ux> |
函数 _Set_ptr_rep_and_enable_shared() 前面分析过,是用来关联 shared_ptr instance 和它的 ref-count 控制块。
这里做了一个异常处理,应该是考虑到 new _Ref_count<_Ux>(_Px)) 可能会出现异常。
类型 _Ref_count 和前面分析过的 _Ref_count_obj 一样,都是 _Ref_count_base 的子类:
1 | // CLASS TEMPLATE _Ref_count |
整个类结构比较简单,只有一个指针成员,指向一开始 heap 上分配的对象。
Conclusion: 通过上面的分析,我们可以看出,两种方式的影响主要在于 shared_ptr 内部使用的 ref-count control block。
通过 make_shared() 创建,使用的是 _Ref_count_obj,内部已经包含了对象实例的内存区域,整体上只有一次内存分配。
而通过构造函数创建,使用的是 _Ref_count,内部仅有一个指针引用预先创建的对象;整体上有两次内存分配。
Why Virtual Dtor is Not Necessary When Deleting From a Base Pointer
通过构造函数创建 shared_ptr 对象有一个牛逼的副作用:shared_ptr 可以正确地通过基类指针析构整个对象,即使基类没有定义 virtual destructor。
换句话说:
1 | struct Base { |
这个“特性”目前是 shared_ptr 独有的,我们可以通过研究代码来理解为什么可以这样做。
回顾前面分析从构造函数创建 shared_ptr 的代码,可以发现,从一开始 shared_ptr 的构造函数到这里的 _Ref_count,所有相关函数都是 template,类型逐层传递保证 _Ref_count::_Ptr 是 heap 对象的实际类型,这意味着这个 shared_ptr 实现了在内部保存了管理对象的实际类型,并且 _Ref_count::_Destroy() 是直接对实际类型进行 delete expression。
所以,哪怕基类的析构函数不是 virtual,sp 一样能够正确析构。
Note,释放相关的细节因为和引用计数有关,留到后面说。
How Custom Deleter Works and Why It Is Not Part of the Type
假设我们有一个 custom deleter,我们可以这样使用:
1 | class C {}; |
看一下目标构造函数:
1 | template <class _Ux, |
Aside:由于 deleter 的定位一直是 function object,所以正确的实现是要支持 value semantics 并且控制拷贝的 cost,所以这里直接传值 + move。
这里通过函数 _Setpd() 设置对象指针和 deleter。
这个函数是不是觉得似曾相识?前面仅设置对象指针的函数叫 _Setp(),因此我们可以大胆猜测,相关的函数序列应该是形如 _Setxyz(),并且用 p 表示对象指针,d 表示 deleter;如果后面要研究 custom allocator,那么就会看到用 a 代替 allocator。
继续看一下 _Setpd() 函数:
1 | template <class _UxptrOrNullptr, class _Dx> |
整体实现和 _Setp() 非常像。不过 ref-count 的数据类型换成了 _Ref_count_resource,靠猜都知道它肯定也是 _Ref_count_base 的子类。
另外,因为要兼容对象指针为 nullptr 的情况,因此这里模板参数将指针类型整体保存了下来,即 _UxptrOrNullptr。
1 | // CLASS TEMPLATE _Ref_count_resource |
类似的,这里的管理对象的类型 _Dx 也是对象的实际类型。
比较有意思的是 _Compressed_pair,它是 MSVC utility 内部使用而一个辅助结构,核心就是一个 pair,不过和标准的 std::pair 不同,_Compressed_pair 做了EBO,亦即:当 _Dx 是一个空结构的情况下,压缩成员,只保留一个对象指针。
到这里就可以下结论:(custom) deleter 保存在 _Ref_count_resource 中,通过 _Ptr_base 的 _Ref_count_base* 去引用。因为 _Ptr_base 这个基类的存在,使得最外层的 shared_ptr 无需通过自身保存 deleter 类型的方式就可以访问到 deleter。
这种 indirection layer 实在是太常见了。
How enable_shared_from_this works
如果一个类需要在某个成员函数中返回指向自己 (this) 的 shared_ptr(常见于需要在成员函数中通过 std::bind() 创建一个 function object),那么就需要使用 std::enable_shared_from_this。
1 | struct SharedThisD : std::enable_shared_from_this<SharedThisD> { |
不过这里要注意的是,对象本身一定要首先是通过 shared_ptr 托管的,后面会看到为什么。
首先简单过一下 enable_shared_from_this 这个类的基本结构(略去无关代码):
1 | // CLASS TEMPLATE enable_shared_from_this |
整个类很简单,default constructor 甚至都是强安全的;但是这里存了一个 weak_ptr,所以我们再看一下 weak_ptr 的简单结构:
1 | // CLASS TEMPLATE weak_ptr |
weak_ptr 一样是 _Ptr_base 的子类,这点和 shared_ptr 同构。
回顾前面第一节 How shared_ptr<T>(new T()) differs from make_shared<T>() 里我们留了一个点,
1 | // From class shared_ptr |
_Enable_shared_from_this(),这个函数就是 enable_shared_from_this 能够正常运转的核心。
1 | template <class _Other, class _Yty> |
前面我们知道无论管理的对象是否使用 enable_shared_from_this,都会调用这个函数,那么为了做到正确区分,这里使用了 SFINAE。
如果不考虑 array 和 volatile 的情况,那么判断核心就是 _Can_enable_shared:
1 | template <class _Yty, class = void> |
通过这个 SFINAE 看出,匹配的原则是: _Esft_type* (其实就是 class enable_shared_from_this)能够 cast 到 _Yty*;这个做法其实也是自己实现 is_derived 常用的技法。
Aside:这里用了 C++ 17 引入的 void_t,部分程度上简化了 SFINAE 的构造。
1 | template <class _Other, class _Yty> |
因为管理实例对象是 enable_shared_from_this 的子类,而 _Enable_shared_from_this1() 这个函数又是一个 friend(往前翻翻),所以可以直接访问到 _Wptr。
接着是非常精彩的一幕:将 eanble_shared_from_this 的 weak_ptr _Wptr 和前面创建完的整个 shared_ptr 关联。
这个关联涉及两个点:
- 通过
_This和_Ptraliasing contruct 一个shared_ptr - 将 (1) 创建的
shared_ptr转存为weak_ptr
两步的核心代码结合在一起如下:
1 | template <class _Ty2> |
如果画一下
1 | auto sp = std::make_shared<SharedThisD>(); |
sp 的结构图,那么大概是这样(部分手绘,忽略渣效果):

可以看出,完成构造之后 enable_shared_from_this 保存了指向子类的 weak-ptr,加上用来管理的 shared_ptr,ref-count 控制块里:
- use-count 是 1
- weak-count 是 2
因为没有修改 use-count,所以不会阻碍实例的正确清理。
Bonus:这里可以想一下,如果 enable_shared_from_this 里存的是 shared_ptr 会怎么样。
同时,那个 weak-ptr 里(via _Ptr_base)保存了子类实例的地址。
在当前的例子里,如果考虑 sp 和 sp 里头存的 _Ref_count_obj,那么就有三分实例地址信息。
NOTE:上面几个信息可以很方便的在 VS 里通过调试器检查验证。
接着看一下 shared_from_this() 这个函数是怎么实现的:
1 | _NODISCARD shared_ptr<_Ty> shared_from_this() { // return shared_ptr |
可以看出这个函数的核心就是直接通过 weak_ptr _Wptr 构造了一个 shared_ptr 对象。
Conclusion:概括一下使用 enable_shared_from_this 的核心是这个类作为子类之后内部的 weak_ptr 保存了对外部实例进行管理的 shared_ptr 的引用。
How Reference Counting Works
终于到了大家都关心的如何实现引用计数的部份。
回顾前面我们知道,shared_ptr 内部保存了一个指向引用技术块 _Ref_count_base 的指针,而计数相关的实现就是由 _Ref_count_base 提供。
现在看一下这个基类的结构:
1 | // CLASS _Ref_count_base |
类初始化的时候,uses 和 weaks 都是 1;引用计数的增减操作是直接利用 _InterlockedIncrement() 和 _InterlockedDecrement() 这两个 intrinsic API。
减计数涉及到对象的销毁,因此逻辑会多一些。从上面可以看出,通常时候,uses 和 weaks 的增减是独立的;除了计数递减至 0 时。
(1) 如果 uses 计数递减归零,则会销毁管理的对象,同时减少 weaks。
注意,这里的销毁不一定会释放管理对象的资源,具体的操作和子类重写的函数有关。
比如,前面提到的 _Ref_count_obj 的 _Destroy() 仅仅调用了自己的析构函数,不释放内存,因为对象的内存和控制块在一起。
(2)如果 weaks 计数递减归零,那么说明整块引用计数控制块都没有存在的必要了,于是就可以“自杀”释放了内存了。
考虑到 uses 计数也是存放在技术控制块中,因此可以发现:当且仅当 weaks 计数归零时,才是真正意义上 shared_ptr 对象的释放。
所以这里可以看出使用 make_shared() 创建对象的一个缺点:只要有一个 weak_ptr 关联着,哪怕对象实例已经归天了,整块内存还是活得好好的。
接下来可以看一下这几个引用计数的增减函数在何时会被上头调用:
_Incref()会在“从一个shared_ptr构造”时被调用,无论是 copy construction 还是 aliasing construction_Decref()仅会在shared_ptr实例析构的时候被调用,不过这里有点意思是的是,shared_ptr调用的是_Ptr_base自己定义的_Decref(), which internally 调用了真正干活的_Decref()_Incwref()仅会在函数_Weakly_construct_from()中被调用_Decwref()会在一个weak_ptr析构时;或 uses 计数归零时被调用;类似的_Ptr_base自己也定义了一层_Decwref()
还有一个 _Incref_nz() 和 weak_ptr 到 shared_ptr 的 promotion 有关,后面再分析。
How weak_ptr relates with shared_ptr
众所周知,一个 weak_ptr 可以从一个 shared_ptr 构造;同时也可以从一个 weak_ptr 提升得到 shared_ptr。
先看看第一点。
因为标准规定 weak_ptr::operator=(const shared_ptr& r) 等价于 std::weak_ptr<T>(r).swap(*this),所以需要分析的标准接口只有构造函数一个。
1 | template <class _Ty2, |
复制了一下两个成员,以及底层 weaks 计数。
因为 weak_ptr 和 shared_ptr 同源(_Ptr_base),所以信息交换的时候不需要啥中间商。
接着看一下很重要的 promotion 过程,这部分操作由 weak_ptr::lock() 提供:
1 | _NODISCARD shared_ptr<_Ty> lock() const _NOEXCEPT { // convert to shared_ptr |
重担又还到了 shared_ptr::_Construct_from_weak() 身上。
1 | // from _Ptr_base |
这个时候可以回顾一下前面分析 weak_ptr 提到的 _Incref_nz() 了。
函数操作很简单,如果 uses 计数为 0,说明管理的对象已经死了,那么这个时候直接返回。
如果对象还在,那么就通过 CAS 对计数进行自增。
为什么这里要用 CAS 而不是简单的 increment,留到后面分析 thread-safe 的时候再说。
Conclusion:说到底其实还是引用计数的事儿,不过因为牵扯到一些线程安全的问题,所以有点说不清道不明。
Thread-safety of shared_ptr Instances
先抛结论:
- 多个线程同时读写多个(引用同一个管理对象)
shared_ptr实例是线程安全的 - 多个线程同时读写一个
shared_ptr实例是非线程安全的
且以上仅针对 shared_ptr 自身而言,非期管理的对象。
先看 case 2,假设有代码
1 | // global |
shared_ptr::operator= 内部会创建临时对象,内部通过 _Copy_construct_from() 完成复制:
1 | template <class _Ty2> |
从代码可以看出,_Ptr 和 _Rep 的更新期间, _Other 完全可以发生改变,引用计数的增减原子性在这里没有什么卵用。
再考虑 case 1,假设我们有一个 sp 管理一个对象实例,那么符合的情况大抵只有:
- sp 的某个
shared_ptr的拷贝对象析构了 - 某个引用 sp 的
weak_ptr通过lock()获得了一个shared_ptr
对于第一点,析构会调用前面说的 _Decref(),对 uses 计数做原子减操作,这一步是安全的。
那么有没有可能存在递减为0之后要执行 _Destroy() 的时候和其他打算做递增的操作冲突呢?
答案是不存在。
原因如下:我们已经排除了多个线程对同一个 sp 读写的可能(因为已经论证了这个是非线程安全),那么说明至少存在一个 sp 的拷贝,因此 uses 的计数恒大于等于 2。
第二点也是类似的情况,除了 —— 要考虑 promotion 的时候对象已经消亡了。
因为,lock() 当且仅当在 uses 还有效时才会做一次递增,而这种“判断-递增”操作是需要 CAS 才能保证原子性的。
Epilogue
至此,MSVC STL 版本的 shared_ptr 和 weak_ptr 几处核心代码就分析完了。
To be continued…