10. 智能指针的线程安全性:引用计数是安全的,但对象不是
这个话题,我每次在团队内部做技术分享时都会重点强调。为什么呢?因为太多人踩过这个坑了。
很多人一听说「智能指针是线程安全的」,就放心大胆地在多线程里随便用。结果程序跑着跑着就崩了,还一脸懵:「不是线程安全吗?」
嗯,这里要分清楚:引用计数的操作是线程安全的,但智能指针管理的对象本身不是。这是两个完全不同的层面。
10.1 引用计数为什么是安全的?
先说说引用计数这块。C++11 标准要求 shared_ptr 的引用计数操作必须是原子操作。说白了,就是 use_count() 的增减是原子的,不会出现数据竞争。
举个例子:
// 线程A
auto sp1 = std::make_shared<int>(42);
// 线程B
auto sp2 = sp1; // 引用计数从1变成2,这个操作是原子的
两个线程同时拷贝同一个 shared_ptr,引用计数的增减不会乱。不会出现两个线程都以为自己是最后一个持有者,结果把对象给 double free 了。
我在项目中遇到过这样一个场景:一个全局的 shared_ptr 被十几个工作线程同时拷贝和释放。上线前我专门 review 了这块代码,确认引用计数操作没问题才放行。运行了几个月,确实没出过引用计数相关的问题。
10.2 但对象本身不是安全的
这才是真正的坑。
shared_ptr 只保证引用计数的原子性,它不保证你管理的对象在多线程环境下是安全的。你想想看,两个线程同时拿到了同一个对象的 shared_ptr,然后同时调用对象的非 const 方法——这不就是经典的 data race 吗?
// 危险的例子
std::shared_ptr<std::vector<int>> sp = std::make_shared<std::vector<int>>();
// 线程A
void threadA() {
sp->push_back(1); // 修改对象
}
// 线程B
void threadB() {
sp->push_back(2); // 同时修改对象
}
这段代码跑起来,大概率会崩。引用计数是安全的没错,但 vector 本身不是线程安全的。两个线程同时 push_back,内部的内存分配、迭代器都会乱掉。
10.3 那怎么办?加锁呗
解决方案其实不复杂。要么你自己加锁,要么用原子操作的数据结构。
我个人习惯的做法是:如果对象需要被多个线程共享访问,就把它包在一个 std::mutex 后面。
#include <mutex>
#include <memory>
#include <vector>
class ThreadSafeVector {
std::vector<int> data_;
mutable std::mutex mtx_;
public:
void push_back(int val) {
std::lock_guard<std::mutex> lock(mtx_);
data_.push_back(val);
}
// 其他方法类似...
};
// 使用
auto sp = std::make_shared<ThreadSafeVector>();
这样,多个线程通过 shared_ptr 访问同一个 ThreadSafeVector 对象时,内部有锁保护,就不会出问题了。
10.4 一个更隐蔽的问题:控制块和对象分离
这里有个细节,我估计很多人没注意到。shared_ptr 内部有两个东西:
- 控制块:存引用计数、删除器等元数据
- 管理的对象:你实际要用的那个对象
控制块是线程安全的,但对象不是。而且这两个东西的内存位置是分开的。
用 make_shared 时,控制块和对象是在一次内存分配里连续存放的。但用 shared_ptr(new T) 时,它们是两次独立分配。这有什么区别呢?
| 创建方式 | 内存布局 | 特点 |
|---|---|---|
make_shared |
控制块 + 对象 连续分配 | 一次分配,性能好;但对象要等控制块一起释放 |
shared_ptr(new T) |
控制块和对象分开分配 | 两次分配,但对象可以提前释放 |
我记得有一次排查内存泄漏,发现一个 shared_ptr 管理的对象明明已经没人用了,但内存就是不释放。查了半天,发现是用 make_shared 创建的,控制块里还有 weak_ptr 在引用,导致整个内存块一直留着。
weak_ptr 长期存在,可以考虑用 shared_ptr(new T) 代替 make_shared。这样对象可以提前释放,只保留小的控制块。
10.5 知识体系总览
下面这张图总结了智能指针线程安全的核心逻辑:
10.6 避坑指南
我曾经在一个高并发的网络服务里犯过一个错。当时用 shared_ptr 管理一个连接对象,多个线程会同时读写这个对象的成员变量。我以为 shared_ptr 是线程安全的,就没加锁。结果线上频繁出现段错误,排查了两天才找到问题。
从那以后,我给自己定了几条规矩:
- 区分「指针安全」和「对象安全」——这是两个概念,别混为一谈
- 多线程共享对象,必须加锁或用原子类型——没有例外
- 能用
make_shared就用它——性能好,异常安全,但要注意 weak_ptr 的生命周期问题 - 如果只是传递所有权,用
unique_ptr——别杀鸡用牛刀
好了,这一章就到这里。记住这个核心区别,写多线程代码时心里就有底了。