🏠 总目录📚 本教程 02 · 值语义与所有权 ← →
📑 本页目录(点开跳转)

02 · 值语义与所有权:谁负责释放

⏱ 90 分钟 | ⭐ Python 的 = 默认共享,C++ 的 = 默认复制 —— 两边的默认值正好反着


🎯 一句话

在 Python 里你从来不用回答「谁负责释放」,因为语言替你答了;到了 C++,这个问题必须由代码里的某个符号明确回答 —— 而源码里那些 const Tensor&、std::move、unique_ptr 就是答案本身。 读懂它们不需要会写 C++,只需要知道每一个符号在说「我拿走了」还是「我只是借来看看」。


🧩 一、同一个问题被切成了三块,这一章只管第三块

你在站内会撞见三个长得很像、其实完全不同的现象。它们分属三个板块,先把界划清楚,免得你在这一章找一个不在这一章的东西。

现象 一句话 归哪一章
名字绑到对象 b = a 之后 a is b 为真,改 b 就是改 a 《Python 会咬你的地方》01
缓冲区被共享 y = x[:2] 之后是两个不同的对象,但它们指着同一块内存 《NumPy 与向量化思维》04
⭐ 谁负责释放 这块内存什么时候消失、由谁决定 这一章

前两块 Python 都替你处理了,所以你从来没被问过第三个问题。下面这段把前两块跑出来,好让你确认自己认得它们 —— 然后这一章就再也不碰它们了:

# c02_three.py
import sys

# ① Python 的 = :给同一个对象再挂一个名字
a = [1, 2, 3]
b = a
b.append(4)
print("① Python:  a =", a, " b =", b, " 同一个对象吗:", a is b, " id 相同吗:", id(a) == id(b))

# ② NumPy 的切片:两个不同的对象,共用同一块缓冲区
import numpy as np
x = np.array([1, 2, 3, 4])
y = x[:2]
y[0] = 99
print("② NumPy :  x =", x, " y =", y, " 同一个对象吗:", x is y, " y 借的是谁的内存:", y.base is x)

# ③ 引用计数:Python 自己数
c = []
print("③ refcount 一开始:", sys.getrefcount(c) - 1)   # 减掉传参那一次
d = c
print("   多挂一个名字后:", sys.getrefcount(c) - 1)
del d
print("   del 掉之后    :", sys.getrefcount(c) - 1)

实跑输出:

操作步骤

  1. Python: a = [1, 2, 3, 4] b = [1, 2, 3, 4] 同一个对象吗: True id 相同吗: True
  2. NumPy : x = [99 2 3 4] y = [99 2] 同一个对象吗: False y 借的是谁的内存: True
  3. refcount 一开始: 1
  4. 多挂一个名字后: 2
  5. del 掉之后 : 1

⭐ 第 ③ 段是这一章的引子。 Python 内置了一个计数器:每多一个名字指向某个对象,计数 +1;每少一个,计数 -1;归零就释放。这件事你从来没写过一行代码去做它,它是语言的一部分。

⭐ C++ 没有这个内置计数器。 所以「什么时候释放」必须写在代码里,而 C++ 提供了好几种写法 —— 源码里那一堆符号,就是不同的写法。


🧩 二、⚠️ 第一个反直觉:C++ 的 = 默认是复制

这是从 Python 下来的人最容易读错的一处,而且它不报错,只是让你把源码理解反。

// c02_copy.cpp
// g++ -O2 -std=c++17 c02_copy.cpp -o c02_copy
#include <cstdio>
#include <vector>
#include <string>
#include <utility>

struct Loud {
    std::string name;
    std::vector<double> data;

    Loud(std::string n, size_t k) : name(std::move(n)), data(k, 1.0) {
        std::printf("  [构造]     %s  (%zu 个 double)\n", name.c_str(), data.size());
    }
    Loud(const Loud& o) : name(o.name + "-拷贝"), data(o.data) {
        std::printf("  [拷贝构造] %s  <- %s   ⚠️ 复制了 %zu 个 double\n",
                    name.c_str(), o.name.c_str(), data.size());
    }
    Loud(Loud&& o) noexcept : name(std::move(o.name)), data(std::move(o.data)) {
        std::printf("  [移动构造] %s   ⭐ 只搬了指针,原对象现在有 %zu 个\n",
                    name.c_str(), o.data.size());
    }
    ~Loud() {
        std::printf("  [析构]     %s\n", name.empty() ? "(已被搬空)" : name.c_str());
    }
};

void take_by_value(Loud x)        { std::printf("    -> 函数里拿到 %s\n", x.name.c_str()); }
void take_by_ref(const Loud& x)   { std::printf("    -> 函数里拿到 %s(没拷贝)\n", x.name.c_str()); }

int main() {
    std::printf("① 传值:\n");
    { Loud a("a", 3); take_by_value(a); }

    std::printf("\n② 传 const 引用:\n");
    { Loud b("b", 3); take_by_ref(b); }

    std::printf("\n③ 传值 + std::move:\n");
    { Loud c("c", 3); take_by_value(std::move(c)); }
    return 0;
}

实跑输出(g++ 15.1.0,-O2 -std=c++17):

操作步骤

① 传值:
[构造] a (3 个 double)
[拷贝构造] a-拷贝 <- a ⚠️ 复制了 3 个 double
-> 函数里拿到 a-拷贝
[析构] a-拷贝
[析构] a
② 传 const 引用:
[构造] b (3 个 double)
-> 函数里拿到 b(没拷贝)
[析构] b
③ 传值 + std::move:
[构造] c (3 个 double)
[移动构造] c ⭐ 只搬了指针,原对象现在有 0 个
-> 函数里拿到 c
[析构] c
[析构] (已被搬空)

⭐ 三件事一次看清:

📋 两边的默认值对照:

b = a 的意思 改 b 会影响 a 吗
Python 给同一个对象再挂一个名字 ✅ 会(它们本来就是一个东西)
C++ 把 a 整个复制一份给 b ❌ 不会(两份互不相干)

⚠️ 所以从 Python 带过来的直觉在这里会给你两种相反的错:以为「不会拷贝」的地方悄悄拷贝了一整个矩阵(性能问题),以为「会跟着变」的地方根本没变(正确性问题)。


🧩 三、于是源码里到处是 const Tensor&

上一节的输出只有 3 个 double,看不出代价。把规模放大量一下 —— 每个 vector 约 78 KB,5000 个:

// c02_cost.cpp
// g++ -O2 -std=c++17 c02_cost.cpp -o c02_cost
#include <cstdio>
#include <vector>
#include <chrono>
#include <utility>

using Clock = std::chrono::steady_clock;
static double ms_since(Clock::time_point t) {
    return std::chrono::duration<double, std::milli>(Clock::now() - t).count();
}

int main() {
    const size_t N = 10'000;     // 每个 vector 1 万个 double ≈ 78 KB
    const int    R = 5000;

    // 计时之外先把 5000 个源 vector 都造好,两组用的是同样的东西
    std::vector<std::vector<double>> poolA(R, std::vector<double>(N, 3.0));
    std::vector<std::vector<double>> poolB(R, std::vector<double>(N, 3.0));

    std::vector<std::vector<double>> bag;
    double sink = 0;

    // ① 拷贝:源对象还留着,所以必须重新分配一块再逐字节复制
    bag.clear(); bag.reserve(R);
    auto t = Clock::now();
    for (int i = 0; i < R; ++i) bag.push_back(poolA[i]);              // ⚠️ 拷贝构造
    double t_copy = ms_since(t);
    sink += bag[0][0] + poolA[0].size();       // ⭐ 源还在,size 仍是 N

    // ② 移动:告诉编译器「源我不要了」,于是只搬指针
    bag.clear(); bag.reserve(R);
    t = Clock::now();
    for (int i = 0; i < R; ++i) bag.push_back(std::move(poolB[i]));   // ⭐ 移动构造
    double t_move = ms_since(t);
    sink += bag[0][0];

    std::printf("%d 个 vector,每个 %.0f KB(合计 %.0f MB)\n",
                R, N * sizeof(double) / 1024.0, R * N * sizeof(double) / 1048576.0);
    std::printf("① 拷贝进 bag: %8.2f ms\n", t_copy);
    std::printf("② 移动进 bag: %8.2f ms\n", t_move);
    std::printf("   倍数      : %8.1f x\n", t_copy / t_move);
    std::printf("移动之后,源 poolB[0] 的 size = %zu(被搬空了)\n", poolB[0].size());
    std::printf("拷贝之后,源 poolA[0] 的 size = %zu(还在)\n", poolA[0].size());
    std::printf("(sink=%.1f)\n", sink);
    return 0;
}

跑三次的实跑结果:

次 ① 拷贝 ② 移动 倍数
1 264.02 ms 0.02 ms 16197x
2 280.50 ms 0.02 ms 17104x
3 300.58 ms 0.02 ms 18107x

⭐ 倍数大到没什么意义,重点是它的形状:移动那一栏基本不随数据量增长 —— 它每次只搬 3 个指针(起点、终点、容量上限),跟里面装 1 万个还是 1 亿个 double 无关。拷贝那一栏则是老老实实分配 381 MB 再逐字节复制。

⚠️ 顺带一个诚实的记录:我最早的版本还量了「传 const&」这一组,结果是 0.00 ms —— 编译器在 -O2 下把整个调用优化没了,算出来的倍数是几万到几百万,纯属噪声。所以正文里的对照换成了上面这组:两边都真的把东西放进了 bag,只有「要不要复制」这一点不同。

🚦 读法:把 & 当成一句声明

这就是为什么 PyTorch 的 C++ 源码里,函数签名几乎清一色长这样:

Tensor add(const Tensor& self, const Tensor& other, const Scalar& alpha);

读它的时候在心里翻译成:

你看到的 它在说
const Tensor& 「借我看看,我不改它,也不负责释放它」 —— 出了这个函数它还是外面那个
Tensor& 「借我用,我会改它」 —— 外面能看见改动
Tensor 「给我一份」 —— 要么复制、要么你 move 给我,反正这份归我了
Tensor&& 「我只收你不要了的那种」

⭐ 这四行是本章最实用的一张表。 读源码卡住时,八成是把某个 & 读漏了。


🧩 四、std::move 不移动任何东西

这个名字起得很坏,坑过所有人。

⭐ std::move(x) 在运行时什么都不做。 它是一个纯粹的类型转换 —— 把 x 标记成「这是一个可以被掏空的东西」,好让编译器在重载里挑中移动那个版本而不是拷贝那个版本。真正搬东西的是移动构造函数/移动赋值,不是 std::move 本身。

所以更准确的读法是:std::move(x) = 一句承诺,「x 我之后不用了,随你处置」。

⚠️ 承诺一旦给出去,就必须遵守。 上一节输出里那两行是硬证据:

要点

移动之后,源 poolB[0] 的 size = 0(被搬空了)

拷贝之后,源 poolA[0] 的 size = 10000(还在)

💀 被 move 走的对象处于「有效但未指定」状态 —— 它还是一个合法对象(析构它是安全的,给它重新赋值也是安全的),但里面装的是什么没有任何保证。上面碰巧是 0,是因为 std::vector 的移动实现会把源置空;换一个类型可能就不是 0。

📋 两条能保命的规矩:

规矩 为什么
⭐ move 之后,只允许对它做两件事:重新赋值,或者让它析构 读它的内容是「合法但结果没有保证」,最难查
⚠️ 不要对准备继续用的东西 move 编译器不会拦你,症状是后面某处数据莫名变空

🛑 读到这里可以停 —— 前半章讲完了(约 31 分钟)。 后半章还有:RAII:C++ 版的 with · 四种指针,四种所有权声明 · 💀 悬垂:这一章真正存在的理由 · ⭐ 于是 Tensor 长成了一个「把手」 回来的时候不用重读,直接从下一节接着看就行。


🧩 五、RAII:C++ 版的 with

现在回答开头那个问题:既然 C++ 没有内置的引用计数,那释放靠什么?

⭐ 靠作用域。 一个对象在离开它所在的大括号时,析构函数一定会被调用。于是 C++ 的惯用法是:把「要还回去的东西」包成一个对象,让析构函数去还。 这套做法有个名字叫 RAII(Resource Acquisition Is Initialization,资源获取即初始化)。

// c02_raii.cpp
// g++ -O2 -std=c++17 c02_raii.cpp -o c02_raii
#include <cstdio>
#include <stdexcept>
#include <memory>

struct Guard {                       // 一个最小的 RAII 对象
    const char* what;
    explicit Guard(const char* w) : what(w) { std::printf("  拿到 %s\n", what); }
    ~Guard()                                { std::printf("  还回 %s\n", what); }
};

void may_throw(bool boom) {
    Guard g("锁");                   // ⭐ 出这个函数时一定还锁,不管怎么出去
    std::printf("  干活中…\n");
    if (boom) throw std::runtime_error("中途炸了");
    std::printf("  正常干完\n");
}

struct Node {
    int id;
    explicit Node(int i) : id(i) { std::printf("  Node%d 出生\n", id); }
    ~Node()                      { std::printf("  Node%d 死亡\n", id); }
};

int main() {
    std::printf("① 正常返回:\n");
    may_throw(false);

    std::printf("\n② 抛异常:\n");
    try { may_throw(true); }
    catch (const std::exception& e) { std::printf("  接住了:%s\n", e.what()); }

    std::printf("\n③ unique_ptr(唯一所有者):\n");
    {
        std::unique_ptr<Node> p = std::make_unique<Node>(1);
        std::unique_ptr<Node> q = std::move(p);          // ⭐ 所有权搬走,不能拷贝
        std::printf("  p 现在是空的吗: %s\n", p ? "不是" : "是");
        std::printf("  q 指着 Node%d\n", q->id);
    }                                                     // ⭐ q 出作用域 -> Node1 死

    std::printf("\n④ shared_ptr(大家共有,最后一个走的关灯):\n");
    {
        std::shared_ptr<Node> a = std::make_shared<Node>(2);
        std::printf("  持有者 %ld 个\n", a.use_count());
        {
            std::shared_ptr<Node> b = a;                  // ⭐ 拷贝 = 计数 +1,不复制 Node
            std::printf("  多了一个 b,持有者 %ld 个\n", a.use_count());
        }
        std::printf("  b 走了,持有者 %ld 个(Node2 还活着)\n", a.use_count());
    }
    std::printf("  a 也走了 ↑\n");
    return 0;
}

实跑输出:

操作步骤

  1. 正常返回:
  2. 拿到 锁
  3. 干活中…
  4. 正常干完
  5. 还回 锁
  6. 抛异常:
  7. 拿到 锁
  8. 干活中…
  9. 还回 锁
  10. 接住了:中途炸了
  11. unique_ptr(唯一所有者):
  12. Node1 出生
  13. p 现在是空的吗: 是
  14. q 指着 Node1
  15. Node1 死亡
  16. shared_ptr(大家共有,最后一个走的关灯):
  17. Node2 出生
  18. 持有者 1 个
  19. 多了一个 b,持有者 2 个
  20. b 走了,持有者 1 个(Node2 还活着)
  21. Node2 死亡
  22. a 也走了 ↑

⭐ ①② 的对比是 RAII 的全部价值:抛异常那一路,「干完」那行没打印,但「还回 锁」打印了,而且是在 catch 接住之前。作用域一结束析构就跑,正常返回和异常离开走的是同一条路。

📋 和 Python 的对应关系:

Python C++ 差别
with open(f) as x: { std::ifstream x(f); ... } Python 要你记得写 with;C++ 是只要对象在栈上就自动生效
__exit__ 析构函数 同一个位置
try / finally 析构函数 C++ 没有 finally,因为 RAII 覆盖了它的用途

⭐ 回扣 01 章:那一章说 py::gil_scoped_release 是 RAII 对象、所以「哪怕函数中途抛异常锁也一定会被还回去」—— 上面 ② 那三行输出就是这句话的实况。构造时放开锁,析构时拿回来,中途炸了照样拿回来。


🧩 六、四种指针,四种所有权声明

unique_ptr / shared_ptr 那两段(③④)演示的是同一件事的两种回答。把它们和裸指针、引用放在一起:

你在源码里看到 谁负责释放 能不能拷贝 读作
T*(裸指针) ⚠️ 文档说了算,代码里看不出来 能(但拷的只是地址) 「这里有个地址,别的什么都别假设」
T&(引用) ❌ 不是我 —— 「借我看看,出了这个函数它还归外面」
std::unique_ptr<T> ⭐ 我,而且只有我 ❌ 不能拷贝,只能 move 「这东西归我,要给别人就得 std::move」
std::shared_ptr<T> 最后一个走的人 ✅ 能(计数 +1) 「大家共有,谁都可能是最后一个」
std::weak_ptr<T> ❌ 不是我 ✅ 能 「我盯着它,但不延长它的命,用之前得先问它还在不在」

⭐ 读源码时把这一列当成注释读,比看真注释可靠 —— 注释会过期,类型不会,编译器会强制它成立。三条最有信息量的:

⚠️ shared_ptr 不是「更安全的 unique_ptr」,别无脑用它:它要多存一个控制块(计数在那里),计数的加减是原子操作,多线程下会真的争抢缓存行(03 章会看到这件事的代价)。默认选 unique_ptr,确实说不清谁最后走才用 shared_ptr。


🧩 七、💀 悬垂:这一章真正存在的理由

前面都是「怎么写对」。这一节是「写错了会怎样」,而它的可怕之处不是崩溃,是不崩溃。

// c02_dangle.cpp
// g++ -O2 -std=c++17 c02_dangle.cpp -o c02_dangle
#include <cstdio>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};
    const int* p = v.data();                 // ⚠️ 记住这个地址
    std::printf("push 之前: data=%p  capacity=%zu  p[0]=%d\n",
                (const void*)p, v.capacity(), p[0]);

    for (int i = 0; i < 100; ++i) v.push_back(i);   // 容量不够 -> 换一块更大的地方

    std::printf("push 之后: data=%p  capacity=%zu\n",
                (const void*)v.data(), v.capacity());
    std::printf("地址变了吗: %s\n", p == v.data() ? "没变" : "⚠️ 变了");
    std::printf("v[0] 从新地址读 = %d  (正确)\n", v[0]);
    // ⚠️⚠️ 下面这一行是【未定义行为】:p 指着已经被释放的那块内存
    std::printf("p[0] 从旧地址读 = %d  (💀 未定义行为,本次没崩)\n", p[0]);
    return 0;
}

连跑三次的实跑输出(只摘关键行):

次 v[0] 从新地址读 p[0] 从旧地址读 退出码
1 10 618801184 0
2 10 -283301856 0
3 10 -1883166688 0

💀💀 三次跑出三个完全不同的垃圾数,一次都没崩,退出码全是 0。

⭐ 这就是「未定义行为」的真实模样,也是这一章存在的全部理由:

Python 出错时 C++ 悬垂时
有没有异常 有,IndexError / AttributeError ❌ 没有
有没有 traceback 有,指到出错那一行 ❌ 没有
能不能复现 能 ⚠️ 时好时坏,加个 printf 可能就"好了"
错在哪一行 就是报错那一行 ⚠️ 可能在几百行之前 —— 崩的地方和错的地方无关

⚠️ 注意它出错的机制:p 拿到地址的那一刻是完全合法的;是后来的 push_back 让 vector 换了一块更大的内存,把旧的那块还给了系统。p 自己什么都没做错,是它指着的东西跑了。

📋 跨语言时最常见的三种悬垂(全是 01 章第五节那个坑的展开):

情形 谁跑了
C++ 那边存了 float* ptr = arr.data(),Python 那边这个数组被回收了 数组的内存
C++ 存了指向某个元素的指针,容器随后扩容 / 重排 整块缓冲区搬家了(就是上面这个实验)
函数返回了一个指向局部变量的引用或指针 局部变量随函数栈帧一起消失

🚦 对照:Python 遇到同一种情况会拦住你

Python 不是没有这个问题,是它加了一道锁:只要还有人在看着某块缓冲区,就不许把它挪走。

# c02_buffer.py
buf = bytearray(b"hello")
mv = memoryview(buf)          # ⭐ 有人在看着这块内存了
try:
    buf.extend(b"world")      # 会导致重新分配 —— 正是上面 C++ 那个实验干的事
except BufferError as e:
    print("BufferError:", e)
mv.release()                  # 看完了,松手
buf.extend(b"world")
print("release 之后再 extend 就成功了:", bytes(buf))

实跑输出:

要点

BufferError: Existing exports of data: object cannot be re-sized

mv.release() 之后再 extend 就成功了: b'helloworld'

⭐ 同样是「有人指着我,而我想换个地方」,Python 抛了一个能看见的异常,C++ 打印了 618801184 然后正常退出。 这一条差别,就是你为什么必须自己去读那些 const& 和 unique_ptr。


🛑 读到这里可以停 —— 已经读了约 57 分钟。 最后一段还有(约 31 分钟):⭐ 于是 Tensor 长成了一个「把手」 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。


🧩 八、⭐ 于是 Tensor 长成了一个「把手」

把前面几节合起来,能解释一个你天天在用、但可能没想过为什么的现象:PyTorch 里 b = a 从来不复制数据。

在 C++ 那一侧,at::Tensor 不是一个装着数据的大对象,它是一个只有一个指针那么大的把手,真正的数据挂在它指向的那个实现对象上。用标准库的 shared_ptr 可以把这个形状完整复现出来:

// c02_handle.cpp
// g++ -O2 -std=c++17 c02_handle.cpp -o c02_handle
#include <cstdio>
#include <vector>
#include <string>
#include <memory>

// 真正装数据的那个东西
struct Impl {
    std::vector<float> data;
    explicit Impl(size_t n) : data(n, 1.0f) { std::printf("  Impl(%zu) 出生\n", n); }
    ~Impl()                                 { std::printf("  Impl 死亡\n"); }
};

// 外面这层只是一个「把手」:里面就一个指针
struct Handle {
    std::shared_ptr<Impl> impl;
    explicit Handle(size_t n) : impl(std::make_shared<Impl>(n)) {}
    size_t size() const { return impl->data.size(); }
    long   owners() const { return impl.use_count(); }
};

int main() {
    std::printf("sizeof(std::vector<float>) = %zu 字节\n", sizeof(std::vector<float>));
    std::printf("sizeof(std::string)        = %zu 字节\n", sizeof(std::string));
    std::printf("sizeof(std::shared_ptr<Impl>) = %zu 字节\n", sizeof(std::shared_ptr<Impl>));
    std::printf("sizeof(std::unique_ptr<Impl>) = %zu 字节\n", sizeof(std::unique_ptr<Impl>));
    std::printf("sizeof(Handle)             = %zu 字节  ⭐ 和里面装多少数据无关\n\n",
                sizeof(Handle));

    Handle a(1'000'000);                       // 100 万个 float
    std::printf("a 有 %zu 个 float,持有者 %ld\n", a.size(), a.owners());

    Handle b = a;                              // ⭐ 拷贝「把手」,不拷贝那 100 万个 float
    std::printf("b = a 之后,持有者 %ld,b 也有 %zu 个\n", b.owners(), b.size());
    std::printf("a 和 b 指着同一块数据吗: %s\n",
                a.impl->data.data() == b.impl->data.data() ? "是" : "否");
    std::printf("主函数结束 ↓\n");
    return 0;
}

实跑输出:

算一算

sizeof(std::vector<float>) = 24 字节

sizeof(std::string) = 32 字节

sizeof(std::shared_ptr<Impl>) = 16 字节

sizeof(std::unique_ptr<Impl>) = 8 字节

sizeof(Handle) = 16 字节 ⭐ 和里面装多少数据无关

Impl(1000000) 出生

a 有 1000000 个 float,持有者 1

b = a 之后,持有者 2,b 也有 1000000 个

a 和 b 指着同一块数据吗: 是

主函数结束 ↓

Impl 死亡

⭐ Handle 只有 16 字节,里面装 100 万个 float 也还是 16 字节。 而 b = a 之后持有者变成 2、两边 data() 是同一个地址、Impl 只死了一次 —— 这正是第二节那个「C++ 的 = 默认复制」的例外形状:复制的确发生了,只不过复制的是把手,不是数据。

⚠️ 注意这不是语言的例外,是设计者主动选的:Handle 里唯一的成员是 shared_ptr,而 shared_ptr 的拷贝构造被定义成「计数 +1」。默认规则没变,是这个类的成员选择让「复制它」变得很便宜。

Python 那一侧看得到同一件事:

# c02_torch_ptr.py
import torch
x = torch.ones(1000)
y = x
z = x.clone()
print("y = x    -> data_ptr 相同:", x.data_ptr() == y.data_ptr())
print("z = clone-> data_ptr 相同:", x.data_ptr() == z.data_ptr())

实跑输出(torch 2.13.0+cpu):

关键信息

y = x -> data_ptr 相同: True
z = clone-> data_ptr 相同: False

⭐ 于是「显存为什么没释放」这类问题有了 C++ 侧的解释:del x 只是让 Python 少一个名字;底下那块显存要等持有者计数归零才真的还回去,而计数里可能还有别人 —— 一个还没被回收的中间结果、一个存进 list 的张量、一段计算图。⚠️ 具体怎么省显存不在这一章,AI 基础设施 09 有完整的账。


🔗 这一章连到哪里

相关的地方 为什么
《Python 会咬你的地方》01 三方分工的第一块:「名字绑到对象」。b = a 之后 a is b 为真是那边的正题,本章第一节只是引用它来划界
《NumPy 与向量化思维》04 三方分工的第二块:「缓冲区被共享」。y.base is x 为什么成立、什么时候是视图什么时候是拷贝,全在那边
01 章 那一章第五节最后一个坑写着「C++ 存了指向 Python 对象内存的指针,而 Python 那边已经把它回收了 —— 谁负责释放是 02 章的正题」,⭐ 第七节就是那句话的兑现;第五节还解释了 gil_scoped_release 为什么抛异常也能还锁
03 章 第六节说 shared_ptr 的原子计数「多线程下会真的争抢缓存行」—— 那件事的代价在下一章量出来
AI 基础设施 09 第八节说显存要等持有者计数归零;怎么让它归零、能省多少是那一章的账,本章不重算
《PyTorch 这个框架本身》 state_dict、detach、计算图什么时候放手,是 Python 接口那一侧的事

✅ 检查点

  1. 「名字绑到对象」「缓冲区被共享」「谁负责释放」分别归哪一章?本章管哪一个?
  2. Python 和 C++ 的 b = a 默认行为分别是什么?从 Python 带过来的直觉会造成哪两种相反的错?
  3. c02_copy.cpp 里,传值、传 const&、传值+std::move 三种写法各触发了什么?「原对象现在有 0 个」这句话说明了什么?
  4. 5000 个 78 KB 的 vector,拷贝进容器和移动进容器实跑各是多少毫秒?移动那一栏为什么基本不随数据量增长?
  5. 为什么正文最后没有拿「传 const&」那一组做对照?
  6. std::move 在运行时做了什么?被 move 走的对象之后只允许对它做哪两件事?
  7. RAII 是什么?c02_raii.cpp 抛异常那一路,哪一行输出证明了它有效?C++ 为什么不需要 finally?
  8. T* / T& / unique_ptr / shared_ptr / weak_ptr 各自在声明「谁负责释放」的什么答案?weak_ptr 最常用来解决什么问题?
  9. c02_dangle.cpp 三次跑出的旧地址读数分别是多少?退出码是多少?这说明「未定义行为」长什么样?
  10. 同样是「有人指着我而我想换地方」,Python 的 memoryview + bytearray 会发生什么?
  11. sizeof(Handle) 是多少字节?为什么它和里面装 100 万个 float 无关?这解释了 PyTorch 里的哪个现象?
👀 答案
  1. 「名字绑到对象」 → 《Python 会咬你的地方》01;「缓冲区被共享」 → 《NumPy 与向量化思维》04;「谁负责释放」 → 本章。前两块 Python 都替你处理了,所以你从来没被问过第三个问题。
  2. Python 的 b = a 是给同一个对象再挂一个名字(a is b 为真,改 b 就是改 a);C++ 的 b = a 是把 a 整个复制一份(两份互不相干)。两种相反的错:以为不会拷贝的地方悄悄拷贝了一整个矩阵(性能问题),以为会跟着变的地方根本没变(正确性问题)。
  3. 传值 → 拷贝构造(那 3 个 double 被逐个复制,函数结束时副本单独析构);传 const Loud& → 一次拷贝都没有,全程只有一次构造一次析构;传值+std::move → 移动构造。「原对象现在有 0 个」说明数据被搬走了、源对象空了。
  4. 拷贝 264.02 / 280.50 / 300.58 ms,移动 0.02 ms(三次都是),倍数 16197x / 17104x / 18107x。移动只搬 3 个指针(起点、终点、容量上限),跟里面装 1 万个还是 1 亿个 double 无关;拷贝要真的分配 381 MB 再逐字节复制。
  5. 因为编译器在 -O2 下把那个调用整个优化没了,量出来是 0.00 ms,算出的倍数是几万到几百万,纯属噪声。换成的对照里两边都真的把东西放进了容器,只差「要不要复制」这一点。
  6. 运行时什么都不做 —— 它只是一个类型转换,把 x 标记成「可以被掏空」,好让编译器挑中移动版本的重载。真正搬东西的是移动构造/移动赋值。之后只允许:重新赋值,或者让它析构。读它的内容是「有效但未指定」。
  7. RAII = 把「要还回去的东西」包成对象,让析构函数去还;对象离开大括号时析构一定执行。抛异常那一路,「正常干完」没打印但「还回 锁」打印了,而且在 catch 接住之前。不需要 finally,是因为析构函数已经覆盖了它的用途。
  8. T*:代码里看不出来,文档说了算;T&:不是我负责,借来看看;unique_ptr:我,而且只有我,不能拷贝只能 move;shared_ptr:最后一个走的人关灯;weak_ptr:不是我,盯着但不延长它的命。weak_ptr 最常用来打破循环引用(两个 shared_ptr 互指,计数永远归不了零就泄漏)。
  9. 618801184 / -283301856 / -1883166688,三次全不一样;退出码全是 0,一次都没崩。所以未定义行为的样子是:没有异常、没有 traceback、时好时坏、崩的地方和错的地方无关(可能在几百行之前)。
  10. Python 会抛 BufferError: Existing exports of data: object cannot be re-sized —— 只要还有人在看着这块缓冲区就不许挪。mv.release() 之后同样的 extend 就成功了。同一种情况,Python 抛了个看得见的异常,C++ 打印了 618801184 然后正常退出。
  11. 16 字节,因为它唯一的成员是一个 shared_ptr(sizeof 也是 16),数据挂在它指向的实现对象上。这解释了 PyTorch 里 b = a 从不复制数据:实跑中 y = x 之后 data_ptr() 相同、z = x.clone() 之后不同。也解释了 del x 之后显存可能没还 —— 要等持有者计数归零。

🛑 可以停在这里

⚡ 走神救援

🧭 同一个问题被切成三块,这一章只管第三块:「名字绑到对象」归 Python 板块、「缓冲区被共享」归 NumPy 板块、⭐ 「谁负责释放」是本章。⭐ 前两块 Python 都替你答了——它内置引用计数,而 C++ 没有,所以「什么时候释放」必须写在代码里。

⚠️ 第一个反直觉:C++ 的 = 默认是复制,和 Python 正好反着。 ⭐ 代价的形状是:拷贝的耗时随数据量走,移动几乎恒定为零——移动只搬几个指针,与数据量无关。 ⚠️ 诚实记录:其中一组被编译器优化成了 0,倍数纯属噪声,所以对照换了一组。

🔑 ⭐ 最实用的一张表是「把 & 当声明读」:const T& 是「借我看看,不改也不负责释放」、T& 是「借我用,我会改」、T 是「给我一份」、T&& 是「只收你不要了的」。⭐ std::move 运行时什么都不做——它只是个类型转换,一句「我之后不用了」的承诺。

⭐ RAII 是靠作用域还东西:实跑抛异常那一路,「正常干完」没打印,而「还回锁」打印了、且在 catch 之前——⭐ 这就是上一章那个释放锁的原理,也是 C++ 不需要 finally 的原因。

💀 这一章真正的理由是「不崩」:拿到内部指针之后让容器扩容,旧指针读出来的是垃圾——三次全不同,而退出码都是 0。⭐⭐ 未定义行为的样子就是:没异常、没 traceback、时好时坏、崩的地方和错的地方无关。 ⭐ 同一种情况 Python 会拦你,抛的是一句明确的「还有人在用这块内存,不能改大小」。

⭐ 最后一节的回报:那个把手的大小和它里面装了多少数据完全无关——Tensor 就是这个形状。

下一节 👉 03-cache与数据布局.md

打卡记录保存在你的浏览器里,首页能看到总进度