文章目录
-
- 概览:为什么要关心内存模型?
- [一、所有权(Ownership)------Rust 的核心](#一、所有权(Ownership)——Rust 的核心)
- 二、Drop(析构)顺序
-
- [Rust 规则](#Rust 规则)
- [C++ 规则](#C++ 规则)
- 三、借用(Borrowing)与引用(References)
- [四、内部可变性(Interior Mutability)](#四、内部可变性(Interior Mutability))
-
- [Rust 模式](#Rust 模式)
- C++
- 五、生命周期(Lifetimes)与泛型生命周期
-
- [Rust 概念](#Rust 概念)
- [C++ 对比](#C++ 对比)
- 六、泛型、方差(Variance)与复杂借用场景
- [七、并发模型详解(Rust ↔ C++ 对比)](#七、并发模型详解(Rust ↔ C++ 对比))
-
- [1. Rust 的并发模型要点](#1. Rust 的并发模型要点)
- [2. C++ 的并发模型要点](#2. C++ 的并发模型要点)
- [3. 对比](#3. 对比)
- [4. 常见误区与建议](#4. 常见误区与建议)
-
- 常见误区
- [建议(在 Rust 中)](#建议(在 Rust 中))
- [实战建议(在 C++ 中)](#实战建议(在 C++ 中))
- [八、移植/互操作建议(从 C++ 到 Rust)](#八、移植/互操作建议(从 C++ 到 Rust))
概览:为什么要关心内存模型?
无论是 Rust 还是 C++,内存模型决定了:谁负责分配/释放资源;什么时候发生析构;并发时如何避免数据竞争。Rust 的设计目标是把许多常见的 C++ 错误(悬垂指针、双重释放、数据竞争)在编译期捕捉到,而不是把问题留到运行时或测试阶段。理解差异能让你更快写出既高效又安全的系统级代码
一、所有权(Ownership)------Rust 的核心
Rust 要点
- 唯一所有者 :每个值有且仅有一个所有者(通常是一个变量或一个作用域)。所有者离开作用域时会自动
drop
(析构)。 - 移动(move) :把一个值赋给另一个变量会把所有权转移(除非类型实现了
Copy
)。移动后旧的绑定不可再使用。 - Copy trait:允许按位复制(例如整数、浮点数、某些小的结构),复制后原有绑定仍可用
示例(Rust)
rust
let x1 = 42; // Copy
let y1 = Box::new(84); // 非 Copy(堆分配)
{
let z = (x1, y1); // x1 被复制进 z,y1 被移动进 z
}
// x1 仍可用,y1 已被移动,无法再访问
C++ 对比(直观映射)
- C++ 并没有语言层面的"移动之后,原先变量就不可用"的强制(C++11 引入了移动语义与
std::move
,但这是程序员可选择的行为)。 - 在 C++ 中:
- 原始对象赋值通常是拷贝(或调用拷贝构造);
std::move
表示移动语义(把资源的所有权从一个对象转移到另一个对象)。 - 如果错误地复制了拥有资源的句柄(例如裸指针),会导致双重释放。
- 原始对象赋值通常是拷贝(或调用拷贝构造);
示例(C++)
cpp
std::unique_ptr<int> p1 = std::make_unique<int>(84);
// auto p2 = p1; // 编译错误:unique_ptr 不可拷贝
auto p2 = std::move(p1); // p1 变为空指针,p2 拥有资源
// p1 不再可用(即为空),p2 在离开作用域时释放
映射总结 :Rust 的所有权语义与 C++ 的 unique_ptr
/std::move
很像,但 Rust 把这类规则内建进了语言/类型系统,编译器强制执行;而 C++ 依赖类型设计与程序员约定。
二、Drop(析构)顺序
Rust 规则
Rust 在 内存安全 的设计上,对析构顺序有非常明确的规则:
-
局部变量(绑定)
- 按逆序析构:后声明的先析构,先声明的后析构。
- 这样能避免"早释放"的情况。例如,如果变量 A 引用了变量 B,那么 A 必须在 B 之前析构,否则会产生悬垂引用。
rustfn main() { let a = String::from("hello"); let b = &a; println!("{}", b); } // 先析构 b(引用),再析构 a(值),安全
-
复合值(元组、结构体、数组)
- 字段按源码顺序析构:先第一个字段,再第二个字段,以此类推。
- 这是因为复合值的内部通常不能产生自引用,Rust 可以放心采用"看上去更自然"的顺序。
ruststruct Pair { a: String, b: String, } fn main() { let p = Pair { a: String::from("foo"), b: String::from("bar") }; } // 先析构 a,再析构 b
⚠️ 需要注意:Rust 的 局部变量整体 还是按逆序析构,但单个复合值内部是源码顺序。这种区分容易让 C++ 背景的开发者搞混。
C++ 规则
C++ 的规则和 Rust 有共通之处,但在细节上不同:
-
局部对象
-
也遵循逆序析构。这是 RAII 的基础:作用域退出时会自动调用析构函数,顺序和构造相反。
-
例如:
cppint main() { std::string a = "hello"; std::string b = "world"; } // 先析构 b,再析构 a
-
-
类/结构体成员
-
成员析构顺序是反声明顺序:即最后声明的先析构。
-
这与 Rust 不同,Rust 使用源码顺序。
-
例如:
cppstruct Pair { std::string a; std::string b; Pair() : a("foo"), b("bar") {} }; int main() { Pair p; } // 先析构 b,再析构 a
-
对比 Rust,Rust 是先 a 后 b,C++ 是先 b 后 a。这点在移植或写 FFI 时非常关键。
-
-
数组元素
- C++ 中数组元素是从最后一个到第一个析构,即反构造顺序。
- Rust 中数组是按索引顺序析构(先 0,再 1 ...)。
cppint main() { std::string arr[2] = {"foo", "bar"}; } // 先析构 arr[1] = "bar",再析构 arr[0] = "foo"
三、借用(Borrowing)与引用(References)
借用是 Rust 的重要概念:允许临时借用而不转移所有权。
Rust 的借用语义
- 共享借用
&T
多个并存,均不可修改,被借用期间值不可被更改。编译器可假定其不可变。 - 可变借用
&mut T
独占借用,任意时刻只能有一个&mut
,且不能同时存在并行的&T
。 - 借用合法性由 借用检查器 在编译期验证(包括互斥和生命周期)。
例子(Rust)
rust
fn foo(x: &i32, y: &mut i32) {
// 编译器确保 x, y 不会同时指向同一块可变数据
}
典型陷阱:方法签名放大可变性需求
在 Rust 中,方法的 &mut self
表示调用该方法需要对整个对象的独占访问。
这在某些场景下会导致借用规则显得"过于严格"。例如:
rust
use std::cell::RefCell;
struct MyStruct {
data: RefCell<i32>,
}
impl MyStruct {
fn f2(&mut self) {
// 假设只是一个内部修改的逻辑
println!("f2 called");
}
fn f1(&mut self) {
// 这里尝试借用 RefCell 的可变引用
let mut d = self.data.borrow_mut();
*d += 1; // 但是调用 f2 时需要 &mut self
self.f2(); // ❌ 编译错误
}
}
为什么错?
self.f1
已经持有了一个&mut self
,- 调用
self.data.borrow_mut()
需要&self
, - 这两者在编译器的规则下冲突:不能同时持有
&mut self
和&self
,即使底层是RefCell
可以通过运行时检查解决。
这就是 Rust 借用规则的一个设计限制 :方法签名里的 &mut self
可能比真正需要的可变性更强。
对策
- 将
f2
的签名改为fn f2(&self)
并用RefCell
内部处理可变性; - 或者调整调用逻辑,缩短
RefMut
的作用域,在调用f2
前释放。
rust
fn f1(&mut self) {
{
let mut d = self.data.borrow_mut();
*d += 1;
} // d 在这里被 drop
self.f2(); // ✅ 可以编译
}
C++
- C++ 的引用(
T&
)语法类似,但 没有编译器层面的借用检查。 const T&
类似于 Rust 的&T
(只读借用),但可以通过const_cast
绕开。- C++ 不会阻止这种"同时借用可变与不可变"的问题,可能导致悬垂引用或数据竞争。
例子(C++)
cpp
#include <iostream>
#include <memory>
struct MyStruct
{
int value = 0;
void f2()
{
std::cout << "f2 called\n";
}
void f1()
{
int* p = &value; // 类似 borrow_mut
*p += 1;
f2(); // C++ 不会报错,但如果有并发,这里可能出问题
}
};
C++ 在这种情况下 完全依赖程序员自律,而 Rust 的借用规则会在编译期强制检查,避免潜在的不安全行为。
映射提示
- 把 Rust 的
&T
看作 C++ 的const T&
(多个只读引用并存)。 - 把 Rust 的
&mut T
看作 C++ 的T&
,但 Rust 会强制保证独占性,C++ 则不会。 - Rust 的
RefCell
/Rc<RefCell<T>>
之类类型提供 运行时的可变性检查,在编译器借用规则无法表达的情况下使用。 - C++ 没有类似的机制,常见做法是用裸指针、锁或智能指针来规避编译限制,但安全性要靠开发者自己保证。
四、内部可变性(Interior Mutability)
Rust 模式
有些类型允许在 &T
(共享借用)下修改内部状态:
RefCell<T>
(仅限单线程,运行时借用检查);Mutex<T>
(线程安全的互斥);Cell<T>
(只能整体替换或复制,不暴露引用);
这些类型底层基于UnsafeCell
实现,提供受控的"逃逸口"。
C++
- C++ 也有类似概念:在
const
成员函数中通过mutable
成员来修改内部状态(例如缓存),但这是语言特性,需程序员保证线程安全。 - C++ 中
std::mutex
、std::atomic
提供线程安全机制;mutable
则允许在const
上修改内部缓冲。Rust 把风险封装在明确的类型(RefCell
/Mutex
)里。
五、生命周期(Lifetimes)与泛型生命周期
Rust 概念
- 生命周期并不总等同于词法作用域;更精确地说,生命周期是一个引用必须保持有效的"代码区域"。
- 借用检查器会沿着引用使用点回溯到借用起点,检查在此期间是否产生冲突。生命周期可以有"空洞"(间断地无效),编译器会根据实际 使用位置 来缩短或拉长生命周期。
- 当在类型中存储引用时,通常需要为类型引入生命周期参数(例如
struct Foo<'a>{ r: &'a str }
)。
C++ 对比
- C++ 没有语言级别的生命周期标注;生命周期由变量作用域与程序员推理决定。缺乏显式生命周期检查意味着易出错,尤其是返回局部变量引用或在容器中保存裸引用时。
对比示例
-
Rust(安全):
rustfn foo() -> &i32 { // 编译错误:不能返回指向局部栈上数据的引用 let x = 1; &x }
-
C++(危险):
cppint& foo() { int x = 1; return x; // 编译通过,但运行时为悬垂引用(UB) }
六、泛型、方差(Variance)与复杂借用场景
这个部分比较复杂,后面会单独出一篇讲解
#TODO
七、并发模型详解(Rust ↔ C++ 对比)
并发是系统编程最容易出错的领域之一。Rust 和 C++ 都能写高性能并发代码,但两者的安全模型大不相同:Rust 把并发安全性尽可能移到类型系统/编译期去做检查 ,而 C++ 把更多责任交给程序员/运行时库。
1. Rust 的并发模型要点
Send 与 Sync(核心)
Send
:一种 marker trait 。如果T: Send
,表示把T
的所有权安全地在线程间传递(move)是安全的。Sync
:如果T: Sync
,表示 可以安全地从多个线程同时共享&T
引用 。等价定义:T: Sync
当且仅当&T: Send
。
这两个 trait 大多数是 auto trait ,即编译器会根据类型的字段自动推断是否实现(除非类型使用 unsafe impl
或包含 UnsafeCell
等不安全构件)。
常见直观例子:基本数值类型(i32
、usize
等)通常是 Send + Sync
;Rc<T>
是 非线程安全 的(!Send
, !Sync
),而 Arc<T>
(原子引用计数)是用于跨线程共享的智能指针。
安全保证(safe Rust)
- safe Rust 中不会出现数据竞争(data race):因为编译器在类型/借用层面阻止同时出现未同步的读写。
- 只有使用
unsafe
或底层原语(例如std::ptr::read_volatile
、裸指针、手写原子操作)才可能引入数据竞争/未定义行为。
常用并发构件
-
std::thread::spawn
:用于创建线程。传入的闭包(lambda表达式)必须满足FnOnce() -> T + Send + 'static
(即闭包可被移动到新线程,且不包含对当前栈帧的非'static
引用)。因此通常用move
捕获语义把所有权搬进闭包:ruststd::thread::spawn(move || { // closure body });
-
共享计数:
Arc<T>
(线程安全的引用计数) +Mutex<T>
/RwLock<T>
用于共享可变数据:rustuse std::sync::{Arc, Mutex}; let v = Arc::new(Mutex::new(0)); let v2 = Arc::clone(&v); std::thread::spawn(move || { let mut g = v2.lock().unwrap(); // MutexGuard<T> *g += 1; });
注意:
Mutex::lock()
返回Result
,因为当持锁线程 panic 时 mutex 会被 poisoned(Rust 的 Mutex 提供"污染/poisoning"语义以提示可能不一致状态)。 -
原子类型:
std::sync::atomic::{AtomicUsize, AtomicBool, ...}
,支持不同的内存序(Ordering::SeqCst
、Acquire/Release
、Relaxed
等),用于无锁同步与高性能并发结构。 -
通道(channels):
std::sync::mpsc
或更常用的第三方crossbeam
提供线程间消息传递(更安全、少锁)。
summary
- 将"能否同时访问"约束上升为类型/trait(
Send
/Sync
)能在编译期捕获很多并发错误。 - 你必须显式选择"按值传递(move)"或"按引用共享":Rust 的所有权语义让这件事明确且安全。
RefCell
/Rc
等运行时借用/非原子类型不能 跨线程使用(编译器会阻止),需要用Mutex
/Arc
/RwLock
等线程安全工具替代。
2. C++ 的并发模型要点
C++11 及以后:内存模型与原语
- C++11 引入了正式的内存模型 ,定义了数据竞争(data race)、原子操作、内存序等语义。数据竞争是未定义行为(UB)。
- 标准库提供:
std::thread
、std::mutex
/std::unique_lock
/std::lock_guard
、std::atomic<T>
、std::condition_variable
、std::shared_mutex
等。
程序员责任更重
- C++ 编译器不会在类型层(像
Send
/Sync
那样)阻止不安全的共享。程序员必须使用锁、原子或其他同步工具来保证无数据竞争。 - 捕获闭包/lambda 时选择捕获方式(按引用或按值)决定能否安全跨线程。例如把一个局部变量的引用捕获并在线程中使用,很容易导致悬垂引用(UB)。
典型构件示例
-
启动线程并传值(move):
cppstd::unique_ptr<int> p = std::make_unique<int>(42); std::thread t([p = std::move(p)]() mutable { // use p }); t.join();
-
互斥锁:
cppstd::mutex m; void f() { std::lock_guard<std::mutex> lock(m); // critical section }
-
原子:
cppstd::atomic<int> flag{0}; flag.store(1, std::memory_order_seq_cst); if (flag.load(std::memory_order_acquire) == 1) { ... }
C++ 的
memory_order
与 Rust 的Ordering
概念对等(SeqCst
、Acquire/Release
、Relaxed
等)。
3. 对比
主题 | Rust | C++ |
---|---|---|
编译期并发检查 | Send / Sync 自动/静态检查;safe Rust 保证无数据竞争 |
无等价的语言级 trait;依赖程序员和审查 |
线程创建 | std::thread::spawn(F) 要求 F: Send + 'static (通常用 move ) |
std::thread lambda 捕获由程序员控制(std::move 做移动) |
共享计数 | Rc<T> 仅单线程 ,Arc<T> 跨线程 |
std::shared_ptr 可跨线程(但非原子引用计数操作需注意) |
可变共享 | Arc<Mutex<T>> 、Arc<RwLock<T>> |
std::shared_ptr + std::mutex / std::shared_mutex |
原子操作 | AtomicUsize 等,Ordering 同概念 |
std::atomic<T> ,memory_order |
锁被污染(poisoning) | 有(Mutex 会在持锁线程 panic 时标示为 poisoned) |
没有统一的"poisoning"语义(异常/terminate 情况需要程序员处理) |
数据竞争 | safe Rust 避免,只有 unsafe 可引入 |
数据竞争 = 未定义行为(UB),由程序员避免 |
常见错位 | 无法在编译期捕获 unsafe 内部的 race |
编译器不会阻止不安全的共享,易出错 |
4. 常见误区与建议
常见误区
- "Arc 自动保证内部 T 线程安全" --- 不完全:
Arc<T>
只保证引用计数线程安全;T 自身是否可被多个线程同时读写仍取决于 T(是否需要Mutex
或原子化)。 - "Rust 中不会发生并发问题" --- safe Rust 防止数据竞争,但逻辑级 race condition(例如错综复杂的状态机)仍然可能,需要良好设计。只有
unsafe
块或外部 FFI 才能绕过类型检查。
建议(在 Rust 中)
-
把所有权移动到线程 :用
move
捕获需要的所有权,满足Send
。rustlet v = vec![1,2,3]; std::thread::spawn(move || println!("{:?}", v));
-
单线程结构用 Rc/RefCell;跨线程改用 Arc/Mutex :
- 单线程:
Rc<T>
+RefCell<T>
(运行时借用检查) - 多线程:
Arc<T>
+Mutex<T>
或Arc<RwLock<T>>
- 单线程:
-
优先使用通道(message passing)而非共享状态:消息传递(channels)常常能写出更简单、安全的并发代码(避免精细锁粒度问题)。
-
用 atomics 做无锁数据结构时要理解内存序(Ordering) :默认使用
SeqCst
是最安全但可能慢;若需要性能,学习Acquire/Release
语义再做优化。 -
处理 Mutex 污染 :当
lock()
返回 Err(因为前一个持锁线程 panic),明确决定如何恢复或传播错误,而不是轻易忽略。 -
使用类型系统作"安全网" :
Send
/Sync
的错误提示会告诉你哪里不安全,按提示改用Arc
/Mutex
/Atomic
重构代码。
实战建议(在 C++ 中)
- 始终明确捕获语义(lambda) ,传线程时 prefer
std::move
捕获所有权。 - 尽量使用
std::atomic<T>
、std::lock_guard
等 RAII 机制,避免裸锁操作。 - 把共享状态封装(封装并提供线程安全接口),不要随意暴露裸指针。
- 配合工具(TSan、ASan)做动态检测,因为编译期无法捕获所有问题。
八、移植/互操作建议(从 C++ 到 Rust)
- 裸指针/引用谨慎映射 :C++ 中的裸指针应映射为
*const T
/*mut T
(unsafe)或更安全的&T
/&mut T
(若能满足借用规则)。 - 智能指针对应关系 :
std::unique_ptr<T>
↔Box<T>
(独占所有权)std::shared_ptr<T>
↔std::sync::Arc<T>
(多所有者、线程安全)std::weak_ptr<T>
↔std::sync::Weak<T>
- 并发结构 :用
Arc<Mutex<T>>
或Arc<RwLock<T>>
替代shared_ptr + mutex
组合。 - 避免剥离所有权语义 :在 C++ 里常见的"把对象传引用然后返回局部引用"的模式在 Rust 会被拒绝,需改写为返回拥有者或使用
Arc
/Box
。