Box: 在堆上分配内存

从c/c++得到Box灵感

我们先看 Box,它是 Rust 中最基本的在堆上分配内存的方式,绝大多数其它包含堆内 存分配的数据类型,内部都是通过 Box 完成的,比如 Vec

为什么有 Box 的设计,我们得先回忆一下在 C 语言中,堆内存是怎么分配的。

  1. C 需要使用 malloc/calloc/realloc/free 来处理内存的分配,很多时候,被分配出来的内存 在函数调用中来来回回使用,导致谁应该负责释放这件事情很难确定,给开发者造成了极 大的心智负担。

  2. C++ 在此基础上改进了一下,提供了一个智能指针  unique_ptr,可以在指针退出作用 域的时候释放堆内存,这样保证了堆内存的单一所有权。这个 unique_ptr 就是 Rust 的 Box 的前身。


Box 的定义里,内部就是一个 Unique 用于致敬 C++,Unique 是 一个私有的数据结构,我们不能直接使用,它包裹了一个 *const T 指针,并唯一拥有这个 指针。

#![allow(unused)]
fn main() {
pub struct Unique<T: ?Sized> {
    pointer: *const T,
    // NOTE: this marker has no consequences for variance, but is necessary
    // for dropck to understand that we logically own a `T`.
    //
    // For details, see:
    // https://github.com/rust-lang/rfcs/blob/master/text/0769-sound-generic-drop.md#phantom-data
    _marker: PhantomData<T>,
}
}

堆上分配内存的 Box 其实有一个缺省的泛型参数 A

设计内存分配器的目的除了保证正确性之外,就是为了有效地利用剩余内存,并控制内存 在分配和释放过程中产生的碎片的数量。 在多核环境下,它还要能够高效地处理并发请 求。(如果你对通用内存分配器感兴趣,可以看参考资料) 堆上分配内存的 Box

其实有一个缺省的泛型参数 A,就需要满足 Allocator trait, 并且默认是 Global:

#![allow(unused)]

fn main() {
pub struct Box<T: ?Sized, A: Allocator = Global>(Unique<T>, A);
}

Allocator trait 提供很多方法:

  1. allocate 是主要方法,用于分配内存,对应 C 的 malloc/calloc;

  2. deallocate,用于释放内存,对应 C 的 free;

  3. 还有 grow / shrink,用来扩大或缩小堆上已分配的内存,对应 C 的 realloc。

替换默认的内存分配器

如果你想替换默认的内存分配器,可以使用 #[global_allocator] 标记宏,定义你自己的全局分配器。下面的代码展示了如何在 Rust 下使用jemalloc:


use jemallocator::Jemalloc;

#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;

fn main() {}

实现内存分配器

内存分配器

use std::alloc::{GlobalAlloc, Layout, System};

struct MyAllocator;

unsafe impl GlobalAlloc for MyAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let data = System.alloc(layout);
        eprintln!("ALLOC: {:p}, size {}", data, layout.size());
        data
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        System.dealloc(ptr, layout);
        eprintln!("FREE: {:p}, size {}", ptr, layout.size());
    }
}

#[global_allocator]
static GLOBAL: MyAllocator = MyAllocator;

#[allow(dead_code)]
struct Matrix {
    // 使用不规则的数字如 505 可以让 dbg! 的打印很容易分辨出来
    data: [u8; 505],
}

impl Default for Matrix {
    fn default() -> Self {
        Self { data: [0; 505] }
    }
}

fn main() {
    // 在这句执行之前已经有一些内存分配和释放
    let data = Box::new(Matrix::default());
    println!(
        "!!! allocated memory: {:p}, len: {}",
        &*data,
        std::mem::size_of::<Matrix>()
    );

    // data 在这里 drop,可以在打印中看到 FREE
    // 之后还有很多其它内存被释放
}

  1. 这里 MyAllocator 就用 System allocator,然后加 eprintln!(),和我 们常用的 println!() 不同的是,eprintln!() 将数据打印到 stderr
  2. 注意这里不能使用 println!() 。因为 stdout 会打印到一个由 Mutex 互斥锁保护的共享全 局 buffer 中,这个过程中会涉及内存的分配,分配的内存又会触发 println!(),最终造成 程序崩溃。而 eprintln! 直接打印到 stderr,不会 buffer。
  3. 在使用 Box 分配堆内存的时候要注意,Box::new() 是一个函数,所以传入它的数据会出现 在栈上,再移动到堆上。所以,如果我们的 Matrix 结构不是 505 个字节,是一个非常大 的结构,就有可能出问题。
// #![feature(dropck_eyepatch)]

// struct MyBox<T>(Box<T>);

// unsafe impl<#[may_dangle] T> Drop for MyBox<T> {
//     fn drop(&mut self) {
//         todo!();
//     }
// }

fn main() {
    // 在堆上分配 16M 内存,但它会现在栈上出现,再移动到堆上
    let boxed = Box::new([0u8; 1 << 24]);
    println!("len: {}", boxed.len());
}
  • cargo run --bin box或者在 playground 里运行,直接栈溢出 stack overflow
  • 本地使用 “cargo run –bin box —release” 编译成 release 代码运行,会正常执行!

这是因为 “cargo run” 或者在 playground 下运行,默认是 debug build,它不会做任 何 inline 的优化,而 Box::new() 的实现就一行代码,并注明了要 inline,在 release 模式 下,这个函数调用会被优化掉, 本质是编译器自动调用下列方式:

#![allow(unused)]

fn main() {
#[cfg(not(no_global_oom_handling))]
#[inline(always)]
#[doc(alias = "alloc")]
#[doc(alias = "malloc")]
#[stable(feature = "rust1", since = "1.0.0")]
pub fn new(x: T) -> Self {
    box x
}
}

这里的关键字 box是 Rust 内部的关键字,用户代码无法调用,它只出现在 Rust 代码中,用于分配堆内存,box 关键字在编译时,会使用内存分配器 分配内存。

内存如何释放

Box默认实现的Drop trait

#![allow(unused)]
fn main() {
#[stable(feature = "rust1", since = "1.0.0")]
unsafe impl<#[may_dangle] T: ?Sized, A: Allocator> Drop for Box<T, A> {
    fn drop(&mut self) {
        // FIXME: Do nothing, drop is currently performed by compiler.
    }
}
}

先稳定接口,再迭代稳定实现

目前 drop trait 什么都没有做,编译器会自动插入 deallocate 的代码。这是 Rust 语 言的一种策略:在具体实现还没有稳定下来之前,我先把接口稳定,实现随着之后的迭代 慢慢稳定。