15、Rust程序设计语言——无畏并发

目录

  • [1. 使用线程同时运行代码](#1. 使用线程同时运行代码)
    • [1.1 使用spawn创建新线程](#1.1 使用spawn创建新线程)
    • [1.2 等待所有线程结束](#1.2 等待所有线程结束)
    • [1.3 将move闭包与线程一起使用](#1.3 将move闭包与线程一起使用)
  • [2. 使用消息传递在线程间通信](#2. 使用消息传递在线程间通信)
    • [2.1 通过信道转移所有权](#2.1 通过信道转移所有权)
    • [2.2 发送多个值](#2.2 发送多个值)
    • [2.3 创建多个生产者](#2.3 创建多个生产者)
  • [3. 共享状态并发](#3. 共享状态并发)
    • [3.1 使用互斥器控制访问](#3.1 使用互斥器控制访问)
      • [3.1.1 Mutex\<T\>的API](#3.1.1 Mutex<T>的API)
      • [3.1.2 给Mutex\<T\>共享访问](#3.1.2 给Mutex<T>共享访问)
      • [3.1.3 多线程和多所有权](#3.1.3 多线程和多所有权)
      • [3.1.4 原子引用计数Arc\<T\>](#3.1.4 原子引用计数Arc<T>)
    • [3.2 比较RefCell\<T\>/Rc\<T\>和Mutex\<T\>/Arc\<T\>](#3.2 比较RefCell<T>/Rc<T>和Mutex<T>/Arc<T>)
  • [4. 使用Sync和Send Traits的可扩展并发](#4. 使用Sync和Send Traits的可扩展并发)
    • [4.1 在线程间转移所有权](#4.1 在线程间转移所有权)
    • [4.2 多线程访问](#4.2 多线程访问)
    • [4.3 手动实现Send和Sync是不安全的](#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函数并传递一个闭包,并在其中包含希望在新线程运行的代码。

rust 复制代码
use std::thread;
use std::time::Duration;

fn main() {
	thread::spawn(|| {
		for i in 1..10 {
			println!("hi number {i} from the spawned thread!");
			thread::sleep(Duration::from_millis(1));
		}
	});

	for i in 1..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的返回值类型是JoinHandle<T>。JoinHandle<T>是一个拥有所有权的值,当对其调用join方法时,它会等待其线程结束。

rust 复制代码
use std::thread;
use std::time::Duration;

fn main() {
	let handle = thread::spawn(|| {
		for i in 1..10 {
			println!("hi number {i} from the spawned thread!");
			thread::sleep(Duration::from_millis(1));
		}
	});
	for i in 1..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的闭包,因为闭包会获取从环境中取得的值的所有权,因此会将这些值的所有权从一个线程传送到另一个线程。

  为了在新建线程中使用来自于主线程的数据,需要新建线程的闭包获取它需要的值。

rust 复制代码
use std::thread;

fn main() {
	let v = vec![1, 2, 3];
	let handle = thread::spawn(|| {
		println!("Here's a vector: {v:?}");
	});
	handle.join().unwrap();
}

  闭包使用了v,闭包会捕获v并使其成为闭包环境的一部分。这里Rust会推断如何捕获v,因为println!只需要v的引用,闭包尝试借用v。但是Rust不知道这个新建线程会执行多久,无法知晓对v的引用是否一直有效。

  下面展示了一个v的引用很有可能不再有效的场景:

rust 复制代码
use std::thread;

fn main() {
	let v = vec![1, 2, 3];
	let handle = thread::spawn(|| {
		println!("Here's a vector: {v:?}");
	});
	drop(v);
	handle.join().unwrap();
}

  如果Rust允许这段代码运行,则新建线程可能会立刻被转移到后台并完全没有机会运行。新建线程内部有一个v的引用,不过主线程立刻丢弃了v。接着当新建线程开始执行,v已不再有效,所以其引用也是无效的。

  通过在闭包之前增加move关键字,强制闭包获取其使用的值的所有权,而不是任由Rust推断它应该借用值。

  而对于使用drop的这个例子,即使使用move,仍然编译错误,因为将v移动到闭包的环境中,就不能对v调用drop:

2. 使用消息传递在线程间通信

  一个日益流行的确保安全并发的方式是消息传递message passing,这里线程或actor通过发送包含数据的消息来相互沟通。这个思想来源于Go编程语言文档:不要通过共享内存来通讯,而是要通过通讯来共享内存。

  为了实现消息传递并发,Rust标准库提供了一个信道channel实现。信道是一个通用编程概念,表示数据从一个线程发送到另一个线程。

  信道有两个组成部分:一个发送端transmitter和一个接收端receiver。代码中的一部分调用发送端的方法以及希望发送的数据,另一部分则检查接收端受到的消息。当发送端或接收端任一被丢弃时可以认为信道被关闭closed。

  创建一个信道,但还不能编译:

rust 复制代码
use std::sync::mpsc;

fn main() {
	let (tx, rx) = mpsc::channel();
}

  mpsc::channel函数创建一个新的信道;mpsc是多生产者,单消费者multiple producer,single consumer的缩写。

  mpsc::channel函数返回一个元组,第一个元素是发送端,第二个元素是接收端。tx和rx这两个缩写在许多领域上分别表示发送端transmitter和接收端receiver。

  我们将发送端移动到一个新建线程中并发送一个字符串,这样新建线程就可以和主线程通讯了。

rust 复制代码
use std::sync::mpsc;
use std::thread;

fn main() {
	let (tx, rx) = mpsc::channel();

	thread::spawn(move || {
		let val = String::from("hi");
		tx.send(val).unwrap();
	});
}

  这里将发送端移动到新建线程中了,通过send方法发送值。send方法返回一个Result<T, E>,如果接收端已经被丢弃了,将没有发送值的目标,发送操作会返回错误。这里unwrap()在send返回Err变体的时候会panic。

  下面在主线程中从信道的接收端接收值。

rust 复制代码
use std::sync::mpsc;
use std::thread;

fn main() {
	let (tx, rx) = mpsc::channel();

	thread::spawn(move || {
		let val = String::from("hi");
		// 这里用unwrap()只是为了演示,实际编码是一定要处理Result返回Err的情况。
		tx.send(val).unwrap();
	});
	
	let received = rx.recv().unwrap();
	println!("Got: {received}");
}

  信道的接收端有两个有用的方法:recv和try_recv。recv会阻塞主线程执行直到从信道中接收一个值。一旦发送了一个值,recv会返回Result<T, E>。当信道关闭,recv返回Err表明不会有新的值了。

  try_recv不会阻塞,相反它立刻返回一个Result<T, E>,Ok包含可用的信息,Err代表此时没有消息。如果线程在等待消息的过程中还有其他工作时使用try_recv很有用;可以编写一个循环来频繁调用try_recv,在有可用消息的时候处理,其余时候处理一会其他工作直到再次检查。

2.1 通过信道转移所有权

  所有权在消息传递中扮演了重要角色,其有助于编写安全的并发代码。防止并发编程中的错误是在Rust程序中考虑所有权的一大优势。尝试在新建线程在信道中发送完val后使用它:

rust 复制代码
use std::sync::mpsc;
use std::thread;

fn main() {
	let (tx, rx) = mpsc::channel();
	thread::spawn(move || {
		let value = String::from("hi");
		tx.send(value).unwrap();
		println!("value is {value}");
	});
	let received = rx.recv().unwrap();
	println!("Got: {received}");
}

  send函数获取其参数的所有权并移动这个值归接收端所有。这可以防止在发送后意外地再次使用这个值;所有权系统检查一切是否合乎规则。

2.2 发送多个值

rust 复制代码
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
	let (tx, rx) = mpsc::channel();
	thread::spawn(move || {
		let vals = vec![
			String::from("hi"),
			String::from("from"),
			String::from("the"),
			String::from("thread"),
		];
		for val in vals {
			tx.send(val).unwrap();
			thread::sleep(Duration::from_secs(1));
		}
	});
	for received in rx {
		println!("Got: {received}");
	}
}

  将rx当做一个迭代器。对于每个接收的值,打印出来。当信道被关闭的时候,迭代器也将结束。

2.3 创建多个生产者

  创建多个线程,并把值发给同一个接收端。可以通过克隆发送端来实现:

rust 复制代码
// 省略
fn main() {
    let (tx, rx) = mpsc::channel();
    let tx1 = tx.clone();
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];
        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];
        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });
    for received in rx {
        println!("Got: {received}");
    }
}

  通过发送端调用clone方法,获得另一个发送端。

3. 共享状态并发

  消息传递是一个很好的处理并发的方式,但并不是唯一一个。另一个方式是让多个线程访问相同的共享数据。

  在某种程度上,任何编程语言中的信道都类似于单所有权,因为一旦将一个值传送到信道中,将无法再使用这个值。共享内存类似于多所有权:多个线程可以同时访问相同的内存位置。

3.1 使用互斥器控制访问

  互斥器mutex是相互排斥mutual exclusion的缩写,因为在同一时刻,它只允许一个线程访问数据。为了访问互斥器中的数据,线程首先需要通过获取互斥器的锁lock来表明其希望访问数据。锁是一个数据结构,作为互斥器的一部分,记录谁有数据的专属访问权。

  互斥器以难以使用著称:

  1. 使用数据之前,必须获取锁。

  2. 使用完被互斥器所保护的数据之后,必须解锁数据,这样其他线程才能够获取锁。

  正确的管理互斥器异常复杂,但是在Rust中,得益于类型系统和所有权,你不可能在锁和解锁上出错。

3.1.1 Mutex<T>的API

rust 复制代码
use std::sync::Mutex;

fn main() {
	let m = Mutex::new(5);
	{
		let mut num = m.lock().unwrap();
		*num = 6;
	}
	println!("m = {m:?}");
}

  像很多类型一样,使用关联函数new来创建一个Mutex<T>。使用lock方法获取锁,从而访问互斥器中的数据。这个调用会阻塞当前线程,直到我们拥有锁为止。

  如果另一个线程持有锁,并且那个线程panic了,则lock调用会失败。在这种情况下,没人能够再获取锁,调用unwrap,使当前线程panic。

  一旦获取了锁,就可以将返回值视为一个其内部数据的可变引用了。

  Mutex<T>是一个智能指针。lock调用会返回一个叫做MutexGuard的智能指针,它实现了Deref来指向其内部数据,也实现了Drop,当MutexGuard离开作用域,自动释放锁。这样一来,就不会有忘记释放锁从而导致互斥器阻塞无法被其他线程使用的潜在风险。

3.1.2 给Mutex<T>共享访问

  尝试使用Mutex<T>在多个线程间共享同一个值。

rust 复制代码
use std::sync::Mutex;
use std::thread;

fn main() {
	let counter = Mutex::new(0);
	let mut handles = vec![];

	for _ in 0..10 {
		let handle = thread::spawn(move || {
			let mut num  = counter.lock().unwrap();
			*num += 1;
		});
		handles.push(handle);
	}

	for handle in handles {
		handle.join().unwrap();
	}
	println!("Result: {}", *counter.lock().unwrap());
}

  错误信息表明counter的值在上一次循环总被移动了。不能将counter锁的所有权移动到多个线程中。可以通过多所有权来修复这个编译错误。

3.1.3 多线程和多所有权

  Rc<T>可以让一个值有多个所有者。尝试将Mutex<T>封装进Rc<T>中,并在将所有权移入线程之前克隆Rc<T>

rust 复制代码
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
	let counter = Rc::new(Mutex::new(0));
	let mut handles = vec![];
	for _ in 0..10 {
		let counter = Rc::clone(&counter);
		let handle = thread::spawn(move || {
			let mut num = counter.lock().unwrap();
			*num += 1;
		});
		handles.push(handle);
	}
	for handle in handles {
		handle.join().unwrap();
	}
	println!("Result: {}", *counter.lock().unwrap());
}

  Send trait没有被Rc<Mutex<i32>>实现。这是一个确保所使用的类型可以用于并发环境的trait。

  Rc<T>并不能安全的在线程间共享。当Rc<T>管理引用计数时,它必须在每一个clone调用时增加计数,并在每一个克隆体被丢弃时减少计数。Rc<T>并没有使用任何并发原语,无法确保改变计数的操作不会被其他线程打断。这可能使计数出错,并导致诡异的bug。

3.1.4 原子引用计数Arc<T>

  Arc<T>正是一个类似Rc<T>并可以安全地用于并发环境的类型。这是一个原子引用计数atomically reference counted类型。原子类型就像基本类型一样,可以安全地在线程间共享。

  线程安全会造成性能损失。如果只在单线程中对值进行操作,不必强制原子性所提供的保证可以使代码运行得更快。

rust 复制代码
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
	let counter = Arc::new(Mutex::new(0));
	let mut handles = vec![];
	for _ in 0..10 {
		let counter = Arc::clone(&counter);
		let handle = thread::spawn(move || {
			let mut num = counter.lock().unwrap();
			*num += 1;
		});
		handles.push(handle);
	}
	for handle in handles {
		handle.join().unwrap();
	}
	println!("Result: {}", *counter.lock().unwrap());
}

3.2 比较RefCell<T>/Rc<T>和Mutex<T>/Arc<T>

  尽管counter是不可变的,我们仍然可以获得其内部值的可变引用;这意味Mutex<T>提供了内部可变性,就像Cell系列那样。使用RefCell<T>可以改变Rc<T>中内容,同样地,使用Mutex<T>我们也可以改变Arc<T>中的内容。

  Rust不能完全避免使用Mutex<T>带来的逻辑错误。使用Rc<T>就有造成引用循环的风险:两个Rc<T>互相引用,造成内存泄漏。同理Mutex<T>也有造成死锁deadlock的风险:当某个操作需要锁住两个资源,而两个线程分别持有两个资源的其中一个锁时,它们会永远相互等待。

4. 使用Sync和Send Traits的可扩展并发

  Rust并发模型中之前讨论的内容都属于标准库,而不是语言本身的内容。处理并发的方案并不受标准库或语言所限。

  然而有一些关键的并发概念是内嵌于语言本身而非标准库的,其中包括std::marker的Send和Sync trait。

4.1 在线程间转移所有权

  Send标记trait表明实现了Send的类型值的所有权可以在线程间传送。几乎所有的Rust类型都是Send的,有一些例外包括Rc<T>:这是不能实现Send的,因为如果克隆Rc<T>的值并尝试将克隆的所有权转移到另一个线程,这两个线程都有可能同时更新引用计数。所以Rc<T>只适用于单线程场景。

  任何完全由Send类型组成的类型也会自动被标记为Send。几乎所有基本类型都是Send的,除了后面会涉及的裸指针raw pointer。

4.2 多线程访问

  Sync标记trait表明了一个实现了Sync的类型可以安全的在多个线程中拥有其值的引用。对于任意类型T,如果&T实现了Send的话T就实现了Sync,意味着其引用可以安全的发送到另一个线程。类似于Send的情况,基本类型都实现了Sync,完全由实现了Sync的类型组成的类型也实现了Sync。

  Rc<T>没有实现Sync,原因和它没有实现Send时一样。RefCell<T>和相关的Cell<T>系列类型也没有实现Sync。RefCell<T>在运行时进行的借用检查不是线程安全的。

4.3 手动实现Send和Sync是不安全的

  通常不需要手动实现Send和Sync trait,因为完全由实现了Send和Sync的类型组成的类型,也会自动实现Send和Sync。因为它们是标记trait,甚至都不需要实现任何方法。它们只是用来强制执行与并发有关的不变性。

  手动实现这些trait需要编写unsafe Rust代码。后面会讲unsafe Rust。要知道如果构建新的并发类型,而它又不是完全由实现了Send和Sync的部分组成,就需要认真思考,以维持其安全保证。

参考

1、无畏并发

相关推荐
k4m7v2pz2 小时前
34 天 33 个版本:rvs(rust-verb-shell)如何用版本号对抗 AI 误判
rust·shell·命令行工具·ai agent·系统信息·跨平台开发
魔镜前的帅比15 小时前
(开源项目)x-claw(总)
python·ai·rust·开源
Source.Liu20 小时前
【Uppsala】入口模块(lib.rs)
xml·rust
SomeB1oody20 小时前
【RustyML入门】2.6. 线性判别分析
开发语言·后端·机器学习·rust·教程
魔镜前的帅比1 天前
(开源项目)x-claw (设计)
python·ai·rust·开源
Cicada1281 天前
ccvt:一个用 Rust 写的中国地图坐标系互转命令行工具
开发语言·后端·rust
Source.Liu1 天前
【Uppsala】介绍
xml·rust
程序员爱钓鱼1 天前
Rust Clone详解:深拷贝、复制成本与正确使用方式
后端·面试·rust
seacracker1 天前
一个 Rust 写的存储系统,想把 HPC 和 AI 的存储栈统一了
开发语言·人工智能·rust