Contents

Rust 中的智能指针

本文记录一些俺在学习Rust智能指针的知识总结,持续更新ing

Box

简介

Rust 中的 Box 是一个智能指针,它提供了一种将值封装在堆上进行内存分配的方法,留在栈上的则是指向堆数据的指针。它的主要优点在于,它可以在编译时自动处理内存管理,避免了手动管理内存分配和释放的麻烦。Box 的工作原理主要分为以下几个方面:

  1. 内存分配:当创建一个 Box 时,它会在堆上分配内存空间,并将值移动到这块内存空间。例如:

    let boxed_value = Box::new(42);
    

    这里,boxed_value 是一个指向堆上的 i32 类型的 BoxBox::new 函数会在堆上分配一块足够存储 i32 类型数据的内存,并将值 42 移动到这个内存空间中。

  2. 所有权和生命周期Box 拥有它所封装的值的所有权。当 Box 离开作用域时,Rust 编译器会自动调用 Drop trait 的 drop 方法来释放堆上的内存。这样,我们无需手动管理内存释放,降低了内存泄漏的风险。例如:

    fn main() {
        let boxed_value = Box::new(42);
    } // boxed_value 离开作用域,同时释放堆上的内存
    
  3. 解引用Box 实现了 Deref trait,这意味着我们可以对 Box 进行解引用操作,以访问其封装的值。例如:

    let boxed_value = Box::new(42);
    let value = *boxed_value;
    println!("value: {}", value);
    

    这里,*boxed_valueboxed_value 进行解引用,提取出封装在 Box 中的值 42Deref trait 的实现使得我们可以在许多情况下将 Box<T> 作为 &T 类型的引用使用。

总之,Rust 中的 Box 智能指针的核心原理包括在堆上分配内存、自动管理值的所有权和生命周期以及实现解引用操作。这使得我们可以更方便地处理内存分配和管理,从而提高代码的安全性和可维护性。

使用场景

由于 Box 是简单的封装,除了将值存储在堆上外,并没有其它性能上的损耗。而性能和功能往往是鱼和熊掌,因此 Box 相比其它智能指针,功能较为单一,可以在以下场景中使用它:

  • 特意的将数据分配在堆上
  • 数据较大时,又不想在转移所有权时进行数据拷贝
  • 类型的大小在编译期无法确定,但是我们又需要固定大小的类型时
  • 特征对象,用于说明对象实现了一个特征,而不是某个特定的类型

在Rust中,Box指针是一个智能指针,它在堆上分配内存。使用Box可以让我们管理一些复杂的数据结构和内存布局。以下是一些Box指针的主要用途和用法:

一、移动大型数据结构到堆上

当数据结构过大时,将其移动到堆上可以避免栈溢出。例如,创建一个包含大量元素的数组:

fn main() {
    let large_array = Box::new([0; 1_000_000]);
    println!("large_array occupies {} bytes", std::mem::size_of_val(&*large_array));
}

当栈上数据转移所有权时,实际上是把数据拷贝了一份,最终新旧变量各自拥有不同的数据,因此所有权并未转移。

而堆上则不然,底层数据并不会被拷贝,转移所有权仅仅是复制一份栈中的指针,再将新的指针赋予新的变量,然后让拥有旧指针的变量失效,最终完成了所有权的转移:

fn main() {
    // 在栈上创建一个长度为1000的数组
    let arr = [0;1000];
    // 将arr所有权转移arr1,由于 `arr` 分配在栈上,因此这里实际上是直接重新深拷贝了一份数据
    let arr1 = arr;

    // arr 和 arr1 都拥有各自的栈上数组,因此不会报错
    println!("{:?}", arr.len());
    println!("{:?}", arr1.len());

    // 在堆上创建一个长度为1000的数组,然后使用一个智能指针指向它
    let arr = Box::new([0;1000]);
    // 将堆上数组的所有权转移给 arr1,由于数据在堆上,因此仅仅拷贝了智能指针的结构体,底层数据并没有被拷贝
    // 所有权顺利转移给 arr1,arr 不再拥有所有权
    let arr1 = arr;
    println!("{:?}", arr1.len());
    // 由于 arr 不再拥有底层数组的所有权,因此下面代码将报错
    // println!("{:?}", arr.len());
}

从以上代码,可以清晰看出大块的数据为何应该放入堆中,此时 Box 就成为了我们最好的帮手。

二、递归数据结构

Rust 需要在编译时知道类型占用多少空间,如果一种类型在编译时无法知道具体的大小,那么被称为动态大小类型 DST。

其中一种无法在编译时知道大小的类型是递归类型:在类型定义中又使用到了自身,或者说该类型的值的一部分可以是相同类型的其它值,这种值的嵌套理论上可以无限进行下去,所以 Rust 不知道递归类型需要多少空间:

Box允许我们创建递归数据结构,如链表和树。例如,创建一个简单的单向链表:

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}

三、动态大小类型(DST)

当我们需要在运行时确定数据大小时,可以使用Box。例如,创建一个动态大小的字符串切片:

fn main() {
    let s: Box<dyn std::fmt::Display> = Box::new("Hello, world!");
    println!("{}", s);
}

四、实现多态

通过Box,我们可以实现多态,使用Trait对象表示不同类型的共享行为。例如,创建一个处理绘图对象的函数:

trait Shape {
    fn area(&self) -> f64;
}

struct Circle {
    radius: f64,
}

struct Square {
    side: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

impl Shape for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

fn print_area(shape: Box<dyn Shape>) {
    println!("Area of the shape is {:.2}", shape.area());
}

fn main() {
    let circle = Circle { radius: 5.0 };
    let square = Square { side: 4.0 };

    print_area(Box::new(circle));
    print_area(Box::new(square));
}

在这个例子中,我们实现了一个处理不同类型形状的print_area函数。Box<dyn Shape>可以接受任何实现了Shape trait的类型。

总之,Box指针在Rust中有很多用途,它可以帮助我们处理复杂的数据结构、内存分配和多态。

Box 内存布局

先来看看 Vec<i32> 的内存布局:

(stack)    (heap)
┌──────┐   ┌───┐
 vec1 │──→│ 1 
└──────┘   ├───┤
            2 
           ├───┤
            3 
           ├───┤
            4 
           └───┘

之前提到过 VecString 都是智能指针,从上图可以看出,该智能指针存储在栈中,然后指向堆上的数组数据。那如果数组中每个元素都是一个 Box 对象呢?来看看 Vec<Box<i32>> 的内存布局:


                    (heap)
(stack)    (heap)   ┌───┐
┌──────┐   ┌───┐ ┌─→│ 1 
 vec2 │──→│B1 │─┘  └───┘
└──────┘   ├───┤    ┌───┐
           B2 │───→│ 2 
           ├───┤    └───┘
           B3 │─┐  ┌───┐
           ├───┤ └─→│ 3 
           B4 │─┐  └───┘
           └───┘   ┌───┐
                 └─→│ 4 
                    └───┘

上面的 B1 代表被 Box 分配到堆上的值 1

可以看出智能指针 vec2 依然是存储在栈上,然后指针指向一个堆上的数组,该数组中每个元素都是一个 Box 智能指针,最终 Box 智能指针又指向了存储在堆上的实际值。

因此当我们从数组中取出某个元素时,取到的是对应的智能指针 Box,需要对该智能指针进行解引用,才能取出最终的值:

fn main() {
    let arr = vec![Box::new(1), Box::new(2)];
    let (first, second) = (&arr[0], &arr[1]);
    let sum = **first + **second;
}

以上代码有几个值得注意的点:

  • 使用 & 借用数组中的元素,否则会报所有权错误
  • 表达式不能隐式的解引用,因此必须使用 ** 做两次解引用,第一次将 &Box<i32> 类型转成 Box<i32>,第二次将 Box<i32> 转成 i32

Box::leak

Box 中还提供了一个非常有用的关联函数:Box::leak,它可以消费掉 Box 并且强制目标值从内存中泄漏,Rust Std 的介绍如下:

pub fn leak<'a>(b: Box<T, A>) -> &'a mut T
where
    A: 'a, 

消耗并泄漏 Box,返回一个可变引用,&'a mut T。 请注意,类型 T 必须超过所选的生命周期 'a。 如果类型仅具有静态引用,或者根本没有静态引用,则可以将其选择为 'static

该函数主要用于在程序的剩余生命期内保留的数据。 丢弃返回的引用将导致内存泄漏。 如果这是不可接受的,则应首先将引用与 Box::from_raw 函数包装在一起,生成 Box

这个 Box 可以被丢弃,这将正确销毁 T 并释放分配的内存。

Note: 这是一个关联函数,这意味着您必须将其称为 Box::leak(b) 而不是 b.leak()。 这样就不会与内部类型的方法发生冲突。

简单用法:

let x = Box::new(41);
let static_ref: &'static mut usize = Box::leak(x);
*static_ref += 1;
assert_eq!(*static_ref, 42);

未定义大小的数据:

let x = vec![1, 2, 3].into_boxed_slice();
let static_ref = Box::leak(x);
static_ref[0] = 4;
assert_eq!(*static_ref, [4, 2, 3]);

解读一下这个例子:使用 Box::leak 函数将 x 转换为一个静态生命周期的引用。Box::leak 函数泄漏了 x(使其无法在程序运行期间被回收),并返回一个指向数据的静态引用。static_ref 变量现在是一个指向堆上分配的切片 [1, 2, 3] 的静态引用。这段代码展示了如何将一个向量转换为一个 boxed slice,使用 Box::leak 函数使其具有静态生命周期,然后修改切片内容并检查其值。

那“强制内存泄漏”在具体实践中有什么用呢?其实还真有点用,例如,你可以把一个 String 类型,变成一个 'static 生命周期的 &str 类型:

fn main() {
   let s = gen_static_str();
   println!("{}", s);
}

fn gen_static_str() -> &'static str{
    let mut s = String::new();
    s.push_str("hello, world");

    Box::leak(s.into_boxed_str())
}

“在之前的代码中,如果 String 创建于函数中,那么返回它的唯一方法就是转移所有权给调用者 fn move_str() -> String,而通过 Box::leak 我们不仅返回了一个 &str 字符串切片,它还是 'static 生命周期的!

要知道真正具有 'static 生命周期的往往都是编译期就创建的值,例如 let v = "hello, world",这里 v 是直接打包到二进制可执行文件中的,因此该字符串具有 'static 生命周期,再比如 const 常量。

又有读者要问了,我还可以手动为变量标注 'static 啊。其实你标注的 'static 只是用来忽悠编译器的,但是超出作用域,一样被释放回收。而使用 Box::leak 就可以将一个运行期的值转为 'static

光看上面的描述,大家可能还是云里雾里、一头雾水。

那么我说一个简单的场景,你需要一个在运行期初始化的值,但是可以全局有效,也就是和整个程序活得一样久,那么就可以使用 Box::leak,例如有一个存储配置的结构体实例,它是在运行期动态插入内容,那么就可以将其转为全局有效,虽然 Rc/Arc 也可以实现此功能,但是 Box::leak 是性能最高的。”

举例如下:

首先,我们定义一个存储配置的结构体:

struct Configuration {
    setting1: String,
    setting2: i32,
}

然后,让我们动态地创建一个 Configuration 实例:

fn create_configuration() -> Configuration {
    Configuration {
        setting1: String::from("Example setting"),
        setting2: 42,
    }
}

接下来,我们使用 Box::leak 函数将 Configuration 实例转换为一个全局有效的引用。为此,我们需要将实例分配在堆上(使用 Box),然后使用 Box::leak 函数将 Box 泄漏(将其转换为一个静态引用),使其生命周期与整个程序相同:

fn main() {
    let config = Box::new(create_configuration());
    let leaked_config: &'static Configuration = Box::leak(config);

    // 使用 `leaked_config` 的内容...
}

在这个例子中,虽然我们可以使用 RcArc 来实现类似的功能,但 Box::leak 提供了更高的性能。这是因为 Box::leak 不涉及引用计数和线程同步(RcArc 需要这些功能),所以使用 Box::leak 的开销更小。

需要注意的是,使用 Box::leak 会导致内存泄漏,因为泄漏的对象在程序运行期间无法回收。然而,在这种情况下,我们希望这个值在整个程序运行期间保持有效,所以这个内存泄漏在这个特定场景下是可以接受的。

Box实现

Rust 的 Box 智能指针是通过一些核心特性和内存管理技术实现的。以下是关于其实现的详细说明:

  1. 包装原始指针Box 是一个包装了原始指针的结构体,它封装了一个指向堆内存的原始指针。当创建一个 Box 时,它会将原始指针包装在一个安全的类型中。

    // `Box` 的定义来自于 Rust 标准库
    pub struct Box<T: ?Sized>(Unique<T>);
    

    这里,Unique<T> 是一个包装了原始指针的内部类型。它保证了指针的唯一性,即同一时刻没有其他智能指针指向相同的堆内存。

  2. 内存分配Box 的内存分配通过 Box::new 函数实现。该函数会在堆上为给定值分配内存,并将值移动到堆上。例如:

    let boxed_value = Box::new(42);
    

    在这个例子中,Box::new 函数会在堆上分配内存空间,并将值 42 移动到该内存空间中。

  3. 实现 Deref 和 DerefMut traits:为了让 Box 能够访问和修改其封装的值,它实现了 DerefDerefMut traits。这允许我们使用解引用操作符(*)来访问和修改封装的值。

    例如,访问 Box 中的值:

    let boxed_value = Box::new(42);
    let value = *boxed_value;
    println!("value: {}", value);
    

    修改 Box 中的值:

    let mut boxed_value = Box::new(42);
    *boxed_value += 1;
    println!("value: {}", *boxed_value);
    
  4. 实现 Drop trait:为了在不需要时自动释放 Box 分配的堆内存,Box 实现了 Drop trait。当 Box 离开作用域时,Rust 编译器会自动调用 Drop trait 的 drop 方法,从而释放堆内存。

    impl<T: ?Sized> Drop for Box<T> {
        fn drop(&mut self) {
            // 实现内存的释放
        }
    }
    

此外,Box 可以处理不定大小类型(DST,Dynamically Sized Types)和强制将数据存储在堆上的场景。这使得 Box 成为一种灵活、安全且高效的内存管理工具。

小结

综上所述,Rust 的 Box 智能指针是通过包装原始指针、实现内存分配和释放以及实现 DerefDerefMut traits 来实现的。这些特性使得 Box 成为一种安全、高效且易于使用的内存管理工具。Box指针在Rust 中有很多用途,它可以帮助我们处理复杂的数据结构、内存分配和多态。

Box 背后是调用 jemalloc 来做内存管理,所以堆上的空间无需我们的手动管理。与此类似,带 GC 的语言中的对象也是借助于 Box 概念来实现的,一切皆对象 = 一切皆 Box, 只不过我们无需自己去 Box 罢了。

最后再补充一下jemallac的一些简单知识:

“jemalloc is a general purpose malloc(3) implementation that emphasizes fragmentation avoidance and scalable concurrency support. jemalloc first came into use as the FreeBSD libc allocator in 2005, and since then it has found its way into numerous applications that rely on its predictable behavior. In 2010 jemalloc development efforts broadened to include developer support features such as heap profiling and extensive monitoring/tuning hooks. Modern jemalloc releases continue to be integrated back into FreeBSD, and therefore versatility remains critical. Ongoing development efforts trend toward making jemalloc among the best allocators for a broad range of demanding applications, and eliminating/mitigating weaknesses that have practical repercussions for real world applications.”

jemalloc 是一种通用的 malloc(3) 实现,强调避免碎片化和可扩展的并发支持。jemalloc 最早于 2005 年作为 FreeBSD libc 分配器开始使用,此后它已经被许多依赖其可预测行为的应用程序所采用。2010 年,jemalloc 的开发工作扩展到了包括堆分析和广泛的监控/调优挂钩等开发者支持功能。现代的 jemalloc 版本继续被整合回 FreeBSD,因此多功能性仍然至关重要。持续的开发努力趋向于使 jemalloc 成为广泛的苛刻应用程序中最佳分配器之一,并消除/缓解对实际应用程序产生实际影响的弱点。

jemalloc 指的是一种高性能、多平台的内存分配器,它提供了一种替代 C 语言标准库中的默认内存分配器(例如 malloccallocreallocfree 等函数)的方法。jemalloc 被设计用于在多线程环境下提高内存分配性能,减小内存碎片,并提供可扩展性。它广泛应用于许多知名项目中,例如 Firefox、Rust 编程语言和 Redis。

jemalloc 的主要优势包括:

  1. 多线程支持jemalloc 能够在多线程环境下有效地处理内存分配和释放,以减少锁争用和提高性能。
  2. 内存碎片减少jemalloc 使用不同大小的内存块来分配内存,这有助于减少内存碎片,从而提高内存利用率。
  3. 可扩展性jemalloc 设计用于在多处理器系统上扩展,以支持大量并发内存分配操作。

接下来通过一个简单的 C 语言示例展示如何使用 jemalloc。首先,确保你已经安装了 jemalloc 库。接着,创建一个名为 main.c 的文件,并输入以下内容:

#include <stdio.h>
#include <jemalloc/jemalloc.h>

int main() {
    // 使用 je_malloc 分配内存
    int *array = (int *)je_malloc(sizeof(int) * 10);

    if (array == NULL) {
        printf("Memory allocation failed.\n");
        return 1;
    }

    // 向分配的内存写入数据
    for (int i = 0; i < 10; i++) {
        array[i] = i;
    }

    // 读取分配的内存中的数据
    for (int i = 0; i < 10; i++) {
        printf("%d ", array[i]);
    }
    printf("\n");

    // 使用 je_free 释放内存
    je_free(array);

    return 0;
}

在这个示例中,我们首先包含 jemalloc/jemalloc.h 头文件,以便使用 jemalloc 提供的内存分配函数。我们使用 je_malloc 函数分配内存,然后像往常一样使用分配的内存。最后,我们使用 je_free 函数释放内存。

要编译这个示例程序,请使用以下命令:

gcc main.c -o main -ljemalloc

这将生成一个名为 main 的可执行文件。运行此文件,你将看到如下输出:

0 1 2 3 4 5 6 7 8 9

总之,jemalloc 是一种高性能的内存分配器,它在多线程环境下提供了优良的性能、减少内存碎片和良好的可扩展性。

// 等待更新其他智能指针…

Reference

  1. GPT - 4
  2. https://course.rs/advance/smart-pointer/intro.html
  3. https://kaisery.github.io/trpl-zh-cn/ch15-01-box.html
  4. https://rustwiki.org/zh-CN/std/boxed/struct.Box.html#method.leak
  5. https://jemalloc.net/