16、线程安全智能指针:shared_ptr的线程安全性、weak_ptr与循环引用、atomic智能指针、自定义删除器的线程安全
智能指针这东西,C++11之后几乎成了标配。但说实话,很多人用shared_ptr的时候,根本没想过它到底是不是线程安全的。我见过不少线上事故,就是因为多线程里乱用shared_ptr,最后数据全乱了。
今天咱们就把这块彻底讲清楚。我会从最基础的shared_ptr线程安全性说起,然后聊weak_ptr怎么解决循环引用,再讲讲C++20的atomic智能指针,最后说说自定义删除器那些坑。
shared_ptr的线程安全性:一半安全,一半不安全
先给个结论:shared_ptr的控制块是线程安全的,但指向的对象不是。
什么意思呢?
多个线程同时拷贝、销毁同一个shared_ptr,控制块里的引用计数会正确增减。这是标准库保证的。但如果你多个线程同时通过shared_ptr去读写它指向的对象,那就得自己加锁了。
核心原则:shared_ptr的拷贝和析构是线程安全的,但解引用后的对象访问不是。
举个例子你就明白了:
// 线程A
shared_ptr<vector<int>> sp = make_shared<vector<int>>();
sp->push_back(42); // 这里没问题,只有线程A在用
// 线程B
auto sp2 = sp; // 引用计数++,安全
sp2->push_back(100); // 危险!和线程A同时操作同一个vector
我在项目中遇到过这种问题。当时一个缓存服务,多个线程同时读取同一个shared_ptr指向的配置对象。一开始觉得shared_ptr是线程安全的,就没加锁。结果线上偶尔出现配置读取到一半的数据——有的字段是新值,有的还是旧值。排查了半天才发现,shared_ptr只保证引用计数安全,不保证对象本身。
我的建议:多线程读同一个shared_ptr指向的对象时,要么用const对象(只读),要么加mutex保护。
weak_ptr与循环引用:一个经典陷阱
循环引用这个问题,说白了就是两个对象互相持有对方的shared_ptr,导致引用计数永远降不到0,内存泄漏。
我刚开始用shared_ptr时也踩过这个坑。写了一个双向链表,每个节点用shared_ptr指向下一个节点,结果程序跑完内存涨了200MB,怎么都释放不掉。
看个典型例子:
struct Node {
shared_ptr<Node> next;
shared_ptr<Node> prev;
int data;
};
// 创建两个节点
auto a = make_shared<Node>();
auto b = make_shared<Node>();
a->next = b;
b->prev = a; // 循环引用!a和b的引用计数都是2
这时候a和b的引用计数都是2。当a和b的原始shared_ptr析构后,引用计数降到1,但永远不会到0。两个节点就这么赖在内存里不走了。
解决方案就是weak_ptr。它不增加引用计数,只是"弱弱地"观察对象:
struct Node {
shared_ptr<Node> next;
weak_ptr<Node> prev; // 用weak_ptr打破循环
int data;
};
auto a = make_shared<Node>();
auto b = make_shared<Node>();
a->next = b;
b->prev = a; // prev是weak_ptr,不增加引用计数
用weak_ptr的时候,记得先调用lock()获取shared_ptr,再使用对象:
if (auto sp = weakPrev.lock()) {
// sp不为空,说明对象还活着
sp->data = 42;
} else {
// 对象已经被销毁了
}
注意:lock()返回的shared_ptr是线程安全的。但如果你在lock()之后、使用对象之前,有其他线程把对象销毁了怎么办?放心,lock()返回的shared_ptr会让引用计数+1,对象不会被销毁。
atomic智能指针:C++20带来的福音
在C++20之前,对shared_ptr的读写操作不是原子的。什么意思呢?
假设线程A正在修改一个shared_ptr(比如重新赋值),线程B同时读取这个shared_ptr。你可能会读到中间状态——控制块指针已经更新了,但指向对象的指针还没更新。这就出大问题了。
C++20引入了atomic<shared_ptr>和atomic<weak_ptr>,解决了这个问题:
#include <atomic>
#include <memory>
std::atomic<std::shared_ptr<int>> asp;
// 线程A
asp.store(make_shared<int>(42));
// 线程B
auto sp = asp.load(); // 原子操作,不会读到中间状态
说实话,这个特性我等了很久。以前要实现类似功能,得自己加锁或者用double-checked locking,代码又丑又容易出错。
不过要注意,atomic智能指针的性能比普通shared_ptr要差一些。它内部用了锁或者CAS操作。所以只在真正需要的地方用,别到处滥用。
我的经验:atomic智能指针最适合的场景是全局配置、单例对象、或者需要频繁更新的共享资源。对于一次性初始化后就不变的对象,用普通的shared_ptr加const就够了。
自定义删除器的线程安全
shared_ptr支持自定义删除器,这个功能很灵活。但多线程环境下,删除器本身也得是线程安全的。
举个例子:
// 自定义删除器
auto deleter = [](int* p) {
// 假设这里需要释放一些共享资源
g_resource_pool->release(p);
delete p;
};
shared_ptr<int> sp(new int(42), deleter);
问题来了:如果多个shared_ptr指向同一个对象,最后一个析构的线程会调用删除器。但如果删除器里访问了共享资源,而其他线程也在操作这个资源,就可能出问题。
我曾经写过一个连接池,每个连接用shared_ptr管理,自定义删除器负责把连接归还到池子里。结果高并发下,多个线程同时归还连接,池子的内部状态就乱了。
解决方案其实很简单:
- 删除器内部加锁:如果删除器访问了共享资源,确保资源本身是线程安全的。
- 删除器只做一件事:尽量让删除器只做简单的内存释放,复杂的逻辑交给其他地方处理。
- 注意异常安全:删除器不应该抛出异常,否则程序会直接终止。
// 安全的删除器
auto safeDeleter = [](DatabaseConnection* conn) {
std::lock_guard<std::mutex> lock(g_poolMutex);
g_connectionPool.push_back(conn); // 归还连接到池子
};
// 使用
shared_ptr<DatabaseConnection> sp(
new DatabaseConnection(),
safeDeleter
);
特别提醒:自定义删除器本身是shared_ptr类型的一部分。两个shared_ptr如果删除器不同,它们就不是同一个类型,不能互相赋值或比较。这在写泛型代码时要格外注意。
知识体系总览
下面这张图把本章的核心内容串起来了,你可以对照着回顾一下:
嗯,这张图基本把今天的内容都覆盖了。你可以看到,每个分支都有自己要注意的点。shared_ptr要区分控制块和对象的安全边界,weak_ptr专门用来打破循环引用,atomic智能指针解决的是读写原子性问题,自定义删除器则要关注内部资源的线程安全。
最后说一句:智能指针是C++给我们的礼物,但礼物也得会用。多线程环境下,别以为用了智能指针就万事大吉了。该加锁的地方加锁,该用weak_ptr的地方别偷懒,该升级到C++20就用atomic版本。这样写出来的代码,才能经得起线上流量的考验。