历史:Rust的内存管理从单个全局分配器接口(GlobalAlloc,在Rust 1.28中稳定)演变为更灵活的、类型感知的系统(Allocator,目前不稳定但在std::alloc中可用)。GlobalAlloc作为低级桥接,连接到操作系统的内存原语(例如,malloc,VirtualAlloc),完全依赖于原始指针和字节大小,而没有类型信息。
问题的出现是因为GlobalAlloc暴露了原始内存操作,编译器无法验证。实现者必须手动强制执行关键的不变性:对齐保证、分配/释放配对和禁止双重释放。由于GlobalAlloc是Box、Vec和Rc的基础,任何违反都将导致整个程序的未定义行为,因此需要unsafe impl标记,以表明程序员对这些安全契约承担责任。
解决方案涉及严格遵守Layout契约。alloc方法必须返回满足Layout::align()的指针,而dealloc只能使用与分配相同的布局进行调用。此外,分配器必须确保在安全抽象仍然引用的情况下不回收内存。Allocator特性通过提供一个安全的、通用的接口来处理内部的Layout计算,从而减轻这些风险,将不安全的操作委派给底层的GlobalAlloc实现。
use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicUsize, Ordering}; struct CountingAllocator { bytes_allocated: AtomicUsize, } unsafe impl GlobalAlloc for CountingAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { let ptr = System.alloc(layout); if !ptr.is_null() { self.bytes_allocated.fetch_add(layout.size(), Ordering::SeqCst); } ptr } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { System.dealloc(ptr, layout); self.bytes_allocated.fetch_sub(layout.size(), Ordering::SeqCst); } } #[global_allocator] static GLOBAL: CountingAllocator = CountingAllocator { bytes_allocated: AtomicUsize::new(0), };
一个开发高频交易引擎的团队观察到,标准库的分配器由于全局堆中的锁竞争引入了不可接受的延迟抖动。他们需要一个从大页中预分配的自定义突增分配器,以确保快速路径订单簿更新的NUMA局部确定性内存访问。
评估了几种解决方案。第一种方法考虑用受互斥锁保护的池来包装系统分配器,但这只是转移了竞争并违反了延迟要求。第二种方法涉及使用不稳定的Allocator API与夜间Rust,为特定订单结构创建一个类型区域;然而,这需要在代码库中大量重构Vec和Box的使用,并且在生产部署中面临稳定性问题。
最终选定的第三种解决方案实现了GlobalAlloc以拦截交易线程内的所有动态分配,通过一个基于mmap区域的线程本地突增分配器进行路由。这个实现需要unsafe impl,因为突增分配器管理原始指针,并且必须保证返回的指针保持对齐,符合最多64字节缓存行边界。团队选择了这条路径,因为它提供了系统范围的干预,而不修改现有的集合类型,尽管它要求用Miri进行严格测试,以验证传递给dealloc的Layout始终与原始分配相匹配。结果是p99延迟减少了40%,尽管团队对unsafe代码块保持严格的审计协议,以防止在市场剧烈波动期间发生内存泄漏。
为什么传递给dealloc的Layout必须与传递给alloc的完全匹配,如果大小不同但对齐正确会发生什么?
GlobalAlloc契约要求分配和释放所用的Layout之间具有逐位相同性,因为许多分配器(如jemalloc或dlmalloc)将在分配块内嵌入元数据或维护大小类别分隔的列表。传递不同的大小——即使是更小的大小——会导致分配器在错误的槽内查找或计算合并的错误偏移,从而导致堆损坏或双重释放漏洞。这与C的free不同,后者通常只需要指针,这使得Rust的要求更加严格,但对分配器的不可知性是必要的。
当调用Box::new时,GlobalAlloc如何与其交互,当Box被释放时,为什么为分配器本身实现Drop是有问题的?
当调用Box::new时,它通过**#[global_allocator]静态调用GlobalAlloc::alloc**。在释放Box时,编译器插入调用GlobalAlloc::dealloc,并自动计算类型的Layout。候选人常常遗漏的是,GlobalAlloc实现本身必须是**'static并且是线程安全的(实现Sync**),但它不得持有引用其管理的分配内存的状态,因为这会导致循环依赖,其中释放分配器将需要访问自身,可能会在程序拆卸期间导致使用后释放。
为什么GlobalAlloc::alloc_zeroed的安全要求与alloc不同,为什么实现不能简单地调用alloc然后调用std::ptr::write_bytes**?**
虽然alloc_zeroed理论上可以实现为alloc加上置零,但标准库将其作为一个独特的方法提供,以允许分配器利用特定于操作系统的零页优化(例如,在Linux上,MAP_ANONYMOUS返回预置零的页面)。从安全的角度来看,alloc_zeroed必须保证返回的内存包含零字节,这是一个比alloc更强的后置条件(alloc返回未初始化的内存)。如果一个实现错误地声称进行置零但返回垃圾,假设零初始化的安全代码(对于安全敏感的上下文至关重要)将读取未初始化的数据,违反了Rust的安全保证。