多线程
多线程风险
- 竞态条件(race conditions),多个线程以非一致性的顺序同时访问数据资源
- 死锁(deadlocks),两个线程都想使用某个资源,但是又都在等待对方释放资源后才能使用,结果最终都无法继续执行
- 一些因为多线程导致的很隐晦的 BUG,难以复现和解决
消息同步
互斥锁 Mutex (mutual exclusion 的缩写)
Mutex让多个线程并发的访问同一个值变成了排队访问:同一时间,只允许一个线程A访问该值,其它线程需要等待A访问完成后才能继续 Mutex具有内部可变性
use std::sync::Mutex; fn main() { // 使用`Mutex`结构体的关联函数创建新的互斥锁实例 let m = Mutex::new(5); { // 获取锁,然后deref为`m`的引用 // lock返回的是Result let mut num = m.lock().unwrap(); *num = 6; // 锁自动被drop } println!("m = {:?}", m); }
Rc
读写锁 RwLock
简单总结下RwLock:
- 同时允许多个读,但最多只能有一个写
- 读和写不能同时存在
- 读可以使用read、try_read,写write、try_write, 在实际项目中,try_xxx会安全的多
Mutex 还是 RwLock
首先简单性上Mutex完胜,因为使用RwLock你得操心几个问题:
-
读和写不能同时发生,如果使用try_xxx解决,就必须做大量的错误处理和失败重试机制
-
当读多写少时,写操作可能会因为一直无法获得锁导致连续多次失败(writer starvation)
-
RwLock 其实是操作系统提供的,实现原理要比Mutex复杂的多,因此单就锁的性能而言,比不上原生实现的Mutex 再来简单总结下两者的使用场景:
-
追求高并发读取时,使用RwLock,因为Mutex一次只允许一个线程去读取
-
如果要保证写操作的成功性,使用Mutex
-
不知道哪个合适,统一使用Mutex
Atomic 原子类型
由于原子操作是通过指令提供的支持,因此它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改,读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型。
可以看出原子类型是无锁类型,但是无锁不代表无需等待,因为原子类型内部使用了CAS循环,当大量的冲突发生时,该等待还是得等待!但是总归比锁要好。
Atomic的值具有内部可变性,你无需将其声明为mut
原子类型的一个常用场景,就是作为全局变量来使用:
use std::ops::Sub; use std::sync::atomic::{AtomicU64, Ordering}; use std::thread::{self, JoinHandle}; use std::time::Instant; const N_TIMES: u64 = 10000000; const N_THREADS: usize = 10; static R: AtomicU64 = AtomicU64::new(0); fn add_n_times(n: u64) -> JoinHandle<()> { thread::spawn(move || { for _ in 0..n { R.fetch_add(1, Ordering::Relaxed); } }) } fn main() { let s = Instant::now(); let mut threads = Vec::with_capacity(N_THREADS); for _ in 0..N_THREADS { threads.push(add_n_times(N_TIMES)); } for thread in threads { thread.join().unwrap(); } assert_eq!(N_TIMES * N_THREADS as u64, R.load(Ordering::Relaxed)); println!("{:?}",Instant::now().sub(s)); }
内存顺序
内存顺序是指 CPU 在访问内存时的顺序,该顺序可能受以下因素的影响:
- 代码中的先后顺序
- 编译器优化导致在编译阶段发生改变(内存重排序 reordering)
- 运行阶段因 CPU 的缓存机制导致顺序被打乱
Atomic 能替代锁吗
那么原子类型既然这么全能,它可以替代锁吗?答案是不行:
- 对于复杂的场景下,锁的使用简单粗暴,不容易有坑
- std::sync::atomic包中仅提供了数值类型的原子操作:AtomicBool, AtomicIsize, AtomicUsize, AtomicI8, AtomicU16等,而锁可以应用于各种类型
- 在有些情况下,必须使用锁来配合,例如上一章节中使用Mutex配合Condvar
Atomic 的应用场景
- 事实上,Atomic虽然对于用户不太常用,但是对于高性能库的开发者、标准库开发者都非常常用,它是并发原语的基石,除此之外,还有一些场景适用:
- 无锁(lock free)数据结构
- 全局变量,例如全局自增 ID, 在后续章节会介绍
- 跨线程计数器,例如可以用于统计指标
基于 Send 和 Sync 的线程安全
Rc 和 Arc 源码对比
#![allow(unused)] fn main() { // Rc源码片段 impl<T: ?Sized> !marker::Send for Rc<T> {} impl<T: ?Sized> !marker::Sync for Rc<T> {} // Arc源码片段 unsafe impl<T: ?Sized + Sync + Send> Send for Arc<T> {} unsafe impl<T: ?Sized + Sync + Send> Sync for Arc<T> {} }
!代表移除特征的相应实现,上面代码中Rc
Send 和 Sync
Send和Sync是 Rust 安全并发的重中之重,但是实际上它们只是标记特征(marker trait,该特征未定义任何行为,因此非常适合用于标记), 来看看它们的作用:
- 实现Send的类型可以在线程间安全的传递其所有权
- 实现Sync的类型可以在线程间安全的共享(通过引用) 这里还有一个潜在的依赖:一个类型要在线程间安全的共享的前提是,指向它的引用必须能在线程间传递。因为如果引用都不能被传递,我们就无法在多个线程间使用引用去访问同一个数据了。
由上可知,若类型 T 的引用&T是Send,则T是Sync。
没有例子的概念讲解都是耍流氓,来看看RwLock的实现:
#![allow(unused)] fn main() { unsafe impl<T: ?Sized + Send + Sync> Sync for RwLock<T> {} }
首先RwLock可以在线程间安全的共享,那它肯定是实现了Sync,但是我们的关注点不在这里。众所周知,RwLock可以并发的读,说明其中的值T必定也可以在线程间共享,那T必定要实现Sync。
果不其然,上述代码中,T的特征约束中就有一个Sync特征,那问题又来了,Mutex是不是相反?再来看看:
#![allow(unused)] fn main() { unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {} }
不出所料,Mutex
实现Send和Sync的类型
在 Rust 中,几乎所有类型都默认实现了Send和Sync,而且由于这两个特征都是可自动派生的特征(通过derive派生),意味着一个复合类型(例如结构体), 只要它内部的所有成员都实现了Send或者Sync,那么它就自动实现了Send或Sync。
正是因为以上规则,Rust 中绝大多数类型都实现了Send和Sync,除了以下几个(事实上不止这几个,只不过它们比较常见):
- 裸指针两者都没实现,因为它本身就没有任何安全保证
- UnsafeCell不是Sync,因此Cell和RefCell也不是
- Rc两者都没实现(因为内部的引用计数器不是线程安全的) 当然,如果是自定义的复合类型,那没实现那哥俩的就较为常见了:只要复合类型中有一个成员不是Send或Sync,那么该复合类型也就不是Send或Sync。
手动实现 Send 和 Sync 是不安全的,通常并不需要手动实现 Send 和 Sync trait,实现者需要使用unsafe小心维护并发安全保证。