在基于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,就一辈子都无法摆脱! 考察以下的简单用例:
用例一:
1 2 3 4 5 | IResource* p = new CResource; { shared_ptr q( p ); } p->Use() // CRASH |
单纯为了解决上述的崩溃,可以自定义一个什么也不做的deleter:
1 2 3 4 5 | 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实例的拷贝来维系自己的生存期。考察以下用例:
用例二:
1 2 3 4 5 6 7 | { shared_ptr p1( CResource ); shared_ptr p2( p1 ); // OK IResource* 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 ); bar( 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 { // ... };
接着,资源对象的成员方法就可以使用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。


16 Comments
这四个问题,任何一个自动化内存管理方案或多或少都会有吧,感觉你只是在单纯鄙视
shared_ptr。
前三个问题,根本原因是shared_ptr的非侵入式设计。非侵入式设计的优点我不想谈,你给出的所有例子都是因为你对shared_ptr的误用和滥用。如果你非要做侵入式方法,那boost不可能提供那么完美的方法,不妨你自己写一个出来,然后好好的比较比较你说的这几点,不一定就比boost的好。
最后一条不觉得成立,你没有认真看过文档吧。
如果不想使用lock-free的原子操作,可以定义BOOST_SP_DISABLE_THREADS宏,可以大大提高效率,但是不具有多线程安全性。如果你又想获得线程安全的特性,又不想加锁,哪有这么好的事。linux和freebsd内核里面也有大规模的代码被加锁,但我认为它们都是必要开销。
如果想使用pthread_mutex_t作为原子操作的锁,可以定义BOOST_SP_USE_PTHREADS宏。
呵呵,并不是鄙视shared_ptr,只是我在项目中应用它的时候遇到了这些问题。事实上在此之前,我一直都是非常推崇shared_ptr的。但是在遇到了实际的问题之后,不得不对shared_ptr重新进行检视和反思。
对于前三个问题,你说的一点都没错。其实我原本并不是想单单针对shared_ptr来写这篇文章,而是想针对非侵入式引用计数。但是我只用过shared_ptr这一种非侵入式引用计数解决方案,怕自己的想法不充分以偏概全,所以才只针对shared_ptr来阐述这些问题。可能是行文风格偏激了些,看起来好像是在一味攻击它
从智能指针实现的角度而言,Boost的做法我很欣赏:既提供了非侵入式的shared_ptr又提供了侵入式的intrusive_ptr,而且还提供了去除拷贝语义的scoped_ptr来代替auto_ptr,和shared_ptr配合的weak_ptr也很出彩。每种智能指针针对自己的用例都有最合适的语义,但同时也有自身固有的缺点。我想Boost.Smart_Ptr设计的本意就是让用户针对不同的场景使用最合适的工具。我在项目中犯的错误在于没有明辨自己的应用场景一味地滥用了shared_ptr,将shared_ptr的缺点放大了。这篇文章的目的就是记录下我所总结出的shared_ptr的缺点和不适合的用例,以便今后不会再犯类似的错误。
关于第四个问题,说实话,share_ptr的文档可能是我所使用的Boost库中看得最多最细的一份了。基于引用计数的资源管理方案,无论是侵入式还是非侵入式,除非在单线程环境下,否则引用计数的原子操作开销都不可能完全省掉。BOOST_SP_DISABLE_THREADS完全不解决问题。加锁是必要的,但是锁不可以滥用。在文中提到的场景下,相较于shared_ptr的非侵入式引用计数,使用侵入式引用计数可以省去大量不必要的原子操作,从而尽量将加锁开销最小化。而这种优化在使用shared_ptr时无法完成。
enable_shared_from_this确实比较丑陋。碰到需要返回this的,就要好好想想是否真的需要用shared_ptr?
嗯,不仅如此,一般情况下CRTP还会对继承体系造成负面的影响,只是在
enable_shared_from_this这个用例下没有体现出来。我明白你意思了,如果程序本身需要同步,必要的锁或原子操作是必须的,非侵入式的也不能避免。但shared_ptr的原子操作不是每次都需要的,用侵入式可以自行控制,减少不必要的原子操作。
解决同步可以将多线程的程序改为单线程呀,我是做服务器的,我们的服务器程序全都是单线程的,很久都没碰过锁这种东西了,呵呵。
@zhangyafei
呃……单线程的话,如何利用多核优势呢?我们的做法是将服务器的epoll线程数目和工作线程数目都做成可配,这样可以很容易地退化为单线程服务。不过线上运行时仍然使用多线程的配置。支持退化为单线程的主要目的是为了简化与多线程无关的bug的诊断与调试。
@liancheng
多进程呗。还是要看具体应用了。
呵呵,单线程进程加多进程,如果你们的系统中没有使用共享内存而是完全采用消息传递的话,倒是跟Erlang的思路不谋而合
真希望能够有机会参与一个实际的Erlang项目体验一下。
我不了解Erlang是啥样的。
视不同服务应用而定吧,总之同步和锁是不可避免的。OS消息队列的锁,或者通过socket自己写的消息队列(socket中的锁),共享内存的锁。共享内存也有优点,在共享较大量数据时很有优势,如果都是小的控制原语,用消息队列也不错啊。:)
@zhangyafei
实际上Erlang进程的mailbox就是一个类似消息队列的东西。说着又想起这么一句话
:
Any sufficiently advanced concurrent C program contains an ad hoc, informally-specified, bug ridden, slow implementation of half of Erlang
总结得不错,shared_ptr的这些问题让我一直不喜欢用它。
对于用例二,作用域结束时,析构顺序是否应该反过来?
@kpx
啊……谢谢提醒!真是个不应该犯的错误
总结的很好。我没有用share_ptr做过很复杂的项目,也没注意过这些问题。其实,与其说share_ptr有这些缺陷,还不如说不能对share_ptr如此误用。在不合适share_ptr的场合,就应该需用其他的资源管理机制。
Boost解决this的share_ptr时,确实比较不人性化的哈
侵入式和非侵入式的引用计数确各有优劣,侵入式的由于对象本身自带引用计数,智能指针就不必总是从最初的智能指针来赋值了;非侵入式的,不必改动管理对象的设计。
你所说的几个问题我觉得不能赞同,任何东西都有它的适用范围和注意事项。就像湿手插拔电器会被电到一样,你不能抱怨这是电器设计不好,只能说自己使用不当。
只有第四个问题我觉得有点意思,不过如果SharedPtr和WeakPtr配合,在局部作用域中都使用WeakPtr来操作,只在跨作用域时采用SharedPtr来传递,这样可以很大程度上减少计数修改,对性能应该没有什么影响。
你说的没错,我在文中也有提到,写这篇文章的初衷就是因为自己在一个项目中误用、滥用了shared_ptr,因此写下这篇文章做个总结,并没有要将shared_ptr或是非侵入式引用计数一棒子打死的意思
你后面所说的用weak_ptr来提升性能这点,我只能部分赞同:在局部作用域使用weak_ptr来做参数传递确实可以免除引用计数的增减,但是直接使用weak_ptr是无法访问资源对象的成员的,必须先lock出一个shared_ptr才可以,而这样节省引用计数操作的目的便无法达到了。
连城,好久没见,本不知道你的博客,下午某同事偶然搜到这里,所以来这里逛逛
个人不太喜欢侵入式的做法,下面说的若非特别指定都是非侵入式。
shared_ptr其实应该生活在它的智能指针世界里,文中例举的部分问题其实是让shared_ptr脱离了其生活的世界,smart_ptr和C-sytle pointer混用虽然没有free遇到delete那么严重,但总归还是有些格格不入,遇到问题在所难免。好好让shared_ptr和weak_ptr为伴才显得和谐。
至于性能问题,我觉得既然选用了智能指针,性能的考量标准就不应该以指令级为粒度,不然还是用回C-sytle pointer吧,呵呵。况且,整数的原子增减操作还是非常快的。当然,如果确实非常在意这个性能问题,我想良好的设计和正确的运用还是能有效扬长避短的,增加引用计数不是困难吗,那我的设计里就少干这事。
倒是外部的引用计数控制块的内存管理倒可能是比较体现技术含量的。