在 C++23 中使用智能指针进行现代内存管理 — 第 2 部分:共享指针

发布于:2025-02-19 ⋅ 阅读:(24) ⋅ 点赞:(0)

C++ 以具有难以处理的内存模型而闻名,尤其是对于来自托管内存语言的程序员。它因越界引用错误和内存泄漏而被吐槽。

尽管如此,现代 C++ 比以前安全得多,现在甚至比托管内存模型更安全(性能更高)。

在本系列的第 1 部分中,我们探讨了托管内存语言以及 C 和旧式 C++ 中的内存模型。我们看到了智能指针可以提供的价值,并探索了标准智能指针之一:unique_ptr。

本文(第 2 部分)将介绍另一个标准的智能指针,即共享指针 (shared_ptr),并探讨它的一些用途。

共享所有权的概念,以及开发者为什么需要它

第 1 部分中,智能指针的主要功能是在不再需要时释放已在堆(和其他资源)上分配的对象。unique_ptr 引入了所有权的概念(就像 Rust 中的所有权一样)。它将所指对象的生命周期与所有者的生命周期(unique_ptr 本身)绑定在一起。既然所有者的一生是可预测的,那么所指对象的一生也是可预测的。

但是,如果所指对象的生命周期不是那么可预测的,会发生什么呢?在多线程或异步程序中,一个进程可能会设置一个对象,另一个进程可能会填充它,第三个进程可能会将其转发到外部端点。只有当每个进程都已完成对象时,才能将其删除。

这就是 shared_ptr 的用途。从表面上看,它就像一个 unique_ptr,因为所指对象的一生与其所有者的一生息息相关。但最大的区别在于,一个共享指针可以有多个所有者,并且在删除引用对象之前,他们都必须放弃自己的所有权。

这听起来很像 managed-memory 语言中使用的内存模型,其中只要程序中的其他对象可以访问对象,它们就会保留在内存中。但是,就像使用唯一指针一样(与托管内存语言不同),不涉及垃圾回收器,因此引用会尽早删除。

计算参考文献

shared_ptr 使用一种称为引用计数的技术工作,该技术是为了管理函数式语言中的内存而发明的。除了每个引用对象之外,还有一条记录,用于计算有多少其他对象对它感兴趣。每次创建链接到引用对象的共享指针时,它都会在后台递增计数器。如果共享指针被删除或与引用对象断开连接,则共享指针将再次递减计数器。最终,当计数器达到零时,离开的指针将删除引用并回收其内存。

总而言之,此技术涉及三个不同的实体:

  • 智能指针,用于管理 counter 数据结构并取消引用引用。
  • 对象,它是所指对象的最终所有者。
  • 所指对象本身

在这里插入图片描述

图 1:参考计数策略

这个过程显然比 unique_ptr 稍微复杂一些,因此使用过程中涉及的开销非常小。

首先,管理计数器涉及空间开销。如果分配的资源很大且复杂,则计数器的开销最小,但如果分配大量微小的对象,则开销可能会变得很大。

根据 counter 的分配方式,也可能有时间开销,因为 counter 是与引用对象一起分配和解除分配的。随着堆的碎片化程度越来越高,这也可能变得很重要。我们稍后将探索一些替代策略来处理这个问题。

当心循环引用

尽管引用计数指针看起来与托管内存语言中的引用非常相似,但开发者需要注意一个很大的区别。考虑一个 shared_ptr 拥有的对象,该对象本身包含 shared_ptrs。当对象被销毁时,包含的 shared_ptrs 也会被销毁,从而销毁它们的引用。整个树将一次性全部删除。

在这里插入图片描述

图 2:级联删除

但是假设 shared_ptrs 在一个循环中相互引用。在这种情况下,每个对象都由链中的前一个共享指针保持活动状态,即使没有对整个结构的引用。那是内存泄漏!

在这里插入图片描述

图 3:对象 1 仍归对象 3 所有,因此不会被删除。

这里有几点需要注意:

  • 不变性是安全的。如果 shared_ptr 仅引用 const 对象(或者至少引用实现不可变接口的对象),则无法创建这些循环。不可变对象不受此问题的影响。
  • Pining。这是一种非常好的技术,允许对象将自身固定在内存中。假设开发者有一个对象正在参与一个长时间运行的异步进程。它可以包含一个引用自身的共享指针,当该过程完成时,它会重置指针。如果没有其他人关心,该对象将被删除。
  • 弱引用。 最后,如果自引用指针是必不可少的,则可以创建一个 weak_ptr。我们稍后会探讨这个问题。

基本用法

与 unique_ptr 类似,创建 shared_ptr 的最简单方法是使用 make_shared:

#include

shared_ptr c = make_shared(p1, p2);

以这种方式创建指针和引用对象时,共享指针在堆上只分配一个内存块,同时包含引用对象和计数器。

在这里插入图片描述

图 4:make_shared 后的内存布局

与 unique_ptr 不同,我们可以将一个 shared_ptr 分配给另一个:

shared_ptr d = c;

这将生成如下所示的内存布局:

在这里插入图片描述

图 5:shared_ptr 分配后的内存布局。

我们可以使用插桩类来证明这是可行的,如下所示:

Class Test {

Test () {

    cout << “Test ctor” << endl;

}

~Test () {

    cout << “Test dtor” << endl;

}

}

shared_ptr s;

{

cout << “Creating t” << endl;

shared_ptr t = make_shared();

s = t; // The reference count is now 2

}

// When t goes out of scope, the reference count falls to 1

cout << “t has been destroyed” << endl;

当程序结束并且 S 被销毁时,计数将降至零

这将产生输出:

Creating t

Test ctor

t has been destroyed

Test dtor

我们在介绍中看到,shared_ptrs 的周期会将它们的内存固定在适当的位置,我们顺便提到 weak_ptr 可以帮助解决这个问题。我们现在可以探索 weak_ptr 的实际作用。

实际上,weak_ptr 就像一个 shared_ptr,但引用计数器不计算 weak_ptrs。更重要的是,开发者无法取消引用 weak_ptr:创建该 shared_ptrs 可能已被销毁,但即使该 weak_ptr 仍然存在,所引用对象也将消失。

要使用 weak_ptr,开发者首先必须尝试将其转换为 shared_ptr。

weak_ptr w;

{

cout << “Creating t” << endl;

shared_ptr t = make_shared();

w = t;

shared_ptr s = w.lock(); // try to get the shared_ptr

if (!s) cout << “t has expired” << endl;

// otherwise use s

}

cout << “t has been deleted” << endl;

shared_ptr s = w.lock(); // try to get the shared_ptr

if (!s) cout << “t has expired” << endl;

这将生成输出:

Creating t

Test ctor

Test dtor

t has been deleted

t has expired

高级用法

就像唯一指针一样,共享指针可以使用预先存在的引用进行初始化:

MyClass *c = new MyClass();

shared_ptr p = new shared_ptr©

当开发者像这样构造 shared_ptr 时,引用计数器和引用需要位于两个不同的分配块中。

在这里插入图片描述

图 6:使用新的 MyClass 构建 shared_ptr 时的内存布局

这种不同的布局具有一些含义。首先,每个 shared_ptr 都需要两个堆分配:一个用于引用,一个用于计数器。这是 make_shared 的两倍。再次销毁它们时,堆活动也是两倍。

最后,weak_ptr 的处理存在显著差异。要使弱指针工作,它需要访问 reference counter。一旦最后一个 shared_ptr 被销毁,就可以立即删除 referent,但只有当没有更多的共享指针或弱指针使用它时,计数器才会被删除:

在这里插入图片描述

图 7:weak_ptr 保存引用计数器时的内存布局

但是,当使用 make_shared 创建 shared_ptr 时,其工作方式略有不同。开发者已经看到 counter 和 referent 是在同一个内存块中创建的。因此,在上面的 weak_ptr 演示中删除 shared_ptr 时,会调用其析构函数,但直到 weak_ptr 也超出范围,内存才会被回收。所指对象的记忆可能比你预期的要长得多!

结论

乍一看,shared_ptr 看起来像是托管内存指针的近似值。但存在重要差异。

你真的需要 shared_ptr吗?共享所有权就其性质而言,意味着开发者具有非本地效果:当每个所有者都具有同等权限时,任何所有者都可以随时更改正在共享的对象。这使得很难预测依赖于它们的任何特定函数的行为。共享状态是一种公认的反模式。

假设共享指针适合开发者的解决方案:

  • 仅共享不可变状态。为了缓解上述问题,开发者应该尽可能显式地创建 shared_ptr 对象或仅共享其接口为 const 的对象。
  • 明智地混合 make_shared 和新 shared_ptr(新 C)。与 unique_ptr 一样,开发者应该避免将 new/delete 与 shared_ptr 混用。相反,请使用其中一种。

注意:虽然始终使用 make_unique 创建指针有一个明显的优势,但对于 shared_ptr,它并不那么明确。当引用对象很大或使用有限的系统资源时,并且开发者希望依赖 weak_ptrs 来管理生命周期,则 new shared_ptr(new C) 比 make_shared 具有明显的优势,因为它能够回收引用对象的内存,即使 weak_ptrs 仍在引用它。

  • 按值传递 shared_ptr s。与 unique_ptr 相比,在中,开发者只应将引用传递给函数,而通常最好按值传递 shared_ptrs。

如果 shared_ptr<>& 的源超出范围,则它(及其引用对象)将被删除,如果函数包含任何异步操作,则指针及其引用对象都将消失。复制 shared_ptr 实际上没有开销,但它会将引用保留在内存中,直到异步操作完成为止。

点击了解 Incredibuild 的 C/C++ 编译加速方案,并获取试用 License


网站公告

今日签到

点亮在社区的每一天
去签到