共享指针四宗罪
↓推荐关注↓
问题描述
在基于C++的大型系统的设计实现中,由于缺乏语言级别的GC支持,资源生存周期往往是一个棘手的问题。系统地解决这个问题的方法无非两种:
使用GC库 使用引用计数
严格地说,引用计数其实也是一种最朴素的GC。相对于现代的GC技术,引用计数的实现简单,但相应地,它也存在着循环引用和线程同步开销等问题。
关于这二者孰优孰劣,已经有过很多讨论,在此就不搅这股混水了。我一直也没有使用过C++的GC库,在实际项目中总是采用引用计数的方案。而作为Boost的拥趸,首选的自然是shared_ptr。一直以来我也对shared_ptr百般推崇,然而最近的一些项目开发经验却让我在shared_ptr上栽了坑,对C++引用计数也有了一些新的的认识,遂记录在此。
本文主要针对基于boost::shared_ptr的C++引用计数实现方案进行一些讨论。C++引用计数方案往往伴随着用于自动管理引用计数的智能指针。按是否要求资源对象自己维护引用计数,C++引用计数方案可以分为两类:
侵入式:侵入式的引用计数管理要求资源对象本身维护引用计数,同时提供增减引用计数的管理接口。通常侵入式方案会提供配套的侵入式引用计数智能指针。该智能指针通过调用资源对象的引用计数管理接口来自动增减引用计数。COM对象与CComPtr便是侵入式引用计数的一个典型实例。 非侵入式:非侵入式的引用计数管理对资源对象本身没有任何要求,而是完全借助非侵入式引用计数智能指针在资源对象外部维护独立的引用计数。shared_ptr便是基于这个思路。
第一宗罪
初看起来,非侵入式方案由于对资源对象的实现没有任何要求,相较于侵入式方案更具吸引力。然而事实却并非如此。下面就来分析一下基于shared_ptr的非侵入式引用计数。在使用shared_ptr的引用计数解决方案中,引用计数完全由shared_ptr控制,资源对象对与自己对应的引用计数一无所知。
而引用计数与资源对象的生存期息息相关,这就意味着资源对象丧失了对生存期的控制权,将自己的生杀大权拱手让给了shared_ptr。这种情况下,资源对象就不得不依靠至少一个shared_ptr实例来保障自己的生存。换言之,资源对象一旦“沾染”了shared_ptr,就一辈子都无法摆脱!考察以下的简单用例:
用例一:
Resource* p = new CResource;
{
shared_ptr q(p);
}
p->Use() // CRASH
单纯为了解决上述的崩溃,可以自定义一个什么也不做的deleter:
struct noop_deleter {
void operator()(void*) {
// NO-OP
}
};
然后将上述用例的第三行改为:
shared_ptr q(p, noop_deleter());
但是这样一来,shared_ptr就丧失了借助RAII自动释放资源的能力,违背了我们利用智能指针自动管理资源生存期的初衷(话说回来,这倒并不是说noop_deleter这种手法毫无用处,Boost.Asio中就巧妙地利用shared_ptr、weak_ptr和noop_deleter来实现异步I/O事件的取消)。
从这个简单的用例可以看出,shared_ptr就像是毒品一样,一旦沾染就难以戒除。更甚者,染毒者连换用其他“毒品”的权力都没有:shared_ptr的引用计数管理接口是私有的,无法从shared_ptr之外操控,也就无法从shared_ptr迁移到其他类型的引用计数智能指针。
不仅如此,资源对象沾染上shared_ptr之后,就只能使用最初的那个shared_ptr实例的拷贝来维系自己的生存期。考察以下用例:用例二:
{
shared_ptr p1(new CResource);
shared_ptr p2(p1); // OK
CResource* p3 = p1.get();
shared_ptr p4(p3); // ERROR
// CRASH
}
该用例的执行过程如下:
p1在构造的同时为资源对象创建了一份外部引用计数,并将之置为1 p2拷贝自p1,与p1共享同一个引用计数,将之增加为2 p4并非p1的拷贝,因此在构造的同时又为资源对象创建了另外一个外部引用计数,并将之置为1 在作用域结束时,p4析构,由其维护的额外的引用计数降为0,导致资源对象被析构 然后p2析构,对应的引用计数降为1 接着p1析构,对应的引用计数也归零,于是p1在临死之前再次释放资源对象 最后,由于资源对象被二次释放,程序崩溃
至此,我们已经认识到了shared_ptr的第一宗罪——传播毒品:
毒性一:一旦开始对资源对象使用shared_ptr,就必须一直使用 毒性二:无法换用其他类型的引用计数之智能指针来管理资源对象生存期 毒性三:必须使用最初的shared_ptr实例拷贝来维系资源对象生存期
第二宗罪
乘胜追击,再揭露一下shared_ptr的第二宗罪——散布病毒。有点耸人听闻了?其实道理很简单:由于使用了shared_ptr的资源对象必须仰仗shared_ptr的存在才能维系生存期,这就意味着使用资源的客户对象也必须使用shared_ptr来持有资源对象的引用——于是shared_ptr的势力范围成功地从资源对象本身扩散到了资源使用者,侵入了资源客户对象的实现。
同时,资源的使用者往往是通过某种形式的资源分配器来获取资源。自然地,为了向客户转交资源对象的所有权,资源分配器也不得不在接口中传递shared_ptr,于是shared_ptr也会侵入资源分配器的接口。
有一种情况可以暂时摆脱shared_ptr,例如:
shared_ptr AllocateResource() {
shared_ptr pResource(new CResource);
InitResource(pResource.get());
return pResource;
}
void InitResource(IResource* r) {
// Do resource initialization...
}
以上用例中,在InitResource的执行期间,由于AllocateResource的堆栈仍然存在,pResource不会析构,因此可以放心的在InitResource的参数中使用裸指针传递资源对象。这种基于调用栈的引用计数优化,也是一种常用的手段。但在InitResource返回后,资源对象终究还是会落入shared_ptr的魔掌。
由此可以看出,shared_ptr打着“非侵入式”的幌子,虽然没有侵入资源对象的实现,却侵入了资源分配接口以及资源客户对象的实现。而沾染上shared_ptr就摆脱不掉,如此传播下去,简直就是侵入了除资源对象实现以外的其他各个地方!这不是病毒是什么?
然而,基于shared_ptr的引用计数解决方案真的不会侵入资源对象的实现吗?
第三宗罪
在一些用例中,资源对象的成员方法(不包括构造函数)需要获取指向对象自身,即包含了this指针的shared_ptr。Boost.Asio的chat示例便展示了这样一个用例:chat_session对象会在其成员函数中发起异步I/O操作,并在异步I/O操作回调中保存一个指向自己的shared_ptr以保证回调执行时自身的生存期尚未结束。
这种手法在Boost.Asio中非常常见,在不考虑shared_ptr带来的麻烦时,这实际上也是一种相当优雅的异步流程资源生存期处理方法。但现在让我们把注意力集中在shared_ptr上。
通常,使用shared_ptr的资源对象必须动态分配,最常见的就是直接从堆上new出一个实例并交付给一个shared_ptr,或者也可以从某个资源池中分配再借助自定义的deleter在引用计数归零时将资源放回池中。无论是那种用法,该资源对象的实例在创建出来后,都总是立即交付给一个shared_ptr(记为p)。
有鉴于之前提到的毒性三,如果资源对象的成员方法需要获取一个指向自己的shared_ptr,那么这个shared_ptr也必须是p的一个拷贝——或者更本质的说,必须与p共享同一个外部引用计数。然而对于资源对象而言,p维护的引用计数是外部的陌生事物,资源对象如何得到这个引用计数并由此构造出一个合法的shared_ptr呢?这是一个比较tricky的过程。为了解决这个问题,Boost提供了一个类模板enable_shared_from_this:
所有需要在成员方法中获取指向this的shared_ptr的类型,都必须以CRTP手法继承自enable_shared_from_this。即:
class CResource :
public boost::enable_shared_from_this<CResource>
{
// ...
};
接着,资源对象的成员方法就可以使用enable_shared_from_this::shared_from_this()方法来获取所需的指向对象自身的shared_ptr了。问题似乎解决了。但是,等等!这样的继承体系不就对资源对象的实现有要求了吗?换言之,这不正是对资源对象实现的赤裸裸的侵入吗?这正是shared_ptr的第三宗罪——欺世盗名。
第四宗罪
最后一宗罪,是铺张浪费。对了,说的就是性能。
基于引用计数的资源生存期管理,打一出生起就被扣着线程同步开销大的帽子。早期的Boost版本中,shared_ptr是借助Boost.Thread的mutex对象来保护引用计数。在后期的版本中采用了lock-free的原子整数操作一定程度上降低了线程同步开销。然而即使是lock-free,本质上也仍然是串行化访问,线程同步的开销多少都会存在。
也许有人会说这点开销与引用计数带来的便利相比算不得什么。然而在我们项目的异步服务器框架的压力测试中,大量引用计数的增减操作,一举吃掉了5%的CPU。换言之,1/20的计算能力被浪费在了与业务逻辑完全无关的引用计数的维护上!而且,由于是异步流程的特殊性,也无法应用上面提及的基于调用栈的引用计数优化。
那么针对这个问题就真的没有办法了吗?其实仔细检视一下整个异步流程,有些资源虽然会先后被不同的对象所引用,但在其整个生存周期内,每一时刻都只有一个对象持有该资源的引用。用于数据收发的缓冲区对象就是一个典型。它们总是被从某个源头产生,然后便一直从一处被传递到另一处,最终在某个时刻被回收。
对于这样的对象,实际上没有必要针对流程中的每一次所有权转移都进行引用计数操作,只要简单地在分配时将引用计数置1,在需要释放时再将引用计数归零便可以了。
对于侵入式引用计数方案,由于资源对象自身持有引用计数并提供了引用计数的操作接口,可以很容易地实现这样的优化。但shared_ptr则不然。shared_ptr把引用计数牢牢地攥在手中,不让外界碰触;外界只有通过shared_ptr的构造函数、析够函数以及reset()方法才能够间接地对引用计数进行操作。
而由于shared_ptr的毒品特性,资源对象无法脱离shared_ptr而存在,因此在转移资源对象的所有权时,也必须通过拷贝shared_ptr的方式进行。一次拷贝就对应一对引用计数的原子增减操作。
对于上述的可优化资源对象,如果在一个流程中被传递3次,除去分配和释放时的2次,还会导致6次无谓的原子整数操作。整整浪费了300%!
事实证明,在将基于shared_ptr的非侵入式引用计数方案更改为侵入式引用计数方案并施行上述优化后,我们的异步服务器框架的性能有了明显的提升。
结语
最后总结一下shared_ptr的四宗罪:
传播毒品一旦对资源对象染上了shared_ptr,在其生存期内便无法摆脱。 散布病毒在应用了shared_ptr的资源对象的所有权变换的整个过程中的所有接口都会受到shared_ptr的污染。 欺世盗名在enable_shared_from_this用例下,基于shared_ptr的解决方案并非是非侵入式的。 铺张浪费由于shared_ptr隐藏了引用计数的操作接口,只能通过拷贝shared_ptr的方式间接操纵引用计数,使得用户难以规避不必要的引用计数操作,造成无谓的性能损失。
探明这四宗罪算是最近一段时间的项目设计开发过程的一大收获。写这篇文章的目的不是为了将shared_ptr一棒子打死,只是为了总结基于shared_ptr的C++非侵入式引用计数解决方案的缺陷,也让自己不再盲目迷信shared_ptr。
作者:liancheng
http://blog.liancheng.info/?p=85
1、手撸一个智能指针
关注『CPP开发者』
看精选C/C++技术文章
点赞和在看就是最大的支持❤️