(四)Rust内存管理

426 阅读17分钟

注意:本教程面向于有语言基础的人员,重点对一些陌生概念和一些差距大的语法进行解释。

关联知识介绍

堆和栈对比:

特性堆(Heap)栈(Stack)
内存分配方式动态分配(在程序运行时分配),分配的内存通常较大且灵活,由操作系统控制分配大小静态分配(在编译时分配),分配的内存通常较小且固定,受栈大小限制(操作系统/硬件规定)
内存管理需要手动管理,可能导致内存泄漏或碎片化自动管理,调用结束时栈自动清理
性能分配和释放速度较慢,因为需要复杂的内存管理机制。由于内存分散,访问速度较慢且可能导致缓存未命中。分配和释放速度快,因为只需要移动栈指针。访问速度较快,由于内存布局连续,且通常使用栈指针进行管理。
安全性内存需要程序员自己管理/依赖垃圾回收机制,内存分配和释放是随机的,可能产生内存碎片,管理不当容易出现内存泄漏、悬垂指针、双重释放等问题内存安全由编译器保证,内存分配和释放是顺序的, 不会产生内存碎片。栈空间较小,容易出现栈溢出问题导致内存混乱、程序崩溃等问题。
适用场景动态对象、需要灵活分配的内存函数调用、局部变量存储

操作系统如何管理栈内存:

  • 分配机制:栈内存由操作系统自动分配和管理(栈指针的移动),每个线程有独立的栈空间,大小固定且内存连续。
  • 保护机制:操作系统通过栈溢出检测和保护页机制防止栈溢出,避免内存遭到破坏。
  • 释放机制:栈内存自动释放,函数返回时栈帧释放,线程结束时操作系统回收栈内存。

操作系统如何管理堆内存:

  • 分配机制:堆内存由程序员或运行时系统动态分配(mallocfree),内存块不连续且大小灵活,可占用大部分可用内存。
  • 保护机制:操作系统通过虚拟内存和页表机制管理堆内存,确保进程独立性和内存访问安全。
  • 释放机制:堆内存需手动释放(如 C/C++)或通过垃圾回收自动释放(如 Java的分代垃圾回收、Go的三色标记清除算法)。

所有权系统

什么是所有权?

所有权是指 Rust 中每个值都有一个变量作为其所有者,所有者负责管理值的内存生命周期。所有权的规则确保内存的分配和释放是明确且安全的。

为什么会有所有权?

在C/C++中,内存管理依赖人为手动管理(如 malloc/free 或 new/delete)。如果稍微不注意,就容易出现内存方面的问题(如内存泄露、悬垂指针、空指针、数据竞争等)。

为了解决这些问题,一些语言(如 Java、Go)引入了垃圾回收机制,但垃圾回收会带来运行时开销和不可预测的停顿时间,性能较低。

为了同时保障性能和安全性,Rust在编译期检查内存是否正常使用,这就避免了在运行时进行垃圾回收的开销。

为了实现编译期检查这套逻辑,Rust 引入了所有权这一概念。所有权系统通过一系列严格的规则来进行约束,以便Rust进行编译时的静态分析,确保内存安全且无需垃圾回收。

所有权的三大规则(核心)

  • 每个值都有一个所有者。当变量被创建时,它就拥有了分配给它的值,并且在变量的作用域结束时,值会被自动清理。

    fn main() {
        let s = String::from("hello"); // s 是字符串 "hello" 的所有者
        // 在这里,s 是有效的
        println!("{}", s); // 可以使用 s
    } // s 的作用域结束,s 被丢弃
    
  • 每个值同一时间只能有一个所有者(但是一个所有者能拥有多个值。Eg:数组)。当你将一个值赋给另一个变量时,所有权会从原来的变量转移到新的变量,原来的变量将不再有效。

    fn main() {
        let s1 = String::from("hello"); // s1 是字符串 "hello" 的所有者
        let s2 = s1; // 所有权从 s1 转移到 s2
        // 此时 s1 不再有效,尝试使用 s1 会导致编译错误
        println!("{}", s2); // 可以使用 s2
    } // s2 的作用域结束,s2 被丢弃
    
  • 当所有者离开作用域时,值将被丢弃。当所有者变量离开其作用域时,Rust 会自动调用 drop 方法来清理资源。这意味着所有者变量所拥有的值将被丢弃,释放其占用的内存。

    fn main() {
        { // 创建一个作用域
            let s = String::from("hello"); // s 是字符串 "hello" 的所有者
            // 在这里,s 是有效的
            println!("{}", s); // 可以使用 s
        } // s 的作用域结束,s 被丢弃
        // s 在这里不再有效,尝试使用 s 会导致编译错误
        // println!("{}", s); // 编译错误
    }
    

所有权的转移机制

问题:在传统编程语言(如C/C++)中,手动管理内存和资源容易导致重复释放、悬空指针、资源管理混乱以及代码语义不明确等问题,如何解决这些内存安全和资源管理的挑战?

Rust的解决方案:通过所有权转移机制,确保每个值有且仅有一个所有者,当所有权转移后,原变量失效,资源在所有者离开作用域时自动释放。

首先记住一句话:同一时间只能有一个所有者。

其次记住转移的条件:值的类型 未实现 Copy trait,有接收者 接收这个值。

trail:Rust 中定义共享行为的机制,类似于其他语言中的接口(Interface),用于为不同类型实现相同的方法集合。

Copy trait :它实现了Clone ,是一个标记 trait ,用于表示一个类型的值可以通过简单的位复制来复制,而不会转移所有权。

值的接收者:变量、结构体、枚举、数组和元组、指针、函数、线程

未实现 Copy trait的类型:

  • 堆分配的类型,字符串、集合、指针(StringVec<T>HashMapBox<T> …)

  • 文件句柄和网络资源(FileTcpStream …)

  • 特别:复合类型(数组、元组)、自定义类型(枚举、结构体)中的值/类型不包含以上类型也属于未实现Copy trait的类型

    // 正例 
    let mut arr = [1, 2, 3]; // [i32; 3] 实现了 Copy
    let arr2 = &mut arr; // arr 的值被复制到 arr2
    println!("{:?}", arr); // 正确:arr 仍然有效
    // 反例
    struct MyStruct {
        value: String, // String 未实现 Copy
    }
    let s1 = MyStruct { value: String::from("hello") };
    let s2 = s1; // 所有权从 s1 转移到 s2
    // println!("{}", s1.value); // 错误:s1 的所有权已经转移
    

实现 Copy trait的类型:

  • 基本类型(整型、浮点型、布尔型、字符型)

如何避免所有权转移?

  • 使用借用机制(引用)&T / &mut T
  • 使用clone() 创建副本
  • 使用智能指针共享所有权Rc<T>Arc<T> (这个后面会讲)

所有权的借用机制

问题:在传统编程语言(如C/C++)中,共享访问数据时容易导致数据竞争、悬空指针以及复杂的资源管理问题,如何在不转移所有权的情况下安全地共享访问数据?

Rust的解决方案:通过借用机制,允许在不转移所有权的情况下共享访问数据。

所有权的借用机制可以允许你在不转移所有权的情况下使用值,它是基于引用实现的(可以理解为就是引用)。

不可变借用: 通过 &T 创建的引用,允许读取数据但不能修改数据。

可变借用: 通过 &mut T 创建的引用,允许读取和修改数据。

借用机制规则: 同一时间,要么只有一个可变引用,要么有多个不可变引用。

let mut s1 = String::from("hello");
let r1 = &mut s1;
// let r2 = &mut s1; // 错误:不能同时有两个可变引用
let mut s2 = String::from("world");
let r2 = &s2; 
let r3 = &s2; // 合法:可以有多个不变引用

所有权的生命周期机制

问题:在传统编程语言(如C/C++)中,引用或指针的有效性难以保证,容易导致悬空指针、内存安全问题以及资源管理混乱,如何确保引用在其有效期内始终指向合法的数据?

Rust的解决方案:通过生命周期机制,显式或隐式地标注引用的有效期,确保引用在其生命周期内始终有效。

生命周期是 Rust 中用来描述引用有效作用域的概念。简单来说,生命周期决定了某个引用在程序的哪个部分是有效的。

在 Rust 中,引用(&)是一种指向数据的指针,但它并不拥有数据的所有权。为了避免引用指向的数据被释放后仍然被使用(即悬垂引用),Rust 引入了生命周期来确保引用的有效性。

fn main(){
    let r;
    { // 创建一个作用域
        let x = 5;
        r = &x; // r 引用 x
    } // x 在这里被释放
    // println!("r: {}", r); // 错误:r 引用的 x 已经被释放
}

在这个例子中,r 引用了 x,但 x 在内部作用域结束后被释放,导致 r 成为一个悬垂引用。Rust 的编译器会检测到这种问题并报错。

生命周期的标注

在大多数情况下,Rust 编译器可以自动推导生命周期,但在某些情况下(函数返回引用时、结构体包含引用时…)编译器难以推导生命周期,这时候就需要程序员手动标注生命周期。

生命周期标注的语法以 ' 开头,通常使用小写字母表示,如 'a。生命周期标注不会改变引用的实际生命周期,它只是告诉编译器多个引用之间的关系。

例子:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

在这个例子中,函数返回一个引用,但编译器无法确定返回的引用是来自 x 还是 y,因此需要手动标注生命周期 'a,表示返回的引用至少和 x 和 y 中生命周期较短的那个一样长。

struct ImportantExcerpt<'a> {
    part: &'a str,
}

在这个例子中,ImportantExcerpt 结构体包含一个引用字段 part,因此需要标注生命周期 'a,表示结构体实例的生命周期不能超过 part 的生命周期。

除此之外,Rust 中有一个特殊的生命周期 'static,表示引用在整个程序运行期间都有效。例如,字符串字面量的生命周期就是 'static,因为它们被硬编码到程序的二进制文件中,永远不会被释放。

let s: &'static str = "我是一个静态生命周期的字符串";

更加灵活的内存管理

由于篇幅限制,这里只会讲解基本的概念和用法,具体如何使用什么时候用可以自己去查。(或者等后面更新🌹)

智能指针

为什么需要智能指针?

Rust 的所有权和借用系统确保了内存安全性,但有些场景下,数据需要被多个部分共享或者需要动态分配内存,它们能够提供对内存的精细控制。

Box<T> 指针

用于在堆上分配内存。通过 Box,可以在堆上存储数据并确保该数据在使用结束后自动释放。一般用于需要动态存储大小的场景(动态数组、大数组…)

fn main() {
    let s = String::from("Hello, world!");
    let boxed_str: Box<str> = s.into_boxed_str(); // 转换为 Box<str>
    println!("{}", boxed_str);
}

Rc<T> 指针

它是一个非线程安全的引用计数智能指针(不可变),用于在单线程环境中实现多个所有者共享同一个数据。

use std::rc::Rc;
fn main() {
    let a = Rc::new(5); // 创建一个 Rc
    let b = Rc::clone(&a); // 克隆了一份智能指针(a和b是一个指针),增加引用计数
    println!("a = {}, b = {}", a, b); // 减少a和b的引用计数
} // a和b的引用计数为 0 时,内存释放

Arc<T> 指针

它是一个线程安全的引用计数智能指针(不可变),用于在多线程环境中共享数据。

use std::sync::Arc;
use std::thread;

fn main() {
    let a = Arc::new(5); // 在堆上分配一个整数 5,并用 Arc 包装它
    let b = Arc::clone(&a); // 克隆 Arc,增加引用计数
    let handle = thread::spawn(move || { // 创建一个新线程
        println!("a in thread = {}", a);
    });
    handle.join().unwrap(); // 等待线程结束
    println!("b in main = {}", b);
}

Mutex<T> 指针

它是一个用于线程同步的智能指针(可变),通过互斥锁来确保一次只有一个线程可以访问其中的数据,通常配合Arc 一起使用,以便在多线程环境中共享数据。

use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0));
let counter_clone = Arc::clone(&counter)
thread::spawn(move || {
    let mut num = counter_clone.lock().unwrap();
    *num += 1;
});

RefCell<T> 指针

它提供了内部可变性,允许在不可变上下文的情况下修改数据,通常配合Rc<T>指针一起使用(Rc<RefCell<T>>),以实现共享的可变数据(递归数据结构(树、链表)会用到)。

use std::rc::Rc;
use std::cell::RefCell;
fn main() {
    let data = Rc::new(RefCell::new(5)); // 创建一个 Rc<RefCell<T>>,共享可变数据
    let data_clone = Rc::clone(&data); // 克隆 Rc,增加引用计
    *data.borrow_mut() += 1; // 修改数据
    println!("data: {}", data.borrow()); // 输出 6
    println!("data_clone: {}", data_clone.borrow()); // 输出 6
}

其他的还有:Weak<T> (非强引用,用于解决循环引用)、RwLock<T> (读写锁)、Cell<T> (提供内部可变性)、NonNull<T> (表示一个非空指针)、Pin<P> (固定堆上的值)、Cow<T> (写时复制)。

Unsafe 代码块

Rust 语言中用于绕过编译器安全检查的机制。Rust 的所有权和借用规则在大多数情况下可以保证内存安全,但是由于这些规则的限制,在开发某些功能时会大大增加开发难度(双向链表就是个很好的例子)。

Unsafe 代码块中,这些操作将被允许:

  • 解引用裸指针(*const T和 *mut T)。
  • 调用 unsafe 函数。
  • 访问或修改可变静态变量。
  • 实现 unsafe trait。
use std::fmt;
unsafe trait MyUnsafeTrait { // 实现 unsafe trait
    fn do_something(&self);
}
unsafe impl MyUnsafeTrait for i32 {
    fn do_something(&self) {
        println!("Doing something unsafe with {}", self);
    }
}
unsafe fn dangerous_function(ptr: *mut i32) {
    *ptr += 1; // 解引用裸指针并修改值
}
static mut COUNTER: i32 = 0;
fn main() {
    unsafe {
        dangerous_function(raw_ptr); // unsafe内调用unsafe函数
        println!("x : {}", x); // 输出 44
        COUNTER += 1; // 访问和修改可变静态变量
        println!("COUNTER after modification: {}", COUNTER); // 输出 1
        let value = 42;
        value.do_something(); // 调用 unsafe trait 的方法
    }
}

Allocator 内存管理器

用于管理内存分配和释放的组件。Rust的标准库提供了默认的全局内存管理器,但允许用户自定义内存管理器,以满足特定的内存管理需求。(一般用不上,给大佬用的)

定义在 std::alloc 模块中,提供了灵活的内存管理功能,包括:

  • allocate:分配内存。
  • deallocate:释放内存。
  • grow 和 shrink:调整已分配内存块的大小。

Rust如何解决内存安全问题

在了解了Rust强大的所有权系统过后,让我们看看Rust是如何利用所有权系统解决内存安全问题的。

野指针

指向不再有效的内存位置的指针。野指针通常指向非法内存地址或未初始化的内存区域(悬垂指针也是一种野指针),访问野指针可能导致未定义行为、程序崩溃或数据损坏。

悬垂指针是指指针指向的内存已经被释放,但指针仍然保留着该内存地址。访问悬垂指针会导致未定义行为。

Rust解决方法: Rust 通过所有权系统确保每个值都有一个明确的所有者,并在编译时通过借用检查器验证所有引用的生命周期和有效性,避免了野指针的产生。

fn main() {
		let x: &i32; // 未初始化的引用
		// println!("{}", x); // 编译错误:使用了未初始化的引用
		let y = String::from("hello");
		x = &y;
		// x 被借用,不能再移动其所有权,否则会变得悬垂
		// let z = x; // 这里会报错,因为 x 被借用中
		println!("{}", y); // 安全地访问借用的值
}

数据竞争

数据竞争是指在多线程环境中,当两个或多个线程同时访问同一个内存位置,并且至少有一个线程在进行写操作时,访问的顺序是未定义的,从而导致程序行为不确定的情况。

Rust解决方法: Rust的所有权系统利用了借用机制,确保在任何给定时间,只有一个线程可以拥有对某个数据的可变引用。

use std::thread;
fn main() {
    let mut data = vec![1, 2, 3]; // 创建一个可变的 Vec
    // 尝试在多线程中修改 data
    let handle = thread::spawn(move || {
        data.push(4); // 在子线程中修改 data
    });
    // 主线程尝试访问 data
    println!("{:?}", data); // 编译错误:data 的所有权已经转移到子线程
    handle.join().unwrap(); // 等待子线程结束
}

如果需要多个线程共享数据,Rust也有解决方案:使用Arc<T>+Mutex (这里之后会讲,现在就不多说了)

缓冲区溢出

缓冲区溢出是指向缓冲区写入的数据超出了其分配的大小,覆盖了相邻的内存区域。

Rust解决方法: Rust 在编译时对数组和切片的访问做边界检查,确保不会发生缓冲区溢出。当你尝试访问一个超出范围的索引时,编译器会报错,或者运行时会发生 panic(动态索引的情况)。

fn main() {
    let arr = [1, 2, 3];
    println!("{}", arr[5]); // 运行时 panic: index out of bounds
}

内存泄露

内存泄漏是指程序分配的内存未被释放,导致内存占用不断增加,最终耗尽系统内存。

Rust解决方法: Rust 使用所有权和生命周期机制自动管理内存,确保每个对象的内存只有一个所有者,且在所有者离开作用域时自动释放内存。

fn main() {
    let x = String::from("hello");
    // x 的所有权在此函数结束时自动释放
} // 离开作用域,x 被自动销毁

双重释放

双重释放是指同一块内存被多次释放,导致内存管理数据结构被破坏。

Rust解决方法: Rust 的所有权规则确保每块内存只有一个所有者,且在所有者离开作用域时自动释放内存。这样,双重释放问题就无法发生,因为内存在转移所有权后不会被再次释放。

fn main() {
    let s = String::from("hello");
    let t = s; // 所有权转移
    // println!("{}", s); // 错误:无法再使用 s,因为它的所有权已经转移
}

空指针

空指针是指指针的值为 NULL 或 nullptr,表示它没有指向任何有效的内存地址。访问空指针会导致程序崩溃。

Rust解决方法: 使用 Option<T> 类型来显式表示可能为空的值。Option<T> 是一个枚举,它有两个变体:Some(T)None,它强制开发者在访问可能为空的值时做空值检查,避免了空指针解引用的问题。

fn main() {
    let x: Option<i32> = Some(5);
    let y: Option<i32> = None;
    match x {
        Some(v) => println!("x is {}", v),
        None => println!("x is None"),
    }
    Result
    // 访问空值时会报错
    // println!("{}", y.unwrap()); //会 panic: called `Option::unwrap()` on a `None` value
}