ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【C++】STL源码仿写(三):shared_ptr

【C++】STL源码仿写(三):shared_ptr 一、介绍 shared_ptr在谈C智能指针对资源管理的优势当中我们谈到了智能指针的重要性看完后相信大家也很熟悉unique_ptr的本质了——使用unique_ptr替代new/delete从而一个对象独占一个unique_ptr当程序运行至超出此对象的作用域时或者由于异常中间退出时得以自动析构释放资源。这也就是最简单的**RAIIResource Acquisition Is Initialization**思想的应用。然而如果资源需要被多个对象所共享呢——比如说对于一个计算机系统而言线程总是有限的资源一线程一核心多开线程无益我们通过一个线程池维持一定数量的线程可供复用。那么可以想象项目中的许许多多个模块都需要共享这个线程池而shared_ptr正是在这个场景中派上用场这篇文章提供了一个C线程池的简单示例以下代码是多个模块共享线程池的示意。classThreadPool{// 线程池的资源需要被多个地方共享};// 场景多个模块都需要使用同一个线程池classModuleA{std::shared_ptrThreadPoolpool;// A 需要线程池};classModuleB{std::shared_ptrThreadPoolpool;// B 也需要同一个线程池};classModuleC{std::shared_ptrThreadPoolpool;// C 也需要同一个线程池};// 主程序创建线程池autog_poolstd::make_sharedThreadPool();// 创建各个模块共享同一个线程池实例ModuleA a{g_pool};// A 内部持有 shared_ptrModuleB b{g_pool};// B 内部持有 shared_ptrModuleC c{g_pool};// C 内部持有 shared_ptr可是这引来一些问题:既然大家都“拥有”谁负责释放资源呢抛出异常的时候怎么办如何保证线程安全二、shared_ptr 实现概述引用计数与原子操作Rule of five2.1. 引用计数与原子操作shared_ptr的典型实现使用引用计数的方法来实现资源共享简单来说拷贝了计数析构了计数–。当计数清空时意味着最后一个使用者也使用结束了它负责释放资源。所以我们的shared_ptr的私有成员中包含两个指针:指向各种资源模板的ptr(用于访问资源)引用计数指针ref_count_注意这里是指向原子变量的指针templatetypenameTclassshared_ptr{private:T*ptr_;std::atomicint*ref_count_;//指向原子变量的指针};refcount使用原子变量, 如此一来我们对它的操作比如增加和减少不可分割。不可分割就是一个线程只能看到原子变量的操作前和操作后两种状态之一中间的状态是无法看到的。原子变量是我们的实现中保证多线程下线程安全的关键。2.2. Rule of five我们的实现中如何做到RAII呢通过构造函数 Rule of fiveRule of five 指如果对象涉及了堆内存的管理则必须手动实现析构函数、拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值函数五个函数。如果不实现则应该显式禁用。从而我们能够实现资源获取构造函数包括空构造资源释放析构函数复制资源拷贝构造函数资源所有权转移移动语义移动构造函数移动赋值函数这些函数的设计的目的是为了实现引用计数管理共享资源即构造时设为1、析构减去1复制时加上1所有权转移时不变在公有成员中将以上函数逐个构建public:// 默认构造函数创建一个空的shared_ptr不管理任何资源shared_ptr();// 构造函数接管原始指针ptr指向的资源初始化引用计数为1explicitshared_ptr(T*ptr);// 析构函数释放管理的资源当引用计数归零时~shared_ptr();// 拷贝构造函数共享同一个资源引用计数1shared_ptr(constshared_ptrTother);// 拷贝赋值运算符释放旧资源共享新资源引用计数相应变化shared_ptrToperator(constshared_ptrTother);// 移动构造函数转移资源所有权源对象变为空shared_ptr(shared_ptrTother)noexcept;// 移动赋值运算符释放旧资源转移新资源所有权源对象变为空shared_ptrToperator(shared_ptrTother)noexcept;三、shared_ptr 的实现空构造函数表示shared_ptr并没有指向任何资源主要是STL容器需要这份实现。//空构造函数shared_ptr():ptr_(nullptr),ref_count_(nullptr){}构造函数和析构函数构造函数声明为explicit是因为避免不想要的隐式转换并且当成功构造这一个对象时引用计数ref_count_初值设置为1。析构函数中的release函数后面会讲。//构造函数explicitshared_ptr(T*ptr):ptr_(ptr),ref_count_(ptr?newstd::atomicstd::size_t(1):nullptr){}//析构函数~shared_ptr(){release();}release函数当引用计数清空的时候恰好最后一个使用者也使用完毕他负责释放资源。private:voidrelease(){if(ref_count_ref_count_-fetch_sub(1)1){deleteptr;deleteref_count_;}}T*ptr_;std::atomicint*ref_count_;//指向原子变量的指针以下代码体现了构造和拷贝构造调用的区别shared_ptrintsp1(newint(42));// 调用普通构造函数shared_ptrintsp2(sp1);// 调用拷贝构造函数// sp2 和 sp1 共享同一个资源引用计数变为 2我们的拷贝构造函数成功调用时引用计数需要增加这里我们使用原子操作fetch_add//拷贝构造函数shared_ptr(constshared_ptrTother):ptr(other.ptr_),ref_count_(other.ref_count_){if(ref_count_){ref_count_-fetch_add(1);}}当对象已经存在的时候比方说已经构造了sp1和sp2std::shared_ptrintsp1(newint(10));std::shared_ptrintsp2(newint(20));我们希望更改sp2所共享的资源保持与sp1一致sp2sp1;// sp2 放弃原来的 20改为和 sp1 共享 10此时将调用赋值运算符重载函数注意需要关注自赋值问题——即检查this和other确保他们不是同一个//赋值运算符重载:需要处理自赋值share_ptrToperator(constshare_ptrTother){if(this!other){release();//丢弃原来的ptrother.ptr_;ref_count_other.ref_count_;if(ref_count_){ref_count_.fetch_add(1);}}return*this;}移动语义也是很重要的一环当我们希望资源的所有权转移到走时使用shared_ptrintsp1(newint(42));shared_ptrintsp2(std::move(sp1));// 移动构造// sp1 现在为空不再管理资源// sp2 接管了资源//移动构造函数share_ptrT(share_ptrTother)noexcept:ptr_(other.ptr_),ref_count_(other.ref_count_){other.ptr_nullptr;other.ref_count_nullptr;}如果sp2是这样赋值则调用移动运算符重载函数。sp2std::move(sp1);// 移动赋值赋值运算符重载函数同样需要注意自赋值的问题//移动运算符重载需要处理自赋值share_ptrToperator(shared_ptrTother)noexcept{if(this!other){release();ptr_other.ptr_;ref_count_other.ref_count_;other.ptr_nullptr;other.ref_count_nullptr;if(ref_count_){ref_count_.fetch_sub(1);}}}总结shared_ptr通过引用计数实现共享资源所有权用原子操作保证线程安全。通过RAII自动管理资源生命周期。其核心实现包括ref_count_、ptr_。ref_count_拷贝时增加析构时减少最后一个使用者使用完毕负责释放资源。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进