本文字数约3000字,阅读时间约8分钟,阅读此博客可期望的收获:通过简单的语言讲解,增进对shared_ptr的了解,并且通过一个简化版的shared_ptr实现实例,对C++ RAII思想有更深入的认识

unique_ptr vs shared_ptr

谈C++智能指针对资源管理的优势当中,我们谈到了智能指针对于资源管理的重要性,相信大家也很熟悉unique_ptr了——使用unique_ptr替代new/delete,从而一个对象独占一个unique_ptr,当程序运行至超出此对象的作用域时,或者,由于异常中间退出时,得以自动析构,释放资源。这也就是最简单的**RAII(Resource Acquisition Is Initialization)**思想的应用。

然而,如果资源需要被多个对象所共享呢?——比如说,对于一个计算机系统而言,线程总是有限的资源(一线程一核心,多开线程无益),我们通过一个线程池维持一定数量的线程可供复用。那么可以想象,项目中的许许多多个模块都需要共享这个线程池,而shared_ptr正是在这个场景中派上用场:

以下代码是多个模块共享线程池的示意。

class ThreadPool {
    // 线程池的资源需要被多个地方共享
};

// 场景:多个模块都需要使用同一个线程池
class ModuleA {
    std::shared_ptr<ThreadPool> pool;  // A 需要线程池
};

class ModuleB {
    std::shared_ptr<ThreadPool> pool;  // B 也需要同一个线程池
};

class ModuleC {
    std::shared_ptr<ThreadPool> pool;  // C 也需要同一个线程池
};

// 主程序创建线程池
auto g_pool = std::make_shared<ThreadPool>();

// 创建各个模块,共享同一个线程池实例
ModuleA a{g_pool};  // A 内部持有 shared_ptr
ModuleB b{g_pool};  // B 内部持有 shared_ptr  
ModuleC c{g_pool};  // C 内部持有 shared_ptr

可是这引来一些问题:

  • 既然大家都“拥有”,谁负责释放资源呢?
  • 抛出异常的时候怎么办?
  • 如何保证线程安全?

shared_ptr的三核心——引用计数、原子操作、RAII

引用计数:最后一个使用者释放资源

shared_ptr的典型实现,使用引用计数的方法来实现资源共享,简单来说:拷贝了,计数++,析构了计数–。当计数清空时,意味着最后一个使用者也使用结束了,它负责释放资源。

所以,我们的shared_ptr的私有成员中包含两个指针:

  • 指向各种资源模板的ptr(用于访问资源)
  • 引用计数指针ref_count_
template <typename T>
class shared_ptr {
private:
    T* ptr_;
    std::atomic<int>* ref_count_; //指向原子变量的指针
};

这里我们使用原子变量atomic, 如此一来,我们对ref_count_的操作(比如增加和减少)可以使用原子操作——即不可分割的操作。

这里可以多解释一下:不可分割就是一个线程只能看到原子变量的操作前和操作后两种状态之一,中间的状态是无法看到的。

原子操作是我们保证多线程下线程安全的关键。

RAII 思想与设计

shared_ptr中体现的RAII,其目标是对象的生命周期绑定资源的生命周期,具体涉及了四个方面的设计,这里可以先大概了解一下:

  • 资源获取:构造函数(包括空构造)
  • 资源释放:析构函数
  • 复制资源:拷贝构造函数
  • 资源所有权转移:移动语义(移动构造函数,移动赋值函数)

这些函数的设计的目的是为了实现引用计数管理共享资源,即构造时设为1、析构减去1,复制时加上1,所有权转移时不变

在公有成员中将以上函数逐个构建:

public:
    // 默认构造函数:创建一个空的shared_ptr,不管理任何资源
    shared_ptr();
    
    // 构造函数:接管原始指针ptr指向的资源,初始化引用计数为1
    explicit shared_ptr(T* ptr);
    
    // 析构函数:释放管理的资源(当引用计数归零时)
    ~shared_ptr();
    
    // 拷贝构造函数:共享同一个资源,引用计数+1
    shared_ptr(const shared_ptr<T>& other);
    
    // 拷贝赋值运算符:释放旧资源,共享新资源,引用计数相应变化
    shared_ptr<T>& operator=(const shared_ptr<T>& other);
    
    // 移动构造函数:转移资源所有权,源对象变为空
    shared_ptr(shared_ptr<T>&& other) noexcept;
    
    // 移动赋值运算符:释放旧资源,转移新资源所有权,源对象变为空
    shared_ptr<T>& operator=(shared_ptr<T>&& other) noexcept;

空构造函数:表示shared_ptr并没有指向任何资源,主要是STL容器需要这份实现。

    //空构造函数
    shared_ptr() : ptr_(nullptr), ref_count_(nullptr) {}

构造函数和析构函数:构造函数声明为explicit是因为避免不想要的隐式转换,并且当成功构造这一个对象时,引用计数ref_count_初值设置为1。析构函数中的release函数后面会讲。

    //构造函数
    explicit shared_ptr(T* ptr) : ptr_(ptr), ref_count_(ptr ? new std::atomic<std::size_t>(1) : nullptr) {}
    //析构函数
    ~shared_ptr() {
        release();
    }

以下代码体现了构造和拷贝构造调用的区别:

shared_ptr<int> sp1(new int(42));  // 调用普通构造函数
shared_ptr<int> sp2(sp1);           // 调用拷贝构造函数
// sp2 和 sp1 共享同一个资源,引用计数变为 2

我们的拷贝构造函数成功调用时,引用计数需要增加,这里我们使用原子操作fetch_add

    //拷贝构造函数
    shared_ptr(const shared_ptr<T>& other) : ptr(other.ptr_), ref_count_(other.ref_count_){
        if (ref_count_) {
            ref_count_->fetch_add(1);
        }
    }

当对象已经存在的时候,比方说已经构造了sp1和sp2

std::shared_ptr<int> sp1(new int(10));
std::shared_ptr<int> sp2(new int(20));

我们希望更改sp2所共享的资源,保持与sp1一致

sp2 = sp1;  // sp2 放弃原来的 20,改为和 sp1 共享 10

此时将调用赋值运算符重载函数:

    //赋值运算符重载:需要处理自赋值
    share_ptr<T>& operator=(const share_ptr<T>& other) {
        if (this != &other) {
            release(); //丢弃原来的
            ptr = other.ptr_;
            ref_count_ = other.ref_count_;
            if (ref_count_) {
                ref_count_.fetch_add(1); 
            }
        }
        return *this;
    }

移动语义也是很重要的一环,当我们希望资源的所有权转移到走时使用:

shared_ptr<int> sp1(new int(42));
shared_ptr<int> sp2(std::move(sp1));  // 移动构造
// sp1 现在为空,不再管理资源
// sp2 接管了资源
    //移动构造函数
    share_ptr<T>(share_ptr<T>&& other) noexcept : ptr_(other.ptr_), ref_count_(other.ref_count_) {
        other.ptr_ = nullptr;
        other.ref_count_ = nullptr;
    }

如果sp2是这样赋值,则调用移动运算符重载函数。

sp2 = std::move(sp1);  // 移动赋值
    //移动运算符重载:需要处理自赋值
    share_ptr<T>& operator=(shared_ptr<T>&& other) 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);
            }
        }
    }

最后,我们之前的析构函数中调用的release函数在shared_ptr私有成员中实现,如此,当引用计数清空的时候,恰好最后一个使用者也使用完毕,他负责释放资源。

private:
    void release() {
        if (ref_count_ && ref_count_->fetch_sub(1) == 1) {
            delete ptr;
            delete ref_count_;
        }
    }
    T* ptr_;
    std::atomic<int>* ref_count_; //指向原子变量的指针

总结

shared_ptr通过引用计数实现共享资源所有权,用原子操作保证线程安全。通过RAII自动管理资源生命周期。其核心实现包括ref_count_、ptr_。ref_count_拷贝时增加,析构时减少,最后一个使用者使用完毕负责释放资源。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐