在 Python 里写 x = torch.zeros(2, 3),你从来不用想这 24 字节放在哪、什么时候还回去。但 PyTorch 的算子、推理引擎的调度器都是 C++ 写的,它们拿到的只是一个地址和几个整数,释放内存的责任也落在某段代码头上。这篇从”一个地址”出发,一路走到 unique_ptr 和 std::move:每一步都是上一步的做法在某处坏掉了,才被迫引入的。

前置:Python 对象模型

例子都在 macOS(Apple Silicon,Apple clang 21 + libc++,clang++ -std=c++20)上实际编译运行,地址每次运行都会变。

地址、指针与引用

内存是一排编了号的字节,号码就是地址。&a 取出变量 a 的起始地址;存着地址的变量叫指针(pointer),*p 顺着地址去访问,叫解引用。引用(reference)int& r = a; 则是给 a 起的另一个名字,定义时必须绑定,之后不能改绑。

为什么需要它们?因为 C++ 的参数默认按值传递,函数拿到的是副本:

void add_one_by_value(int x) { x += 1; }      // 改的是副本
void add_one_by_pointer(int* p) { *p += 1; }
void add_one_by_ref(int& r) { r += 1; }
 
int a = 41;  int* p = &a;  int& r = a;
// ... 打印,再依次调用三个函数
int b = 100;
p = &b;   // 指针可以改指向
r = b;    // 引用不能改绑:这句是把 b 的值写进 a
a = 41, sizeof(a) = 4
&a = 0x16d78681c, p = 0x16d78681c
*p = 41, sizeof(p) = 8
&r == &a ? yes
after by_value:   a = 41
after by_pointer: a = 42
after by_ref:     a = 43
a = 100, *p = 100

64 位机器上指针固定 8 字节,不管指向什么。&r == &a 说明引用没有自己的存储身份,它就是 a。指针可以是 nullptr、可以改指向,引用都不行,所以惯例是”可能没有”的参数用指针,“一定有”的用引用。

和 Python 对照:Python 的每个名字都像一根自动管理的指针,b = a 只是让两个名字指向同一个对象。C++ 把这件事拆开了,int b = a 是复制值,int* b = &a 才是共享,你得自己选。

停一下,检查理解单选

执行 int a = 1; int b = 2; int& r = a; r = b; b = 3; 之后,a 是多少?

连续内存:多维数组只是一个换算公式

数组元素在内存里紧挨着。数组名在表达式里会退化成首元素指针,而 p[i] 就是 *(p + i)。关键细节是:指针 +1 前进的不是 1 个字节,而是 sizeof(元素) 个字节。

float arr[6] = {0, 1, 2, 3, 4, 5};
float* p = arr;
// 把 6 个连续 float 当成 2x3 行优先矩阵:(i, j) -> p[i*stride0 + j*stride1]
const int stride0 = 3, stride1 = 1;
sizeof(float) = 4
(char*)(p+1) - (char*)p = 4 bytes
p[3] = 3, *(p+3) = 3
as 2x3 (stride 3,1):
   0   1   2
   3   4   5
transposed 3x2 (stride 1,3):
   0   3
   1   4
   2   5

内存里永远只有一条 6 个 float 的带子。“2×3 矩阵”是公式 offset = i*stride0 + j*stride1 给出的解释,转置只是交换两个 stride,一个字节都没搬。PyTorch 的 tensor 正是这样:x.t() 和 x 的 data_ptr() 相同,stride 从 (3, 1) 变成 (1, 3),is_contiguous() 变成 False。view 与 contiguous 的规则在 资源核算与 Scaling Laws 里展开。

这对写扩展的人是个陷阱。C++ 或 CUDA kernel 往往只拿到 data_ptr<float>() 这一个裸指针,下标要自己按 stride 算;如果 kernel 假设了行优先,收到的却是转置过的 tensor,它不会报错,只会安静地读错位。这就是很多扩展在入口先调 .contiguous() 的原因。连续存储为什么对缓存友好,见 内存层次与缓存。

停一下,检查理解多选

一个 kernel 假设输入是行优先连续的 (M, N) 矩阵,按 ptr[i*N + j] 读取。传入 x.t()(x 是连续的 (N, M))会怎样?

栈与堆:对象活多久

有了地址,下一个问题是这块内存从哪来、什么时候还。C++ 有两个主要来源。

栈(stack)放局部变量。每个线程有一段预留好的栈内存,进入函数时把栈指针减去所有局部变量的总大小,返回时加回来,分配和释放各是一条指令(栈帧的细节见 机器级程序)。代价是大小在编译期就要定,空间也小:macOS 主线程的栈约 8 MB。分别在堆上和栈上开 40 MB 的 float 数组:

void on_heap()  { std::vector<float> buf(10'000'000); buf[9'999'999] = 2; /* 打印 ok */ }
void on_stack() { volatile float buf[10'000'000];     buf[9'999'999] = 2; /* 打印 ok */ }
$ ulimit -s
8176
$ ./main; echo "exit=$?"
heap 40 MB ok
exit=139

栈版本越过了栈的边界,访问到不可访问的页,进程收到 SIGSEGV(退出码 139 = 128 + 11),C++ 层面没有任何报错信息。

堆(heap)由分配器管理,new / malloc 申请,活到 delete / free 为止,和作用域无关。分配器要在空闲块里找一块够大的、记下大小,free 时再归还,比栈慢得多,但大小可以运行时决定,寿命也不受函数调用限制(分配器的实现思路见 虚拟内存)。

两者的寿命差别用一个在构造和析构时打印的类就能看清。析构函数(destructor,~类名())在对象生命结束时自动调用:

Tracer a("a (stack)");
{
    Tracer b("b (stack)");
    Tracer* h = new Tracer("h (heap)");
    delete h;  // 去掉这一行,h 指向的对象永远不会析构
}
enter main
  construct a (stack)
enter inner scope
  construct b (stack)
  construct h (heap)
leave inner scope
  destruct  h (heap)
  destruct  b (stack)
leave main
  destruct  a (stack)

栈对象在 } 处自动析构,顺序与构造相反(后构造的可能依赖先构造的,所以先拆)。注意 h 这个指针变量本身在栈上,离开作用域时只销毁这 8 字节地址;它指向的堆对象若没人 delete,就一直占着内存,这就是内存泄漏。

停一下,检查理解单选

函数里写 std::vector<float> v(1'000'000);,这 4 MB 数据在哪里?

RAII:让析构函数替你 free

手动 malloc / free 的问题不在于难写,而在于每一条离开函数的路径(正常返回、提前 return、异常)都得记得释放,漏一条就泄漏。上一节其实已经给出了解法的线索:栈对象的析构函数一定会在离开作用域时运行。那就把释放写进析构函数,把资源的寿命绑在一个栈对象上。这就是 RAII(Resource Acquisition Is Initialization):构造时获取,析构时释放。

class Buffer {
public:
    explicit Buffer(size_t n) : n_(n), data_(static_cast<float*>(std::malloc(n * sizeof(float)))) {
        if (!data_) throw std::bad_alloc();
    }
    ~Buffer() { std::free(data_); }
    // 两个 Buffer 共享同一块 data_ 会 double free,先禁止拷贝
    Buffer(const Buffer&) = delete;
    Buffer& operator=(const Buffer&) = delete;
    // ...
};
 
void work(bool fail) {
    Buffer buf(4);
    if (fail) throw std::runtime_error("something went wrong");
}
normal path:
  Buffer(4) malloc
  work done, buf[3] = 3
  ~Buffer(4) free
exception path:
  Buffer(4) malloc
  ~Buffer(4) free
  caught: something went wrong

异常路径上 work 里一行 free 都没写,析构函数仍在异常到达 catch 之前运行了。异常传播时会逐层析构沿途已构造的栈对象,这叫栈展开(stack unwinding)。资源不限于内存:文件句柄、互斥锁(std::lock_guard)、CUDA stream 都用同样的模式,Python 的 with 语句是同一个想法的动态语言版本。

RAII 管住了”忘记释放”,但管不住”用了已经释放的内存”。越界访问和悬垂指针(对象已释放,地址还留在指针里)在 C++ 里是未定义行为(UB):标准不规定结果,可能崩,也可能安静地算错。一个 off-by-one 的求和:

int* a = new int[4]{10, 20, 30, 40};
int sum = 0;
for (int i = 0; i <= 4; ++i) sum += a[i];   // <= 多读了 a[4]
$ clang++ -std=c++20 -g oob.cpp -o oob && ./oob
sum = 103
$ clang++ -std=c++20 -g -fsanitize=address oob.cpp -o oob_asan && ./oob_asan
==4499==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6020000000e0 ...
0x6020000000e0 is located 0 bytes after 16-byte region [0x6020000000d0,0x6020000000e0)
SUMMARY: AddressSanitizer: heap-buffer-overflow oob.cpp:7 in main

正确答案是 100,普通构建给出 103,没有任何报错,“不崩但错”正是 UB 最危险的形态。AddressSanitizer(-fsanitize=address)在编译时给每次内存读写插入检查,运行时在每块堆内存两侧放一圈不可访问的红区,free 掉的内存也先隔离一段时间不复用,于是越界和 use-after-free 都会当场报告位置。它让程序明显变慢,只在 Debug 和测试构建里开,而且只能抓到真正执行到的路径。下面几节的坑,都用它来抓。

停一下,检查理解单选

函数里写了 float* p = new float[1024];,随后抛出异常并被外层 catch,这块内存会怎样?

vector:堆上的连续数组,以及它会搬家

std::vector<T> 把前两节合在了一起:元素在堆上连续存放,对象本身是一个 RAII 包装。对象里只有三根指针,分别指向元素开头、已用部分末尾、已分配容量末尾。

sizeof(std::vector<float>) = 24
sizeof after 3 elements   = 24
&v       = 0x16f32a7b0 (stack)
v.data() = 0x101459b00 (heap)
&v[1] - &v[0] = 4 bytes

sizeof 永远是 24 字节(3 × 8),元素不在对象里;对象地址在栈区、data() 在堆区;相邻元素差 4 字节,和 C 数组一样连续,所以 v.data() 能直接交给 C 接口、BLAS 或 GPU 拷贝函数。

容量用完时 push_back 必须重新分配:申请更大的新缓冲区,把旧元素搬过去,释放旧缓冲区。新容量按倍数增长而不是每次 +1,否则插入 n 个元素要搬约 n^2/2 次;按 2 倍增长时搬动总数 1 + 2 + 4 + … < 2n,均摊下来每次 push_back 是 O(1)(均摊分析见 算法设计)。libc++ 的实际序列:

push_back growth:
  size= 0 cap= 0
  size= 1 cap= 1  data moved: (first alloc)
  size= 2 cap= 2  data moved: yes
  size= 3 cap= 4  data moved: yes
  size= 5 cap= 8  data moved: yes
  size= 9 cap=16  data moved: yes
  size=17 cap=32  data moved: yes
  size=33 cap=64  data moved: yes

每次扩容 data() 都换了地址。这引出 vector 最常见的坑:扩容前拿到的指针、引用、迭代器全都指向已释放的旧缓冲区。

std::vector<int> v = {1, 2, 3, 4};
int& first = v[0];   // 引用和指针一样,绑的是旧缓冲区里的那个 int
v.push_back(5);      // size == capacity,扩容,旧缓冲区被释放
std::printf("first = %d\n", first);
==4439==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000d0 ...
    #0 0x000100ad4b14 in main stale.cpp:8
SUMMARY: AddressSanitizer: heap-use-after-free stale.cpp:8 in main

典型场景是一边遍历 vector 一边往里 push_back,或者把 v.data() 交给另一个线程后 vector 还在长。已知最终大小就先 reserve(n):一次分配到位,实测 reserve(1000) 后连续 1000 次 push_back,data() 没有变。注意 reserve 只改容量不改 size(),之后直接写 v[5] 仍是越界;clear() 只把 size 归零,容量还在。

只想”借用”一段连续内存而不拥有它时,C++20 有 std::span<T>:一个指针加一个长度,16 字节,vector、C 数组、std::array 都能隐式转过来,适合当函数参数。它和裸指针一样不延长数据寿命,底层 vector 扩容或被析构后 span 就悬空,所以只在调用期间借用,别存成成员。

停一下,检查理解多选

std::vector<int> v = {1,2,3,4};(capacity 为 4),int* p = v.data(); v.push_back(5); 之后,哪些说法正确?

拷贝把 RAII 弄坏了:移动语义

回到 Buffer 为什么要禁止拷贝。如果不禁止,编译器生成的拷贝构造只会逐成员复制,也就是复制 data 这个地址:

struct Buf {
    float* data;
    explicit Buf(size_t n) : data(static_cast<float*>(std::malloc(n * sizeof(float)))) {}
    ~Buf() { std::free(data); }
    // 没写拷贝构造:编译器生成的版本只复制 data 这个地址
};
Buf a(4);
Buf b = a;
a.data == b.data ? yes
==4604==ERROR: AddressSanitizer: attempting double-free on 0x6020000000d0 in thread T0:
SUMMARY: AddressSanitizer: double-free shallow.cpp:6 in Buf::~Buf()

两个对象以为自己都拥有同一块内存,析构时各 free 一次。问题的本质是所有权(ownership):谁负责释放。RAII 要求每块资源恰好有一个所有者。

禁止拷贝能堵住 double free,但又带来新麻烦:Buf 不能从函数里返回,不能放进 vector(扩容时要把元素搬到新缓冲区)。可这些场景里源对象马上就没用了,我们真正想要的不是”复制一份”,而是”把资源转交过去”。这就是移动(move):

// 偷走指针,再把源置空:否则源析构时会把刚交出去的内存释放掉
Buf(Buf&& o) noexcept : data(o.data), n(o.n) { o.data = nullptr; o.n = 0; }
 
Buf a(4);
float* old = a.data;
Buf b = std::move(a);
Buf c = make(1024);           // 函数按值返回
std::vector<Buf> v;
v.push_back(std::move(c));
b.data == old ? yes, a.data = 0x0
c.n = 1024, c.data[0] = 7
v[0].n = 1024, c.data = 0x0

移动只复制了两个字段,堆上的 4096 字节一个没动;源对象被置空后析构时 free(nullptr) 是空操作,ASan 也没有报错。

编译器怎么知道该拷贝还是该移动?看实参是左值还是右值。左值是有名字、之后还可能被读的对象,比如 a;右值是临时对象或即将消亡的对象,比如函数按值返回的结果。Buf&& 是右值引用,只绑定右值,所以重载决议让右值走移动构造、左值走拷贝构造。从右值偷资源是安全的,因为之后没人再用它。

std::move(a) 本身什么都不搬,它等价于 static_cast<Buf&&>(a),只是把左值 a 标成”可以被掏空”,让重载选中移动版本。真正转交资源的是移动构造函数。所以对 const 对象调 std::move 会悄悄退回拷贝;被移动过的标准库对象处于”合法但未指定”的状态,可以析构、重新赋值,但别依赖它的值(unique_ptr 例外,标准保证移动后为空)。返回局部变量时不要写 return std::move(x);,编译器本来会直接在调用方位置构造它(返回值优化),手动 move 反而妨碍这一点,clang 的 -Wall 会给出 -Wpessimizing-move 警告。

最后是 noexcept。vector 扩容要保证搬到一半出错能回滚,如果移动可能抛异常,搬走一半的元素就回不去了,所以 vector 只在移动构造标了 noexcept 时才用移动,否则退回拷贝。把移动构造的 noexcept 去掉,扩容时打印的是:

grow:
  copy

对于持有大块堆内存的元素,这意味着每次扩容都深拷贝全部数据。

停一下,检查理解单选

const std::string s = "hello"; std::string t = std::move(s); 执行后会怎样?

智能指针:把所有权写进类型

手写移动构造、把源置空、记得 noexcept,每管一种资源都写一遍太容易出错。更根本的问题是裸指针 Tensor* p 看不出拿到它的人该不该 delete。标准库把”谁拥有”写进了类型:

类型所有权拷贝典型用途
std::unique_ptr<T>独占禁止,只能移动默认选择;工厂函数返回值
std::shared_ptr<T>共享,引用计数允许,计数 +1多处共同延长寿命,如共享权重
std::weak_ptr<T>不拥有,只观察允许,不影响计数打破环、缓存
T* / T&不拥有—借用,释放不归它管

unique_ptr 就是上一节的 Buf 的通用版:持有一个 T*,析构时 delete,拷贝被删除,移动后源为空。写 auto q = p; 会得到编译错误 call to implicitly-deleted copy constructor of 'unique_ptr<int>',转移所有权必须显式 std::move,让所有权流向在代码里一眼可见:

void consume(std::unique_ptr<Tensor> t) { /* ... */ }  // 按值收 = 调用后归我
std::unique_ptr<Tensor> make(int id) { return std::make_unique<Tensor>(id); }
 
auto p = make(1);
auto q = std::move(p);
consume(std::move(q));
auto r = make(2);
  Tensor 1 alloc
sizeof(unique_ptr) = 8, sizeof(raw ptr) = 8
after move: p is null, q->id = 1
call consume:
  consume got Tensor 1
  Tensor 1 free
back in main, q is null
  Tensor 2 alloc
end of main
  Tensor 2 free

它和裸指针一样是 8 字节,没有任何额外开销,所以是默认选择。Tensor 1 在 consume 返回时释放,Tensor 2 在 main 结束时释放,代码里没有一个 delete。

有些对象确实没有唯一的主人,比如被多个模块共享的权重。shared_ptr 额外维护一个控制块,里面有强引用计数:拷贝 +1,析构或 reset() −1,归零时释放对象。

after make_shared: use_count = 1
copy into w2:      use_count = 2
copy into w3:      use_count = 3
w2, w3 out of scope: use_count = 1
move into w4:      use_count = 1, w is null
w4.reset():
  Weights freed

拷贝只加计数,不复制 Weights;移动是转交,计数不变。shared_ptr 本身是两根指针(对象 + 控制块),替换全局 operator new 数一下堆分配:

sizeof(unique_ptr<Weights>) = 8
sizeof(shared_ptr<Weights>) = 16
shared_ptr(new T): allocations = 2
make_shared<T>():  allocations = 1
copy shared_ptr:   allocations = 0, use_count = 2

make_shared 把对象和控制块放进一次分配,所以优先用它。计数的增减是原子操作,多线程同时拷贝也不会算错,但原子指令比普通加减贵,而且原子的只是计数,多个线程改同一个对象仍要加锁(见 并发与同步)。只读使用时传 const T& 或 T* 即可,不必按值传 shared_ptr。

引用计数有一个结构性缺陷:两个对象用 shared_ptr 互相指着,各自的计数都被对方贡献了 1,局部变量离开作用域后计数停在 1,永远不会归零。weak_ptr 观察对象但不增加强计数,把环上的一条边(通常是子指向父的反向边)换成它,环就断了:

[strong cycle]
  a.use_count = 2, b.use_count = 2
  (left scope: no destructor ran -> leaked)
[shared next + weak prev]
  a.use_count = 1, b.use_count = 2
  b->prev.lock() ok: A
  ~NodeWeak A
  ~NodeWeak B
  observer.expired() = true

weak_ptr 不能直接解引用,要先 lock() 换成一个临时 shared_ptr,对象已死时拿到的是空指针。CPython 的引用计数有同样的环问题,它靠额外的循环垃圾回收器兜底;C++ 没有这个兜底,只能靠你在设计时分清哪条边拥有、哪条边只是观察。

把这些拼起来,就得到写 C++ 类的默认姿势 Rule of Zero:成员都用自己会管资源的类型,类本身一个析构、拷贝、移动函数都不写,编译器逐成员生成的版本自然正确。

struct Layer {
    std::string name;
    std::vector<float> weight;          // 可拷贝、可移动
    std::unique_ptr<float[]> scratch;   // 只能移动 -> 整个 Layer 自动变成只能移动
};
Layer a{"fc1", std::vector<float>(1024, 0.5f), std::make_unique<float[]>(256)};
const float* old_data = a.weight.data();
Layer b = std::move(a);
b.name = fc1, b.weight.size() = 1024
weight buffer reused (no copy): yes
a.weight.size() = 0, a.scratch is null

4 KB 权重没有被复制;因为有 unique_ptr 成员,误写 Layer c = b; 会在编译期报错,而不是运行时 double free。前面手写的 Buf 是为了看清机制,实际代码里它应该直接是一个 std::vector<float>。

停一下,检查理解多选

一个类的成员是 std::vector<float> 和 std::unique_ptr<int>,没有手写任何特殊成员函数。哪些说法正确?

记住这三件事

  1. 指针就是地址,多维数组和 tensor 都是”一段连续内存 + stride 换算公式”,转置不搬数据。
  2. 每块资源恰好有一个所有者:栈对象的析构函数一定会运行,RAII 把释放写进析构,unique_ptr / shared_ptr 把所有权写进类型。
  3. 移动是转交资源而不是复制资源,std::move 只是类型转换;给移动构造标 noexcept,类里尽量一个特殊成员函数都不写。

延伸阅读