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 内部其实有两个指针:
- 一个指向管理的对象(比如上面的
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 变量,而是每个线程持有自己的副本。
- 共享所有权,引用计数管理生命周期
- 优先使用 make_shared
- 注意循环引用,用 weak_ptr 打破
- 多线程下注意数据竞争
- 自定义删除器扩展了使用场景
好了,关于 shared_ptr 的上半部分就讲到这里。说实话,这些内容看起来简单,但真正用好它需要大量的实践。我建议你写几个小例子,亲手感受一下引用计数的变化,比光看文章强得多。