Std::shared_ptr

本文最后更新于:April 20, 2022 am

本篇为智能指针系列第二篇,介绍std::shared_ptr

为了便于理解,阅读者最好对模板编程有一定的了解

系列导航:智能指针

shared_ptr:共享所有权

使用有垃圾回收机制的语言编程的开发者有时嘲笑C++开发者因为极力避免资源泄露所经历的痛苦。C++开发者会以资源回收操作的通用性和可预测性来回击。

我们能否同时拥有自动垃圾回收的系统,但同时也能够预测到析构函数的执行时机呢?

std::shared_ptr 就是C++11将这两者绑定的方式。被std::shared_ptr所访问的资源的生命周期,通过共享所有权的方式来被std::shared_ptr实例管理。没有任何一个单独的std::shared_ptr实例拥有其所能访问的资源,而是所有能够访问到那个资源的std::shared_ptr实例通力合作,共同确保当资源不再被使用时,它能够被自动释放。

当最后一个能访问到此资源的std::shared_ptr实例和资源的联系断开——比如std::shared_ptr实例被析构,或者它被指向了一个新的资源——其原先所指向的资源就会被释放。

引用计数

共同指向同一个资源的std::shared_ptr实例共同维护一个叫做引用计数reference count的变量,这个变量记录了当前有多少个std::shared_ptr实例指向此资源。

std::shared_ptr的构造器一般情况下会让引用计数+1,析构器则是让引用计数-1,拷贝复制运算符则两者都做(sp1 = sp2会引起左侧原先指向的资源的引用计数减少,右侧指向的资源的引用计数增加)。

因此,一旦在一次递减操作之后,std::shared_ptr实例发现引用计数减为0了,也就意味着没有其它std::shared_ptr实例指向这个资源了,因此也就到了释放资源的时刻。

引用计数的存在表明:

  • std::shared_ptr实例的大小至少是裸指针的两倍,因为引用计数是也是通过指针维护。[1]

  • 引用计数的变量必须在堆上。

    从概念上来讲,引用计数和在堆上的资源不应该被关联在一起吗?是的,但问题是,被指向的资源对引用计数的存在和作用是一无所知的,同时被指向的资源也没有多余的空间来存这个变量。

    虽然这比较令人头疼,但是这也意味着任何原始数据类型也都可以被std::shared_ptr所管理,比如int。Item21表明,当使用std::make_shared创建std::shared_ptr实例时,可以避免因为动态分配内存导致的代价,但仍有无法使用std::make_shared函数的情况。

  • 对于引用计数的递增和递减操作必须是原子的。

    对于一个被管理的资源而言,可能在线程A中std::shared_ptr实例正在执行析构,发试图将引用计数减少1,而在线程B中被执行了拷贝构造。两个线程引起对引用计数的竞争条件。

    因此,即使引用计数不过一个字的大小,也应该认为对于其的读写是代价较高的。

上文也提到,std::shared_ptr的构造器也只是通常引起引用计数的自增。有一个例外——移动构造。当我们将一个std::shared_ptr实例移动到另一个实例时,我们会让左侧的std::shared_ptr实例指向堆上的资源,而将右侧的std::shared_ptr实例置空,因此不需要修改引用计数。减少了两次引用计数访问也使得移动构造比拷贝构造更快。显然,移动赋值也会比拷贝复制更快。

自定义deleter

std::unique_ptr类似,std::shared_ptr也使用delete操作符作为默认的deleter

与std::unique_ptr的不同

虽然std::shared_ptr也支持自定义deleter,但是它的设计和std::unique_ptr是不一样的。对于std::unique_ptr而言,deleter的类别是std::unique_ptr实例自身的一部分——模板实例化时,deleter自身的类别作为了实例化参数的一部分——而对于std::shared_ptr则不是,请看下面一段代码:

1
2
3
4
5
6
7
8
9
auto loggingDel = [](Widget *pw){
makeLogEntry(pw);
delete pw;
}//custom deleter by lamda

std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget(), loggingDel);
// 模板参数含有lamda表达式
std::shared_ptr<Widget> spw(new Widget(), loggingDel);
// 模板参数不含lamda表达式

显然,std::shared_ptr的设计更灵活。考虑有两个std::shared_ptr实例,它们管理同一类型的资源,但是构造时,传入了两个内容不同的lamda表达式。

1
2
3
4
5
auto deleter1 = [](Widget *pw){...};
auto deleter2 = [](Widget *pw){...};// doing sth different

std::shared_ptr<Widget> pw1(new Widget, deleter1);
std::shared_ptr<Widget> pw2(new Widget, deleter2);

因为pw1和pw2的类型是相同的,它们可以被放到同一个泛型容器中:

1
std::vector<std::shared_ptr<Widget>> vct{pw1,pw2};

它们也可以被相互赋值,也可以被当成参数传给一个需要std::shared_ptr<Widget>类型的函数中。以上这些操作,对于std::unique_ptr来说都是无法实现的,因为其自身的类型已经和deleter的类型绑定了。

空间开销

另一个和std::unique_ptr不同的点是,为std::shared_ptr实例传入自定义的deleter不会引起std::shared_ptr实例额外的空间开销,不论有没有自定义的deleterstd::shared_ptr的实例都是2个裸指针的大小。这不免使人隐隐感到不安,std::shared_ptr如何才能屏蔽deleter对其自身大小的影响?

答案是,std::shared_ptr并没有这种神通,它可能也会使用更多的内存,但是这部分东西并不是存储在std::shared_ptr实例自己内部的(栈上),而是在堆上,更准确地来说,如果std::shared_ptr实例的创建者利用std::shared_ptr支持自定义内存分配器的特性,内存分配器分配内存到哪里,它就在哪里。

上文提到,std::shared_ptr实例会通过指针去访问引用计数,但是其实这有一些误导,因为引用计数这个变量其实是一个更大的数据结构的一部分——控制块(control block)。对于每一个被std::shared_ptr管理的资源来说,它都有一个自己的控制块,这个数据结构包含了引用计数,一份自定义deleter的拷贝,还有一份自定义的内存分配器的拷贝(如果声明了的话)。此外,可能还有一些其它的数据,比如Item21会提到的,一个被称为弱引用计数weak count的第二个引用计数,不过在这里暂时不讨论它。std::shared_ptr实例和与之相关联的内存格局可以用下图来概括。

std::shared_ptr<T>的内存格局

控制块

一个对象的控制块被第一个指向它的std::shared_ptr实例的函数所设置。一般而言,一个正在创建一个std::shared_ptr实例,并且使之指向某个资源的函数,是不可能知道是否存在某个其它的std::shared_ptr实例,并且这个实例已经指向了那个资源(也就是说这个实例已经有一份控制块与被管理的资源对应了)。为什么无法知道呢?因为此时还在考虑指向控制块的指针是已经存在了,还是待创建。因此,有了以下的控制块创建规则:

  • std:: make_shared() 总是创建一个新的控制块,再创建一个std::shared_ptr实例指向它,然后返回此std::shared_ptr实例。

  • 当从独占式指针(std::auto_ptr, std::unique_ptr)转换到std::shared_ptr时,创建一个新的控制块。顺带一提的是,从std::unique_ptrstd::shared_ptr的转换,会置空前者。

  • 当使用一个裸指针创建std::shared_ptr实例时,创建一个新的控制块。

    反之,如果希望从一个已经创建过控制块的对象构造std::shared_ptr实例,应该将std::shared_ptr实例传入,或者传入一个std::weak_ptr实例。

这些规则导致的一个直接结果就是,从一个同裸指针多次构造std::shared_ptr实例会导致同一份动态资源有多个与之对应的控制块,也就是多个引用计数,也就是多次裸指针的delete操作,最终这会让你光速体验什么叫做undefined behavior

千万不要写出这样的代码:

1
2
3
4
5
6
7
8
9
10
11
{
auto pw = new Widget();
//这一句就很令人生厌,虽然它不会导致任何问题
//不要用裸指针变量去接它
//直接把new操作符的返回值扔到std::make_shared()/std::shared_ptr(...)里不好吗
...
std::shared_ptr<Widget>(pw, loggingDel) spw1;
...
std::shared_ptr<Widget>(pw, loggingDel) spw2;
}
//代码执行到此处,spw1和spw2都会被析构,因此pw指向的地方会被delete两次。

这样的操作和智能指针的设计初衷背道而驰,明明希望用RAII式的类替代裸指针,却偏偏还要用裸指针来多次创建智能指针。

从此处我们可以吸取两个教训:

  • 尽量不要往std:: shared_ptr的构造函数里扔裸指针,相反,应该是往std:: make_shared函数里面扔裸指针,而往std::shared_ptr构造函数扔其它的std::shared_ptr实例。但是此处我们使用了自定义的deleter,所以必须直接调用std:: shared_ptr的构造函数。

  • 即使要往std::shared_ptr的构造函数里扔裸指针,扔new的返回值就够了,不要额外用一个裸指针的变量去接new的返回值。比如这样:

    1
    2
    std::shared_ptr<Widget>spw1 (new Widget(),loggingDel);
    std::shared_ptr<Widget>spw2 (spw1);

安全构造一个指向this的实例

从上面的讨论中,我们知道了将裸指针传到std::shared_ptr的构造函数中去是十分糟糕的行为。

一种令人极为吃惊的典型犯错方式,是将对象的this指针传进去。

假设我们的程序使用了std::shared_ptr来管理Widget对象,并且我们有一个数据结构用于追踪已经处理过的Widget对象:std::vector<std::shared_ptr<Widget>> processedWidgets;

进一步假设Widget有一个成员函数用于处理,以下代码给出一种看起来很合理的处理流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Widget{
public:
...
void process();
...
};

void Widget::process(std::vector<std::shared_ptr<Widget>>& processedWidgets){
...
processedWidgets.emplace_back(this);
//隐式将this指针转换为std::shared_ptr,引起调用std::shared_ptr的构造函数
//不要这样使用!!
}
int main(){
auto ptr= make_shared(new Widget());
ptr->process();//double control block
}

上述代码将一个裸的指针传入了一个std::shared_ptr的容器,会引起隐式构造一个新的std::shared_ptr对象,并且为this创建一个新的控制块。不要忘了此时main()里已经有一个ptr拥有这个对象的控制块了,因此undefined behavior已经离你不远了。

所幸,std::shared_ptr提供的API中有一个解决这种场景的成员——可能是在Standard C++ Library库中最老的名字——std::enable_shared_from_this,一个模板类。如果你希望你的类可以在成员函数中安全地生成指向thisstd::shared_ptr对象,你应该这么做:

1
2
3
4
5
6
7
8
9
10
11
class Widget: public std::enbale_shared_from_this<Widget>{
public:
...
void process();
...
};

void Widget::process{
...
processedWidgets.emplace_back(shared_from_this());
}

可能乍一看会让人觉得疑惑。That’s fine,这种编程模式就是会让人感到疑惑,但是这种代码是完全合法的(可以通过编译,也肯定能跑),只要其基类内部满足一定的条件。

这种编程模式也已经很成熟,它也有一个标准的名字,并且它几乎和std::enable_shared_from_this一样古老——它叫做奇异模板递归编程(The Curiously Recurring Template Pattern, CRTP)。


在理解CRTP上,可以先看这一段代码,更多内容还需网友自行去了解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<typename T>class CRTP_example{
//请注意,T类型可能永远都还没有被定义,此时的CRTP_example对T类型应当一无所知。
public:

T obj;//能否定义T类型的成员变量?

T getAnInstance();//能否定义返回T类型变量的成员函数?

void process(const T&); //能否定义以T类型引用为参数的成员函数?

void dosth(const T& obj){
obj.function();//能否调用T类型对象的某个成员函数?
}

void staticPolymorphism(){
static_cast<T*>(this)->staticPolymorphism();
//如果T类型也有一个函数叫做staticPolymorphism,并且签名和这里的一样
//请问这里能否调用成功?
//如果可以调用,那调用子类的函数还是CRTP_example类的函数?
}

}

上文提到了std::enable_shared_from_this<>是一个模板类,继承它时,模板的实例化参数永远都是继承它的类型。这样做以后,派生类Widget获得一个成员函数shared_from_this,此函数会安全地创建一个指向thisstd::shared_ptr实例——并且保证不会重复创建控制块

在内部,shared_from_this函数查看当前对象的控制块,并且创建一个新的std::shared_ptr实例,使之指向上述控制块。

整个设计的合理性都完全依赖于,在调用shared_from_this函数之前,当前对象this已经对应有一个控制块了,也就是至少有一个std::shared_ptr实例已经指向了this

如果不满足此条件,则对shared_from_this的调用是未定义的,尽管shared_from_this通常抛出一个异常

为了避免这一情况的发生,std::enable_shared_from_this类通常将其构造函数声明为私有的,并且让使用者通过调用工厂函数的方法创建std::shared_ptr,比如

1
2
3
4
5
6
7
8
9
10
class Widget:public std::enable_shared_from_this<Widget>{
public:
//实现完美转发的工厂函数
template<typename... Ts> static std::shared_ptr<Widget> create(Ts&&... params);
//other stuff all the same
...
void process();
...
//all the same
}

尽管定制的deleterallocator会撑大空间,一个控制块通常只有几个字的大小。

常见的控制块的实现可能比你想象得要更加精巧,这包含继承和虚函数(用于确保被指向的对象被正确销毁了)。于是,使用std::shared_ptr也会引起 控制块调用虚函数伴随的开销。

性能评价

读了关于std::shared_ptr这么多的内容,可能你对它的热情有所减少,又是虚函数,又是控制块,还有令人头疼的CRTP

That’s fine,尽管std::shared_ptr并不是一种对所有种类资源的管理通用最佳解决方案,但是std::shared_ptr的所提供的功能,无法否认地只用了合理的代价,就达到了目的。在通常的情况下,使用默认的deleter和默认的allocatorstd::shared_ptr也被std::make_shared创建,这种情况下,控制块就仅仅只有大概3个字的大小,并且控制块的内存分配是无成本的(见Item21)。

std::shared_ptr进行解引用,代价和对裸指针解引用差不多。当操作导致对引用计数进行修改时,会引起原子操作,但是这些操作通常都会被映射到宿主机的指令上去。即使原子操作会比较费时费力,但是它们也就是一条指令而已。

而控制块中的虚函数,在整个资源的生命周期中,通常情况下也仅仅会被调用一次——用于确保资源已经被释放。

这些并不昂贵的性能代价,所换来的是,全自动的针对动态资源的生命周期管理。绝大多数情况下,使用std::shared_ptr总是比手动地来管理某个资源的生命周期要好得多,这也是被推荐的。如果发现自己在怀疑是否能接受使用std::shared_ptr带来的代价,可能需要先考虑是否真的需要共享的资源拥有权。如果独占式管理权更好,或者可能更好,std::unique_ptr都是一个更优的选择——因为std::unique_ptr的性能和裸指针非常接近,并且如果要将之转换成std::shared_ptr,也非常简单。相反的情况则不成立,如果已经使用std::shared_ptr来管理资源的生命周期,就无法再改变了。即使引用计数为1,也无法创建一个std::unique_ptr实例来取代最后一个std::shared_ptr实例。std::shared_ptr和资源之间的联系,是“至死方休”的。

std::shared_ptrstd::unique_ptr的另一个区别是,std::shared_ptr不支持数组类型,也就是说,没有std::shared_ptr<T[]>这种用法。

时不时会有开发者在试图使用std::shared_ptr去指向一个数组上摔跟头。这些开发者通过使用std::shared_ptr<T>让其指向一个数组头,然后自定义deleterdelete[]

这样能够通过编译,但是却是极其糟糕的。第一,std::shared_ptr<T>没有重载[]运算符,因此通过索引取元素时要笨拙地使用指针的加法;第二,std::shared_ptr支持从派生类到基类的指针转换,这只对单一对象有意义,对于数组来说是没有意义的(因此std::unique_ptr<T[]>直接禁止了这种转换);更重要的是,我们已经有了C++对内建数组类型的上位替代,比如std::array, std::vector, std::string等,为啥还要非要玩原始数组嘞。

总结

  • std::shared_ptr可以方便地对任意资源进行共享式的生命周期管理,从而达到垃圾回收的效果。

  • 比起std::unique_ptrstd::shared_ptr一般都会大一倍,还会带来控制块的时间代价和空间代价,和引用计数上的原子操作

  • 默认通过delete操作符销毁资源,也可以自定义deleter函数。deleter的类型对std::shared_ptr实例化后的类型没有影响,这一点和std::unique_ptr不一样。

  • 不要,不要,不要从一个裸指针变量来创建std::shared_ptr的实例


尾注

  1. 原书作者注:虽然这不是标准的一部分,但是我所熟知的每一个标准库都是如此实现的

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!