9. shared_ptr 详解(中):控制块结构、引用计数线程安全、自定义删除器与别名构造函数
好,咱们接着聊 shared_ptr。上一章我们把基本用法过了一遍,这一章要深入它的“内脏”了。说白了,shared_ptr 之所以强大,全靠背后那个看不见的控制块在撑着。你想想看,多个智能指针怎么知道彼此的存在?怎么知道什么时候该释放资源?答案全在控制块里。
9.1 控制块结构:shared_ptr 的“幕后管家”
每个 shared_ptr 对象本身很小——通常就两个指针大小。一个指向管理的对象,另一个指向一个叫“控制块”的东西。这个控制块是堆上分配的一块内存,里面存着什么呢?
- 引用计数(use_count):当前有多少个
shared_ptr共享同一个对象。 - 弱引用计数(weak_count):当前有多少个
weak_ptr在观察这个对象。 - 删除器(deleter):销毁对象时调用的函数,可以是默认的
delete,也可以是自定义的。 - 分配器(allocator):控制块本身的内存分配策略。
我画了一张图,帮你把结构理清楚:
看到没?两个 shared_ptr 指向同一个控制块,控制块里记录着引用计数。当最后一个 shared_ptr 销毁时,控制块会先析构对象,再释放自己。嗯,这里要注意:控制块本身也是堆上分配的,所以 make_shared 能一次分配对象+控制块,性能更好。
关键点:shared_ptr 的拷贝只增加引用计数,不复制对象。这是它比 unique_ptr 更“重”的原因——多了一次控制块的开销。
9.2 引用计数的线程安全
这是面试高频题,也是实际项目中容易踩坑的地方。我直接说结论:引用计数本身是线程安全的,但指向的对象不是。
为什么会这样?标准库保证了对控制块中 use_count 的增减操作是原子的。也就是说,你在线程A里拷贝一个 shared_ptr,在线程B里销毁另一个 shared_ptr,引用计数的加减不会乱。我曾在项目中验证过这一点——用 std::atomic<int> 模拟过类似逻辑,确实没问题。
但是!对 shared_ptr 指向的对象的读写,你得自己加锁。举个例子:
// 线程安全吗?不安全!
std::shared_ptr<std::vector<int>> sp = std::make_shared<std::vector<int>>();
// 线程A
sp->push_back(42);
// 线程B
sp->clear();
两个线程同时操作 vector,数据竞争就来了。引用计数没乱,但对象内部乱了。所以我的建议是:多线程共享 shared_ptr 时,要么加锁,要么用 std::atomic<shared_ptr>(C++20 支持)。
避坑指南:我曾经在日志系统中犯过这个错。多个线程同时写同一个 shared_ptr<Logger>,引用计数没崩,但日志内容全乱了。后来加了个 std::mutex 才搞定。
9.3 自定义删除器:不止是 delete
默认情况下,shared_ptr 用 delete 销毁对象。但有些场景需要特殊处理,比如:
- 对象是用
malloc分配的,得用free释放。 - 对象是文件句柄、socket 等资源,需要调用专门的关闭函数。
- 你想在销毁时打日志或做其他清理工作。
自定义删除器就是一个可调用对象,可以是函数、lambda、函数对象等。看代码:
// 自定义删除器:用 free 释放 malloc 分配的内存
auto deleter = [](int* p) {
std::cout << "自定义删除器被调用,释放内存\n";
free(p);
};
std::shared_ptr<int> sp(static_cast<int*>(malloc(sizeof(int))), deleter);
这里有个细节:删除器的类型会被存储在控制块中。所以两个 shared_ptr 如果删除器类型不同,即使指向同一对象,也不能共享控制块。嗯,这会导致额外的控制块分配,性能上要注意。
小技巧:如果你用 make_shared,没法指定自定义删除器。所以需要自定义删除器时,得用裸指针构造 shared_ptr。我个人习惯把删除器封装成 lambda,简洁又安全。
9.4 别名构造函数:指向成员,共享生命周期
别名构造函数是 shared_ptr 的一个高级特性,很多人不知道。它的签名长这样:
template<class Y>
shared_ptr(const shared_ptr<Y>& r, T* ptr) noexcept;
什么意思呢?就是创建一个新的 shared_ptr,它指向 ptr,但共享 r 的控制块。说白了,新指针和原指针指向不同的对象,但引用计数是同一个。当所有 shared_ptr 都销毁时,控制块会释放 r 管理的对象,而不是 ptr。
这有什么用?最常见的场景是访问对象的成员:
struct BigObject {
int data;
std::string name;
};
auto sp = std::make_shared<BigObject>();
// 创建一个 shared_ptr,指向 sp->name,但共享 sp 的控制块
std::shared_ptr<std::string> sp_name(sp, &sp->name);
现在 sp_name 指向 name 成员,但它和 sp 共享同一个引用计数。只要 sp_name 还活着,BigObject 就不会被销毁。这比单独创建一个 shared_ptr<std::string> 要安全得多——你不用担心 BigObject 被提前释放导致悬空指针。
实际案例:我在一个网络库中用过别名构造函数。每个连接对象有一个 socket 成员,我想让 socket 的生命周期和连接绑定。用别名构造函数,直接 shared_ptr<Socket>(conn_sp, &conn_sp->socket),完美解决。
不过要注意:别名构造函数不会帮你做类型安全检查。如果你传了一个错误的指针,比如指向栈上变量,那后果自负。嗯,这是 C++ 的哲学——信任程序员。
9.5 本章小结
这一章我们挖了 shared_ptr 的底层实现。控制块是它的心脏,引用计数的线程安全是它的护城河,自定义删除器给了它灵活性,别名构造函数则让它能优雅地管理成员生命周期。说实话,这些细节平时可能用不到,但一旦遇到诡异的内存问题,理解它们能帮你快速定位。
下一章我们继续聊 shared_ptr 的陷阱和最佳实践,包括循环引用、性能开销、以及和 weak_ptr 的配合。别走开。