分享

C 智能指针的底层实现原理

 新用户73286115 2023-07-23 发布于北京

C++智能指针的头文件:

#include <memory>

1. shared_ptr:

智能指针从本质上来说是一个模板类,用类实现对指针对象的管理。

template <typename T>
class shared_ptr;

template <typename Y, class Deleter>
shared_ptr(Y* ptr, Deleter d);

template <typename Y, class Deleter, class Alloc>
shared_ptr(Y* ptr, Deleter d, Alloc alloc);

shared_ptr能解决的问题:

  1. 忘记释放资源导致的内存泄漏;

  2. 多个指针指向同一资源时可能产生的悬垂指针;

  3. (待补充)。。。

1.1 从避免出现悬垂指针引出shared_ptr的实现原理:

先来看一个普通指针可能出现的悬垂问题:

当有多个指针指向同一个基础对象时,如果某个指针delete了该基础对象,对于其他指针来说,它们是无法感知的,此时则出现了悬垂指针,如果再对其他指针进行操作,则可能会导致core dump。

(core dump的原因:因为已经调用了delete,相当于已经将内存资源归还给了系统,如果有其他地方向系统申请资源时,系统则重新分配这块内存。此时有两种情况:① 原始的悬垂指针调用delete,系统检测到二次释放,直接core dump;② 原始的悬垂指针对指针地址上的内存进行读、写操作,可能意外的改写了其他程序的内容,即“踩内存”,导致发生意想不到的情况。)

int* ptr1 = new int(42);
int* ptr2 = ptr1;
int* ptr3 = ptr1;

cout << *ptr1 << endl;
cout << *ptr2 << endl;
cout << *ptr3 << endl; //没有问题,三个指针指向同一块内存地址

delete ptr1; //通过ptr1释放了内存资源,ptr2和ptr3成为悬垂指针

//...

cout << *ptr2 << endl; //可能没问题,可能有问题

普通指针出现悬垂的根本原因在于:当多个指针同时指向同一个内存资源时,如果通过其中的某一个指针delete释放了资源,其他指针无法感知到。

解决方法自然想到了“引用计数” ---- 通过一块额外的内存,实现对原始内存的管理。

在这块 “控制块” 内存中,保存当前对原始内存资源的引用计数。

普通指针多指针场景下出现悬垂指针的原因:

图片

引入“控制块”,保存对于基础对象的“引用计数”,示例中有ptr1、ptr2、ptr3三个指针同时指向同一基础对象,因此对应这个基础对象的引用计数为 3:

图片

当有某个指针退出作用域,或调用了delete释放资源时,系统并非真正的释放基础对象,而是对引用计数减一。

那么何时才可以删除基础对象呢?当只有一个指针指向基础对象的时候,就可以大大方方的通过该指针将基础对象删除(真正的调用delete释放基础对象的资源)。

图片

对于“控制块”的实现方式:

图片

对“控制块”中“引用计数”的管理:

1、构造函数:

当创建类的新对象时,初始化指针,并将引用计数设置为 1;

2、拷贝构造函数:

当对象作为另一个对象的副本时(即发生“拷贝构造”时),拷贝构造函数拷贝副本指针,并对引用计数 加1;

3、拷贝赋值运算符:

当使用“拷贝赋值运算符”(=)时,处理复杂一点:

a. 先使“左操作数”的指针的引用计数减1 (为何减一:因为该指针已经指向别的地方,则指向原基础对象的指针个数减1), 如果减1后引用计数降为0,则释放指针所指对象的内存资源;

b. 然后增加“右操作数”所指对象的引用计数(为何加一:因为此时左操作数转而指向此基础对象,则指向此基础对象的指针个数加1);

4、析构函数:

调用析构函数时,析构函数先使引用计数减1,如果减至0则delete释放对象。

shared_ptr类的“构造函数”使得基础对象的引用计数递增,shared_ptr类的“析构函数”使得基础对象的引用计数递减。

当最后一个指向基础对象的shared_ptr被析构时,会调用delete释放基础对象的内存资源。

需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享

图片

1.2 make_shared:

注意 make_shared 是 函数模板,不是类模板,make_shared函数模板的返回值类型是 shared_ptr。

template<typename T>
shared_ptr<T> make_shared(); //make_shared()模板函数,返回一个shared_ptr<T> 类型的返回值

template<typename T, typename... Args>
shared_ptr<T> make_shared(Args&&... args);


//例如:
shared_ptr<string> p1 = make_shared<string>(10, '9');
shared_ptr<string> p2 = make_shared<string>('hello');
shared_ptr<string> p3 = make_shared<string>();

make_shared的优点:

  1. 效率更高;

  2. 异常安全。

make_shared的缺点:

  1. 构造函数是保护或私有时,无法使用make_shared;

  2. 对象内存可能无法及时回收。

1.2.1 优点:效率更高:

假设原始对象类型为 widget,shared_ptr的“控制块”中需要维护的关于“引用计数”的信息包括:

  1. 强引用:用来计数当前有多少存活的shared_ptr正持有该对象,共享的对象会在最后一个强引用离开的时候释放;

  2. 弱引用:用来记录当前有多少个正在观察该对象的weak_ptr,当最后一个弱引用离开的时候,共享的内部信息控制块会被释放。

如果通过原始的new表达式分配对象,然后传递给shared_ptr(即使用shared_ptr内部的构造函数),则“控制块内存”与“基础对象内存”是分离开的,如图所示:

此时是两个分配内存的动作,所以控制块与基础对象的内存是分离的(可能造成内存碎片)。控制块的内存是在shared_ptr的构造函数中分配的。

auto p = new widget(); //先使用new表达式分配一个widget类型的对象

shared_ptr<widget> sp1(p); //用p去初始化sp1,此时引用计数为1
shared_ptr<widget> sp2(sp1); //用sp1去拷贝初始化sp2,此时引用计数为2

图片

如果使用 make_shared 的方式,则只需要一次分配内存,分配出的内存结构如图所示:

auto sp1 = make_shared<widget>();
auto sp2(sp1);

图片

1.2.2 优点:异常安全:

可能会出现异常的情况:

//函数F的定义:
void F(shared_ptr<Lhs>& lhs, shared_ptr<Rhs>& rhs) { ... }

//调用F函数:
F(shared_ptr<Lhs>(new Lhs('foo')), shared_ptr<Rhs>(new Rhs('bar')));

C++是不保证参数求值顺序,以及内部表达式的求值顺序,所以可能的执行顺序如下:

1. new Lhs('foo')
2. new Rhs('bar')
3. shared_ptr<Lhs>
4. shared_ptr<Rhs>

此时,如果程序在第2步时抛出一个异常(比如out of memory等,Rhs的构造函数异常的),那么在第1步中new分配的Lhs对象内存将无法释放,导致内存泄漏。

这个问题的核心在于 shared_ptr 没有立即获得new分配出来的裸指针,shared_ptr与new结合使用时是要分成两步。

修复这个问题的方式有两种:

(1)不要将new操作放到函数形参初始化中,这样将无法保证求值顺序:

//解决方法是先保证两个new分配内存都没有错误,并在new之后立即初始化shared_ptr:
auto lhs = shared_ptr<Lhs>(new Lhs('foo'));
auto rhs = shared_ptr<Rhs>(new Rhs('bar'));
F(lhs, rhs);

(2)更推荐的方法,是使用make_shared,一步到位 :

F(make_shared<Lhs>('foo'), make_shared<Rhs>('bar'))

1.2.3 缺点:构造函数是保护或私有时无法使用:

当我们想要创建的对象没有公有的构造函数时,make_shared就无法使用了。

1.2.4 对象内存可能无法及时回收:

make_shared的优点是只需申请一次内存,带来了性能上的提升。但这一性能同样也给make_shared带来了缺点:

智能指针的“控制块”中保存着两类关于“引用计数”的信息:

  1. 强引用;(strong refs)

  2. 弱引用。(weak refs)

“弱引用计数”用来保存当前正在指向此基础对象的weak_ptr指针的个数,weak_ptr会保持控制块的生命周期,因此有一种特殊情况是:强引用的引用计数已经降为0,没有shared_ptr再持有基础对象,然而由于仍有weak_ptr指向基础对象,弱引用的引用计数非0,原本因为强引用计数已经归0就可以释放的基础对象内存,现在变成了“强引用、弱引用都减为0时才能释放”, 意外的延迟了内存释放的时间。这对于内存要求高的场景来说,是一个需要注意的问题。

(一般情况下,程序中无需考虑这种微小的差别。)

1.3 shared_ptr实现说明:

摘自cppreference:

在典型的实现中,shared_ptr 只保有两个指针:

  1. get()所返回的指针;(基础对象的内存地址)

  2. 指向控制块的指针。(控制块对象的内存地址)

控制块是一个动态分配的对象,其中包含:

  1. 指向被管理对象的指针或被管理对象本身;(基础对象的内存地址)

  2. 删除器;(Deleter,类型擦除)

  3. 分配器;(Allocator,类型擦除)

  4. 占用被管理对象的shared_ptr的数量(strong refs强引用的引用计数);

  5. 涉及被管理对象的weak_ptr的数量(weak refs弱引用的引用计数) 。

1.4 shared_ptr的线程安全性:

多线程环境下,调用不同shared_ptr实例的 成员函数是不需要额外的同步手段的(例如use_count()等成员函数),即使这些shared_ptr拥有的是同样的对象。

但是,如果多线程访问(有写操作)同一个shared_ptr,则需要线程同步,否则就会有race condition发生。

shared_ptr的引用计数本身是安全且无锁的,但shared_ptr中封装的基础对象的读写则不是。

出现这种情况的原因是:shared_ptr有两个数据成员(指向被管理对象的指针,和指向控制块的指针),读写操作不能原子化。

1.5 shared_from_this:

1.5.1 多个shared_ptr管理同一指针时的重复释放问题:

在使用shared_ptr管理指针时,有一个原则就是要尽量避免“先new、后用裸指针初始化shared_ptr” 的方式,这是因为当有两个或多个shared_ptr同时管理一个指针时,多个shared_ptr之间无法共享彼此的引用计数,导致可能造成double free。

异常场景示例:(两个shared_ptr共同管理同一个裸指针)

int main() {
int *ptr = new int(42);

shared_ptr<int> sp1(ptr);
shared_ptr<int> sp2(ptr);

cout << sp1.use_count() << endl;
cout << sp2.use_count() << endl;
//sp1记录的引用计数是1,sp2记录的引用计数也是1,
//此时有两个智能指针sp1和sp2同时管理ptr,相当于有两个独立的控制块

return 0;
//此时退出作用域,sp1、sp2会分别调用delete去释放基础对象*ptr,
//重复释放,导致程序段错误
}

由此引出一个使用shared_ptr的原则:

当我们使用智能指针管理资源时,必须统一使用智能指针,而不能在某些地方使用智能指针,某些地方使用raw pointer,否则不能保持智能指针管理这个类对象的语义,从而产生各种错误。

给shared_ptr管理的资源必须在分配时立即交给shared_ptr,即:shared_ptr sp(new T());,而不是先new出ptr,再在后面的某个地方将ptr赋给shared_ptr。

1.5.1 shared_from_this的使用场景:

上述的情况同样可能会发生在 this指针 上面。

当一个类被shared_ptr管理(当使用shared_ptr管理类对象时,实际上是管理的类对象的 *this指针),且在类的成员函数中需要把当前类对象作为参数传递给其他函数时,就需要返回当前对象的this指针,但是,直接传递this指针(相当于裸指针)到类外,有可能会被多个shared_ptr所管理,造成与上面一样的二次释放的异常错误。

错误示例:

//C是一个可以返回类对象this指针的类:
class C {
public:
C(int b = 10) : a(b) { cout << 'constructor' << endl; }
~C() { cout << 'destructor' << endl; }

void show() const { cout << 'a = ' << a << endl; }
C* object_ptr() { return this; } //一个返回*this指针的成员函数

private:
int a;
};


int main() {
shared_ptr<C> sp1(new C(42)); //构造一个C类对象,并由shared_ptr对此对象资源进行管理

shared_ptr<C> sp2(sp1->object_ptr());
//在某种场景下返回类对象的this指针给其他函数,我们的本意是在原有C类对象的基础上累加引用计数
cout << sp1.use_count() << ', ' << sp2.use_count() << endl;
//sp1、sp2的引用计数都是 1

return 0;
//在退出程序前sp1、sp2的引用计数都降为0,会分别调用delete去释放C类对象,导致重复释放,段错误
}

出现上述异常的原因很简单,类的成员函数将对象的this指针返回出去,this是一个普通指针,交给智能指针sp2管理,而sp2根本感知不到这个裸指针已经被其他智能指针sp1给管理起来了。

使用shared_ptr直接管理this指针导致“重复释放”的原因在于:

  1. 使用智能指针管理“类对象”的本质是管理类对象的 this 指针;

  2. this指针与其他的普通裸指针并无区别,当多个shared_ptr同时管理同一个this指针时,相互之间无法感知。

C++11 引入shared_from_this,使用方式如下:

  1. 继承 enable_shared_from_this 类;

  2. 调用 shared_from_this() 成员函数先将this指针封装进一个shared_ptr,再将shared_ptr返回到类外供其他人使用。

使用shared_from_this 改写上面的错误示例:

//首先,继承enable_shared_from_this模板类:
//注意继承模板类时需要先将类模板实例化,否则编译器无法知道具体的数据类型
class C : public enable_shared_from_this<C> {
public:
C(int b = 10) : a(b) { cout << 'constructor' << endl; }
~C() { cout << 'destructor' << endl; }

void show() const { cout << 'a = ' << a << endl; }
//C* object_ptr() { return this; } //一个返回*this指针的成员函数
//在需要返回类对象this指针的地方,调用shared_from_this成员函数先将this封装成shared_ptr再返回
shared_ptr<C> object_ptr() { return shared_from_this(); }

private:
int a;
};


int main() {
shared_ptr<C> sp1(new C(42));
cout << 'sp1.use_count : ' << sp1.use_count() << endl;

shared_ptr<C> sp2(sp1->object_ptr());
cout << 'sp2.use_count : ' << sp2.use_count() << endl;

return 0;
}

------
运行结果:

constructor
sp1.use_count : 1
sp2.use_count : 2
destructor

shared_from_this的使用公式为:

class A : public enable_shared_from_this<A> {
public:
shared_ptr<A> object_ptr() {
return shared_from_this();
}
};

上面的例子中,虽然在类成员函数 object_ptr() 中先将this指针封装成了一个shared_ptr,但是this指针的引用计数并没有因此而比正常时多1,这涉及到shared_from_this的实现原理。

1.5.2 shared_from_this的实现原理:

要实现上述的shared_from_this 的功能,首先要考虑两个设计原则:

1、首先要考虑的是:

在类对象本身当中不能存储类对象本身的shared_ptr,否则类对象shared_ptry永远也不会为0,从而这些资源永远不会释放,除非程序结束。

2、其次,类对象肯定是外部函数通过某种机制分配的,而且一经分配立即交给shared_ptr管理(强调:给shared_ptr管理的资源必须在分配时交给shared_ptr),而且以后凡是需要共享使用类对象的地方必须使用这个shared_ptr当作右值来构造生产或者拷贝产生另一个shared_ptr从而达到共享使用的目的。

基于以上两点要求,boost中使用的是 weak_ptr 的方式来实现的。

boost 1.39.0 中是这样实现的:

1、首先生成类A:会依次调用 enable_shared_from_this 的构造函数 以及 类A的构造函数。

enable_shared_from_this 类中有一个 weak_ptr 成员,在enable_shared_from_this构造函数中对其初始化,此时weak_ptr无效的,不指向任何对象。

2、接着,外部程序会把指向类A 对象的this指针作为初始化参数来初始化一个shared_ptr,就是下面的过程:

shared_ptr<A> sp(new A(42));

关键点在于这个shared_ptr如何初始化, shared_ptr模板类中定义了如下的构造函数:

template <typename Y>
explicit shared_ptr(Y *p) : px(p), pn(p)
{
boost::detail::sp_enable_shared_from_this(this, p, p);
}

//boost::detail::sp_enable_shared_from_this :
template <typename X, typename Y, typename T>
inline void sp_enable_shared_from_this(boost::shared_ptr<X> const * ppx,
Y const * py, boost::enable_shared_from_this< T > const * pe)
{
if( pe != 0 )
{
pe->_internal_accept_owner( ppx, const_cast< Y* >( py ) );
}
}


//_internal_accept_owner
template<class X, class Y> void _internal_accept_owner( shared_ptr<X> const * ppx, Y * py ) const
{
if( weak_this_.expired() )
{
weak_this_ = shared_ptr<T>( *ppx, py );
}
}

而在这里对 enable_shared_from_this 的成员weak_ptr进行拷贝赋值,使得整个 weak_ptr作为类对象 shared_ptr的一个观察者。这时,当类对象本身需要自身的shared_ptr时,就可以从这个weak_ptr来生成一个了。

2. weak_ptr:

2.1 weak_ptr的特性:

  1. weak_ptr 只能从shared_ptr构建;

  2. weak_ptr 并不影响动态对象的生命周期,即其存在与否并不影响对象的引用计数(强引用的引用计数);

  3. weak_ptr 没有重载 operator-> 和 operator* 操作符,因此不可以直接通过 weak_ptr 使用对象(必须通过weak_ptr获取到shared_ptr后才能访问基础对象);

  4. weak_ptr 提供了 expired() 和 lock() 成员函数,分别用于判断基础对象是否已被销毁、返回指向基础对象的shared_ptr指针。

weak_ptr 模板类中常用的成员函数:

use_count : 与 shared_ptr.use_count() 的功能类似,返回指向基础对象的shared_ptr的个数
对于一个“空”weak_ptr(还未指向任何shared_ptr),它的use_count是0

expired : 用于判断weak_ptr所指向的对象是否已经被销毁(即判断use_count是否为0),返回值为bool类型
如果所指对象已被销毁,返回值为1(true)。对于“空”weak_ptr,expired返回值为1

lock : 返回weak_ptr所指向的基础对象上的一个shared_ptr指针
如果对象已经被销毁,则返回“空”shared_ptr
如果成功返回一个shared_ptr,则强引用计数加1

2.2 weak_ptr的使用场景:

  1. 当你想使用对象,但是并不管理对象,并且在需要的时候可以返回对象的shared_ptr时,则使用

  2. 解决shared_ptr的“循环引用”问题。

3. unique_ptr 与 auto_ptr:

智能指针可分为两类:

  1. 独占型: 如 unique_ptr,一份资源仅能由一个unique_ptr管理;

  2. 共享型: 如 shared_ptr,一份资源可以有多个shared_ptr共同管理,当没有shared_ptr对象指向这份资源时,资源才会释放,即基于引用计数原理。

C++11中共有四种智能指针:auto_ptr、unique_ptr、shared_ptr、weak_ptr。

所有这些智能指针都是为了管理动态分配对象的生命周期而设计的,换言之,通过保证这样的对象在适当的时机以适当的方式析构(包括发生异常的场合),来防止资源泄漏。

auto_ptr是个从c++98中残留下来的弃用特性,它是一种对智能指针进行标准化的尝试,这种尝试后来成为了c++11中的unique_ptr。

要正确的完成这个特性就需要移动语义,但在c++98中却并没有这样的语义。作为一种变通手段,auto_ptr使用了 拷贝复制操作 来完成移动任务,这就导致:

  1. 可能在运行期产生异常的代码(原始auto_ptr实例中的裸指针被置空);

  2. 某些使用限制(例如 不能在容器中存储auto_ptr对象);

3.1 auto_ptr的核心实现代码:

template <typename _Tp>
class auto_ptr {
private:
_Tp *_M_ptr;

public:
typedef _Tp element_type;

explicit auto_ptr(element_type *__p = 0) : _M_ptr(__p) { } //默认构造函数

auto_ptr(auto_ptr& __a) throw() : _M_ptr(__a.release()) { } //拷贝构造函数。release()成员函数会返回auto_ptr内封装的裸指针地址

auto_ptr& operator=(auto_ptr& __a) throw() { //赋值运算符.将原始auto_ptr实例中的裸指针置为空指针
reset(__a.release());
return *this;
}

element_type* release() throw() { //当本类的实例作为参数给另外的auto_ptr赋值时,会调用实例的release成员函数,将自身内封装的裸指针置为空,并返回资源的地址给新的auto_ptr实例
element_type *__tmp = _M_ptr;
_M_ptr = 0;
return __tmp;
}

void reset(element_type* __p = 0) throw() {
if(__p != _M_ptr) {
delete _M_ptr;
_M_ptr = __p;
}
}

};

3.2 unique_ptr的核心实现代码:

unique_ptr的设计主要有如下两点:

  1. 禁止拷贝构造函数、拷贝赋值运算符,即设置为=delete

  2. 实现了移动构造函数和移动赋值运算符。

unique_ptr必须直接初始化,且不能通过隐式转换来构造,因为unique_ptr的构造函数被声明为explicit。

//unique_ptr 有两个版本:管理单个对象 或 管理动态分配的对象数组:
template <typename _Tp, typename _Dp = default_delete<_Tp>>
class unique_ptr { /****/ };

template <typename _Tp, typename _Dp>
class unique_ptr<_Tp[], _Dp> { /****/ };

3.3 unique_ptr 的常用操作:

u.get(); //返回unique_ptr中保存的裸指针
u.reset(); //重置unique_ptr
u.release(); //放弃指针的控制权,返回裸指针,并将unique_ptr自身置为空。
u.swap(); //交换两个unique_ptr所指向的对象

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多