i007.cc

i007.cc

优先队列-降维打击

05.价值资料

std::weak_ptr 的 lock()操作到底做了什么?如果对象已被回收,是返回nullptr?他如何知道对象是否被回收的?

这个问题问到了 weak_ptr 的核心实现,是 Senior 面试里少有人能完整回答的一道题。


先看内存布局

理解 lock() 之前,必须先知道 shared_ptr 和 weak_ptr 共享的控制块长什么样:

make_shared<T>() 之后的内存布局:

┌─────────────────────────────────────────┐
│           Control Block                 │
│  ┌─────────────────────────────────┐   │
│  │  use_count  (atomic<long>) = 1  │   │  ← 强引用计数
│  │  weak_count (atomic<long>) = 1  │   │  ← 弱引用计数(初始为1,含义下面解释)
│  │  deleter                        │   │
│  │  allocator                      │   │
│  └─────────────────────────────────┘   │
│           Object Data (T)              │  ← make_shared 时和控制块在一起
└─────────────────────────────────────────┘
         ↑                    ↑
    weak_ptr 持有          shared_ptr 持有
    (指向控制块)          (指向控制块 + 对象)

 

关键设计:weak_ptr 只持有指向控制块的指针,不持有对象指针。控制块的生命周期独立于对象本身。


use_count 和 weak_count 的语义

两个计数器的精确含义:

use_count  = 当前存活的 shared_ptr 数量

weak_count = 当前存活的 weak_ptr 数量 + 1
             (这个 +1 代表"至少还有一个 shared_ptr 存在"这件事本身)

触发条件:
  use_count  降为 0 → 销毁对象(调用析构函数,释放对象内存)
  weak_count 降为 0 → 销毁控制块(释放控制块内存)

 

为什么 weak_count 初始是 1 而不是 0?因为要把”有 shared_ptr 存在”这件事也计入 weak_count,当最后一个 shared_ptr 被销毁时,use_count 降为 0,同时把 weak_count 减 1。这样只要还有 weak_ptr 存在,weak_count 就不会到 0,控制块就不会被释放。

用时间线来看:

初始:
  shared_ptr a = make_shared<Player>();
  use_count=1, weak_count=1

加一个 weak_ptr:
  weak_ptr<Player> w = a;
  use_count=1, weak_count=2

shared_ptr 销毁:
  a.reset();  或 a 出作用域
  → use_count 降为 0
  → 销毁 Player 对象(析构函数 + 释放内存)
  → weak_count 减 1,变为 1
  → 控制块还活着!

此时 w 还持有一个指向控制块的指针:
  w 知道 use_count=0,对象已死

weak_ptr 也销毁:
  w 出作用域
  → weak_count 减 1,变为 0
  → 释放控制块内存

 


lock() 的完整实现逻辑

现在可以精确解释 lock() 做了什么:

cpp
// 伪代码,贴近 libstdc++ 实际实现
shared_ptr<T> weak_ptr<T>::lock() const noexcept {
    // 关键操作:原子地检查 use_count 并尝试加 1
    // 必须是原子操作,否则有 TOCTOU 竞争条件(下面详细说)

    long count = control_block->use_count.load(memory_order_relaxed);

    while (true) {
        if (count == 0) {
            // 对象已被销毁,返回空的 shared_ptr(等同于 nullptr)
            return shared_ptr<T>();
        }

        // 尝试把 use_count 从 count 改为 count+1(原子 CAS)
        if (control_block->use_count.compare_exchange_weak(
                count,          // expected
                count + 1,      // desired
                memory_order_acquire,
                memory_order_relaxed)) {
            // CAS 成功:我们成功"复活"了一个强引用
            // 此刻对象一定存活,因为 use_count 已经 >= 1
            return shared_ptr<T>(ptr, control_block);
        }
        // CAS 失败:use_count 被其他线程改了,用新值重试
        // count 已被 compare_exchange_weak 更新为当前值
    }
}

 

它如何知道对象是否被回收:直接读控制块里的 use_countuse_count == 0 就意味着对象已销毁。控制块在 weak_ptr 存活期间永远不会被释放,所以这个读操作是安全的。

为什么必须用 CAS 而不是普通读写:这是防止如下竞争条件:

// 危险的非原子实现(假设):

线程1 (lock):                    线程2 (reset shared_ptr):

1. 读 use_count,看到值为 1
                                 2. use_count-- → 变为 0
                                 3. 销毁 Player 对象,释放内存
4. 看到 use_count > 0,决定创建 shared_ptr
5. 返回一个指向已释放内存的 shared_ptr ← 悬空指针!

// CAS 的原子保证:
// 如果 lock() 的 CAS 成功,use_count 就从 N 变成了 N+1
// 线程2 在这之后执行 use_count--,只会让它变成 N
// 对象不会被销毁,因为 use_count 仍然 >= 1

 


make_shared 带来的一个隐蔽副作用

cpp
// 用 new 分配:对象内存和控制块内存是分离的
std::shared_ptr<BigObject> sp(new BigObject());

// 内存布局:
// [Control Block] (独立分配)
// [BigObject]     (独立分配)

// 当 use_count=0 时:
//   → BigObject 析构 + BigObject 内存立刻释放
//   → 控制块继续存在(等 weak_count=0 再释放)

 

cpp
// 用 make_shared:对象和控制块在同一块内存
std::shared_ptr<BigObject> sp = std::make_shared<BigObject>();

// 内存布局:
// [Control Block | BigObject] (一次分配,连续内存)

// 当 use_count=0 时:
//   → BigObject 析构(析构函数被调用)
//   → 但!内存不能释放!因为控制块还在同一块内存里
//   → 必须等 weak_count=0,整块内存才一起释放

// 结论:如果 weak_ptr 的生命周期很长,make_shared 反而会让
//        BigObject 的内存在"逻辑死亡"后继续占用,直到最后一个 weak_ptr 消失

 

实际影响:

cpp
// 场景:资源管理器用 weak_ptr 做缓存,用 make_shared 创建资源
class ResourceCache {
    std::unordered_map<std::string, std::weak_ptr<Texture>> cache_;

    std::shared_ptr<Texture> get(const std::string& name) {
        auto& weak = cache_[name];
        if (auto sp = weak.lock()) return sp;

        // make_shared:如果 cache_ 长期持有 weak_ptr,
        // 即使外部没有人用这张贴图(use_count=0),
        // 贴图的内存(GPU数据)不会被释放,直到 cache_ 清理 weak_ptr
        auto tex = std::make_shared<Texture>(name);
        weak = tex;
        return tex;
    }
};

// 对内存敏感的场景,改用 new 分配:
// auto tex = std::shared_ptr<Texture>(new Texture(name));
// 这样 use_count=0 时,Texture 的大块 GPU 数据立刻释放
// 控制块只是几十字节,晚一点释放无所谓

 


一图总结整个生命周期

创建:make_shared<Player>()
  ┌──────────────────────────────┐
  │  use_count  = 1              │
  │  weak_count = 1              │
  │  [Player data]               │
  └──────────────────────────────┘
      ↑ shared_ptr 指向这里

加 weak_ptr:
  use_count  = 1
  weak_count = 2   (多了一个 weak_ptr)
      ↑ weak_ptr 也指向控制块

shared_ptr 全部销毁(use_count → 0):
  ┌──────────────────────────────┐
  │  use_count  = 0  ✗           │  Player 析构函数被调用
  │  weak_count = 1              │  内存是否释放取决于分配方式
  │  [Player data - 已析构]      │
  └──────────────────────────────┘
  控制块还活着,weak_ptr 可以安全读 use_count

lock() 被调用:
  读到 use_count = 0
  → 返回空 shared_ptr(nullptr)

weak_ptr 销毁(weak_count → 0):
  整块内存最终释放

 


面试里这道题的完整回答就是这三句话的展开:weak_ptr 持有指向控制块的指针,控制块的生命周期由 weak_count 控制,独立于对象本身;lock() 用原子 CAS 检查 use_count 是否为 0 并尝试加 1,保证线程安全的同时不存在 TOCTOU 竞争;use_count == 0 就是”对象已被回收”的唯一判据,因为控制块在 weak_ptr 存活期间永远不会被释放,读它是安全的。

发表回复