目录1. 使用线程同时运行代码1.1 使用spawn创建新线程1.2 等待所有线程结束1.3 将move闭包与线程一起使用2. 使用消息传递在线程间通信2.1 通过信道转移所有权2.2 发送多个值2.3 创建多个生产者3. 共享状态并发3.1 使用互斥器控制访问3.1.1 Mutex\T\的API3.1.2 给Mutex\T\共享访问3.1.3 多线程和多所有权3.1.4 原子引用计数Arc\T\3.2 比较RefCell\T\/Rc\T\和Mutex\T\/Arc\T\4. 使用Sync和Send Traits的可扩展并发4.1 在线程间转移所有权4.2 多线程访问4.3 手动实现Send和Sync是不安全的参考1. 使用线程同时运行代码在大多数当前的操作系统中一个运行中的程序代码会在一个进程process中执行操作系统同时管理多个进程。在一个程序内部也可以存在彼此独立、同时运行的多个部分。运行这些独立部分的功能被称为线程threads。将程序中的计算拆分进多个线程可以改善性能因为程序可以同时进行多个任务不过这也会增加复杂性。因为线程是同时运行d的所以无法预先保证不同线程中的代码的执行顺序。这会导致诸如此类的问题竟态条件race conditions多个线程以不一致的顺序访问数据或资源。死锁deadlocks两个线程互相等待对方这会阻止两者继续运行。只会发生在特定情况且难以稳定重现和修复的bug。Rust尝试减轻使用线程的负面影响。不过在多线程上下文中编程仍需要格外小心同时其所要求的代码结构也不同于运行于单线程的程序。编程语言实现线程的方式各不相同许多操作系统都提供了可供编程语言调用、用来创建新线程的API。Rust标准库使用的是线程实现的1:1模型也就是程序中的每一个语言级线程都对应一个操作系统线程。也有一些crate实现了其他线程模型它们相对于1:1模型有着不同的取舍。1.1 使用spawn创建新线程emsp: 为了创建一个新线程需要调用thread::spawn函数并传递一个闭包并在其中包含希望在新线程运行的代码。usestd::thread;usestd::time::Duration;fnmain(){thread::spawn(||{foriin1..10{println!(hi number {i} from the spawned thread!);thread::sleep(Duration::from_millis(1));}});foriin1..5{println!(hi number {i} from the main thread!);thread::sleep(Duration::from_millis(1));}}注意当Rust程序的主线程结束时所有新线程也会结束而不管其是否执行完毕。thread::sleep调用强制线程停止执行一小段时间这会允许其他不同的线程运行。这些线程可能会轮流运行不过并不保证如此这依赖操作系统如何调度线程。在这里主线程主线程首先打印即便新创建线程的打印语句位于程序的开头甚至即便我们告诉新建的线程打印直到i等于9它在主线程结束之前也只打印到5。如果运行代码只看到了主线程的输出或没有出现重叠打印的现象尝试增大区间来增加操作系统切换线程的机会。1.2 等待所有线程结束可以通过将thread::spawn的返回值储存在变量中来修复新建线程部分没有执行或者完全没有执行的问题。thread::spawn的返回值类型是JoinHandleT。JoinHandleT是一个拥有所有权的值当对其调用join方法时它会等待其线程结束。usestd::thread;usestd::time::Duration;fnmain(){lethandlethread::spawn(||{foriin1..10{println!(hi number {i} from the spawned thread!);thread::sleep(Duration::from_millis(1));}});foriin1..5{println!(hi number {i} from the main thread!);thread::sleep(Duration::from_millis(1));}handle.join().unwrap();}对句柄调用join会阻塞当前正在运行的线程直到该句柄所代表的线程结束。阻塞blocking一个线程意味着这个线程被阻止继续工作或退出。这两个线程仍会交替执行不过主线程会由于handle.join()的调用而不会结束直到新创建线程执行完毕。将handle.join()移到main中for循环之前主线程会等待直到新创建线程执行完毕之后才开始执行for循环输出不会交替出现。1.3 将move闭包与线程一起使用move关键字经常用于传递给thread::spawn的闭包因为闭包会获取从环境中取得的值的所有权因此会将这些值的所有权从一个线程传送到另一个线程。为了在新建线程中使用来自于主线程的数据需要新建线程的闭包获取它需要的值。usestd::thread;fnmain(){letvvec![1,2,3];lethandlethread::spawn(||{println!(Heres a vector: {v:?});});handle.join().unwrap();}闭包使用了v闭包会捕获v并使其成为闭包环境的一部分。这里Rust会推断如何捕获v因为println!只需要v的引用闭包尝试借用v。但是Rust不知道这个新建线程会执行多久无法知晓对v的引用是否一直有效。下面展示了一个v的引用很有可能不再有效的场景usestd::thread;fnmain(){letvvec![1,2,3];lethandlethread::spawn(||{println!(Heres a vector: {v:?});});drop(v);handle.join().unwrap();}如果Rust允许这段代码运行则新建线程可能会立刻被转移到后台并完全没有机会运行。新建线程内部有一个v的引用不过主线程立刻丢弃了v。接着当新建线程开始执行v已不再有效所以其引用也是无效的。通过在闭包之前增加move关键字强制闭包获取其使用的值的所有权而不是任由Rust推断它应该借用值。而对于使用drop的这个例子即使使用move仍然编译错误因为将v移动到闭包的环境中就不能对v调用drop2. 使用消息传递在线程间通信一个日益流行的确保安全并发的方式是消息传递message passing这里线程或actor通过发送包含数据的消息来相互沟通。这个思想来源于Go编程语言文档不要通过共享内存来通讯而是要通过通讯来共享内存。为了实现消息传递并发Rust标准库提供了一个信道channel实现。信道是一个通用编程概念表示数据从一个线程发送到另一个线程。信道有两个组成部分一个发送端transmitter和一个接收端receiver。代码中的一部分调用发送端的方法以及希望发送的数据另一部分则检查接收端受到的消息。当发送端或接收端任一被丢弃时可以认为信道被关闭closed。创建一个信道但还不能编译usestd::sync::mpsc;fnmain(){let(tx,rx)mpsc::channel();}mpsc::channel函数创建一个新的信道mpsc是多生产者单消费者multiple producersingle consumer的缩写。mpsc::channel函数返回一个元组第一个元素是发送端第二个元素是接收端。tx和rx这两个缩写在许多领域上分别表示发送端transmitter和接收端receiver。我们将发送端移动到一个新建线程中并发送一个字符串这样新建线程就可以和主线程通讯了。usestd::sync::mpsc;usestd::thread;fnmain(){let(tx,rx)mpsc::channel();thread::spawn(move||{letvalString::from(hi);tx.send(val).unwrap();});}这里将发送端移动到新建线程中了通过send方法发送值。send方法返回一个ResultT, E如果接收端已经被丢弃了将没有发送值的目标发送操作会返回错误。这里unwrap()在send返回Err变体的时候会panic。下面在主线程中从信道的接收端接收值。usestd::sync::mpsc;usestd::thread;fnmain(){let(tx,rx)mpsc::channel();thread::spawn(move||{letvalString::from(hi);// 这里用unwrap()只是为了演示实际编码是一定要处理Result返回Err的情况。tx.send(val).unwrap();});letreceivedrx.recv().unwrap();println!(Got: {received});}信道的接收端有两个有用的方法recv和try_recv。recv会阻塞主线程执行直到从信道中接收一个值。一旦发送了一个值recv会返回ResultT, E。当信道关闭recv返回Err表明不会有新的值了。try_recv不会阻塞相反它立刻返回一个ResultT, EOk包含可用的信息Err代表此时没有消息。如果线程在等待消息的过程中还有其他工作时使用try_recv很有用可以编写一个循环来频繁调用try_recv在有可用消息的时候处理其余时候处理一会其他工作直到再次检查。2.1 通过信道转移所有权所有权在消息传递中扮演了重要角色其有助于编写安全的并发代码。防止并发编程中的错误是在Rust程序中考虑所有权的一大优势。尝试在新建线程在信道中发送完val后使用它usestd::sync::mpsc;usestd::thread;fnmain(){let(tx,rx)mpsc::channel();thread::spawn(move||{letvalueString::from(hi);tx.send(value).unwrap();println!(value is {value});});letreceivedrx.recv().unwrap();println!(Got: {received});}send函数获取其参数的所有权并移动这个值归接收端所有。这可以防止在发送后意外地再次使用这个值所有权系统检查一切是否合乎规则。2.2 发送多个值usestd::sync::mpsc;usestd::thread;usestd::time::Duration;fnmain(){let(tx,rx)mpsc::channel();thread::spawn(move||{letvalsvec![String::from(hi),String::from(from),String::from(the),String::from(thread),];forvalinvals{tx.send(val).unwrap();thread::sleep(Duration::from_secs(1));}});forreceivedinrx{println!(Got: {received});}}将rx当做一个迭代器。对于每个接收的值打印出来。当信道被关闭的时候迭代器也将结束。2.3 创建多个生产者创建多个线程并把值发给同一个接收端。可以通过克隆发送端来实现// 省略fnmain(){let(tx,rx)mpsc::channel();lettx1tx.clone();thread::spawn(move||{letvalsvec![String::from(hi),String::from(from),String::from(the),String::from(thread),];forvalinvals{tx.send(val).unwrap();thread::sleep(Duration::from_secs(1));}});thread::spawn(move||{letvalsvec![String::from(hi),String::from(from),String::from(the),String::from(thread),];forvalinvals{tx1.send(val).unwrap();thread::sleep(Duration::from_secs(1));}});forreceivedinrx{println!(Got: {received});}}通过发送端调用clone方法获得另一个发送端。3. 共享状态并发消息传递是一个很好的处理并发的方式但并不是唯一一个。另一个方式是让多个线程访问相同的共享数据。在某种程度上任何编程语言中的信道都类似于单所有权因为一旦将一个值传送到信道中将无法再使用这个值。共享内存类似于多所有权多个线程可以同时访问相同的内存位置。3.1 使用互斥器控制访问互斥器mutex是相互排斥mutual exclusion的缩写因为在同一时刻它只允许一个线程访问数据。为了访问互斥器中的数据线程首先需要通过获取互斥器的锁lock来表明其希望访问数据。锁是一个数据结构作为互斥器的一部分记录谁有数据的专属访问权。互斥器以难以使用著称1. 使用数据之前必须获取锁。2. 使用完被互斥器所保护的数据之后必须解锁数据这样其他线程才能够获取锁。正确的管理互斥器异常复杂但是在Rust中得益于类型系统和所有权你不可能在锁和解锁上出错。3.1.1 MutexT的APIusestd::sync::Mutex;fnmain(){letmMutex::new(5);{letmutnumm.lock().unwrap();*num6;}println!(m {m:?});}像很多类型一样使用关联函数new来创建一个MutexT。使用lock方法获取锁从而访问互斥器中的数据。这个调用会阻塞当前线程直到我们拥有锁为止。如果另一个线程持有锁并且那个线程panic了则lock调用会失败。在这种情况下没人能够再获取锁调用unwrap使当前线程panic。一旦获取了锁就可以将返回值视为一个其内部数据的可变引用了。MutexT是一个智能指针。lock调用会返回一个叫做MutexGuard的智能指针它实现了Deref来指向其内部数据也实现了Drop当MutexGuard离开作用域自动释放锁。这样一来就不会有忘记释放锁从而导致互斥器阻塞无法被其他线程使用的潜在风险。3.1.2 给MutexT共享访问尝试使用MutexT在多个线程间共享同一个值。usestd::sync::Mutex;usestd::thread;fnmain(){letcounterMutex::new(0);letmuthandlesvec![];for_in0..10{lethandlethread::spawn(move||{letmutnumcounter.lock().unwrap();*num1;});handles.push(handle);}forhandleinhandles{handle.join().unwrap();}println!(Result: {},*counter.lock().unwrap());}错误信息表明counter的值在上一次循环总被移动了。不能将counter锁的所有权移动到多个线程中。可以通过多所有权来修复这个编译错误。3.1.3 多线程和多所有权RcT可以让一个值有多个所有者。尝试将MutexT封装进RcT中并在将所有权移入线程之前克隆RcTusestd::rc::Rc;usestd::sync::Mutex;usestd::thread;fnmain(){letcounterRc::new(Mutex::new(0));letmuthandlesvec![];for_in0..10{letcounterRc::clone(counter);lethandlethread::spawn(move||{letmutnumcounter.lock().unwrap();*num1;});handles.push(handle);}forhandleinhandles{handle.join().unwrap();}println!(Result: {},*counter.lock().unwrap());}Send trait没有被RcMutexi32实现。这是一个确保所使用的类型可以用于并发环境的trait。RcT并不能安全的在线程间共享。当RcT管理引用计数时它必须在每一个clone调用时增加计数并在每一个克隆体被丢弃时减少计数。RcT并没有使用任何并发原语无法确保改变计数的操作不会被其他线程打断。这可能使计数出错并导致诡异的bug。3.1.4 原子引用计数ArcTArcT正是一个类似RcT并可以安全地用于并发环境的类型。这是一个原子引用计数atomically reference counted类型。原子类型就像基本类型一样可以安全地在线程间共享。线程安全会造成性能损失。如果只在单线程中对值进行操作不必强制原子性所提供的保证可以使代码运行得更快。usestd::sync::{Arc,Mutex};usestd::thread;fnmain(){letcounterArc::new(Mutex::new(0));letmuthandlesvec![];for_in0..10{letcounterArc::clone(counter);lethandlethread::spawn(move||{letmutnumcounter.lock().unwrap();*num1;});handles.push(handle);}forhandleinhandles{handle.join().unwrap();}println!(Result: {},*counter.lock().unwrap());}3.2 比较RefCellT/RcT和MutexT/ArcT尽管counter是不可变的我们仍然可以获得其内部值的可变引用这意味MutexT提供了内部可变性就像Cell系列那样。使用RefCellT可以改变RcT中内容同样地使用MutexT我们也可以改变ArcT中的内容。Rust不能完全避免使用MutexT带来的逻辑错误。使用RcT就有造成引用循环的风险两个RcT互相引用造成内存泄漏。同理MutexT也有造成死锁deadlock的风险当某个操作需要锁住两个资源而两个线程分别持有两个资源的其中一个锁时它们会永远相互等待。4. 使用Sync和Send Traits的可扩展并发Rust并发模型中之前讨论的内容都属于标准库而不是语言本身的内容。处理并发的方案并不受标准库或语言所限。然而有一些关键的并发概念是内嵌于语言本身而非标准库的其中包括std::marker的Send和Sync trait。4.1 在线程间转移所有权Send标记trait表明实现了Send的类型值的所有权可以在线程间传送。几乎所有的Rust类型都是Send的有一些例外包括RcT这是不能实现Send的因为如果克隆RcT的值并尝试将克隆的所有权转移到另一个线程这两个线程都有可能同时更新引用计数。所以RcT只适用于单线程场景。任何完全由Send类型组成的类型也会自动被标记为Send。几乎所有基本类型都是Send的除了后面会涉及的裸指针raw pointer。4.2 多线程访问Sync标记trait表明了一个实现了Sync的类型可以安全的在多个线程中拥有其值的引用。对于任意类型T如果T实现了Send的话T就实现了Sync意味着其引用可以安全的发送到另一个线程。类似于Send的情况基本类型都实现了Sync完全由实现了Sync的类型组成的类型也实现了Sync。RcT没有实现Sync原因和它没有实现Send时一样。RefCellT和相关的CellT系列类型也没有实现Sync。RefCellT在运行时进行的借用检查不是线程安全的。4.3 手动实现Send和Sync是不安全的通常不需要手动实现Send和Sync trait因为完全由实现了Send和Sync的类型组成的类型也会自动实现Send和Sync。因为它们是标记trait甚至都不需要实现任何方法。它们只是用来强制执行与并发有关的不变性。手动实现这些trait需要编写unsafe Rust代码。后面会讲unsafe Rust。要知道如果构建新的并发类型而它又不是完全由实现了Send和Sync的部分组成就需要认真思考以维持其安全保证。参考1、无畏并发