5.4. shared_ptr 与 weak_ptr 指针

通常,unique_ptr<T>会是智能指针的首选,小巧、高效,能满足绝大多数代码的需求。但某些特定且重要的使用场景中,unique_ptr<T>并不适用,这些场景通常具有以下共同点:

如果执行过程非并发,通常可以明确资源的最后持有者 —— 即程序中最后一个访问该资源并销毁对象。这一点很重要:即使是在共享资源的并发代码中,仍然可以使用unique_ptr管理资源。资源的非持有者(如原始指针)可以在不获取所有权的情况下访问资源(本章后续会详述),这种方式已足够满足需求。

当然,非并发代码中也可能存在,无法预先确定资源最后持有者的情况。例如,某种协议可能要求资源提供者在将资源返回给使用端后仍保留其所有权,但后续可能被要求释放资源(此时使用户端将成为最后持有者),也可能永远不会释放(此时提供者可能成为最后持有者)。这类情况显然非常特殊,但即使是非并发代码,也可能存在需要使用std::shared_ptr表达的共享所有权语义的场景。

由于共享资源的最后持有者无法发确定这一情况,最可能地出现在并发代码中。因此我们将以此为切入点展开分析,回顾本章开头的示例:

// ...
void f() {
  X *p = new X;
  thread th0{ [p] { /* 使用 *p */ };
  thread th1{ [p] { /* 使用 *p */ };
  th0.detach();
  th1.detach();
}

f() 中的 p 作为原始指针并不拥有其指向的 X 对象,而 th0 和 th1 又各自复制了这个原始指针,因此两者均不对目标对象负责(至少类型系统强制规则如此;可以通过复杂技巧实现所有权管理,但这会引入繁琐、易错且易出漏洞的代码)。

若要将此示例改为具有明确所有权语义,只需将 p 从 X* 改为 shared_ptr<X>。改进后的逻辑如下:

// ...
void f() {
  std::shared_ptr<X> p { new X };
  thread th0{ [p] { /* 使用 *p */ };
  thread th1{ [p] { /* 使用 *p */ };
  th0.detach();
  th1.detach();
}

在 f() 中,p 最初是其指向的 X 对象的唯一所有者。当复制 p 时(如在 th0 和 th1 的 lambda 捕获块中),shared_ptr 的机制会确保 p 及其两个副本共享 X* 和一个引用计数器,该计数器用于追踪资源的共享所有者数量。

shared_ptr 的核心功能包括:

这些函数的实现均需精细处理。为帮助理解其原理,第6章将提供简化实现示例。此外,shared_ptr 也支持移动语义,用于实现所有权转移。

需注意,shared_ptr<T> 实现的是外置式(非侵入式)共享所有权语义。类型 T 可以是基础类型,无需为实现此功能提供特定接口。这与本章提到的侵入式共享语义(如 releasable 类型示例)形成对比。

5.4.1 实用性与成本

shared_ptr<T> 模型存在固有的开销。最明显的一点是,对于任何类型 T,sizeof(shared_ptr<T>) > sizeof(unique_ptr<T>),因为 shared_ptr<T> 需要同时存储指向共享资源的指针和指向共享计数器的指针。

另一个开销是,复制 shared_ptr<T> 并非廉价操作。shared_ptr<T> 主要用于并发代码,其中无法预先确定资源的最后持有者。因此,共享计数器的增减操作需要同步,所以计数器通常是一个原子整数(如 atomic<int>),而修改原子对象(例如 atomic<int>)的开销比修改普通 int 更高。

还有一个不可忽视的开销如下:

shared_ptr<X> p{ new X };

这样的语句会导致两次内存分配,而非一次 —— 一次用于 X 对象,另一次(由 shared_ptr 内部执行)用于计数器。由于这两次分配是分开进行的(一次由用户代码执行,一次由 shared_ptr 构造函数执行),分配的两个对象可能位于不同的缓存行,从而在访问 shared_ptr 对象时可能降低效率。

5.4.2 make_shared()

有一种方法可以缓解后一种开销,那就是让同一个实体执行两次分配,而不是让使用端代码执行一次,构造函数再执行另一次。实现这一点的标准工具是 std::make_shared<T>() 工厂函数。

比较以下两条语句:

shared_ptr<X> p{ new X(args) };
auto q = make_shared<X>(args);

构造 p 时,shared_ptr<X> 被赋予一个已存在的 X* 来管理,因此它别无选择,只能为共享计数器执行第二次独立分配。相反,make_shared<X>(args) 的调用直接指定了要构造的类型 X 及其构造函数参数 args。该函数的职责是同时创建 shared_ptr<X>、X 对象和共享计数器,可以将 X 和计数器放在同一块连续内存空间(即控制块)中,具体实现可能采用联合体(union)或 placement new 等机制(第7章将探讨这些机制)。

显然,若构造参数相同,上述 p 和 q 会是等效的 shared_ptr<X> 对象。但通常q 的性能优于 p,因为其两个关键组件以更符合缓存局部性。

5.4.3 weak_ptr为何物?

如果 shared_ptr<T> 的应用范围比 unique_ptr<T> 更窄(但仍然至关重要),那么 weak_ptr<T> 的适用场景则更为特定(但同样不可或缺)。weak_ptr<T> 的作用是临时所有权模型,专门用于与 shared_ptr<T> 交互,使使用端代码能够检测所指向对象是否仍然存在。

一个 weak_ptr 使用示例(参考优秀的 cppreference 网站:https://en.cppreference.com/w/cpp/memory/weak_ptr)如下:

// 借鉴了 cppreference 中的一个示例
#include <iostream>
#include <memory>
#include <format>

void observe(std::weak_ptr<int> w) {
  if (std::shared_ptr<int> sh = w.lock())
    std::cout << std::format("*sh == {}\n", *sh);
  else
    std::cout << "w is expired\n";
}

int main() {
  std::weak_ptr<int> w;
  {
    auto sh = std::make_shared<int>(3);
    w = sh; // shared_ptr 创建的 weak_ptr
    //  w 当前指向一个有效的 shared_ptr<int> 对象
    observe(w);
  }
  // w 指向一个已过期的 shared_ptr<int> 对象
  observe(w);
}

可以从 shared_ptr<T> 创建 weak_ptr<T>,但 weak_ptr 并不拥有资源的所有权,除非对其调用 lock() 方法 —— 该方法返回一个 shared_ptr<T>。通过检查返回的 shared_ptr 是否为空,可以安全地访问资源。

std::weak_ptr 和 std::shared_ptr 的另一个用例是资源缓存,其场景如下:

这时,Cache 可以持有 std::shared_ptr<Resource>,但向使用端代码提供 std::weak_ptr<Resource>。这样:

  1. 当 Cache 需要释放资源时,可以直接销毁 shared_ptr;
  2. 使用端代码仍可通过 weak_ptr::lock() 检查资源是否有效,避免访问已失效的对象。

以下是一个简化示例(完整代码可参考本书的 GitHub库):

// ...
template <auto Cap>
class Cache {
  using clock = std::chrono::system_clock;

  // 一个容量为 Cap 的缓存,保存最近使用的 Resource 对象
  std::vector<std::pair<
    decltype(clock::now()),
    std::shared_ptr<Resource>
  >> resources;

  bool full() const { return resources.size() == Cap; }

  // 前置条件:resources 不为空
  void expunge_one() {
    auto p = std::min_element(
      std::begin(resources), std::end(resources),
      [](auto && a, auto && b) {
        return a.first < b.first;
      }
    );

    assert(p != std::end(resources));
    p->second.reset(); // 放弃对资源的所有权
    resources.erase(p);
  }

public:
  void add(Resource *p) {
    const auto t = clock::now();

    if(full()) {
      expunge_one(); // 如果缓存已满,移除一个资源
    }

    resources.emplace_back(
      t, std::shared_ptr<Resource>{ p }
    );
  }

  std::weak_ptr<Resource> obtain(Resource::id_type id){
    const auto t = clock::now();
    auto p = std::find_if(
      std::begin(resources),
      std::end(resources),
      [id](auto && p) {
        return p.second->id() == id;
      }
    );

    if(p == std::end(resources))
      return {};

    p->first = t;
    return p->second; // 从 shared_ptr 创建 weak_ptr
  }
};

int main() {
  Cache<5> cache;
  for(int i = 0; i != 5; ++i)
    cache.add(new Resource{ i + 1 });

  // 我们获取指向资源 3 的指针
  auto p = cache.obtain(3);
  if(auto q = p.lock(); q)
    std::cout << "Using resource " << q->id() << '\n';

  // 后续发生了一些操作,资源被添加、使用等
  for(int i = 6; i != 15; ++i)
    cache.add(new Resource{ i + 1 });
  if(auto q = p.lock(); q)
    std::cout << "Using resource " << q->id() << '\n';
  else
    std::cout << "Resource not available ...\n";
}

当缓存经过足够多次的添加后,main() 函数中的指针 p 所指向的对象会标记为失效并从资源集合中移除(这是本示例的核心需求之一 —— 如果没有这一需求,完全可以使用 std::shared_ptr)。但此时,main() 仍然可以通过持有的 std::weak_ptr 构造 std::shared_ptr 来检测对象的有效性。

实际应用中,weak_ptr 常用于打破 shared_ptr 之间的循环引用。若两个类型的对象(假设为 X 和 Y)相互持有对方的指针,且无法确定哪个对象会先销毁,则可以考虑将其中一个指针设为所有者(shared_ptr),另一个设为可验证的非所有者(weak_ptr)。

这样可以避免两个对象因互相持有引用而导致内存泄漏。以下代码虽然会正常结束,但 X 和 Y 的析构函数永远不会调用:

#include <memory>
#include <iostream>

struct Y;
struct X {
  std::shared_ptr<Y> p;
  ~X() { std::cout << "~X()\n"; }
};

struct Y {
  std::shared_ptr<X> p;
  ~Y() { std::cout << "~Y()\n"; }
};

void oops() {
  auto x = std::make_shared<X>();
  auto y = std::make_shared<Y>();
  x->p = y;
  y->p = x;
}

int main() {
  oops();
  std::cout << "Done\n";
}

如果将X::p或Y::p更改为weak_ptr,将看到X和Y析构函数都被调用:

#include <memory>
#include <iostream>

struct Y;
struct X {
  std::weak_ptr<Y> p;
  ~X() { std::cout << "~X()\n"; }
};

struct Y {
  std::shared_ptr<X> p;
  ~Y() { std::cout << "~Y()\n"; }
};

void oops() {
  auto x = std::make_shared<X>();
  auto y = std::make_shared<Y>();
  x->p = y;
  y->p = x;
}

int main() {
  oops();
  std::cout << "Done\n";
}

当然,避免陷入 shared_ptr<T> 循环引用问题的最简单方法,一开始就不要构建这种循环。但在使用第三方库或工具时,这往往知易行行难。