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 #1 ptr → 对象 | 控制块 → 0xABC shared_ptr #2 ptr → 对象 | 控制块 → 0xABC 控制块 (0xABC) use_count = 2 weak_count = 1 deleter = custom_deleter allocator = std::allocator managed_object = 0x200 实际对象 地址: 0x200

看到没?两个 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_ptrdelete 销毁对象。但有些场景需要特殊处理,比如:

  • 对象是用 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 的配合。别走开。


公众号:蓝海资料掘金营,微信 deep3321