19.在进行共享式资源管理时使用std::shared_ptr

前言

 
有人认为C++的手动内存回收机制过于原始,这钟说辞相当正确,但是否存在一种方式,可以保证C++既可以自动完成内存回收,又能够明确它们释放的时间?


shared_ptr

 
std::shared_ptrs所指向的对象,其生命周期由这些共享其所有权的指针共同管理,不存在某个特定的std::shared_ptr拥有该对象。同时,所有指向该对象的std:: shared_ptrs都将保证在该对象不再需要时对其进行销毁。当指向对象的最后一个std :: shared_ptr停止指向那里时(例如该std::shared_ptr被销毁或被指向另一个对象),此时该std::shared_ptr会析构它指向的对象。类似于内存回收机制,客户端无需关心管理对象的生命周期,但这种行为同时也兼具析构函数的特点,即对象销毁时间是确定的。

std::shared_ptr通过查询资源的引用计数来判断它是否是指向该资源的最后一个sp指针,当执行构造函数时通常会导致引用计数自增,析构时自减,执行copy assignment operator同时执行二者(原有资源引用计数自减,新资源引用计数自增)。当std::shared_ptr在执行递减后发现当前引用计数为0,则表明此时不再有任何std::shared_ptrs指向该资源,那么shared_ptr则会执行析构。


引用计数对性能的影响

 
shared_ptr中的引用计数对性能存在如下影响:

  1. shared_ptr大小是原始指针的两倍
    原因在于shared_ptr持有两个指针,一个指针指向资源,另一个指向引用计数。
  2. 必须动态分配引用计数的内存
    理论上引用计数与资源相关联,但资源自身对此却一无所知,因此,资源不会存储引用计数(这也意味着任何对象,甚至是内置类型的对象也可由shared_ptr管理)。无论哪种方式,引用计数都存储为动态分配的数据。动态分配需要成本,Item21阐明当使用std::make_shared创建std::shared_ptr时可以避免该开销,但并非任何情况都可以使用make_shared。总之,无论采取何种方式,引用计数都必须存储于动态分配的内存中。
  3. 引用计数的自增与自减行为必须是atomic
    不同的线程之中可能同时存在着reader与writer。举例而言,线程A中,指向某资源的std::shared_ptr正在执行析构函数(引用计数自减),而在线程B中,存在一个shared_ptr被复制(引用计数自增)。atomic操作通常慢于non-atomic操作,因此即使引用计数大小通常只是一个word,我们也应当认为其读取和写入操作成本较高。

shared_ptr的创建

 
在前文中我们提到,shared_ptr在创建时通常会自增引用引用,那为什么不是总是自增引用计数呢?原因在于move constructor。在执行移动构造时,源shared_ptr会被置为null,而引用计数不需要发生任何改变。因此,移动std::shared_ptrs比复制它们要快:move construct优于copy construct,move assignment优于copy assignment。


shared_ptr的删除器

 
类似于unique_ptr,shared_ptr也使用delete作为默认删除器,并且支持自定义删除器行为,不过对于这一特性,shared_ptr的设计与unique_ptr的设计颇有不同。对于unique_ptr而言,删除器是智能指针的一部分,但对于shared_ptr而言并非如此:

1
2
3
auto loggingDel = [](Widget *pw){makeLogEntry(pw);delete pw;};
std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget, loggingDel);
std::shared_ptr<Widget> spw(new Widget, loggingDel);

std::shared_ptr设计具备更高的灵活性。 考虑当前存在两个std::shared_ptr<lWidget>,每个都有一个不同类型的自定义删除器(其原因可能是自定义删除器是一个lambda):

1
2
3
4
auto customDeleter1 = [](Widget *pw) { … }; // custom deleters,
auto customDeleter2 = [](Widget *pw) { … }; // each with a different type
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

因为pw1和pw2具备相同的类型,因此它们可以放在同一个容器之中:
1
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };

pw1与pw2也可以相互赋值,并且可以都能够作为实参传递给一个形参类型为 std::shared_ptr<Widget>的函数。对于自定义删除器类型不同的std::unique_ptrs,上述操作均无法实现,因为自定义删除器的类型会影响std::unique_ptr的类型。此外,自定义删除器不会更改std::shared_ptr对象的大小。无论采用何种删除器,std::shared_ptr对象的大小始终为两个指针大小。但std::shared_ptr如何在不使用任何额外内存的情况下引用任意大小的删除器?


control block

 
事实上shared_ptr仍然需要额外的内存以放置与使用删除器,只是这块内存并非std::shared_ptr对象的一部分。它位于堆上,或者是allocator分配的内存所在的位置(如果shared_ptr使用了自定义allocator)。前文曾经提到,std::shared_ptr对象包含一个指向引用计数的指针,但实际上该指针其实指向的是一个更大的数据结构control block,引用计数是它的一部分。std::shared_ptrs所管理的每个对象都具备一个control block。除引用计数之外,control block还包含自定义删除程序的副本(如果拟制定了自定义删除器的话)。如果你在定义shared_ptr时指定了自定义分配器,则control block也包含该副本。control block还可以包含附加数据(见Item21),但这一节我们先不做理会。我们可以设想与std::shared_ptr <T>对象相关联的内存看起来如下所示:
image_1chk5vpusl9c187q1pa7o9p16si1p.png-48kB


control block创建规则

 
理论上而言,对象的controol block应当由第一个指向该对象的std::shared_ptr设置,但在实际情况中,构建std::shared_ptr的函数不可能明确是否已经存在其他std::shared_ptr已指向该对象,因此control block有如下创建规则:

  1. std::make_shared(见Item21)总是创建一个control block
    它生成一个需要被指向的新对象,因此在调用std::make_shared时肯定没有该对象的控制块。
  2. 当std::shared_ptr的构造源是唯一所有权指针(即std::unique_ptr或std::auto_ptr)时,创建一个control block
    唯一所有权指针并不需要control block,因此需要创建,此外,当shread_ptr被构建完毕后,唯一所有权指针将会被置为null。
  3. 当以原始指针作为实参调用std::shared_ptr构造函数时,创建一个control block
    如果你想通过一个已有control block的对象创建一个std::shared_ptr,你可能会将std::shared_ptr或std ::weak_ptr(见Item20)作为实参传递给shared_ptr的构造函数,而不是使用原始指针。事实上,当它们作为实参构造shared_ptr时不会构造control block。

这些规则可能会触发一些不良后果,假设你以一个原始指针创建了多个shared_ptr,那么将会产生多个control block,从而触发多次析构:

1
2
3
4
5
auto pw = new Widget; // pw is raw ptr

std::shared_ptr<Widget> spw1(pw, loggingDel); // create control block for *pw

std::shared_ptr<Widget> spw2(pw, loggingDel); // create 2nd control block for *pw!

为动态创建的对象分配一个原始指针是一种不良行为,这完全违背了本章的主旨:以智能指针取代原始指针。但这充其量只是一种不正确的编程风格,因为它至少没有主动引起未定义行为。在刚才的代码示例中,一个对象有两个相关联的control block,这必然会导致该对象会被析构两次,在第二次析构时将会导致未定义行为。

刚代码示例至少蕴含两个启示:

  1. 应当尽量避免使用原始指针创建shared_ptr
    上述程序之所以不使用make_shared,是因为我们使用了自定义删除器的原因。
  2. 如果一定要以原始指针初始化shared_ptr,则直接以new表达式初始化之
    如果示例中的代码被重写为:
    1
    std::shared_ptr<Widget> spw1(new Widget,loggingDel);
    当我们需要创建第二个shared_ptr时,则通常会选择使用copy construct:
    1
    std::shared_ptr<Widget> spw2(spw1); // spw2 uses same control block as spw1
    如此则不会产生任何问题。

问题实例

 
假设我们的程序使用std::shared_ptrs来管理Widget对象,并且我们有一个数据结构来跟踪被处理的Widgets:

1
std::vector<std::shared_ptr<Widget> > processedWidgets;

进一步地假设Widget具备一个负责处理的成员函数:
1
2
3
4
5
6
class Widget {
public:

void process();

};

process一种可能的实现方式如下所示:
1
2
3
4
void Widget::process(){
// process the Widget
processedWidgets.emplace_back(this); // add it to list of processed Widgets
}

关于emplace_back可见Item42.此实现的最大错误在于将this,也就是原始指针作为参数传入容器,从而直接导致关于某Wideget可能存在多个control block。


解决方案(std::enable_shared_from_this)

 
std::shared_ptr早已考虑到这种情况,并为之提供了API,只是名字有点奇怪:std::enable_shared_from_this。如果你希望std::shared_ptrs管理的类能够通过this指针安全地创建std::shared_ptr,那么你必须令你的class继承此基类模板,例如:

1
2
3
4
5
6
class Widget: public std::enable_shared_from_this<Widget> {
public:

void process();

};

std::enable_shared_from_this是一个基类模板,它的类型参数始终是派生类的名称,基类决定于派生类这一行为十分奇怪,但这段代码完全合法,甚至存在一个与之相关的设计模式Curiously Recurring Template Pattern (CRTP)。(实际上在Effective C++ Item49中已有涉及)

std::enable_shared_from_this定义了一个成员函数,它可为当前对象创建一个std::shared_ptr,但该函数不会造成重复的control block。该成员函数名为shared_from_this,其隐含实参为*this,返回由*this初始化的shared_ptr,其使用示例如下:

1
2
3
4
5
6
void Widget::process(){
// as before, process the Widget

// add std::shared_ptr to current object to processedWidgets
processedWidgets.emplace_back(shared_from_this());
}


shared_from_this实现

 
shared_from_this会查找当前对象的control block,并创建一个引用该control block的新std::shared_ptr。显而易见,该函数要求当前必须已经存在一个shared_ptr指向该对象,如果不存在(即当前对象并无相关control block),则其行为未定义(事实上会抛出一个异常)。

为了防止客户在尚未存在std::shared_ptr指向*this之前调用shared_from_this,继承自std::enable_shared_from_this的类通常将其构造函数声明为private,并让客户通过调用返回std::shared_ptrs的工厂函数来创建对象,举例而言,Widget的实现可能如下所示:

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);

void process(); // as before

private:
// ctors
};


shared_ptr使用成本

 
control block的大小通常只有几个word,不过自定义删除器和分配器可能会令它较大一些。一般而言,control block的实现比人们想象地更加复杂,它可能会用到继承甚至虚函数的概念(确保对象可被正确析构),这意味着使用std::shared_ptrs也需要付出虚函数所带来的那些成本(详见More Effective C++ Item24)。

在介绍了如此之多的细节后,你可能会对shared_ptr所产生的control block如此复杂难用而感到不满,但实际上针对它们所完成的那些功能,使用shared_ptr绝对物超所值。此外,当你处于典型应用环境(无自定义删除器及allocator)时,control block的大小约为3个word,并且它通常会被合并到对象所占用的内存空间之中(详见Item21)。

解引用std::shared_ptr并不比解引用原始指针所消耗的成本更高。执行需要引用计数的操作(例如copy construct,copy assignment operator,destructor)需要一到两个atomic 操作,但这些操作仅仅相对于non-atomic而言开销较多。conrtrol block中虽然蕴含虚函数,但虚函数的使用频率并不高(实际上仅在析构时才会被使用)。在付出了上述代价后,你收获了内存回收机制,甚至比它还要更加出色一点,我认为这是相当值得的。


shared_ptr与unique_ptr

 
如果你发现当前资源是独占式的,std::unique_ptr相对于shared_ptr无疑是更好的选择,它的性能与原始指针相差无几,并且从std::unique_ptr到std::shared_ptr的转变十分方便。但这种情况反之却并不成立,一旦你将资源的生命周期管理转换为std::shared_ptr,就再也无法收回成命。也就是说,unique_ptr到shared_ptr总是允许的,但发生转变之后就再也不能后悔了(即使当前对象的引用计数为1你也不能将其转交给unique_ptr)。

shared_ptr与unique_ptr的另一点不同是它无法指向数组,也就是说没有std::shared_ptr<T[]>。有人会想到使用自定义删除器来执行数组删除(即delete []),这种程序可以编译,但毫无价值。首先,std::shared_ptr不提供operator[],因此索引到数组需要基于指针算术。另一方面,std::shared_ptr支持单一对象的派生类指针到基类指针之间的转换,但其无法应用于数组之中。STL存在如此之多的容器,使用数组无疑是一种较为不智的设计。


总结

  1. std::shared_ptr为C++开发者提供了接近内存回收机制般的便利。
  2. 相较于unique_ptr,shared_ptr对象通常较大,并且引入了control block与atomic操作(并且导致了性能下降)。
  3. 自定义删除器对shared_ptr的类型并无任何影响。
  4. 应当尽量避免以原始指针初始化shared_ptr