Allocator 与内存布局

本页目录

分配器提供满足大小和对齐要求的存储,类型规则决定哪些字节组成有效值,所有权决定何时析构和释放。把这几层分开,才能理解为什么“拿到非空指针”并不等于已经拥有一个合法的 T。

计算 Layout检查大小、对齐与溢出1申请存储并检查失败成功得到对齐的未初始化内存2初始化并使用 T写入合法值,维护所有权和访问规则3结束生命周期并释放存储需要时析构 T;使用匹配的分配器和 Layout4申请字节与创建有效的 T 是不同阶段普通业务优先使用 Box、Vec 等 RAII 容器,避免手动维护完整路径。

全局分配器与系统分配器

Rust 没有把“所有平台默认调用 glibc malloc”规定为语言契约。System 表示平台系统分配器,在不同系统上使用不同基础设施;默认选择和静态/动态库环境也应以目标文档为准。System

下面的完整程序显式选择 System,不需要为了转发而手写一份 GlobalAlloc:

use std::alloc::System;

#[global_allocator]
static GLOBAL: System = System;

fn main() {
    let values = vec![10, 20, 30];
    assert_eq!(values.iter().sum::<i32>(), 60);
}

自定义 GlobalAlloc 是 unsafe 实现责任:必须满足分配和释放契约,不能让这些方法 unwind。还要避免在分配器内部用可能再次分配的日志或容器,否则可能递归进入自己。

不要依赖“源码里写了 Box::new 就必定调用分配器一次”维持程序正确性。优化器可能消除或改变分配,分配次数可以用于受限制的观测,不能作为安全不变量。GlobalAlloc

Layout:大小、对齐和可表示范围

Layout::new::() 从类型获得布局,Layout::array::(n) 检查数组尺寸。from_size_align 要求对齐非零且为 2 的幂,并保证大小向对齐取整后的结果不超过 isize::MAX。

Layout 本身允许大小零,但 GlobalAlloc::alloc 的调用契约要求非零大小。不能把“成功创建 Layout”当成“可对任意分配 API 直接调用”。不同分配接口对零大小的规则需要分别核对。Layout

use std::alloc::Layout;

fn main() {
    assert!(Layout::from_size_align(16, 3).is_err());
    assert!(Layout::array::<u64>(usize::MAX).is_err());
    let first = Layout::from_size_align(1, 1).unwrap();
    let next = Layout::from_size_align(4, 4).unwrap();
    let (combined, offset) = first.extend(next).unwrap();
    let padded = combined.pad_to_align();
    assert_eq!(offset, 4);
    assert_eq!(padded.size(), 8);
    assert_eq!(padded.align(), 4);
}

extend 返回第二部分的偏移,不应自己无检查地把字段大小相加。pad_to_align 补齐尾部,使连续对象也满足对齐;这个例子按显式 Layout 组合存储,不是在证明任意 Rust struct 都采用相同字段顺序。

尾部填充为什么影响数组步长

假设一条记录先放 4 字节、对齐 4 的字段,再放 1 字节字段。第二字段偏移是 4,组合的有效末端是 5;若紧接着在偏移 5 放下一条记录,其首字段就不满足 4 字节对齐。因此整体步长需要补成 8。

use std::alloc::Layout;
fn main() {
    let word = Layout::from_size_align(4, 4).unwrap();
    let tag = Layout::from_size_align(1, 1).unwrap();
    let (record, tag_offset) = word.extend(tag).unwrap();
    assert_eq!(tag_offset, 4);
    assert_eq!(record.size(), 5);
    assert_eq!(record.pad_to_align().size(), 8);
    assert_eq!(record.pad_to_align().align(), 4);
}

这里的 3 字节尾部填充用于保证下一元素对齐,不承载业务字段。为外部协议编码时应写出字段,而不是把填充字节也作为稳定内容发送或哈希。布局算术解决空间安排,不解决填充是否初始化、值是否合法或指针能否访问的问题。

原始分配:先检查 null,再初始化

use std::alloc::{alloc, dealloc, handle_alloc_error, Layout};

fn main() {
    let layout = Layout::new::<u64>(); // 非零大小
    // SAFETY: layout 满足 alloc 的要求。
    let storage = unsafe { alloc(layout) };
    if storage.is_null() {
        handle_alloc_error(layout);
    }
    let pointer = storage.cast::<u64>();
    // SAFETY: 存储足够大且对齐;独占访问,写入后才读取。
    let answer = unsafe {
        pointer.write(42);
        let value = pointer.read();
        // u64 不需要额外析构;分配器、地址和 Layout 与申请匹配。
        dealloc(storage, layout);
        value
    };
    assert_eq!(answer, 42);
}

alloc 返回的存储未初始化。普通赋值可能涉及替换旧值的语义,原始初始化应使用适当的 write 或 MaybeUninit 接口。对含资源的 T,必须明确 read 是否已移出所有权、是否还需要 drop_in_place,防止泄漏或重复析构。

dealloc 只释放存储,不会自动调用 T::drop。这个 u64 例子特意没有在分配和释放之间加入可失败业务;更复杂的构造需要处理部分初始化和 panic 路径,通常应交给 Box、Vec 或专门的 RAII 封装。

槽位 0有效 T槽位 1有效 T槽位 2未初始化空间槽位 3未初始化空间槽位 4未初始化空间len = 2:允许读取与析构capacity = 5:可用存储,不代表已有 5 个 TVec 的容量边界与有效元素边界必须分开图示假设容量为 5;分配 API 的具体容量应读取 capacity,不从请求值反推。

Vec:先初始化,再扩展有效长度

Vec::with_capacity 预留空间,但 len 仍为 0,不能直接通过索引读取空槽。spare_capacity_mut 将空闲部分作为 [MaybeUninit<T>] 暴露,写入之后仍需更新 len,才把这些值纳入 Vec 的访问和析构责任。Vec 空闲容量

fn main() {
    let mut values = Vec::<u32>::with_capacity(5);
    assert_eq!(values.len(), 0);
    assert!(values.capacity() >= 5);
    {
        let spare = values.spare_capacity_mut();
        spare[0].write(10);
        spare[1].write(20);
    }
    // SAFETY: 槽位 0、1 都已写入有效 u32,且 2 不超过 capacity。
    unsafe { values.set_len(2); }
    assert_eq!(values, [10, 20]);
    values.push(30);
    assert_eq!(values, [10, 20, 30]);
}

这是用于理解初始化边界的底层例子;普通追加直接 push 更容易维护。不能先 set_len 再逐项初始化:一旦中途 panic,Vec 可能尝试析构尚未有效的 T。反过来,若写入了 String 却始终没把它计入 len,Vec 不知道要析构它,可能泄漏其资源。

批量构造有可失败步骤时,优先让容器逐项接管已完成对象。下面在第二个元素处返回错误,已放入 Vec 的第一个对象会自动释放:

use std::cell::Cell;
use std::rc::Rc;
struct Tracked(Rc<Cell<usize>>);
impl Drop for Tracked {
    fn drop(&mut self) { self.0.set(self.0.get() + 1); }
}
fn build(drops: &Rc<Cell<usize>>) -> Result<Vec<Tracked>, &'static str> {
    let mut items = Vec::with_capacity(3);
    for index in 0..3 {
        if index == 1 { return Err("构造失败"); }
        items.push(Tracked(Rc::clone(drops)));
    }
    Ok(items)
}
fn main() {
    let drops = Rc::new(Cell::new(0));
    assert!(build(&drops).is_err());
    assert_eq!(drops.get(), 1);
}

RAII 的价值就在于让错误路径与正常路径共享所有权规则。若确实需要原始批量初始化,应由专门 guard 记录已初始化数量,并在提前退出时只析构该前缀;不能把完整数组的析构逻辑应用于部分有效存储。

先分配 Box,再初始化值

fn main() {
    let mut storage = Box::<u64>::new_uninit();
    storage.write(42);
    // SAFETY: 上一行已完整初始化唯一的 u64,之后没有破坏它。
    let value: Box<u64> = unsafe { storage.assume_init() };
    assert_eq!(*value, 42);
}

Box::new_uninit 返回 Box<MaybeUninit<T>>,assume_init 依赖调用方证明 T 已有效。只分配、未初始化就释放这个 Box 是允许的,但 MaybeUninit 不会自动替里面手动写入的 T 调用析构;初始化后仍要正确转移责任。零填充也不能代替此证明:全零不是任意 T 的有效表示。Box::new_uninit

容量、初始化与 len 的三条边界

把 len 当成容器作出的有效值承诺:它不仅决定索引范围,也决定正常析构哪些元素。分别移动初始化与 len 边界,就能看到两种不同错误:先扩大 len 会让容器相信未初始化的值存在;写好 String 却不接管它,则可能漏掉析构。普通 push 会把两步合在安全接口中。

正在呈现知识画面
容量、初始化与 len 的三条边界

len 内必须全是有效值;len 外已写入的 String 不会由 Vec 自动析构。

全局替换与每容器分配器不是同一个 API

GlobalAlloc 和 #[global_allocator] 是稳定接口。标准库 Allocator trait 及许多 new_in 等按容器选择分配器的接口,在本次核对的文档中仍标记为 allocator_api 实验功能;不能把 nightly 示例直接称为稳定 Rust 用法。Allocator

选择分配器应依据碎片、延迟、线程竞争及工作负载测量,不根据名称承诺普遍更快。对于可预估的批量对象,首先考虑减少分配次数、预留容量或改变数据结构。

预留容量、释放容量与进程内存不是同一指标

操作或指标可以据此判断什么不能直接推出什么
Vec::clear元素已被移除并按规则析构,len 归零容量已归还给分配器
drop(Vec)容器释放自己拥有的存储操作系统 RSS 立即下降同样字节数
try_reserve可处理容量溢出或分配失败的 Result输入规模已经符合业务资源预算
shrink_to_fit请求缩小多余容量容量一定等于 len、内存一定立刻归还 OS

分配器可能缓存释放块,页面也可能混放仍存活对象,因此 RSS 与对象拥有的有效字节不同。分析“泄漏”时应区分仍可达对象持续增长、容器保留容量、分配器保留以及系统统计口径;只观察一次 RSS 不回落不足以定位责任。

预留输入声明的数量之前,先验证业务上限,再用 try_reserve 处理资源失败。即使大小算术不溢出,攻击性或错误输入仍可能要求不可接受的容量。泛型容器还要考虑零大小类型:它们的容量语义不能直接换算成真实申请字节。

repr 与 Pin 各自保证什么

repr(C) 约束结构布局遵循对应的 C 布局规则,但不自动递归改变字段类型的表示;repr(align(N)) 提高对齐,也可能增加填充。目标 ABI、字段有效值与端序仍需考虑,不能直接把结构内存当成跨平台序列化格式。

Pin 则表达地址相关的契约,不决定分配器,也不把一个类型自动变成不可移动。对于 Unpin 类型,仍可通过安全接口取到可移动的 &mut T;一个仅包含 String 和裸指针的自引用结构通常仍自动 Unpin,套上 Pin<Box<_>> 并不足以使其安全。

自引用抽象需要在稳定地址完成初始化、选择适当的 !Unpin 设计(如 PhantomPinned)、限制投影与移动,并遵守析构约定。固定的是 pointee;持有它的 Box 或 Pin 包装自身仍可移动。异步 Future 的相关机制见 async/await,原始访问契约见 裸指针与 FFI。

Pin 不禁止普通字段修改

use std::pin::Pin;
fn main() {
    let mut pinned = Box::pin(String::from("old"));
    let text: &mut String = Pin::as_mut(&mut pinned).get_mut();
    let old = std::mem::replace(text, String::from("new"));
    assert_eq!(old, "old");
    assert_eq!(pinned.as_ref().get_ref(), "new");
}

String 是 Unpin,所以安全接口允许替换它。即使换成带 PhantomPinned 的自定义类型,也只是让相关移动入口受到限制;裸指针目标的初始化顺序、投影是否允许移动字段、析构期间是否保持契约,仍是抽象作者的责任。先明确哪些地址关系必须保持,再决定是否需要 Pin,不能把 Pin 当作通用的“堆对象加固”。

参考