一、std::shared_ptr 底层实现原理
std::shared_ptr是 C++11 基于RAII 思想 实现的共享所有权智能指针 ,核心是「引用计数机制 + 资源托管」,底层实现分为两大核心部分,缺一不可:
核心实现 1:「资源管理块」+「引用计数区」的双区设计
- 原始指针区 :
shared_ptr对象自身存储一个「指向业务堆对象的裸指针」(比如TcpConnection*),用于日常的解引用、调用成员函数; - 共享的控制块(Control Block) :这是
shared_ptr的核心,一块堆上的独立内存区域 ,是所有指向「同一个业务对象」的shared_ptr实例共享的唯一区域 ,里面固定存储 3 个核心内容:- 强引用计数(use_count) :记录当前有多少个
shared_ptr实例 共同持有 这个业务堆对象; - 弱引用计数(weak_count) :记录当前有多少个
weak_ptr实例 观察 这个业务堆对象; - 资源释放器(析构器) :存储业务对象的
delete逻辑,用于最终释放堆内存。
- 强引用计数(use_count) :记录当前有多少个
核心实现 2:引用计数的核心规则
- 引用计数的操作是原子操作 :所有对
use_count/weak_count的 ++/--,底层都是std::atomic原子操作,线程安全 ,高并发服务器的多线程场景下,无需额外加锁就能安全的拷贝 / 销毁shared_ptr; - 强引用计数的增减规则 :
- 当拷贝 一个
shared_ptr时(比如传参、赋值、放入容器),use_count原子自增 1; - 当销毁 一个
shared_ptr时(出作用域、被重置reset()),use_count原子自减 1;
- 当拷贝 一个
- 资源释放的核心条件 :
- 当强引用计数
use_count减到 0 时,说明没有任何shared_ptr持有这个业务对象了 ,此时会调用控制块的析构器,自动释放业务堆对象的内存; - 当强引用计数为 0、且弱引用计数
weak_count也减到 0 时,才会释放「共享控制块」自身的堆内存。
- 当强引用计数
二、shared_ptr 的引用计数存在哪里?
引用计数 绝对不是存在
shared_ptr对象自身里的,而是存在【堆上的共享控制块】中。
情况 1:通过std::make_shared<T>(...)创建shared_ptr
std::make_shared会在一次堆内存申请中,同时分配「业务对象的内存」和「共享控制块的内存」,两块内存是连续的,引用计数就存在这个连续内存的「控制块区域」。
**优点:**只申请 1 次堆内存,内存碎片少、效率高,是高并发服务器的最优写法。
情况 2:通过new手动创建再传给shared_ptr(shared_ptr<T>(new T(...)))
此时会触发两次堆内存申请 :第一次new T申请业务对象的内存,第二次shared_ptr的构造函数会自动申请一块独立的堆内存作为「共享控制块」,引用计数就存在这块独立的控制块里。
缺点: 两次堆申请,有微小的内存碎片和性能开销,高并发场景尽量用std::make_shared。
为什么引用计数不能存在shared_ptr对象自身?
如果每个shared_ptr对象都存一份引用计数,那么多个指向同一个业务对象的shared_ptr,就无法保证引用计数的一致性(比如 A 对象把计数 + 1,B 对象感知不到),这是设计上的致命错误。只有放在共享的控制块里,才能保证所有shared_ptr共用一份计数。
三、weak_ptr 解决了shared_ptr的什么核心问题?
核心结论:
weak_ptr专门解决shared_ptr的 循环引用(环形引用) 问题,这是shared_ptr唯一的致命缺陷。
1. 什么是shared_ptr的循环引用?为什么会造成内存泄漏?
循环引用:两个(或多个)对象,互相持有对方的shared_ptr成员变量 ,形成一个「引用闭环」,最终导致强引用计数永远无法减到 0 ,业务对象的内存永远不会被释放,造成永久性内存泄漏。
TcpConnection + EventLoop 的循环引用
cpp
// 高并发服务器核心类
class EventLoop;
class TcpConnection {
public:
// 连接对象持有事件循环的shared_ptr
std::shared_ptr<EventLoop> loop_;
};
class EventLoop {
public:
// 事件循环持有管理的所有连接的shared_ptr
std::vector<std::shared_ptr<TcpConnection>> connList_;
};
// 业务逻辑中创建对象,形成循环引用
void test() {
std::shared_ptr<EventLoop> loop = std::make_shared<EventLoop>();
std::shared_ptr<TcpConnection> conn = std::make_shared<TcpConnection>();
conn->loop_ = loop; // conn持有loop的shared_ptr,loop的use_count=2
loop->connList_.push_back(conn); // loop持有conn的shared_ptr,conn的use_count=2
}
问题根源 :当test()函数执行完毕,两个shared_ptr出作用域,各自的强引用计数都会减 1 → loop.use_count=1、conn.use_count=1,形成闭环。此时两个对象的强引用计数永远到不了 0,内存永远不会释放,造成内存泄漏。
2. weak_ptr的解决思路:打破强引用闭环,只「观察」不「持有」
weak_ptr是弱引用智能指针 ,它的核心设计原则:只观察共享的业务对象,不参与强引用计数的维护 ,对shared_ptr的生命周期无任何影响。
weak_ptr必须从一个shared_ptr/ 另一个weak_ptr构造而来,它会指向同一个「共享控制块」;weak_ptr的创建 / 拷贝 / 销毁,只会修改弱引用计数weak_count,不会修改强引用计数use_count;weak_ptr没有重载*和->解引用运算符,不能直接访问业务对象,因为它不保证对象一定存活;- 当
shared_ptr的强引用计数use_count=0时,会立刻释放业务对象的内存,此时所有指向该对象的weak_ptr都会变成「失效状态」。
cpp
class EventLoop;
class TcpConnection {
public:
std::shared_ptr<EventLoop> loop_;
};
class EventLoop {
public:
// 核心修改:把shared_ptr改成weak_ptr,只观察连接,不持有
std::vector<std::weak_ptr<TcpConnection>> connList_;
};
修复后:EventLoop对TcpConnection是弱引用,不会增加 conn 的强引用计数。当连接断开时,conn 的强引用计数减到 0,内存正常释放,彻底解决循环引用的内存泄漏问题。
四、weak_ptr::lock() 方法 是什么?
lock()是weak_ptr的核心成员函数 ,也是weak_ptr唯一能「接触到业务对象」的方式,函数的核心逻辑
检查当前weak_ptr观察的业务对象 是否存活 (即:对应的shared_ptr的强引用计数是否 > 0):
- 如果对象存活 :
lock()会创建一个新的std::shared_ptr对象,指向该业务对象,同时强引用计数use_count原子自增 1 ;返回这个有效的shared_ptr;- 如果对象已销毁 :
lock()直接返回一个空的shared_ptr(shared_ptr.get() == nullptr)。
作用 1:安全的获取业务对象的可用shared_ptr,保证「对象访问期间一定存活」
weak_ptr不能直接访问对象,而lock()返回的shared_ptr会持有对象,只要这个新的shared_ptr存在,对象的强引用计数就 > 0,绝对不会在访问过程中被销毁 ,这是高并发服务器的线程安全核心保障。
作用 2:原子性的「判活 + 获取」,无竞态条件
lock()的判活和创建shared_ptr是原子操作 ,不会出现「刚判断对象存活,对象就被其他线程销毁」的竞态问题。如果自己手动写weak_ptr.expired()+weak_ptr.lock(),是有线程安全风险的,而lock()本身是安全的。
作用 3:是weak_ptr的唯一使用方式
所有weak_ptr的业务场景,最终都是通过lock()获取shared_ptr后,再访问业务对象的成员函数 / 数据。
EventLoop 通过 weak_ptr 遍历所有连接,发送心跳包,必须用 lock () 保证安全:
cpp
// 高并发服务器 - 心跳检测:遍历所有连接,发送心跳包
void EventLoop::sendHeartbeat() {
for (auto& weakConn : connList_) {
// 核心:通过lock()获取可用的shared_ptr,判断连接是否存活
std::shared_ptr<TcpConnection> conn = weakConn.lock();
if (conn) { // 如果conn不为空,说明连接存活
conn->send("heartbeat"); // 安全调用连接的成员函数
} else {
// 连接已销毁,清理失效的weak_ptr
removeExpiredConn(weakConn);
}
}
}
weak_ptr::expired() 方法
和lock()配套的判活函数,返回bool值:判断观察的对象是否已销毁(use_count == 0返回 true,否则 false)。注意:不要单独用 expired (),一定要配合 lock () 使用。
五、高性能服务器中,管理TcpConnection连接对象生命周期,为什么必须用智能指针?不用会有什么问题?
TcpConnection是高并发服务器的核心对象,代表一个客户端与服务器的 TCP 连接,它的生命周期有 3 个核心特点,也是管理的痛点:
- 生命周期完全异步、不可控 :连接的创建(客户端建联)、销毁(客户端断连、超时、异常)都是异步事件,由 epoll 的网络事件驱动;
- 多线程访问 :
TcpConnection会被IO 线程 (处理读写事件)、工作线程 (处理业务逻辑)、定时器线程(心跳检测)同时访问;- 对象在堆上创建 :连接数是动态的(百万级并发),必须用
new在堆上创建TcpConnection,栈内存完全无法承载。
为什么必须用智能指针 管理TcpConnection?
原因 1:【最核心】基于RAII 思想,彻底杜绝内存泄漏,保障服务器 7×24 稳定运行
TcpConnection是堆对象,手动管理时,必须在「连接断开、超时、异常」等所有场景手动调用delete conn,但高并发服务器中,连接的销毁场景极其复杂 (比如客户端突然断连、网络闪断、业务逻辑抛出异常),只要漏写一次 delete,就会造成内存泄漏 。而shared_ptr/unique_ptr是 RAII 的实现,连接对象的内存会被智能指针自动释放 ,只要强引用计数到 0,内存必释放,从语法层面根除内存泄漏,这是服务器稳定运行的基石。
原因 2:完美解决多线程下的「野指针访问」问题,这是手动管理的致命痛点
高并发服务器中,最常见的崩溃原因:一个线程正在访问TcpConnection对象,另一个线程把这个对象 delete 了,导致野指针访问,直接 core dump 。而智能指针的「引用计数」天然解决这个问题:只要有任意一个线程持有该连接的shared_ptr,强引用计数就 > 0,对象绝对不会被销毁,访问期间永远是合法的,彻底杜绝野指针崩溃。
原因 3:解决shared_ptr的循环引用问题,兼顾内存安全和多线程访问
如前文所述,TcpConnection和EventLoop会互相引用,用shared_ptr会造成循环引用内存泄漏,而weak_ptr可以完美打破闭环:EventLoop 用weak_ptr观察连接,不影响连接的销毁;需要访问时通过lock()获取shared_ptr,保证访问安全。这是唯一的最优解。
原因 4:无需关注对象的释放时机和顺序,大幅降低开发心智负担
高并发服务器的核心逻辑是「处理网络事件、业务逻辑」,不是「手动管理连接的创建和销毁」。用智能指针后,程序员只需要关注连接的业务逻辑,不需要在每个分支(正常断连、异常断连、超时断连)都写delete conn,消除所有人为的释放错误,开发效率和代码健壮性都大幅提升。
如果不用智能指针 ,手动管理TcpConnection会出现什么问题?
问题 1:内存泄漏(最轻的问题,但日积月累必宕机)
手动管理时,一定会出现漏写delete conn的场景:比如连接异常断开时、业务逻辑抛出异常时、定时器超时清理连接时,只要漏一次,就会泄漏一个TcpConnection对象的内存。高并发下,每秒上千个连接创建销毁,内存泄漏会导致服务器的内存占用持续上涨,最终触发 OOM(内存耗尽),服务器直接宕机。
问题 2:野指针访问,服务器随机崩溃(core dump)
这是手动管理的头号杀手 ,高并发下100% 必现 :比如:IO 线程正在处理某个连接的读事件(调用conn->onMessage()),此时客户端突然断连,定时器线程检测到超时,执行delete conn释放了连接对象。IO 线程继续访问已经被释放的conn,就是野指针访问,会直接触发段错误(SIGSEGV),服务器 core dump 崩溃。
问题 3:重复释放内存(double free),服务器立刻崩溃
手动管理时,连接的销毁逻辑可能被多个线程触发:比如客户端主动断连触发一次delete conn,同时定时器线程检测到超时也触发一次delete conn,这就是重复释放 。double free 会直接触发 SIGABRT 信号,服务器立刻崩溃,无任何挽救余地。
问题 4:循环引用导致的永久性内存泄漏
如果手动管理时,TcpConnection和EventLoop互相持有指针,那么当连接断开时,程序员必须手动解除双方的指针引用,否则两个对象都无法被释放。但高并发下,手动解除引用的时机极难把控,最终会造成永久性内存泄漏,服务器的内存占用只会涨不会跌,最终宕机。
问题 5:开发效率极低,维护成本极高,代码臃肿
为了规避上述问题,手动管理时需要写大量的「指针判空、引用计数、锁保护」代码:比如给每个TcpConnection加一个手动的引用计数ref_cnt,访问时加 1,释放时减 1,计数为 0 时 delete。这相当于自己实现了一个简陋的 shared_ptr,但手写的引用计数无法保证原子性,多线程下会出现计数错误,最终还是会崩溃。而且代码会变得极其臃肿,核心业务逻辑被淹没在内存管理的代码中,后期迭代和维护几乎不可能。
六、补充
Q1:shared_ptr的引用计数是原子操作,那shared_ptr是绝对线程安全的吗?
A:不是。正确结论:
shared_ptr的引用计数操作是线程安全的(拷贝 / 销毁时的 ++/-- 是原子的);shared_ptr的读写操作不是线程安全的 :如果多个线程同时对同一个shared_ptr对象 做赋值、reset 等修改操作,需要加锁;如果多个线程持有不同的shared_ptr对象指向同一个业务对象,是线程安全的。
Q2:高并发服务器中,TcpConnection用unique_ptr还是shared_ptr?
A:优先用shared_ptr 。因为TcpConnection会被多个线程访问(IO 线程、工作线程、定时器线程),需要共享所有权;unique_ptr是独占所有权,无法在多线程间传递,只能在单线程场景使用(比如客户端的本地连接)。
Q3:weak_ptr的内存开销大吗?在高并发服务器中会不会影响性能?
A:几乎无开销 。weak_ptr只存储指向控制块的指针,大小和shared_ptr一致(都是两个指针的大小),lock()方法是原子操作,开销极小,完全可以忽略不计。而且weak_ptr解决的是内存泄漏和野指针问题,带来的性能收益远大于微小的开销。