查看原文
其他

内存管理:具有共享所有权的智能指针(二)

月踏 知知爸爸是码农 2022-06-13

上篇文章《内存管理:具有共享所有权的智能指针(一)》主要讲了下面几个点:

  • shared_ptr的引用计数如何更新

  • 自定义析构器原理

  • make_shared分析

本文来继续挖掘具有共享所有权语义的智能指针的更多细节,主要针对下面几个点:

  • weak_ptr在解决什么问题

  • 多线程使用shared_ptr要不要加锁

  • shared_from_this

  • size和performance分析

一、weak_ptr在解决什么问题

weak_ptr算是shared_ptr的一个扩充,它本身不是一种独立的智能指针,它可以用来指向shared_ptr中管理的数据块,但是它不会影响前文提到的强引用计数_M_use_count,它只会影响弱引用计数_M_weak_count。

之所以引入weak_ptr,是为了解决shared_ptr的循环引用问题,举个例子说明一下,假如有下面两个示例类:

class A {  ... other data ...  shared_ptr<B> SP2B;};class B { ... other data ...  shared_ptr<A> SP2A;};
int main(int argc, char** argv) {  shared_ptr<A> SP2A = make_shared<A>(...);  shared_ptr<B> SP2B = make_shared<B>(...);  SP2A.SP2B = SP2B;  SP2B.SP2A = SP2A;  return 0;}

上面示例代码很简单,A类中有一个指向B类对象的shared_ptr SP2B,B类中也有一个指向A类对象的shared_ptr SP2A,在main函数中分别创建两个shared_ptr的变量SP2A、SP2B,并对它们的类成员赋值,赋值之后数据块A、B对应的引用计数都为2,这时智能指针、数据块、控制块的内部关系如下图:

图1

在main函数执行结束之后,局部对象SP2A、SP2B被销毁,我们期望数据块的空间会被自动释放,但实际情况并非如此,由于A、B类的内部分别持有指向对方的智能指针,这时数据块对应的引用计数由2变为1,数据块不会被释放,由此出现内存泄漏:

图2

这种情况下,因为weak_ptr不影响强引用计数,只需要把前面A、B类中的shared_ptr换成用weak_ptr就可以解决循环引用这个问题。

二、多线程使用shared_ptr要不要加锁

直接说结论,多线程使用shared_ptr需要加锁,虽然控制块中的引用计数是原子类型,引用计数本身的更新是线程安全的,但是会有下面两种race condition出现:

  1. 不同线程同时访问数据块,这种情况很好理解,不多赘述

  2. 智能指针在赋值时简单来说有两步操作,赋值数据块的指针,赋值控制块的指针,下面重点说下这种情况

假如有下面示例代码:

// thread xshared_ptr<Type> A = make_shared<Type>(...);shared_ptr<Type> B = A;
// thread yshared_ptr<Type> C = make_shared<Type>(...);A = C;

如上面示例代码所示,在thread x中,B = A其实包含两步操作:

1. B.control_ptr = A.control_ptr

2. B.data_ptr = A.data_ptr

同样的,在thread y中,A = C也包含两步操作:

3. A.control_ptr = C.control_ptr

4. A.data_ptr = C.data_ptr

我们知道线程x、y的内部指令顺序是固定的,但线程间的指令执行顺序就不固定了,大家可以自行排列组合一下,假如执行顺序是1、3、4、2,结果如下图所示:

图3

如图3所示,这个执行序列将B的控制块指针和数据块指针分别指向了错误的地方,导致data A一直不会被释放,A、C析构完时,data C被释放一次,当B析构时,data C又被释放一次,这就导致data C被释放两次,1、3、4、2只是其中一种race condition,其它的同理,大家可以自行推演。

三、shared_from_this

关于shared_from_this,先问两个问题:

  1. 有什么用

  2. 原理是什么

对于第一个问题,如果有这样一个需求,一个类的成员函数,参数是一个回调函数,如果要在这个成员函数里以当前对象地址为参数异步调用这个回调函数,首先想到按照下面代码来写:

class Foo { 。。。 void test(function<void(Foo*)> callback) { // 以当前对象的指针为参数,异步调用callback函数    async(std::launch::async, callback, this); } 。。。};

但是这样来写存在一个很大的问题,假如当前线程释放了this指针所代表的内存空间,而异步调用的线程还没结束,还在用这个this指针,程序就会运行出错。

这时我们马上想到使用shared_ptr来自动管理this指针所对应内存空间的生命周期,改为使用shared_ptr后代码如下所示:

class Foo { 。。。  void test(function<void(shared_ptr<Foo>*)> callback) {    // 以当前对象的指针为参数,创建一个shared_ptr    shared_ptr sp(this); // 异步调用callback函数    async(std::launch::async, callback, sp); } 。。。};

这样的话是不是就解决问题了呢,在回答这个问题之前,先来看一下shared_ptr对应的控制块会被创建的三种情况:

  1. 使用raw pointer作为参数构造shared_ptr

  2. 使用unique_ptr作为参数构造shared_ptr

  3. 使用make_shared构造shared_ptr

很显然,上面的代码属于第一种情况,每次调用都会创建一个控制块出来,这还不包括这个函数外被创建的控制块,但是数据块只有一个,这样当每个控制块中的引用计数为0时,就会释放一次数据块,可想而知,这样程序运行也会出错。

这时,用shared_from_this就可以很好的解决这个问题,代码需要修改如下:

class Foo : public enable_shared_from_this<Foo> { 。。。 void test(function<void(shared_ptr<Foo>*)> callback) { // 以当前对象的指针为参数,创建一个shared_ptr    shared_ptr sp = shared_from_this(this); // 异步调用callback函数 async(std::launch::async, callback, sp); } 。。。};

这样可以work的根本原因是shared_from_this没有额外创建一个控制块,而是使用了之前创建好的控制块(也就是创建this指针所表示对象时创建的控制块,这就要求this指针所指对象必须通过智能指针的方式创建),下面就来详细回答这一节开始所问的第二个问题:shared_from_this是什么原理,先来看enable_shared_from_this的关键代码定义:

template<typename _Tp>class enable_shared_from_this { shared_ptr<_Tp> shared_from_this() { return shared_ptr<_Tp>(this->_M_weak_this); }
template<typename _Tp1> void _M_weak_assign(_Tp1* __p, const __shared_count<>& __n) const noexcept { _M_weak_this._M_assign(__p, __n); }
friend const enable_shared_from_this* __enable_shared_from_this_base(const __shared_count<>&, const enable_shared_from_this* __p) { return __p; }
mutable weak_ptr<_Tp> _M_weak_this;};

代码里可以直接看到shared_from_this是使用一个weak_ptr创建了一个shared_ptr返回了出去,所以不会创建新的控制块,现在的关键问题是,_M_weak_this这个变量是在哪里被初始化的,上面代码中有_M_weak_assign这个函数,这个函数中对_M_weak_this进行了赋值,很容易可以找到下面的代码对它做了调用:

template<typename _Tp, _Lock_policy _Lp>class __shared_ptr : public __shared_ptr_access<_Tp, _Lp> {  。。。 template<typename _Yp, typename _Yp2 = typename remove_cv<_Yp>::type> typename enable_if<__has_esft_base<_Yp2>::value>::type _M_enable_shared_from_this_with(_Yp* __p) noexcept { if (auto __base = __enable_shared_from_this_base(_M_refcount, __p)) __base->_M_weak_assign(const_cast<_Yp2*>(__p), _M_refcount); } template<typename _Yp, typename _Yp2 = typename remove_cv<_Yp>::type> typename enable_if<!__has_esft_base<_Yp2>::value>::type _M_enable_shared_from_this_with(_Yp*) noexcept { } 。。。};

再继续看_M_enable_shared_from_this_with这个函数,这个函数在相关的__shared_ptr的构造函数中被调用,可以看到它通过enable_if定义了两个互斥的版本:

  1. 版本A:调用了_M_weak_assign这个函数来对_M_weak_this赋值

  2. 版本B:空实现

简单来讲,如果数据类继承自enable_shared_from_this类,则在第一次创建shared_ptr对象的时候,在__shared_ptr的构造函数内部会调用版本A的_M_enable_shared_from_this_with函数,完成_M_weak_this的初始化,到这里为止就回答了本节开始提出的第二个问题。

四、size和performance分析

shared_ptr的size由前面图2中展示的__shared_ptr两个数据成员可以看出,如果是64位系统的话,sizeof(shared_ptr<DType>)的值是16,即使使用自定义析构器,也不会影响shared_ptr的size,这点和unique_ptr是不同的。

影响shared_ptr性能的,主要有下面三点:

  1. 一个是需要维护两个引用计数的更新,而引用计数更新又是原子操作,和普通的加减操作相比,原子操作的成本更高一些

  2. 从本文刚开始的介绍可知,有三种控制块,因为需要在控制块中实现多态,所以控制块中有虚函数,比如前面代码示例中用来释放数据块的_M_dispose函数,虽然_M_dispose只会在引用计数为0析构数据块时被调用,但总而言之还是增加了使用开销,或多或少的影响了性能

  3. 如果数据块和控制块的内存空间不连续或者不邻近的话,可能会对cache的命中率有影响,这也是为什么推荐使用前面介绍的make_shared来创建shared_ptr对象

weak_ptr和shared_ptr的size相同,它们使用相同的控制块,它的构造、析构、赋值操作也同样包含了对引用计数的原子操作,从performance的角度看,本质上来讲它们是一样的。

五、summary and reference

本文和上篇《内存管理:具有共享所有权的智能指针(一)》一样,都是从代码实现的角度上分析了具有共享所有权的智能指针,主要参考cppreference和C++标准库代码:

  • https://en.cppreference.com/w/cpp/memory/shared_ptr

  • https://en.cppreference.com/w/cpp/memory/weak_ptr

  • https://github.com/RTEMS/gnu-mirror-gcc/blob/master/libstdc++-v3/include/bits/shared_ptr.h

  • https://www.jianshu.com/p/b6ac02d406a0


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存