找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1185

积分

0

好友

153

主题
发表于 14 小时前 | 查看: 3| 回复: 0

之前在知乎上看到一篇讨论,大意是说当前存在对 std::shared_ptr 的滥用现象,很多时候其实 std::unique_ptr 才是更合适的选择。这引发了我的思考。今天,我们就结合实际开发中的几个典型案例,聊聊 std::shared_ptr 可能遇到的“坑”以及对应的解决思路。

历史背景

shared_ptr 并非从一开始就是 C++ 标准库的成员。它最早于 1999 年出现在 Boost 库中,甚至早于 Boost 自身的版本号体系。在那个年代,标准库中唯一的“智能指针”是 auto_ptr。然而,auto_ptr 因其诡异的拷贝语义和极易导致悬空指针的问题而“声名狼藉”,在实际项目中几乎无人敢用。最终,auto_ptr 在 C++11 中被弃用,并在 C++17 中被彻底移除。

直到 C++11,boost::shared_ptr 才被正式纳入标准库,成为 std::shared_ptr。在随后的十多年里,它几乎成为了 C++ 世界中使用最广泛的智能指针

核心原理浅析

从极度简化的视角看,shared_ptr 内部维护着两块关键信息:一块是指向堆上托管对象的指针,另一块是指向控制块(control block)的指针。控制块中包含了引用计数等关键的元数据。

其生命周期管理遵循几条基本规则:

  • shared_ptr 被拷贝构造时,其引用计数加一。
  • shared_ptr 被赋值时,右侧操作数的引用计数加一,左侧操作数的引用计数减一。
  • shared_ptr 被析构时,引用计数减一。
  • 当引用计数降为 0 时,托管的堆对象被销毁。

示例一:警惕不必要的拷贝

许多人误以为使用 shared_ptr 只是简单的引用计数加减,因此常常无脑使用拷贝传递。我们来看看下面这个对比示例:

#include <memory>
#include <chrono>
#include <iostream>

using shared_ptr_t = std::shared_ptr<int>;

void receiver_by_value(shared_ptr_t ptr){
    volatile int x = *ptr;
    (void)x;
}

void receiver_by_ref(const shared_ptr_t& ptr){
    volatile int x = *ptr;
    (void)x;
}

void test_by_value(uint64_t n){
    auto ptr = std::make_shared<int>(100);
    for (uint64_t i = 0; i < n; ++i) {
        receiver_by_value(ptr);
    }
}

void test_by_ref(uint64_t n){
    auto ptr = std::make_shared<int>(100);
    for (uint64_t i = 0; i < n; ++i) {
        receiver_by_ref(ptr);
    }
}

int main(){
    uint64_t n = 1000000;
    {
    auto start = std::chrono::high_resolution_clock::now();

        test_by_value(n);

    auto end = std::chrono::high_resolution_clock::now();
    std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()
              << " us\n";
    }

    {
    auto start = std::chrono::high_resolution_clock::now();

        test_by_ref(n);

    auto end = std::chrono::high_resolution_clock::now();
    std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()
              << " us\n";
    }
}

输出结果可能类似于:

29460 us
2359 us

结论非常明显:在同一台机器上,按值传递的耗时在毫秒级,而按常量引用传递的耗时几乎可以忽略不计。 因此,一个重要的优化准则是:除非你明确需要共享所有权(例如跨线程传递),否则应当通过 const 引用传递 shared_ptr,以避免不必要的原子操作开销。

示例二:优先使用 std::make_shared

不少开发者会这样创建 shared_ptr

auto ptr = std::shared_ptr<int>(new int(42));

这段代码看起来没问题,但实际上它触发了两次独立的堆内存分配:

  • 一次用于 int 对象本身。
  • 一次用于 shared_ptr 的控制块。

这不仅增加了分配成本,还降低了数据的缓存局部性。std::make_shared 正是为了解决这个问题而设计的:

auto ptr = std::make_shared<int>(42);

make_shared 会尝试将对象和控制块分配在连续的内存区域中,通常只需一次内存分配。我们可以通过一个简单的测试来感受差异:

#include <memory>
#include <vector>
#include <iostream>

void test_ctor(size_t n){
    std::vector<std::shared_ptr<size_t>> v;
    v.reserve(n);
    for (size_t i = 0; i < n; ++i) {
        v.emplace_back(std::shared_ptr<size_t>(new size_t(i)));
    }
}

void test_make_shared(size_t n){
    std::vector<std::shared_ptr<size_t>> v;
    v.reserve(n);
    for (size_t i = 0; i < n; ++i) {
        v.emplace_back(std::make_shared<size_t>(i));
    }
}

使用 valgrind 等工具测试上述代码(例如 n=100000),可以观察到,使用构造函数直接创建对象时内存分配次数约为 200003 次,而使用 std::make_shared 创建时,内存分配次数约为 100003 次,优势显著。

示例三:小心循环引用

循环引用是 shared_ptr 的经典陷阱。请看以下代码:

struct B;

struct A {
    ~A() { std::cout << "~A\n"; }
    std::shared_ptr<B> b;
};

struct B {
    ~B() { std::cout << "~B\n"; }
    std::shared_ptr<A> a;
};

void test(){
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->b = b;
    b->a = a;
}

这是一个典型的循环引用场景。当 test 函数结束时,局部变量 ab 的引用计数各减为1(因为彼此持有对方的 shared_ptr),并未归零,因此它们的析构函数永远不会被调用,导致内存泄漏。这种问题在大型、复杂的对象关系图中往往隐藏极深。

解决循环引用的标准方法是引入 std::weak_ptrweak_ptr 是一种不控制对象生命周期的智能指针,它指向一个由 shared_ptr 管理的对象,但不会增加其引用计数。

struct B;

struct A {
    ~A() { std::cout << "~A\n"; }
    std::shared_ptr<B> b;
};

struct B {
    ~B() { std::cout << "~B\n"; }
    std::weak_ptr<A> a; // 将 shared_ptr 改为 weak_ptr
};

B 中指向 A 的指针改为 weak_ptr 后,循环被打破。当 test 函数结束时,a 的引用计数首先归零并被销毁,随后 b 的引用计数也归零。在需要访问 A 对象时,可以通过 weak_ptr::lock() 方法尝试获取一个临时的 shared_ptr切记,必须检查 lock() 的返回值是否为空,这是唯一线程安全的做法。

示例四:make_sharedweak_ptr 的联用细节

make_sharedweak_ptr 单独使用都是极佳实践,但将它们结合使用时,有一个容易被忽略的细节。

如前所述,make_shared 将对象和控制块分配在同一块内存中。这意味着,只要还有 weak_ptr 指向该控制块,这块完整的内存(包含对象和控制块)就不能被释放,即使对象本身的析构函数已经被调用。这会带来以下影响:

  • 对象的析构函数会被如期调用。
  • 对象所占用的内存却依然被保留。
  • 如果通过某些非法手段获取了原始指针,它可能“看起来”仍然指向有效内存,但对象状态已失效。

cppreference 中对这种现象的描述如下:

If any std::weak_ptr references the control block created by std::make_shared after the lifetime of all shared owners ended, the memory occupied by T persists until all weak owners get destroyed as well, which may be undesirable if sizeof(T) is large.

看一个具体例子:

std::weak_ptr<LargeType> weakPtr;
{
    auto sharedPtr = make_shared<LargeType>();
    weakPtr = sharedPtr;
    // ...
} // sharedPtr 析构,LargeType对象析构函数被调用,但其内存未被释放

在上面的代码中,离开作用域后,LargeType 对象的内存并没有被立即释放,而是会持续到最后一个 weakPtr 被销毁。

而如果使用分开分配的方式:

std::weak_ptr<LargeType> weakPtr;
{
    auto sharedPtr = std::shared_ptr<LargeType>(new LargeType); // 两次分配
    weakPtr = sharedPtr;
    // ...
} // sharedPtr 析构,LargeType对象立即被销毁且其内存被释放(控制块内存会等weakPtr销毁)

在这种情况下,由于对象内存和控制块内存是分开的,当所有 shared_ptr 销毁后,对象内存会立即被回收,只有控制块内存会等待 weak_ptr 释放。

因此,在设计需要大量、长期存在 weak_ptr 且托管对象体积 (sizeof(T)) 非常大的场景时,需要权衡是否使用 make_shared。这是内存管理中一个高级但重要的考量点。

总结

std::shared_ptr 是一个强大的工具,但“能力越大,责任越大”。盲目滥用会导致性能损耗和难以察觉的内存问题。记住几个关键点:非必要不拷贝、优先使用 make_shared、用 weak_ptr 打破循环引用、了解内存延迟释放的细节。在实际的 C++ 项目开发中,结合 RAII 等理念审慎地使用智能指针,才能构建出既安全又高效的程序。希望这些来自实践的经验和思考,能帮助你在云栈社区及其他技术交流中更好地驾驭 C++ 的内存管理艺术。




上一篇:破阵阁网安淬锋公开赛决赛WP:RIPS代码审计与SM3哈希逆向实战解析
下一篇:开源播放器Screenbox测评:Win11原生美感,优雅替代PotPlayer与VLC
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-10 19:53 , Processed in 0.457388 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表