条款19.对于共享资源使用shared_ptr

对于共享资源使用std::shared_ptr

std::shared_ptr指针访问的对象采用共享所有权来管理其生存期。没有特定的std::shared_ptr拥有该对象。相反,所有指向它的std::shared_ptr都能相互合作确保在它不再使用的那个点进行析构。当最后一个std::shared_ptr到达那个点,std::shared_ptr会销毁它所指向的对象。就垃圾回收而言,客户端不需要关心指向对象的声明周期,而对象的析构是确定的。

std::shared_ptr通过引用计数来确保它是否是最后一个指向某种资源的指针,引用计数关联资源并跟踪有多少std::shared_ptr指向该资源。std::shared_ptr构造函数递增引用计数值(通常),析构函数递减值,拷贝赋值运算符可能递增也可能递减值(如果sp1sp2std::shared_ptr并且指向不同的对象,赋值运算符sp1 = sp2会使sp1指向sp2指向的对象,直接效果就是sp1引用计数减一,sp2引用计数加一)。如果std::shared_ptr发现引用计数值为零,没有其他std::shared_ptr指向该资源,它就会销毁资源

引用计数会带来性能问题:

  • std::shared_ptr大小是原始指针的两倍,因为它内部包含一个指向资源的原始指针,还包含一个资源的引用计数值。
  • 引用计数必须动态分配。理论上,引用计数与所指对象关联起来,但是被指向的对象不知道这个事情(不知道有指向自己的指针)。因此它们没有办法存放一个引用计数值。使用std::make_shared创建的std::shared_ptr可以避免引用计数的动态分配,但是还存在一些std::make_shared不能使用的场景,这时候引用计数就会动态分配
  • 递增递减引用计数必须是原子性的。因为多个reader,writer可能在不同的线程。比如,指向某种资源的std::shared_ptr可能在一个线程执行析构(所指资源的引用计数会递减),在另一个不同的线程,std::shared_ptr指向相同的对象可能同时正在被赋值(因此该引用计数又会递增)。原子操作一般都比非原子操作慢,所以即使引用计数通常只有一个字长,也应该假设读写它们成本比较高昂

前面说到,std::shared_ptr构造函数只是“通常”递增指向对象的引用计数会不会让你有点好奇?创建一个指向对象的std::shared_ptr至少产生了一个指向对象的智能指针。为什么我没说总是增加引用计数值呢?

原因是移动构造函数的存在。从另一个std::shared_ptr移动构造新的std::shared_ptr会将原来的std::shared_ptr设置为空。这意味着一旦新的std::shared_ptr产生后,原有的std::shared_ptr将不再指向资源,结果是不需要进行任何引用计数操作。因此,移动std::shared_ptr比复制它们要快:复制要求递增引用计数,而移动则不需要这一点对于构造和赋值操作同样成立。所以,移动构造函数比拷贝构造函数要快,移动赋值比拷贝赋值要快

类似std::unique_ptrstd::shared_ptr使用delete作为资源的默认销毁器,但是它也支持自定义的销毁器,这种支持不同于std::unique_ptr对于std::unique_ptr来说,销毁器类型是智能指针类型的一部分,对于std::shared_ptr则不是

auto loggingDel = [](Widget* pw)	//自定义销毁器
{
    makeLogEntry(pw);
    delete pw;
};

std::unique_ptr<Widget, decltype(loggingDel)> 
    upw(new Widget, loggingDel);	//销毁器类型是ptr类型的一部分

std::shared_ptr<Widget>
    spw(new Widget, loggingDel);	//销毁器类型不是ptr类型的一部分

std::shared_ptr的设计更为灵活。考虑有两个std::shared_ptr,每个自带不同的析构器(比如通过lambda表达式自定义析构器):

auto customDeleter1 = [](Widget* pw){ ... };
auto customDeleter2 = [](Widget* pw){ ... };
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

因为pw1pw2具有同一类型,所以它们都可以放到存放那个类型的对象的容器中;

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

它们也能相互赋值,也可以传入形参为std::shared_ptr<Widget>的函数,但是std::unique_ptr就不行,因为std::unique_ptr把析构器视为类型的一部分。

另一个不同于std::unique_ptr的地方是,自定义析构器不会改变std::shared_ptr的尺寸,无论析构器是怎么的类型,std::shared_ptr对象的尺寸都相当于裸指针的两倍。自定义析构器有可能是函数对象,而函数对象可以包含任意数量的数据,这意味着它们的尺寸可能是任意大小。std::shared_ptr如何能够在不使用更多内存的前提下,指涉到任意尺寸的析构器?

std::shared_ptr不得不使用更多内存。然而,该部分内存却不属于std::shared_ptr对象的一部分,它位于堆上,又或是std::shared_ptr的创建者利用了std::shared_ptr堆自定义内存分配器,则它会在托管给该分配器的内存位置。前文中,我们提到std::shared_ptr对象包含了所指对象的引用计数。这一点没错,但是可能会误导人。因为该引用计数是控制块这样一个更大的数据结构的一部分,每一个由std::shared_ptr管理的对象都有一个控制块除了包含引用计数之外,该控制块还包含自定义析构器的一个复制,如果该自定义析构器被指定的话。如果指定了一个自定义内存分配器,控制块也会包含一份它的复制。控制块还有可能包含其他附加数据。

一个对象的控制块由创建首个指向该对象的std::shared_ptr的函数来确定

控制块的创建遵循了以下规则

  • std::make_shared总是创建一个控制块。它创建一个指向新对象的指针,因此在调用std::make_shared的时刻,显然不会有针对该对象的控制块存在。
  • 从具备专属所有权的指针(即std::unique_ptrstd::auto_ptr)出发构造一个std::shared_ptr时,会创建一个控制块。专属所有权指针不使用控制块,所以指针指向的对象没有关联其他控制块(作为构造过程的一部分,std::shared_ptr被指定了其所指向的对象的所有权,因此那个专属所有权的智能指针会被置空)。
  • std::shared_ptr构造函数使用裸指针作为实参来调用时,它会创建一个控制块。如果你想从一个早已存在控制块的对象上创建std::shared_ptr,你将假定传递一个std::shared_ptr或者std::weak_ptr作为构造函数实参,而不是原始指针。用std::shared_ptr或者std::weak_ptr作为构造函数实参创建std::shared_ptr不会创建新控制块,因为它可以依赖传递来的智能指针指向控制块。

这些规则会导致一个后果:从同一个裸指针出发来构造超过一个std::shared_ptr就会让你走上未定义行为的快车道,因为指向的对象有多个控制块关联。多重的控制块意味着多重的引用计数,而多重的引用计数意味着该对象将被析构多次(每个引用计数会导致一次析构)。这意味着,如下的代码是行不通。

auto pw = new Widget;	//pw是个裸指针

std::shared_ptr<Widget> spw1(pw, loggingDel);	//为*pw创建一个控制块

std::shared_ptr<Widget> spw2(pw, loggingDel);	//为*pw创建了第二个控制块

创建裸指针pw,使其指向一个动态分配对象,这本身就是不好的。spw1的构造函数调用时传入了一个裸指针,因此它为所指向的对象创建一个控制块(并同时创建一个引用计数)。在这种情况下,就是*pw(即被pw指向到的对象)。spw2的构造函数在调用时传入了同一个裸指针,它也为*pw创建了一个控制块(同时创建了又一个引用计数)。这么一来,*pw既有了两个引用计数,而每个引用计数最终都会变为零,从而导致*pw被析构两次,第二次析构将会引发未定义行为。

首先,尽可能避免将裸指针传递给一个std::shared_ptr的构造函数。常用的替代手法是使用std::make_shared。不过在上面的例子中,我们使用了自定义析构器,std::make_shared无法做到。第二,如果你必须传给std::shared_ptr构造函数原始指针,直接传new出来的结果,而非传递一个裸指针变量

std::shared_ptr<Widget> spw1(new Widget, loggingDel);	//直接使用new的结果

这将大大降低试图由相同的裸指针创建第二个std::shared_ptr的可能。取而代之的是,代码撰写者在创建spw2时会自然地使用spw1作为初始化实参(亦即,会调用std::shared_ptr的估值构造函数),这将不会产生任何诸如此类的问题。

std::shared_ptr<Widget> spw2(spw1);	//spw2使用spw1一样的控制块

一个尤其令人意外的地方是使用this原始指针作为std::shared_ptr构造函数实参的时候可能导致创建多个控制块。假设我们的程序使用std::shared_ptr管理Widget对象,我们有一个数据结构用于跟踪已经处理过的Widget对象

std::vector<std::shared_ptr<Widget>> processdWidgets;

又假设Widget有个成员函数用来做这种处理:

class Widget{
public:
    ...
    void process();
	...
};

Widget::process而言,有一种看似合理的方法来完成跟踪操作。

void Widget::process()
{
    ...									//处理Widget
    processWidgets.emplace_back(this);	//然后将它加入到已处理过的Widget列表中
    									//这是错的
}

关于错误部分的注释说明了一切,或至少说明了大部分(错误部分在于this指针的传递,而不是emplace_back的使用)。该代码能够通过编译,然而它把一个裸指针this传入了一个std::shared_ptr容器,由此构造的std::shared_ptr将为其所指向的Widget类型的对象创建一个新的控制块。这似乎没什么危害,直到你意识到,如果在已经指向到该Widget类型的对象的成员函数外部再套一层std::shared_ptr的话,未定义行为就会发生。

std::shared_ptr的API为类似这种情况提供了一种基础设施,std::enable_shared_from_this。当你希望一个托管到std::shared_ptr的类能够安全地由this指针创建一个std::shared_ptr时,他将为你继承而来的基类提供一个模板

class Widget : public std::enable_shared_from_this<Widget>
{
public:
    ...
    void process();
    ...      
};

std::enable_shared_from_this是一个用作基类的模板类。它的模板参数总是某个继承自它的类。所以Widget继承自std::enable_shared_from_this<Widget>。这种设计模板有个标准名字叫奇异递归模板模式

std::enable_shared_from_this定义了一个成员函数,成员函数会创建指向当前对象的std::shared_ptr却不创建多余控制块。这个成员函数就是shared_from_this,无论在哪当你想使用std::shared_ptr指向this所指对象时都请使用它

void Widget::process()
{
	//和之前一样,处理Widget
    ...
    //把指向当前对象的shared_ptr加入processWidgets
    processedWidgets.emplace_back(shared_from_this());
}

shared_from_this查找当前对象控制块,然后创建一个新的std::shared_ptr指向这个控制块。设计的依据是当前对象已经存在一个关联的控制块。为了实现这一点,就必须有一个已经存在的指向当前对象的std::shared_ptr(即调用shared_from_this的成员函数外面已经存在一个std::shared_ptr)。如果没有std::shared_ptr指向当前对象(即当前对象没有关联控制块),行为是未定义的,shared_from_this通常抛出一个异常

要想防止客户端在调用std::shared_ptr前先调用shared_from_this,继承自std::enable_shared_from_this的类通常将它们的构造函数声明为private,并让客户端通过工厂方法创建std::shared_ptr。以Widget为例,代码可以是这样:

class Widget : public std::enable_shared_from_this<Widget>{
public:
    //将实参完美转发给private构造函数的工厂函数
    template<typename... Ts>
    static std::shared_ptr<Widget> create(Ts&&... params);
    
    ...
    void process();	//同前
    ...
private:
    ...			//构造函数
};

一个控制块的尺寸通常只有几个字长,尽管自定义析构器和内存分配器可能会使其变得更大。通常的控制块的实现比你与其的要更加复杂。它使用了继承,甚至会用到虚函数(用以确保所指向的对象被适当地析构)。这就意味着,使用std::shared_ptr也会带来控制块用到的虚函数带来成本。

在通常情况下std::shared_ptr创建控制块会使用默认析构器和默认分配器,控制块只需要三个字长,它的分配基本上是无开销的。对std::shared_ptr解引用的开销不会比原始指针高。执行原子引用计数修改操作需要承担一两个原子操作开销,这些操作通常都会一一映射到机器指令上,所以即使对比非原子执行来说,原子指令开销较大,但是它们仍然只是单个指令。对于每个被std::shared_ptr指向的对象来说,控制块中的虚函数机制产生的开销通常只需要承受一次,即对象销毁的时候。

可以从std::unique_ptr升级到std::shared_ptr,因为std::shared_ptr可以从std::unique_ptr上创建。

反之不行,不能从std::shared_ptr转换为std::unique_ptr。因为std::shared_ptr指向的资源之前签订的协议是“除非死亡否则永不分离”。

std::shared_ptr不能处理数组。和std::unique_ptr不同的是,std::shared_ptr的API设计之初就是针对单个对象的,没有办法std::shared_ptr<[]>

要点速记

  • std::shared_ptr提供方便的手段,实现了任意资源在共享所有权语义下进行生命周期管理的垃圾回收
  • std::unique_ptr相比,std::shared_ptr的尺寸通常是裸指针的两倍,它还会带来控制块的开销,并要求原子化的引用计数操作
  • 默认的资源析构通过delete运算符进行,但同时也支持定制析构器,析构器的类型对于std::shared_ptr的类型没有影响
  • 避免使用裸指针类型的变量来创建std::shared_ptr指针
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值