多线程

多线程风险

  • 竞态条件(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/RefCell用于单线程内部可变性, Arc/Mutex用于多线程内部可变性。

读写锁 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特征被特地移除了实现,而Arc则相反,实现了Sync + Send,再结合之前的编译器报错,大概可以明白了:Send和Sync是在线程间安全使用一个值的关键。

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中的T并没有Sync特征约束。

实现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小心维护并发安全保证。