6. shared_ptr(上):共享所有权模型,引用计数原理

好,咱们今天来聊聊 shared_ptr。说实话,这是 C++ 智能指针里最常用、也最容易出问题的一个。我当年刚接触它的时候,觉得这东西简直是神器——自动释放内存,再也不用担心忘了 delete。但后来在项目里踩了几个坑,才明白它背后的门道有多深。

先问大家一个问题:如果多个对象需要共享同一块内存,谁来负责释放? 你可能会说「最后一个用完的人」。没错,shared_ptr 就是干这个的。它通过引用计数来跟踪有多少个指针指向同一块资源,当计数归零时,自动释放资源。

共享所有权模型

什么叫「共享所有权」?说白了,就是一块内存可以被多个 shared_ptr 同时拥有。每个 shared_ptr 都持有这块内存的「股份」,谁都不能独占。只有最后一个股东退出时,公司才清算(释放内存)。

我举个例子。假设你有一个 std::vector<int>,需要在多个函数之间传递,而且每个函数都可能修改它。如果用原始指针,你得自己保证谁最后释放。用 unique_ptr 呢?它只能转移所有权,不能共享。这时候 shared_ptr 就派上用场了。

#include <memory>
#include <vector>
#include <iostream>

void process_data(std::shared_ptr<std::vector<int>> sp) {
    // 在这里,引用计数已经 +1 了
    sp->push_back(42);
    std::cout << "process_data 中,引用计数: " << sp.use_count() << std::endl;
}

int main() {
    auto sp = std::make_shared<std::vector<int>>();
    sp->push_back(1);
    sp->push_back(2);
    
    std::cout << "main 中,引用计数: " << sp.use_count() << std::endl; // 输出 1
    
    process_data(sp); // 传值,引用计数变为 2
    
    std::cout << "process_data 返回后,引用计数: " << sp.use_count() << std::endl; // 又变回 1
    
    // main 结束,sp 析构,引用计数归零,vector 被释放
    return 0;
}

你看,process_data 函数内部拿到的是 sp 的一个副本,引用计数从 1 变成了 2。函数返回后,那个临时副本析构,计数又变回 1。整个过程完全自动,不需要你手动干预。

核心要点: shared_ptr 的拷贝构造函数和拷贝赋值运算符会增加引用计数。析构函数会减少引用计数。当计数为 0 时,删除所管理的对象。

引用计数原理

引用计数是怎么实现的?嗯,这里要稍微深入一点。每个 shared_ptr 内部其实有两个指针:

  • 一个指向管理的对象(比如上面的 vector<int>
  • 一个指向控制块(control block),控制块里存着引用计数、弱引用计数、删除器等信息

控制块是动态分配的。当你用 std::make_shared 创建 shared_ptr 时,控制块和对象本身是在一次内存分配中完成的,效率更高。如果你直接用 new 构造,那就是两次分配——一次给对象,一次给控制块。

我的建议: 优先使用 std::make_shared 而不是 std::shared_ptr<T>(new T(...))。原因有二:一是性能更好(一次分配 vs 两次分配),二是异常安全。我在项目中见过因为 new 抛出异常导致内存泄漏的案例,用 make_shared 就能避免。

引用计数的操作是原子化的。这意味着多线程环境下,多个线程同时拷贝或销毁 shared_ptr 是安全的。但注意,对 shared_ptr 所指向的对象的读写,仍然需要你自己加锁。引用计数只保证指针本身的生命周期安全,不保证数据竞争。

引用计数的性能开销

说实话,引用计数不是免费的午餐。每次拷贝、赋值、析构 shared_ptr,都要进行原子操作。原子操作比普通整数加减要慢得多。如果你在性能敏感的热路径上频繁拷贝 shared_ptr,可能会成为瓶颈。

我记得有一次优化一个网络库,发现大量时间花在了 shared_ptr 的拷贝上。后来我们把一些只读的路径改成了 const shared_ptr& 传引用,性能提升很明显。

操作 开销 说明
拷贝构造 原子加 1 相对较重,多线程下更明显
析构 原子减 1,可能触发删除 如果减到 0,还要调用析构函数和释放内存
移动构造 无原子操作 只是转移所有权,引用计数不变
use_count() 原子读 通常较轻,但不要频繁调用
注意: 不要用 use_count() == 1 来判断是否可以安全修改对象。这在多线程环境下是竞态条件。我曾经在代码 review 时看到有人这么写,差点酿成大祸。

循环引用问题

引用计数有一个著名的陷阱——循环引用。当两个对象互相持有对方的 shared_ptr 时,它们的引用计数永远不会归零,导致内存泄漏。

struct Node {
    std::shared_ptr<Node> next;
    // 其他数据...
};

void test() {
    auto a = std::make_shared<Node>();
    auto b = std::make_shared<Node>();
    
    a->next = b;
    b->next = a;  // 循环引用!
    
    // 函数结束,a 和 b 的引用计数都是 2,不会释放
}

怎么解决?用 weak_ptr。这个我们下一章会详细讲。简单说,weak_ptr 不增加引用计数,只提供一种「观察」能力。你可以在需要的时候通过 lock() 获取一个临时的 shared_ptr

自定义删除器

shared_ptr 还支持自定义删除器。什么意思?就是当引用计数归零时,你可以指定如何释放资源,而不一定是 delete。比如你管理的是一个文件句柄、socket 描述符,或者是从某个内存池分配的对象。

// 自定义删除器:关闭文件
auto file_deleter = [](FILE* fp) {
    if (fp) {
        fclose(fp);
        std::cout << "文件已关闭" << std::endl;
    }
};

std::shared_ptr<FILE> sp(fopen("test.txt", "r"), file_deleter);
// 当 sp 析构时,自动调用 fclose

这个特性很实用。我在项目中用它管理过数据库连接、GPU 内存、甚至是 mmap 映射的区域。只要你能把「释放逻辑」封装成一个可调用对象,shared_ptr 就能帮你自动管理生命周期。

引用计数与多线程

前面提到过,引用计数的加减是原子操作。但这里有个细节:shared_ptr 本身的读写不是原子的。什么意思?

假设线程 A 正在修改一个 shared_ptr(比如给它赋值新的对象),线程 B 同时读取这个 shared_ptr。这会导致数据竞争,因为 shared_ptr 内部有两个指针(对象指针和控制块指针),它们的赋值不是原子的。

正确的做法是:要么用 std::atomic<std::shared_ptr>(C++20 支持),要么用互斥锁保护。我个人的习惯是,尽量避免在多个线程中直接共享同一个 shared_ptr 变量,而是每个线程持有自己的副本。

总结一下 shared_ptr 的核心要点:
  • 共享所有权,引用计数管理生命周期
  • 优先使用 make_shared
  • 注意循环引用,用 weak_ptr 打破
  • 多线程下注意数据竞争
  • 自定义删除器扩展了使用场景

好了,关于 shared_ptr 的上半部分就讲到这里。说实话,这些内容看起来简单,但真正用好它需要大量的实践。我建议你写几个小例子,亲手感受一下引用计数的变化,比光看文章强得多。

shared_ptr 内部结构示意图 shared_ptr<T> 对象指针 (T*) 控制块指针 实际对象 (T) 例如:vector<int> 控制块 (Control Block) 引用计数 (use_count) N 弱引用计数 (weak_count) M 删除器 (Deleter) 自定义或默认 使用 make_shared 时,控制块和实际对象在同一块内存中分配
公众号:蓝海资料掘金营,微信 deep3321