核心思想
问题
在裸指针时代,最大的问题不是“如何释放内存”,而是“ 谁负责释放内存 ”。
如果一个对象被五个指针指着,到底该在什么时候、由谁去 delete 它?
C++ 的设计哲学是:资源管理应该符合所有权语义,且零开销抽象。
1. 独享指针
设计理念:独占所有权,零开销。
就是让一个指针和一块内存完全锁死绑定,并且不允许其他指针指向这块内存。
那么当这个指针离开作用域被销毁时,指向的这块内存就没有办法被访问了,
那么就顺便在析构函数里一起销毁了呗。
所以可以理解为,独享指针代表的是一块内存的所有权,并且只能归一个人所有,
所以不可以用 Copy,只可以用 Move 来移交所有权。
而零开销的含义是:在写代码的时候,编译器检查你是不是严格遵守独享所有权,
只要你严格遵守这个规范,那么内存就是安全的,否则编译器直接报错,
你的内存不安全就过不了编译,最后编译器其实会把独享指针拆开成裸指针进行编译,
所以你使用独享指针是完全不会影响到程序性能的,但是又保证了安全性。
2. 共享指针
设计理念:共享所有权,引用计数。
独享指针哪里都好,就是不能共享。
所以就设计了共享指针解决这个问题(比较多线程的时候有时必须共享内存)
也就是多个指针同时指向一块内存,然后当所有的指针都不指向这块内存时,才自动释放。
但是你怎么知道那么多指针还指不指?答:有一个内存控制块。(注意这会产生额外内存开销)
你要管理一块内存,就有一个对应的内存控制块,这个控制块指向那块要管理的内存,并且
所有的共享指针在指向被管理内存的同时还有一个成员变量指向的就是内存控制块。
内存控制块就这样就能对引用和弱引用进行计数了。
3. 弱引用指针
只是为了解决共享指针互相回环引用,这将导致谁的引用计数都不能先减小到 0,
所以大家都不能释放内存,而导致的内存泄漏问题。
很简单,产生回环时,哪怕只有一个地方使用的是弱引用指针,那么这个指针不会增加引用计数,
那么也就是引用为 0,就可以释放了,这一但有一个节点释放,前一个节点的引用计数又-1,
又可以释放了,就这样就可以依次全部释放了,就解决了环形引用导致的内存泄漏。
概念补充
- 分段错误(Segmentation fault)
程序尝试访问它无法访问的内存空间时所发生的错误。
最经典的比如说数组索引越界。
为了避免分段错误,常用手段:
- 初始化指针为 null,使用指针前都先判断是否为 null
- 使用向量而不是数组,避免索引越界
- 递归时避免堆栈溢出
- 使用智能指针
- 不当使用指针会导致的问题
- 内存泄漏(memory leak):重复分配内存而不释放,然后爆内存
- 悬垂指针(dangling pointer):指针在释放内存后没有改回 null
- 野指针(wild pointer):指针变量被声明后却没有被初始化
- 数据不一致性(data inconsistency):内存中的一些数据没有用一致性方法更新
- 缓存溢出(buffer overflow):使用指针将数据写入缓存时,指针越界内存块导致崩溃
- 智能指针的最大特点就是不需要手动释放内存!
只要内存被塞进了智能指针,哪怕它是用 new 出来的,你这辈子都不需要、也不能再对它写 delete 关键字!
- 例如:这样申请到的内存就不需要手动释放!
// 做法 A:用 new 直接塞给智能指针
std::unique_ptr<int> ptr(new int(100));
- 千万别用“裸指针变量”去初始化智能指针(因为必须手动将旧的裸指针设为 nullptr)
int* raw_p = new int(10);
std::unique_ptr<int> p1(raw_p);
// 此时如果忘了 raw_p = nullptr; 极其危险!
std::unique_ptr<int> p2(raw_p); // 灾难降临!
如果你这么写,p1 和 p2 都以为自己是这块内存的“唯一霸主”。等它们离开作用域时,这块内存会被杀两次。
- 工业界强制要求只用
make_
也就是:std::make_unique 和 std::make_shared
这也是 大厂的 C++ 代码规范(比如 Google C++ Style Guide)强制要求
因为:
(1)极致的性能压榨(专属于 shared_ptr 的物理魔法)
如果你写:std::shared_ptr<int> p(new int(100)); 在底层,物理内存会发生两次极其昂贵的堆分配(Heap Allocation) :
new int(100):在堆上找一块地,存数字 100。- 智能指针初始化:在堆上的另一个角落,再找一块地,建立“控制块(Control Block,存引用计数)”。 这两块内存是分离的!这就导致了 CPU 极其痛恨的 Cache Miss(缓存未命中) 。
如果你写:std::shared_ptr<int> p = std::make_shared<int>(100); 在底层,编译器只做一次内存分配!它会直接申请一块足够大的连续物理内存,把“数字 100”和“控制块”挨在一起塞进去。速度极快。
make_shared 通过一次内存分配,把“控制块”和“对象本身”放在了连续的内存中,极大地提高了缓存空间局部性(Cache Locality)。但是否“完美契合 L1 缓存线(通常是 64 Bytes)”取决于你的对象大小。如果你的对象非常大(比如一个包含大段连续内存的结构体),它必然会跨越多个缓存线。更准确的表述是:它消除了控制块和对象分离导致的两次 Cache Miss。
(2)绝对的异常安全(防弹级代码)
假设你有一个函数 process(智能指针, 另一个极有可能崩溃的函数())。 如果你在传参时用 new,在 C++17 之前的编译器里,有极小概率会发生这样的执行顺序:先 new 了内存,然后准备放进智能指针之前,那个“极有可能崩溃的函数”突然抛出了异常。 结果是什么?内存 new 出来了,还没来得及交给智能指针保管,程序就炸了。这块内存永远泄漏了。 而 make_unique 就像一个极其严密的保险箱,它在内部把申请内存和接管控制权彻底绑定成了一个原子操作。无论外面发生什么爆炸,绝不泄漏一字节!
工业界箴言: “现代 C++ 的代码里,不应该出现任何一个显式的 new 或 delete 关键字。”
实例
unique_ptr
核心 API
头文件:#include <memory>
- 创建:
std::unique_ptr<T> p1 = make_unique<t>(T {初始化列表}) - 重置:
p1.reset(new T{初始化列表})旧的会被释放! - 释放:
T * pt = p1.release();p1 变为 nullptr,pt 是裸指针,指向原对象 - 移动:
std::unique_ptr<T> p2 = std::move(p1);
Code1 简单例子
#include <memory>
#include <iostream>
struct ImuData {
double accel[3];
double gyro[3];
long timestamp;
};
void demo_unique_ptr() {
// [构造] 推荐 make_unique,异常安全且更简洁
std::unique_ptr<ImuData> p1 = std::make_unique<ImuData>();
// [访问] 类似裸指针,通过 -> 或 *
p1->timestamp = 1000;
// [重置] 释放当前内存,并接管新的内存(或置空)
p1.reset(new ImuData{1.0, 2.0, 3.0, 2000});
// [释放] 放弃所有权,返回裸指针,p1 变为 nullptr
// 注意:此时内存需由返回的指针手动管理,慎用
ImuData* raw_ptr = p1.release();
delete raw_ptr; // 演示需要,工程中尽量避免手动 delete
// [移动语义] unique_ptr 不可拷贝,但可移动
// 这也是后续我们将数据从驱动层转移到算法层的核心机制
std::unique_ptr<ImuData> p2 = std::make_unique<ImuData>();
// std::unique_ptr<ImuData> p3 = p2; // 编译错误:禁止拷贝
std::unique_ptr<ImuData> p3 = std::move(p2); // 正确:所有权转移,p2 变为 nullptr
if (!p2) {
std::cout << "p2 is now nullptr after move." << std::endl;
}
}
Code2 生产者消费者模型例子
#include <iostream>
#include <memory>
// IMU 数据结构定义
struct ImuRawData
{
float ax, ay, az; // 加速度
float gx, gy, gz; // 角速度
long long timestamp;
};
// 1. 数据生产者:生成一个 IMU 数据包
// TODO: 定义函数签名,返回一个持有 ImuRawData 的 unique_ptr
// [你的代码在这里]
std::unique_ptr<ImuRawData> createImuData(float ax = 0,
float ay = 0,
float az = 0,
float gx = 0,
float gy = 0,
float gz = 0,
long long timestamp = 0);
// 2. 数据消费者:处理 IMU 数据包
// 要求:该函数需要“夺走”数据的所有权,处理完后自动销毁数据
// TODO: 定义函数签名,参数为 unique_ptr
// [你的代码在这里]
void processImuData(std::unique_ptr<ImuRawData> raw_data);
// 3. 主流程
int main()
{
// 场景 A: 基础创建与访问
// 创建一个 ImuRawData 对象,并初始化 ax=0.1, ay=0.2, az=9.8
// TODO: 使用 make_unique 创建 pImu
// [你的代码在这里]
std::unique_ptr<ImuRawData> pImu = createImuData(0.1, 0.2, 9.8); // 生产者函数生产
std::cout << "Accel X: " << pImu->ax << std::endl;
// 场景 B: 所有权转移
// 将 pImu 的所有权转移给另一个指针 pImuMoved
// TODO: 执行移动语义
// [你的代码在这里]
std::unique_ptr<ImuRawData> pImuMoved = std::move(pImu); // 独占指针不可copy但可move
if (!pImu)
{
std::cout << "Scene B: Original pointer is empty." << std::endl;
}
// 场景 C: 函数参数传递
// 调用消费者函数处理 pImuMoved
// TODO: 调用 processImuData 函数
// [你的代码在这里]
processImuData(std::move(pImuMoved)); // 因为是独占指针,所以只能移交所有权
return 0;
}
// 函数实现部分
// [createImuData 实现请写在这里]
std::unique_ptr<ImuRawData> createImuData(float ax,
float ay,
float az,
float gx,
float gy,
float gz,
long long timestamp)
{
return std::make_unique<ImuRawData>(
ImuRawData{
ax, ay, az,
gx, gy, gz,
timestamp});
}
// [processImuData 实现请写在这里,内部简单打印 timestamp 即可]
void processImuData(std::unique_ptr<ImuRawData> raw_data)
{
std::cout << "timestamp: " << raw_data->timestamp << std::endl;
}
shared_ptr
核心 API
头文件:
- 构造:
auto p1 = std::make_shared<T>(初始化列表-注意-需要T有构造函数); - 拷贝:
std::shared_ptr<T> p2 = p1;也可以 auto - 自定义删除:在创建指针的时候,构造函数的第二个参数接受一个 labmda 表达式,会在析构时调用
std::shared_ptr<FILE> file_ptr( // 创建一个共享指针(把裸指针包起来)
fopen("log.txt", "w"), // 返回一个文件指针(需要包起来的指针)
[](FILE* f){ // 析构时回调这个函数进行关闭
if(f) {
fclose(f);
std::cout << "File closed safely.\n";
}
});
- 查看计数:
p1.use_count()
Code1
#include <memory>
#include <iostream>
struct ImuData {
double accel[3];
ImuData(double x, double y, double z) { accel[0]=x; accel[1]=y; accel[2]=z; }
~ImuData() { std::cout << "ImuData Destroyed\n"; }
};
void demo_shared_ptr() {
// [构造] 推荐 make_shared:一次内存分配(对象+控制块),性能更优且异常安全
auto p1 = std::make_shared<ImuData>(1.0, 2.0, 3.0);
std::cout << "p1 use_count: " << p1.use_count() << std::endl; // 1
{
// [共享] 拷贝构造,引用计数 +1
std::shared_ptr<ImuData> p2 = p1;
std::cout << "p1 use_count inside scope: " << p1.use_count() << std::endl; // 2
// [重置] p2 离开作用域,引用计数 -1
}
std::cout << "p1 use_count outside scope: " << p1.use_count() << std::endl; // 1
// [自定义删除器] 常用于对接 C 语言接口或特殊硬件资源
// 例如:管理由 malloc 分配的内存,或文件句柄
std::shared_ptr<FILE> file_ptr(fopen("log.txt", "w"), [](FILE* f){
if(f) {
fclose(f);
std::cout << "File closed safely.\n";
}
});
}
Code2
#include <iostream>
#include <memory>
#include <cstdlib> // for malloc, free
// IMU 数据结构
struct ImuSensorData
{
float ax, ay, az;
long long timestamp;
// 自定义构造器(初始化列表)
ImuSensorData(float ax = 0, float ay = 0, float az = 0, long long timestamp = 0) : ax(ax), ay(ay), az(az), timestamp(timestamp)
{
}
};
// 模拟数据处理函数
void processFilter(std::shared_ptr<ImuSensorData> data)
{
if (!data)
return;
std::cout << "[Filter] Processing data at " << data->timestamp << std::endl;
// 模拟处理...
}
// 模拟日志记录函数
void logData(std::shared_ptr<ImuSensorData> data)
{
if (!data)
return;
std::cout << "[Logger] Logging data: " << data->ax << std::endl;
// 模拟记录...
}
// 任务一:数据分发
// 要求:创建一个 shared_ptr,分别传递给 processFilter 和 logData
// 打印出引用计数的变化,观察共享行为
void task1_shared_ownership()
{
std::cout << "--- Task 1: Shared Ownership ---" << std::endl;
// 1. 创建一个 shared_ptr,初始化 timestamp 为 100, ax 为 9.8
// TODO: 使用 make_shared 创建 pData
std::shared_ptr<ImuSensorData> pData = std::make_shared<ImuSensorData>(9.8, 0, 0, 100);
std::cout << "Count after creation: " << pData.use_count() << std::endl;
// 2. 将数据传递给两个处理函数
// 注意:思考这里是值传递还是引用传递?值传递会增加引用计数吗?
// TODO: 调用 processFilter 和 logData
std::cout << "Count before : " << pData.use_count() << std::endl; // 调用前打印
// 应该是值传递,相当于Copy,会使得引用计数增加
processFilter(pData);
logData(pData);
// 稍等,如果这里直接调用,函数返回后引用计数就会下降。
// 请在调用前打印一次计数,调用后打印一次计数。
std::cout << "Count after : " << pData.use_count() << std::endl; // 调用后打印
}
// 任务二:管理 C 风格资源
// 要求:使用 shared_ptr 管理由 malloc 分配的原始内存
// 场景:某些底层驱动返回的是 malloc 的内存,必须用 free 释放
void task2_custom_deleter()
{
std::cout << "\n--- Task 2: Custom Deleter ---" << std::endl;
// 模拟驱动分配的原始内存
void *raw_buffer = malloc(1024);
// TODO: 创建一个 shared_ptr<void> 来管理 raw_buffer
// 提示:构造函数第二个参数传入 lambda 表达式调用 free()
// [你的代码在这里]
std::shared_ptr<void> buffer(
raw_buffer, // 传入原指针
[](void *p) // lambda释放
{
std::free(p);
// p = nullptr; // 不用写,这里立马就结束了生命周期
});
raw_buffer = nullptr; // 防止悬垂
std::cout << "Buffer managed by shared_ptr." << std::endl;
// 当 pBuf 离开作用域时,应该自动调用 free()
}
int main()
{
task1_shared_ownership();
task2_custom_deleter();
return 0;
}
补充!有关于性能优化
Code2 中的最佳实践是:(请注意这是在 Code2 不是多线程的情况下)
现在的写法:
void processFilter(std::shared_ptr<ImuSensorData> data) // 值传递
这在工程中是一个性能陷阱!
- 开销分析:每次调用这个函数,都会触发一次
shared_ptr的拷贝构造。这不仅仅是复制一个指针,还涉及到引用计数的原子自增操作(Atomic Increment)。在高频 IMU 数据处理中(例如 200Hz 甚至 1kHz),原子操作带来的总线锁开销是不可忽视的。 - 最佳实践:如果我们只是在函数内部使用对象,不涉及所有权共享,请传递引用或裸指针。
推荐写法:
// 推荐写法:传递常量引用
// 1. 避免了引用计数的原子增减开销
// 2. 明确语义:我只是借用一下数据,不参与所有权管理
void processFilter(const std::shared_ptr<ImuSensorData>& data) {
if (!data) return;
// ...
}
或者更激进的 C++ 核心指南写法:
// 假设调用者保证 data 有效,直接传引用
void processFilter(const ImuSensorData& data) {
std::cout << "[Filter] Processing data at " << data.timestamp << std::endl;
}
(这为后续的 Move 语义和多线程数据共享埋下了伏笔:在多线程环境下,如果你不想增加引用计数,就意味着你放弃了线程安全的所有权保护,你需要自己保证对象生命周期)
在多线程环境下,绝大多数情况应该使用“值传递”(配合 std::move ),而不是引用传递。
这听起来似乎与我们刚才强调的“性能优化”相悖。
1. 核心矛盾:生命周期 vs 数据竞争
在单线程中,引用传递之所以安全,是因为调用者必然比被调用函数活得更久。但在多线程中,这个假设失效了。
场景推演:引用传递的灾难
假设你有一个全局的数据队列,线程 A 生产数据,线程 B 处理数据。
// 线程 B 的处理函数
void processImuData_ref(const std::shared_ptr<ImuData>& data_ref) {
// 此时,外部(线程 A)可能正持有这个 shared_ptr
// 线程 A 此时可能正在做:data_ref.reset() 或者 data_ref = nullptr
// 或者线程 A 结束了,销毁了它持有的 shared_ptr
// 危险!如果这是最后一个引用,对象在这里就被销毁了
// 接下来访问 data_ref->timestamp 就是访问悬垂指针 -> 程序崩溃!
std::cout << data_ref->timestamp << std::endl;
}
引用传递的问题:它不会增加引用计数。这意味着,你把对象的生命周期控制权完全交给了外部(线程 A)。如果线程 A 跑得快,先把对象销毁了,线程 B 就会拿着空指针或悬垂指针去操作,这就是 Data Race(数据竞争) 和 Use-After-Free(释放后使用) 的根源。
场景推演:值传递的安全性
// 线程 B 的处理函数
void processImuData_val(std::shared_ptr<ImuData> data_val) {
// 发生了一次拷贝(或移动),引用计数 +1 (原子操作)
// 即使外部(线程 A)把它持有的指针释放了,引用计数 -1
// 只要引用计数不为 0,对象就还活着!
// 这里绝对安全,线程 B 拥有独立的“所有权份额”
std::cout << data_val->timestamp << std::endl;
// 函数结束,data_val 析构,引用计数 -1,安全释放
}
值传递的优势:它通过增加引用计数,延长了对象的生命周期。无论外部线程如何风起云涌,只要你的函数还在执行,对象就绝对不会被销毁。这是用微小的原子操作开销,换取了线程安全的巨大红利。
2. 最佳实践总结
| 场景 | 传递方式 | 理由 |
|---|---|---|
| 单线程 | const shared_ptr& (引用) | 避免无意义的原子操作开销,性能最优。 |
| 多线程 (跨线程传递) | shared_ptr (值传递) | 保证对象生命周期,防止并发析构导致崩溃。 |
| 多线程 (启动线程时) | std::move(shared_ptr) | 既然源对象不再需要,直接转移所有权,避免原子增减,既安全又高效。 |
3. 还有一个误区需要澄清
shared_ptr 保证的是“指针本身”的线程安全,而不是“指向对象”的线程安全。
- 指针安全:多个线程同时拷贝/析构
shared_ptr本身(即操作引用计数)是线程安全的(原子操作保证)。 - 对象不安全:如果两个线程同时通过
shared_ptr去修改data->ax = 1.0,这依然会产生数据竞争,你需要加锁(std::mutex)。
结论
回到你的问题:
在多线程编程中,当你把 shared_ptr 交给另一个线程时,请使用值传递(如果源对象不再需要,优先用 std::move 实现值传递)。
这是端侧开发中最稳妥的策略:宁可牺牲一点点引用计数的原子开销,也不要冒着访问悬垂指针导致算法模块崩溃的风险。
- 补充一个 HPC 视角的常识:在多线程中频繁触发
shared_ptr的值传递(即频繁引发引用计数的原子自增减),会导致严重的 Cache Line Bouncing(缓存线弹跳) 。底层的 MESI 缓存一致性协议会让各个 CPU 核心的缓存频繁失效,总线锁开销会急剧拖慢整个系统的吞吐量。
- 端侧优化建议:在高性能场景下,尽量通过架构设计减少多线程间的所有权共享。最理想的情况是
std::move,将unique_ptr的所有权在线程间转移,实现无锁(Lock-free)且零原子开销的跨线程数据流转。
weak_ptr
核心 API
头文件:
- 创建:
std::weak_ptr<T> weak_obs; // 初始为空 - 取得地址:
weak_obs = shared_owner; // 后面这个是一个shared_ptr - 尝试取得所有权:
if (auto locked = weak_obs.lock()) // 获取成功则为true! - 检测过期:
if (weak_obs.expired()) // 如果过期了就为true!
Code1
#include <memory>
#include <iostream>
struct ImuData
{
int id;
ImuData(int i) : id(i) { std::cout << "ImuData " << id << " created.\n"; }
~ImuData() { std::cout << "ImuData " << id << " destroyed.\n"; }
};
void demo_weak_ptr()
{
std::weak_ptr<ImuData> weak_obs; // 初始为空
{
auto shared_owner = std::make_shared<ImuData>(1);
weak_obs = shared_owner; // 观察者赋值,引用计数不增加!
std::cout << "Inside scope, use_count: " << shared_owner.use_count() << std::endl; // 1
// [核心用法] 尝试获取使用权
if (auto locked = weak_obs.lock())
{
// lock() 返回一个 shared_ptr
// 如果对象已死,返回空的 shared_ptr (nullptr)
// 如果对象存活,返回一个新的 shared_ptr,引用计数 +1
std::cout << "Successfully locked data id: " << locked->id << std::endl;
}
else
{
std::cout << "Data expired." << std::endl;
}
} // shared_owner 离开作用域,对象销毁
// [检测存活]
if (weak_obs.expired()) // expired 过期,也就是被销毁了
{
std::cout << "Data has been destroyed (expired)." << std::endl;
}
// [再次尝试 lock]
if (auto locked = weak_obs.lock())
{
// 不会执行
}
else
{
std::cout << "Lock failed, data is gone." << std::endl;
}
}
auto main() -> int
{
demo_weak_ptr();
}
Code2
#include <iostream>
#include <memory>
// 定义 IMU 节点
struct ImuNode
{
int id;
// TODO: 这里应该填什么?shared_ptr 还是 weak_ptr?
// 场景:下一个节点引用上一个节点。如果是双向链表,这里容易造成循环引用。
// 假设这是指向前一个节点的指针 prev
std::weak_ptr<ImuNode> prev; // 上节点用弱引用
std::shared_ptr<ImuNode> next; // 下一节点用共享
// 指向前一个链表的指针应该为弱引用!指向下一个节点的则应该用shared!
// 这是为了避免环形引用导致的内存泄漏(计数不能为0 无法释放内存)
ImuNode(int i) : id(i) { std::cout << "Node " << id << " created.\n"; }
~ImuNode() { std::cout << "Node " << id << " destroyed.\n"; }
};
// 任务一:打破循环引用
// 要求:修改上面的 struct ImuNode 定义中的 prev 指针类型,使其避免内存泄漏。
// 注意:这只需要你修改一行代码,并在提交时说明原因。
// 任务二:安全观测者
// 模拟监控线程函数
void monitorData(std::weak_ptr<int> dataWatcher)
{
// TODO: 实现
// 1. 使用 lock() 尝试获取数据
// 2. 如果获取成功,打印 "Monitor: Data is " << *data
// 3. 如果获取失败(对象已销毁),打印 "Monitor: Data expired!"
if (auto data = dataWatcher.lock()) // .lock()尝试获取所有权
{
std::cout << "Monitor: Data is " << *data << std::endl;
}
else
{
std::cout << "Monitor: Data expired!" << std::endl;
}
}
void task2_observer()
{
std::weak_ptr<int> watcher;
{
// 创建一个被 shared_ptr 管理的 int 数据 (值为 42)
auto data = std::make_shared<int>(42);
// 将其赋值给 watcher
watcher = data;
// 模拟监控
monitorData(watcher); // 此时应该打印 42
} // data 离开作用域,int 42 被销毁
// 再次监控
monitorData(watcher); // 此时应该打印 expired
}
int main()
{
std::cout << "--- Task 1: Cycle Reference ---" << std::endl;
{
auto node1 = std::make_shared<ImuNode>(1);
auto node2 = std::make_shared<ImuNode>(2);
// 构建循环引用:node2 的前驱指向 node1
node2->prev = node1;
// 如果不修改 prev 类型,这里离开作用域后,两个 node 都不会销毁
}
// 期望输出:Node 2 destroyed, Node 1 destroyed
std::cout << "\n--- Task 2: Observer ---" << std::endl;
task2_observer();
return 0;
}
注意:数据竞争相关
lock()的原子性:lock()的原子性体现在“检查引用计数”和“增加引用计数”这两个动作之间没有间隙。
-
- 如果没有
lock(),你先判断expired()(未过期),然后准备使用数据,就在这两步之间,另一个线程可能正好释放了对象——这就导致你使用了悬垂指针。 lock()保证了“我要使用”这个动作一旦开始,对象就绝对不会被释放(引用计数 +1 成功),直到你用完释放了这个新的shared_ptr。
- 如果没有
- 数据竞争依然存在:如果线程 A 和线程 B 同时调用
lock()成功,它们各自拿到了一个shared_ptr,引用计数变成了 2。此时,如果它们同时去修改数据(比如*data = 100),依然会发生数据竞争! - 结论:
lock()保护的是指针(生命周期),而不是数据内容。要保护数据内容,你依然需要std::mutex或原子类型。
智能指针综合
核心知识点:enable_shared_from_this
这是一个容易被误用的神器。当一个对象已经被 shared_ptr 管理,而你需要在成员函数内获取指向自己的 shared_ptr 时,不能直接 shared_ptr(this)(会导致双重释放),必须继承 enable_shared_from_this 并调用 shared_from_this()。
例子:
#include <memory>
#include <iostream>
// 正确用法示例
class CallbackHandler : public std::enable_shared_from_this<CallbackHandler> {
public:
void registerCallback() {
// 获取指向自己的 shared_ptr,安全地传递给外部
auto self = shared_from_this();
std::cout << "Registered self." << std::endl;
}
};
void demo_enable_shared_from_this() {
// 必须先让 shared_ptr 接管对象
auto handler = std::make_shared<CallbackHandler>();
handler->registerCallback();
// 错误示范:如果 handler 是栈对象或裸指针,调用 shared_from_this() 会崩溃!
// CallbackHandler bad_handler;
// bad_handler.registerCallback(); // 抛出 std::bad_weak_ptr 异常
}
Code 实例
“所有权分离”的原则——内部缓冲区由 unique_ptr 独占管理(高性能),外部接口通过 shared_ptr 返回副本(安全性)。这是端侧开发中处理数据快照的标准范式。
核心问题解答
Q:为什么 getLatestData() 不能直接返回 buffer_ 中的裸指针?
A:这会导致严重的生命周期失控。
- 所有权冲突:
buffer_是unique_ptr管理的数组,它拥有这块内存的绝对控制权。如果你返回一个裸指针ImuRawData*,外部使用者可能会尝试delete这个指针(灾难性),或者在ImuProcessor析构后继续访问它(悬垂指针)。 - 数据一致性:环形缓冲区是不断覆盖的。如果外部直接持有了
buffer_[i]的引用或指针,当下一次pushData覆盖了这个位置时,外部持有的数据就会莫名改变,这在多线程或异步逻辑中是极难排查的 Bug。 - 解决方案:返回副本(
make_shared拷贝一份)虽然有一次内存分配和拷贝开销,但它保证了外部拿到的数据是独立的、安全的,其生命周期由外部的shared_ptr自行管理,与内部缓冲区解耦。
#include <iostream>
#include <memory>
#include <vector>
// 配置参数结构
struct ProcessorConfig
{
int buffer_size = 10; // 环形缓冲区大小
double sample_rate = 200.0; // 采样率
};
// IMU 数据结构
struct ImuRawData
{
float ax, ay, az;
float gx, gy, gz;
long long timestamp;
};
// 任务一:IMU 处理器类
// 要求:
// 1. 继承 enable_shared_from_this,以便能够安全地获取自身的 shared_ptr
// 2. 内部使用 unique_ptr 管理缓冲区数组
// 3. 接受一个 shared_ptr<ProcessorConfig> 作为配置
class ImuProcessor : public std::enable_shared_from_this<ImuProcessor>
{
private:
std::shared_ptr<ProcessorConfig> config_; // 共享配置
std::unique_ptr<ImuRawData[]> buffer_; // 环形缓冲区(独占)
int write_index_ = 0; // 写入位置
int count_ = 0; // 当前数据量
public:
// 构造函数
// TODO: 实现构造函数,初始化配置和缓冲区
// 提示:缓冲区大小从 config_ 中读取,使用 make_unique<ImuRawData[]> 分配数组
ImuProcessor(std::shared_ptr<ProcessorConfig> config)
: config_(config), write_index_(0), count_(0)
{
// [你的代码在这里]
this->buffer_ = std::make_unique<ImuRawData[]>(
this->config_->buffer_size); // 直接构造就行,注意是数组
// 分配 buffer_,大小为 config_->buffer_size
}
// 插入数据
// TODO: 实现环形缓冲区的写入逻辑
void pushData(const ImuRawData &data)
{
// [你的代码在这里]
// 1. 将数据写入 buffer_[write_index_]
this->buffer_[this->write_index_] = data;
// 2. 更新 write_index_(环形:index = (index + 1) % size)
this->write_index_ = (this->write_index_ + 1) % this->config_->buffer_size;
// 3. 更新 count_(不超过 buffer_size)
if (this->count_ < this->config_->buffer_size)
this->count_++;
}
// 获取最新的一条数据
// 返回 shared_ptr<ImuRawData>,方便外部安全使用
std::shared_ptr<ImuRawData> getLatestData()
{
if (count_ == 0)
return nullptr;
// 注意:这里不能直接返回 buffer_ 中的指针,因为 buffer_ 是 unique_ptr 管理的数组
// 我们需要创建一个新的 shared_ptr 来持有数据的副本
// TODO: 创建一个新的 shared_ptr,拷贝最新数据
// 最新数据的位置:(write_index_ - 1 + config_->buffer_size) % config_->buffer_size
// [你的代码在这里]
return std::make_shared<ImuRawData>(
this->buffer_[(this->write_index_ - 1 + this->config_->buffer_size) % this->config_->buffer_size]);
// 直接make,同时传入的是值,也就是用这个临时拷贝的对象,右值引用过去
// return nullptr; // 临时返回
}
// 注册到外部系统
void registerToSystem()
{
// TODO: 使用 shared_from_this() 获取自身的 shared_ptr
auto p = shared_from_this();
// 打印 "Processor registered with buffer size: " << config_->buffer_size
std::cout << "Processor registered with buffer size: " << p->config_->buffer_size << std::endl;
// [你的代码在这里]
}
// 获取当前缓冲区数据量
int getCount() const { return count_; }
};
// 任务二:测试主流程
int main()
{
std::cout << "=== Task: Integrated IMU Processor ===" << std::endl;
// 1. 创建共享配置
auto config = std::make_shared<ProcessorConfig>();
config->buffer_size = 5; // 测试用小缓冲区
// 2. 创建处理器
// TODO: 使用 make_shared 创建 ImuProcessor,传入 config
// [你的代码在这里]
auto processor = std::make_shared<ImuProcessor>(config);
// 3. 注册到系统
// TODO: 调用 registerToSystem()
// [你的代码在这里]
processor->registerToSystem();
// 4. 模拟数据流
std::cout << "\n--- Pushing Data ---" << std::endl;
for (int i = 0; i < 8; ++i)
{
ImuRawData data{i * 1.0f, 0, 9.8f, 0, 0, 0, i * 10};
// TODO: 调用 pushData 插入数据
// [你的代码在这里]
processor->pushData(data);
std::cout << "Pushed data with timestamp: " << i * 10
<< ", Buffer count: " << processor->getCount() << std::endl;
}
// 5. 获取最新数据
std::cout << "\n--- Getting Latest Data ---" << std::endl;
// TODO: 调用 getLatestData() 并打印 timestamp
// [你的代码在这里]
{
auto lastestData = processor->getLatestData();
std::cout << "timestamp: " << lastestData->timestamp << std::endl;
}
return 0;
}
补充
知识树中还需要补齐以下几块“硬核”拼图:
1. unique_ptr 的自定义删除器(端侧设备管理的利器)
笔记中演示了 shared_ptr 的自定义删除器,但漏掉了 unique_ptr 的自定义删除器。 在端侧 AI 部署中经常要管理非内存资源:CUDA Stream、TensorRT 的 Context、NPU 的硬件句柄等。shared_ptr 的自定义删除器会把删除器藏在控制块里(涉及类型擦除,有微小运行时开销);而 unique_ptr 是将删除器作为模板参数在编译期确定的,真正的零开销。
// 典型的端侧硬件句柄管理(零开销)
struct TensorRTEngineDeleter {
void operator()(nvinfer1::ICudaEngine* engine) const {
if (engine) engine->destroy();
}
};
// 哪怕带着自定义删除器,它在内存中的大小依然等于一个裸指针!
std::unique_ptr<nvinfer1::ICudaEngine, TensorRTEngineDeleter> engine_ptr;
2. make_shared 的致命暗面:内存劫持 (Memory Hold-up)
在资源极其受限的端侧设备上,make_shared 有一个必须警惕的坑。 由于 make_shared 将对象和控制块分配在同一块物理内存上,这意味着:只要还有 weak_ptr 存在(弱引用计数不为 0),控制块就不能释放,因此连带那块内存也不能交还给操作系统。 如果你的对象非常大(比如一个 10MB 的图像缓存矩阵),哪怕强引用归零,对象调用了析构函数,但这 10MB 的物理内存依然会被一个僵尸 weak_ptr “劫持”,直到所有的 weak_ptr 都被销毁。在内存吃紧的边缘设备上,这会导致隐性的 OOM(内存溢出)。此时,老老实实用 new 分开分配反而更安全。
3. shared_ptr 的别名构造函数 (Aliasing Constructor)
在处理复杂数据结构时极其有用。假设你加载了一个巨大的 AI 模型,包含很多个 Layer。你想把某个特定 Layer 的指针传递给其他模块,但不希望这个 Layer 被析构,这需要绑定整个模型的生命周期。
auto model = std::make_shared<DeepLearningModel>();
// 创建一个指向内部特定层级数据的指针,但与 model 共享整个模型的引用计数!
std::shared_ptr<Layer> layer_ptr(model, &model->target_layer);
4. 动态数组的智能指针管理
在 C++14 之后,unique_ptr 原生支持数组。在 HPC 中,如果不需要 std::vector 那样动态扩容带来的额外开销(比如只需要一块固定大小的连续内存做 Buffer),使用 std::unique_ptr<T[]> 是最佳选择。
auto buffer = std::make_unique<float[]>(1024); // 零开销,自动 delete[]
其他
- 从代码规范层面:代码示例(如
std::make_unique配合std::move在生产者消费者模型中的应用)非常符合现代 C++ (Modern C++) 的工业级标准,请继续保持这种“代码洁癖”。 - 底层探索:建议下一步去阅读一下 GCC/Clang 标准库中
shared_ptr控制块的源码,弄清楚_Sp_counted_base中强引用和弱引用是如何通过std::atomic指令(如lock xadd)来实现线程安全的,这会对你理解多线程性能瓶颈有质的提升。 - 架构思维的转变:在系统设计时,默认使用
unique_ptr。只有当你从架构上明确发觉“这块数据必须由多个独立的实体共同决定生命周期”时,才升级为shared_ptr。绝不要为了“方便传参”而滥用shared_ptr。