Loading [MathJax]/jax/output/CommonHTML/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >C++ 共享指针四宗罪

C++ 共享指针四宗罪

作者头像
C语言中文社区
发布于 2022-05-31 01:42:07
发布于 2022-05-31 01:42:07
55600
代码可运行
举报
文章被收录于专栏:C语言中文社区C语言中文社区
运行总次数:0
代码可运行

问题描述

在基于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,就一辈子都无法摆脱!考察以下的简单用例:

用例一:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
Resource* p = new CResource;  
{  
    shared_ptr q(p);  
}  
p->Use() // CRASH   

单纯为了解决上述的崩溃,可以自定义一个什么也不做的deleter:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
 struct noop_deleter {  
    void operator()(void*) {  
        // NO-OP  
    }  
};  

然后将上述用例的第三行改为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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实例的拷贝来维系自己的生存期。考察以下用例:用例二:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
 {  
    shared_ptr p1(new CResource);  
    shared_ptr p2(p1);            // OK  
    CResource* p3 = p1.get();  
    shared_ptr p4(p3);            // ERROR  
                                  // CRASH  
}   

该用例的执行过程如下:

  1. p1在构造的同时为资源对象创建了一份外部引用计数,并将之置为1
  2. p2拷贝自p1,与p1共享同一个引用计数,将之增加为2
  3. p4并非p1的拷贝,因此在构造的同时又为资源对象创建了另外一个外部引用计数,并将之置为1
  4. 在作用域结束时,p4析构,由其维护的额外的引用计数降为0,导致资源对象被析构
  5. 然后p2析构,对应的引用计数降为1
  6. 接着p1析构,对应的引用计数也归零,于是p1在临死之前再次释放资源对象 最后,由于资源对象被二次释放,程序崩溃

至此,我们已经认识到了shared_ptr的第一宗罪——传播毒品

  • 毒性一:一旦开始对资源对象使用shared_ptr,就必须一直使用
  • 毒性二:无法换用其他类型的引用计数之智能指针来管理资源对象生存期
  • 毒性三:必须使用最初的shared_ptr实例拷贝来维系资源对象生存期

第二宗罪

乘胜追击,再揭露一下shared_ptr的第二宗罪——散布病毒。有点耸人听闻了?其实道理很简单:由于使用了shared_ptr的资源对象必须仰仗shared_ptr的存在才能维系生存期,这就意味着使用资源的客户对象也必须使用shared_ptr来持有资源对象的引用——于是shared_ptr的势力范围成功地从资源对象本身扩散到了资源使用者,侵入了资源客户对象的实现

同时,资源的使用者往往是通过某种形式的资源分配器来获取资源。自然地,为了向客户转交资源对象的所有权,资源分配器也不得不在接口中传递shared_ptr,于是shared_ptr也会侵入资源分配器的接口

有一种情况可以暂时摆脱shared_ptr,例如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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。即:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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。

链接 | http://blog.liancheng.info/?p=85

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-05-13,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 C语言中文社区 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
C++智能指针
C++中,动态内存的管理是通过一对运算符来完成的,new用于申请内存空间,调用对象构造函数初始化对象并返回指向该对象的指针。delete接收一个动态对象的指针,调用对象的析构函数销毁对象,释放与之关联的内存空间。动态内存的管理在实际操作中并非易事,因为确保在正确的时间释放内存是极其困难的,有时往往会忘记释放内存而产生内存泄露;有时在上游指针引用内存的情况下释放了内存,就会产生非法的野指针(悬挂指针)。
恋喵大鲤鱼
2018/08/03
3.6K0
C++智能指针
C++智能指针原理和实现
  在C++中,动态内存的管理是由程序员自己申请和释放的,用一对运算符完成:new和delete。
C语言与CPP编程
2020/12/02
5750
【C++】智能指针详解
参考资料:《C++ Primer中文版 第五版》 我们知道除了静态内存和栈内存外,每个程序还有一个内存池,这部分内存被称为自由空间或者堆。程序用堆来存储动态分配的对象即那些在程序运行时分配的对象,当动态对象不再使用时,我们的代码必须显式的销毁它们。
全栈程序员站长
2022/09/14
9490
【C++】智能指针详解
从零开始学C++之boost库(一):详解 boost 库智能指针(scoped_ptr<T> 、shared_ptr<T> 、weak_ptr<T> 源码分析)
本文讲述了 C++ 中 shared_ptr 的用法和注意事项。主要包括 shared_ptr 的声明方式、内存管理方式、循环引用问题、弱引用以及 shared_ptr 的过期等。同时,也介绍了 boost 库中 shared_ptr 的实现方式,包括引用计数、弱引用以及自定义引用计数的实现。最后,通过一个例子说明了 shared_ptr 的用法和注意事项。
s1mba
2017/12/28
1.9K0
从零开始学C++之boost库(一):详解 boost 库智能指针(scoped_ptr<T> 、shared_ptr<T> 、weak_ptr<T> 源码分析)
【C++高阶】:智能指针的全面解析
📒除了静态内存和栈内存,每个程序还拥有一个内存池。这部分内存被称作自由空间(free store)或堆(heap)。程序用堆来存储动态分配(dynamically allocate)的对象。动态对象的生存期由程序来控制,也就是说,当动态对象不再使用时,我们的代码必须显式地销毁它们。
IsLand1314
2024/10/15
3690
【C++高阶】:智能指针的全面解析
Boost C++ 库 | 智能指针(共享指针、共享数组、弱指针、介入式指针、指针容器)入门
Qt历险记
2024/10/10
2400
Boost C++ 库 | 智能指针(共享指针、共享数组、弱指针、介入式指针、指针容器)入门
【C++】智能指针
若p2处new抛异常,则相当于p2的new没有成功,而p1的new成功了,所以需要释放p1,然后再重新抛出
lovevivi
2023/10/17
1840
【C++】智能指针
muduo网络库学习之EventLoop(五):TcpConnection生存期管理(连接关闭)
s1mba
2018/01/03
1.4K0
muduo网络库学习之EventLoop(五):TcpConnection生存期管理(连接关闭)
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
在 C++编程的世界里,智能指针无疑是管理内存资源的得力助手。它们为我们自动处理内存的分配与释放,极大地减少了因手动管理内存而可能引发的诸如内存泄漏、悬空指针等棘手问题。然而,就像任何工具都有其两面性一样,智能指针在带来便利的同时,如果使用不当,尤其是频繁地创建和销毁它们,也可能会给程序性能带来意想不到的影响。
程序员阿伟
2024/11/21
1090
C++智能指针
什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
二肥是只大懒蓝猫
2023/03/30
7520
C++智能指针
掌握C++中智能指针的综合指南:深入现代内存管理
智能指针主要解决以下问题: (1)内存泄漏:内存手动释放,使用智能指针可以自动释放。 (2)共享所有权指针的传播和释放,比如多线程使用同一个对象时析构问题。
Lion Long
2024/10/28
3470
掌握C++中智能指针的综合指南:深入现代内存管理
【C++】简单实现C++11的三种智能指针
本篇是尝试对C++11的三种智能指针(unique_ptr, shared_ptr, weak_ptr)进行的复现结果, 智能指针的复现在面试中经常考到, 需要好好熟悉.
ZifengHuang
2022/11/18
2.1K0
【C++】简单实现C++11的三种智能指针
C++内存管理
C++继承了C语言的指针,一直以来指针的一些问题困扰着开发人员,常见的指针问题主要有:内存泄露、野指针、访问越界等。值得庆幸的是C++标准委员会给我们提供了auto_ptr智能指针,后面又引入了share_ptr以及weak_ptr帮助我们正确和安全的使用指针,本文主要是介绍boost库提供的解决方案,期望通过本文能够给你提供一个新的天地。
CPP开发前沿
2022/01/13
5040
【C++修炼之路】32.智能指针
对于上述代码,如果p1在new时异常,那么就会被main函数中的catch捕获,直接跳到最外面去,由于没有new成功就没有需要释放的,div抛异常,就会被Func中的catch捕获。那p1成功,p2抛异常,p2申请堆空间产生的异常就会直接被main中的catch捕获。而此时程序继续从main里向下运行,但是由于new是在堆里申请内存,即便跳转出函数,申请空间也不会随着函数栈帧的销毁而还给OS,所以就产生了内存泄漏。因此,为了避免这种情况的发生,就需要让p2申请内存失败之后不直接跳出函数,或者说起码等到p1释放空间再跳转出去,这样就给了p1释放空间的间隙避免了内存泄漏。
每天都要进步呀
2023/10/16
2540
【C++修炼之路】32.智能指针
C++:智能指针
在学习异常的时候,我们知道了由于异常的反复横跳可能会导致内存泄露的问题,但是对于一些自定类类型来说他在栈帧销毁的时候会去调用对应的析构函数,但是以下这种必须手动释放的场景,一旦抛出异常就会造成内存泄露的结果。
小陈在拼命
2024/05/27
1280
C++:智能指针
现代 C++:一文读懂智能指针
简单说,当我们独占资源的所有权的时候,可以使用 std::unique_ptr 对资源进行管理——离开 unique_ptr 对象的作用域时,会自动释放资源。这是很基本的 RAII 思想。
linjinhe
2020/06/22
1.4K0
C++智能指针
func函数中在堆中申请了资源,在func函数结束前也要释放资源,又因为异常的原因,所以抛异常的前面还需要再加一次资源释放,这非常的不方便,代码看起来也很差劲。 并且new本身也会抛异常,如果都抛异常了怎么办,也很容易导致内存泄漏。 所以这里就有了智能指针,这是大体思路:
有礼貌的灰绅士
2023/06/14
1890
C++智能指针
C++智能指针详解
智能指针不是指针,是一个管理指针的类,用来存储指向动态分配对象的指针,负责自动释放动态分配的对象,防止堆内存泄漏和空悬指针等等问题。
小灵蛇
2024/06/06
1490
C++智能指针详解
C++ 新特性学习(一) -- 概述+智能指针(smart_ptr)
C++ 0x/11 终于通过了,真是个很爽的消息。于是乎我决定对新的东西系统学习一下。
owent
2018/08/01
5960
详解C++11智能指针
C++里面的四个智能指针: auto_ptr, unique_ptr,shared_ptr, weak_ptr 其中后三个是C++11支持,并且第一个已经被C++11弃用。
WindSun
2019/09/02
1.8K0
相关推荐
C++智能指针
更多 >
领券
💥开发者 MCP广场重磅上线!
精选全网热门MCP server,让你的AI更好用 🚀
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验