这篇文章上次修改于 332 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
1 Rust 优缺点
Rust 作为一门系统语言,与 C++ 相比
优点:
- 可读的代码风格。
- 内存安全,在编译期就阻止了内存错误,比如内存泄漏、分段错误、二次释放等。
- 比 C++ 性能好,接近于 C 的性能。
- 优秀的包管理工具。
缺点:
- 编译器检查更为严格。初学者可能需要与编译器做一番斗争,才能让自己的代码通过编译。
- 学习曲线较为陡峭。如果会 C++ 的话可以很快学会 Rust。
- 一些库会缺失。比如缺失视频编解码的库,可以在 Rust 中调用 C++ 中的库。
2 所有权
所有权是用来管理堆上内存的一种方式,在编译阶段就可以追踪堆内存的分配和释放,不会对程序的运行期造成任何性能上的损失。
所有权规则:
- Rust 中的每一个值都有一个被称为其 所有者(owner)的变量。
值在任一时刻有且只有一个所有者。
- 可以避免二次释放。
- 对于基本数据类型,一个变量赋给另外一个变量,是 Copy 语义。
- 对于复杂数据类型,一个变量赋给另外一个变量,是 Move 语义。
当所有者(变量)离开作用域,这个值将被丢弃。
- 当变量离开作用域后,Rust 会自动调用 drop 函数并清理变量的堆内存。
借用/引用
- 获取变量的引用。允许使用值但不获取其所有权。
- 像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。
包括可变引用和不可变引用。
- 可变引用同时只能存在一个。同一作用域,特定数据只能有一个可变引用。可以避免数据竞争。
- 可变引用与不可变引用不能同时存在。
注意,引用的作用域 s 从创建开始,一直持续到它最后一次使用的地方,这个跟变量的作用域有所不同,变量的作用域从创建持续到某一个花括号 }。
3 生命周期
生命周期,是引用的有效作用域。是为了避免悬垂引用而引入的,即数据已经被释放了,但引用还被使用。即被引用者的生命周期必须要比引用长。
语法:
- 以 ' 开头,名称往往是一个单独的小写字母,大多数人都用 'a 来作为生命周期的名称。 如果是引用类型的参数,那么生命周期会位于引用符号 & 之后,并用一个空格来将生命周期和引用参数分隔开。
- 和泛型一样,使用生命周期参数,需要先声明 <'a>。
- impl 中必须使用结构体的完整名称,包括 <'a>,因为生命周期标注也是结构体类型的一部分。
含义:如果某个引用被标注了生命周期 'a,是告诉编译器该引用的作用域至少能持续 'a 这么久。注意,生命周期标注并不会改变任何引用的实际作用域。
一个生命周期标注,它自身并不具有什么意义,因为生命周期的作用就是告诉编译器多个引用之间的关系。
在大多数时候,无需手动声明生命周期,因为编译器可以自动进行推导:
- 每一个引用参数都会获得独自的生命周期。
例如一个引用参数的函数就有一个生命周期标注: fn foo<'a>(x: &'a i32),两个引用参数的有两个生命周期标注:fn foo<'a, 'b>(x: &'a i32, y: &'b i32), 依此类推。 - 若只有一个输入生命周期(函数参数中只有一个引用类型),那么该生命周期会被赋给所有的输出生命周期,也就是所有返回值的生命周期都等于该输入生命周期。
例如函数 fn foo(x: &i32) -> &i32,x 参数的生命周期会被自动赋给返回值 &i32,因此该函数等同于 fn foo<'a>(x: &'a i32) -> &'a i32 - 若存在多个输入生命周期,且其中一个是 &self 或 &mut self,则 &self 的生命周期被赋给所有的输出生命周期。
拥有 &self 形式的参数,说明该函数是一个方法,该规则让方法的使用便利度大幅提升。
在 Rust 中有一个非常特殊的生命周期 'static,拥有该生命周期的引用可以和整个程序活得一样久。
生命周期约束:
- 假设有两个引用 &'a i32 和 &'b i32,它们的生命周期分别是 'a 和 'b,若 'a >= 'b,则可以定义 'a:'b,表示 'a 至少要活得跟 'b 一样久。
4 智能指针
实现了 Deref 和 Drop trait,即为智能指针。
4.1 Box
类似 C++ 中的 unique_ptr,是独占指针。对象的所有权可以从一个独占指针转移到另一个指针,其转移方式为:对象始终只能有一个指针作为其所有者。当独占指针离开其作用域或将要拥有不同的对象时,它会自动释放自己所管理的对象。
使用场景:
- 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候。
- 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候。
- 当希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候。
4.2 Rc、Arc 和 Weak
类似 C++ 中的 shared_ptr,是共享指针。共享指针将记录有多少个指针共同享有某个对象的所有权。当有更多指针被设置为指向该对象时,引用计数随之增加;当指针和对象分离时,则引用计数也相应减少。当引用计数降低至 0 时,该对象被删除。
Rc 是引用计数(reference counting)的缩写。
Rc 适用于单线程,Arc 适用于多线程,它的内部计数器是多线程安全的。
每次调用 Rc/Arc 的 clone() 时,strong_count 会加 1,当离开作用域时,Drop trait 的实现会让 strong_count 自动减 1。
Weak 是为了避免循环引用而引入的,调用其 clone() 时,strong_count 不会加 1,而是对 weak_count 加 1。
Rc/Arc 是不可变引用,无法修改它指向的值,只能进行读取,如果要修改,需要配合内部可变性 RefCell 或互斥锁 Mutex。Rc
4.3 Cell 和 RefCell
Cell 和 RefCell 用于内部可变性,可以在拥有不可变引用的同时修改内部数据。
Cell 和 RefCell 在功能上相同,区别在于 Cell 针对的是实现了 Copy 特征的值类型,它并非提供内部值的引用,而是把值拷贝进和拷贝出 Cell
对于引用和 Box
当创建不可变和可变引用时,分别使用 & 和 &mut 语法。对于 RefCell
5 多线程并发
5.1 Rust 的并发模型
由于操作系统提供了创建线程的 API,因此部分语言会直接调用该 API 来创建线程,因此最终程序内的线程数和该程序占用的操作系统线程数相等,称之为 1:1 线程模型。如果愿意牺牲一些性能来换取更精确的线程控制以及更小的线程上下文切换成本,那么可以选择 Rust 中的 M:N 模型,这些模型由三方库提供了实现,例如 tokio。
在线程闭包中使用 move
- Rust 无法确定新的线程会活多久(多个线程的结束顺序并不是固定的),所以也无法确定新线程所引用的 v 是否在使用过程中一直合法,因此需要使用 move 关键字拿走 v 的所有权。
5.2 线程 or async/await
- 有大量 IO 任务需要并发运行时,选 async 模型。
- 有部分 IO 任务需要并发运行时,选多线程,如果想要降低线程创建和销毁的开销,可以使用线程池。
- 有大量 CPU 密集任务需要并行运行时,例如并行计算,选多线程模型,且让线程数等于或者稍大于 CPU 核心数。
- 无所谓时,统一选多线程。
5.3 线程同步
5.3.1 消息传递
多发送者,单接收者 std::sync::mpsc
- 不阻塞的 try_recv 方法。想对于 recv(),该方法并不会阻塞线程,当通道中没有消息时,它会立刻返回一个错误。
- 异步通道:无论接收者是否正在接收消息,消息发送者在发送消息时都不会阻塞。创建方式:
mpsc::channel();
- 同步通道:发送消息是阻塞的,只有在消息被接收后才解除阻塞。创建方式:
mpsc::sync_channel(0);
- 当消息数没有超过通道容量时,为异步通道;超过时,为同步通道:
mpsc::sync_channel(10);
。异步消息虽然能非常高效且不会造成发送线程的阻塞,但是存在消息未及时消费,最终内存过大的问题。在实际项目中,可以考虑使用一个带缓冲值的同步通道来避免这种风险。
多发送者,多接收者
- crossbeam-channel,性能强,功能全。
- flume,官方给出的性能数据某些场景要比 crossbeam 更好些。
5.3.2 锁
互斥锁 Mutex
- 会对每次读写都进行加锁。
- 使用方法 m.lock() 向 m 申请一个锁时, 会阻塞当前线程,直到获取到锁。
死锁
- 单线程死锁:只要在另一个锁还未被释放时去申请新的锁时触发。
- 多线程死锁:当我们拥有两个锁,且两个线程各自使用了其中一个锁,然后试图去访问另一个锁时触发。
- try_lock:与 lock 方法不同,try_lock 会尝试去获取一次锁,如果无法获取会返回一个错误,因此不会发生阻塞。
读写锁 RwLock
- 同时允许多个读,但最多只能有一个写。
- 读和写不能同时存在。
5.3.3 条件变量 Condvar
解决资源访问顺序的问题。它经常和 Mutex 一起使用,可以让线程挂起,直到某个条件发生后再继续执行。
5.3.4 信号量
精准的控制当前正在运行的任务最大数量。
5.3.5 原子变量 Atomic
原子类型是无锁类型,但是无锁不代表无需等待,因为原子类型内部使用了 CAS 循环。
内存顺序是指 CPU 在访问内存时的顺序,该顺序可能受以下因素的影响:
- 代码中的先后顺序。
- 编译器优化导致在编译阶段发生改变(内存重排序 reordering)。
- 运行阶段因 CPU 的缓存机制导致顺序被打乱。
限定内存顺序的 5 个规则
- Relaxed, 这是最宽松的规则,它对编译器和 CPU 不做任何限制,可以乱序。
- Release 释放,设定内存屏障(Memory barrier),保证它之前的操作永远在它之前,但是它后面的操作可能被重排到它前面。
- Acquire 获取, 设定内存屏障,保证在它之后的访问永远在它之后,但是它之前的操作却有可能被重排到它后面,往往和Release在不同线程中联合使用。
- AcqRel, Acquire 和 Release 的结合,同时拥有它们俩提供的保证。比如你要对一个 atomic 自增 1,同时希望该操作之前和之后的读取或写入操作不会被重新排序。
- SeqCst 顺序一致性, SeqCst 就像是AcqRel 的加强版,它不管原子操作是属于读取还是写入的操作,只要某个线程有用到 SeqCst 的原子操作,线程中该 SeqCst 操作前的数据操作绝对不会被重新排在该 SeqCst 操作之后,且该 SeqCst 操作后的数据操作也绝对不会被重新排在 SeqCst 操作前。
内存顺序的选择
- 不知道怎么选择时,优先使用 SeqCst,虽然会稍微减慢速度,但是慢一点也比出现错误好。
- 多线程只计数 fetch_add 而不使用该值触发其他逻辑分支的简单使用场景,可以使用 Relaxed。
使用场景
- 无锁(lock free)数据结构
- 全局变量,例如全局自增 ID
- 跨线程计数器,例如可以用于统计指标
5.3.6 比较
5.3.6.1 消息传递 or 锁
- 忘记释放锁是经常发生的,虽然 Rust 通过智能指针的 drop 机制帮助我们避免了这一点,但是由于不及时释放锁导致的性能问题也是常见的。导致很多用户都热衷于使用消息传递的方式来实现同步。
5.3.6.2 Mutex or RwLock
首先简单性上 Mutex 完胜,因为使用 RwLock 需要操心几个问题:
- 读和写不能同时发生,如果使用 try_xxx 解决,就必须做大量的错误处理和失败重试机制。
- 当读多写少时,写操作可能会因为一直无法获得锁导致连续多次失败(writer starvation)。
- RwLock 其实是操作系统提供的,实现原理要比 Mutex 复杂的多,因此单就锁的性能而言,比不上原生实现的 Mutex。
再来简单总结下两者的使用场景:
- 追求高并发读取时,使用 RwLock,因为 Mutex 一次只允许一个线程去读取。
- 如果要保证写操作的成功性,使用 Mutex。
- 不知道哪个合适,统一使用 Mutex。
需要注意的是,RwLock 虽然看上去貌似提供了高并发读取的能力,但这个不能说明它的性能比 Mutex 高,事实上 Mutex 性能要好不少,后者唯一的问题也仅仅在于不能并发读取。
一个常见的、错误的使用 RwLock 的场景就是使用 HashMap 进行简单读写,因为 HashMap 的读和写都非常快,RwLock 的复杂实现和相对低的性能反而会导致整体性能的降低,因此一般来说更适合使用 Mutex。
总之,如果使用 RwLock 要确保满足以下两个条件:并发读,且需要对读到的资源进行"长时间"的操作,HashMap 也许满足了并发读的需求,但是往往并不能满足后者:"长时间"的操作。
5.3.6.3 Atomic or 锁
- 对于复杂的场景下,锁的使用简单粗暴,不容易有坑。
- std::sync::atomic包中仅提供了数值类型的原子操作:AtomicBool, AtomicIsize, AtomicUsize, AtomicI8, AtomicU16等,而锁可以应用于各种类型。
- 在有些情况下,必须使用锁来配合,例如使用 Mutex 配合 Condvar。
6 常见 trait
6.1 Copy 和 Clone
Copy
- 可以用在类似整型这样在栈中存储的类型,实现类似深拷贝的效果。如果一个类型拥有 Copy 特征,一个旧的变量在被赋值给其他变量后仍然可用。
- 任何基本类型的组合都实现了 Copy。例如:
所有整数类型,比如 u32。
布尔类型,bool,它的值是 true 和 false。
所有浮点数类型,比如 f64。
字符类型,char。
元组,当且仅当其包含的类型也都是 Copy 的时候。比如,(i32, i32) 是 Copy 的,但 (i32, String) 就不是。
不可变引用 &T,但是注意: 可变引用 &mut T 是不可以 Copy 的。 - Copy 是给编译器用的,对用户透明。
Clone
- 对于存储在堆中的数据,当一个值被移动时,Rust 会做一个浅拷贝;如果想创建一个像 C++ 那样的深拷贝呢,需要实现 Clone Trait。
- Clone trait 是给用户用的,用户需要手动调用 clone 方法。
6.2 Deref 和 Drop
实现 Deref 后的智能指针结构体,就可以像普通引用一样,通过 * 进行解引用。
Drop 允许指定智能指针超出作用域后自动执行的代码,例如做一些数据清除等收尾工作。
对智能指针 Box 进行解引用时,实际上 Rust 为我们调用了方法 (p.deref())。首先调用 deref() 返回值的常规引用,然后通过 对常规引用进行解引用,最终获取到目标值。如果 deref() 返回的是值而不是引用,*T 会拿走智能指针中包含的值,转移所有权。
Deref 会进行隐式转换,例如 &String 会自动转换为 &str。
Rust 还支持将一个可变的引用转换成另一个可变的引用以及将一个可变引用转换成不可变的引用。
6.3 Display 和 Debug
{} 和 {:?} 都是占位符:
- {} 适用于实现了 std::fmt::Display 的类型,用来以更优雅、更友好的方式格式化文本,例如展示给用户。
- {:?} 适用于实现了 std::fmt::Debug 的类型,用于调试场景。
大部分类型都实现了 Debug,但实现了 Display 的 Rust 类型并没有那么多,往往需要我们自定义想要的格式化方式。
6.4 Send 和 Sync
实现 Send 的类型可以在线程间安全的传递其所有权。
实现 Sync 的类型可以在线程间安全的共享(通过引用)。
这里还有一个潜在的依赖:一个类型要在线程间安全的共享的前提是,指向它的引用必须能在线程间传递。因为如果引用都不能被传递,就无法在多个线程间使用引用去访问同一个数据了。
由上可知,若类型 T 的引用 &T 是 Send,则 T 是 Sync。
在 Rust 中,几乎所有类型都默认实现了 Send 和 Sync,而且由于这两个特征都是可自动派生的特征(通过derive派生),意味着一个复合类型(例如结构体), 只要它内部的所有成员都实现了 Send 或者 Sync,那么它就自动实现了 Send 或 Sync。只要复合类型中有一个成员不是 Send 或 Sync,那么该复合类型也就不是 Send 或 Sync。
可以为自定义类型实现 Send 和 Sync,但是需要 unsafe 代码块。
可以为部分 Rust 中的类型实现 Send、Sync,但是需要使用 newtype。
7 Future 执行与任务调度
7.1 Future
Future 是异步函数的返回值和被执行的关键。
简化版的 Future:
#![allow(unused)]
fn main() {
trait SimpleFuture {
type Output;
fn poll(&mut self, wake: fn()) -> Poll<Self::Output>;
}
enum Poll<T> {
Ready(T),
Pending,
}
}
Future 需要被执行器 poll(轮询)后才能运行。
若在当前 poll 中, Future 可以被完成,则会返回 Poll::Ready(result) ,反之则返回 Poll::Pending, 并且安排一个 wake 函数:当未来 Future 准备好进一步执行时,该函数会被调用,然后管理该 Future 的执行器会再次调用 poll 方法,此时 Future 就可以继续执行了。
Future 模型允许将多个异步操作组合在一起,同时还无需任何内存分配。
实际的 Future:
#![allow(unused)]
fn main() {
trait Future {
type Output;
fn poll(
// 首先值得注意的地方是,`self`的类型从`&mut self`变成了`Pin<&mut Self>`:
self: Pin<&mut Self>,
// 其次将`wake: fn()` 修改为 `cx: &mut Context<'_>`:
cx: &mut Context<'_>,
) -> Poll<Self::Output>;
}
}
Pin:见 7.3。
Context:包含 wake() 和 wake() 携带的数据。
7.2 执行器 Executor
Rust 的 Future 是惰性的,在 async 函数中使用 .await 来调用另一个 async 函数,但是这个只能解决 async 内部的问题,最外层的 async 函数需要 executor 来运行。
Executor:包含 task_receiver,从一个任务通道(channel)中拉取 Task,然后运行它们。
Spawner:包含 task_sender,创建新的 Task 然后将它发送到任务通道(channel)中。
Task:包含 Future 和 task_sender,它可以调度自己(将自己放入任务通道中),然后等待 Executor 去poll
。
Executor 在 poll 一个 Task 之前,会先由 Waker 将该任务放入任务通道(channel)中。
创建 Waker 的最简单的方式就是让 Task 实现 ArcWake trait。
当 Task 实现了 ArcWake trait 后,Executor 在调用其 wake() 对其唤醒后会将复制一份所有权(Arc),然后将其发送到任务通道(channel)中。最后 Executor 将从通道中获取任务,然后进行 poll 执行。
7.3 Pin
主要是为了避免自引用类型地址改变后造成的错误。
自引用类型:自己一个成员指向自己的另一个成员。例如:
struct SelfRef {
value: String,
pointer_to_value: *mut String,
}
pointer_to_value 指向了 value。假如 value 发生了移动,而 pointer_to_value 依然指向之前的地址,就会导致 bug。如果能将 SelfRef 在内存中固定到一个位置,就可以避免这种问题的发生,也就可以安全的创建上面这种引用类型。
Pin 是一个结构体:
pub struct Pin<P> {
pointer: P,
}
它包裹一个指针,并且能确保该指针指向的数据不会被移动,例如 Pin<&mut T> , Pin<&T> , Pin<Box
Unpin 是一个 trait,它表明一个类型可以随意被移动。
可以被 Pin 住的值必须实现 !Unpin trait。
如果类型实现了 Unpin trait,还是可以 Pin 的,只是没有效果而已。
可以通过以下方法为自己的类型添加 !Unpin 约束:
- 使用文中提到的 std::marker::PhantomPinned
- 使用nightly 版本下的 feature flag
可以将值固定到栈上,也可以固定到堆上
- 将 !Unpin 值固定到栈上需要使用 unsafe
- 将 !Unpin 值固定到堆上无需 unsafe ,可以通过 Box::pin 来简单的实现
当固定类型T: !Unpin时,你需要保证数据从被固定到被 drop 这段时期内,其内存不会变得非法或者被重用。
8 切换 nightly
rustup override set nightly
参考
Rust 语言圣经. https://course.rs/about-book.html
Rust 程序设计语言. https://kaisery.github.io/trpl-zh-cn/foreword.html
带你了解 Rust 中的 move, copy, clone. https://rustcc.cn/article?id=7916f651-f20a-4356-848b-95268036ccc1
没有评论