✨✨ 欢迎大家来到景天科技苑✨✨
🎈🎈 养成好习惯,先赞后看哦~🎈🎈
🏆 作者简介:景天科技苑
🏆《头衔》:大厂架构师,华为云开发者社区专家博主,阿里云开发者社区专家博主,CSDN全栈领域优质创作者,掘金优秀博主,51CTO博客专家等。
🏆《博客》:Rust开发,Python全栈,Golang开发,云原生开发,PyQt5和Tkinter桌面开发,小程序开发,人工智能,js逆向,App逆向,网络系统安全,数据分析,Django,fastapi,flask等框架,云原生K8S,linux,shell脚本等实操经验,网站搭建,数据库等分享。所属的专栏:Rust语言通关之路
景天的主页:景天科技苑
文章目录
Rust并发编程
进程是资源分配的最小单位,线程是CPU调度的最小单位。每个进程都最少有一个线程,而这个线程就是我们常说的主线程
安全并高效的处理并发编程是 Rust 的另一个主要目标。
并发编程(Concurrent programming),代表程序的不同部分相互独立的执行,而 并行编程(parallel programming)代表程序不同部分于同时执行,
这两个概念随着计算机越来越多的利用多处理器的优势时显得愈发重要。
由于历史原因,在此类上下文中编程一直是困难且容易出错的:Rust 希望能改变这一点。
起初,Rust 团队认为确保内存安全和防止并发问题是两个分别需要不同方法应对的挑战。
随着时间的推移,团队发现所有权和类型系统是一系列解决内存安全 和 并发问题的强有力的工具!
通过改进所有权和类型检查,Rust 很多并发错误都是 编译时 错误,而非运行时错误。
因此,相比花费大量时间尝试重现运行时并发 bug 出现的特定情况,Rust 会拒绝编译不正确的代码并提供解释问题的错误信息。
因此,你可以在开发时而不是不慎部署到生产环境后修复代码。我们给 Rust的这一部分起了一个绰号 无畏并发(fearless concurrency)。
无畏并发令你的代码免于出现诡异的 bug 并可以轻松重构且无需担心会引入新的 bug。
一、Rust并发编程基础
在大部分现代操作系统中,执行中程序的代码运行于一个 进程(process)中,操作系统则负责管理多个进程。
在程序内部,也可以拥有多个同时运行的独立部分。这个运行这些独立部分的功能被称为 线程(threads)。
将程序中的计算拆分多个线程可以改善性能,因为程序可以同时进行多个任务,不过这也会增加复杂性。
因为线程是同时运行的,所以无法预先保证不同线程中的代码的执行顺序。
这会导致诸如此类的问题:
- 1)竞争状态(Race conditions),多个线程以不一致的顺序访问数据或资源
- 2)死锁(Deadlocks),两个线程相互等待对方停止使用其所拥有的资源,造成两者都永久等待
- 3)只会发生在特定情况且难以稳定重现和修复的 bug
Rust 尝试缓和使用线程的负面影响。不过在多线程上下文中编程仍需格外小心,同时其所要求的代码结构也不同于运行于单线程的程序。
编程语言有一些不同的方法来实现线程。很多操作系统提供了创建新线程的 API。
这种由编程语言调用操作系统 API 创建线程的模模型有时被称为 1:1,一个 OS 线程对应一个语言线程。
很多编程语言提供了自己特殊的线程实现。编程语言提供的线程被称为 绿色(green)线程,使用绿色线程的语言会在不同数量的 OS 线程中执行它们。
为此,绿色线程模式被称为 M:N 模型: M 个绿色线程对应 N 个 OS 线程,这里 M 和N 不必相同,如Go语言。
每一个模型都有其优势和取舍。对于 Rust 来说最重要的取舍是运行时支持。
运行时是一个令人迷惑的概念,其在不同上下文中可能有不同的含义。
在当前上下文中,运行时 代表二进制文件中包含的由语言自身提供的代码。
这些代码根据语言的不同可大可小,不过任何非汇编语言都会有一定数量的运行时代码。
为此,通常人们说一个语言 “没有运行时”,一般意味着 “小运行时”。
更小的运行时拥有更少的功能不过其优势在于更小的二进制输出,这使其易于在更多上下文中与其他语言相结合。
虽然很多语言觉得增加运行时来换取更多功能没有什么问题,但是 Rust 需要做到几乎没有运行时,同时为了保持高性能必需能够调用 C 语言,这点也是不能妥协的。
绿色线程的 M:N 模型更大的语言运行时来管理这些线程。
为此,Rust 标准库只提供了 1:1 线程模型实现,即一个Rust线程对应一个OS线程。
因为 Rust 是如此底层的语言,所以有相应的 crate 实现了 M:N 线程模型,如果你宁愿牺牲性能来换取例如更好的线程运行控制和更低的上下文切换成本。
1.1 线程的基本使用
Rust标准库提供了std::thread模块来支持线程操作。创建一个新线程非常简单:
use std::thread;
use std::time::Duration;
fn main() {
// 创建一个新线程,thread::spawn返回一个JoinHandle
// JoinHandle是一个智能指针,它指向一个线程
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
// 暂停线程,让出CPU,每次暂停1毫秒
thread::sleep(Duration::from_millis(1));
}
});
// 主线程的工作
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
// handle.join(); 等待子线程结束,在有join的地方就等待子线程的结束,然后才会执行主线程的代码
//handle.join()返回一个Result,所以可以使用unwrap()来获取Result中的值
handle.join().unwrap();
}
thread::spawn 的返回值类型是 JoinHandle 。 JoinHandle 是一个拥有所有权的值,当对其调用 join 方法时,它会等待其线程结束。
1.2 线程与 move 闭包
move 闭包,其经常与 thread::spawn 一起使用,因为它允许我们在一个线程中使用另一个线程的数据。
如果我们希望强制闭包获取其使用的环境值的所有权,可以在参数列表前使用 move 关键字。
这个技巧在将闭包传递给新线程以便将数据移动到新线程中时最为实用。
如果我们尝试在另一个线程中使用主线程创建的vector,会报错
//线程与所有权
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
Rust 会 推断 如何捕获 v ,因为 println! 只需要 v 的引用,闭包尝试借用 v 。
然而这有一个问题:Rust 不知道这个新建线程会执行多久,所以无法知晓 v 的引用是否一直有效。
通过在闭包之前增加 move 关键字,我们强制闭包获取其使用的值的所有权,而不是任由 Rust 推断它应该借用值。
//线程与所有权
use std::thread;
fn main() {
let v = vec![1, 2, 3];
//使用move关键字,将v的所有权移动到闭包中
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
主线程调用了 drop 的代码会发生什么呢?不幸的是,我们会因为尝试进行由于不同的原因所不允许的操作而得到不同的错误。
如果为闭包增加 move ,将会把 v 移动进闭包的环境中,如此将不能在主线程中对其调用 drop 了。我们会得到如下不同的编译错误:
Rust 的所有权规则又一次帮助了我们!通过告诉 Rust 将 v 的所有权移动到新建线程,我们向 Rust 保证主线程不会再使用v 。
move 关键字覆盖了 Rust 默认保守的借用:其也不允许我们违反所有权规则。
当子线程通过move获取到v的所有权后,主线程中就不能再使用v了
1.3 线程间通信:通道
Rust提供了通道(channel)来实现线程间通信。通道有发送端和接收端,多个线程可以通过发送端发送数据,而接收端则可以接收这些数据。
Rust 中一个实现消息传递并发的主要工具是 通道(channel),一个 Rust 标准库提供了其实现的编程概念。
你可以将其想象为一个水流的通道,比如河流或小溪。如果你将诸如橡皮鸭或小船之类的东西放入其中,它们会顺流而下到达下游。
编程中的通道有两部分组成,一个发送者(transmitter)和一个接收者(receiver)。
发送者一端位于上游位置,在这里可以将橡皮鸭放入河中,接收者部分则位于下游,橡皮鸭最终会漂流至此。
代码中的一部分调用发送者的方法以及希望发送的数据,另一部分则检查接收端收到到达的消息。
当发送者或接收者任一被丢弃时可以认为通道被 关闭(closed)了
这里,我们将开发一个程序,它会在一个线程生成值向通道发送,而在另一个线程会接收值并打印出来。
这里会通过通道在线程间发送简单值来演示这个功能。
一旦你熟悉了这项技术,就能使用通道来实现聊天系统或利用很多线程进行分布式计算并将部分计算结果发送给一个线程进行聚合。
1.3.1 通道的创建
(1) 通过mpsc::channel,创建通道,mpsc是多个生产者,单个消费者
这里使用 mpsc::channel 函数创建一个新的通道; mpsc 是 多个生产者,单个消费者(multiple producer, single consumer)的缩写。
简而言之,Rust 标准库实现通道的方式意味着一个通道可以有多个产生值的 发送(sending)端,但只能有一个消费这些值的 接收(receiving)端。
想象一下多条小河小溪最终汇聚成大河:所有通过这些小河发出的东西最后都会来到大河的下游。
目前我们以单个生产者开始,但是当示例可以工作后会增加多个生产者。
mpsc::channel 函数返回一个元组:第一个元素是发送端,而第二个元素是接收端。
由于历史原因, tx 和 rx 通常作为发送者(transmitter)和 接收者(receiver)的缩写,所以这就是我们将用来绑定这两端变量的名字。
这里使用了一个let 语句和模式来解构了此元组;
(2) 通过SPMC::channel,创建通道,SPMC是一个生产者,多个消费者(sigle producer, multiple consumer)
(3) 创建通道后返回的是发送者和消费者
示例:
let(tx,rx)= mpsc::channel();
let(tx,rx)= spmc::channel();
1.3.2 基本使用
在一个线程生成值向通道发送,而在另一个线程会接收值
use std::sync::mpsc;
use std::thread;
fn main() {
//多线程通信
let (sender, receiver) = mpsc::channel();
//创建个线程,发送数据
thread::spawn(move || {
sender.send("Hello from thread").unwrap();
});
//主线程接收数据
//recv会阻塞主线程,直到有数据可读,所以不用join
let received = receiver.recv().unwrap();
println!("Got: {}", received);
}
发送端与接收端:
通道的发送端有一个 send 方法用来获取需要放入通道的值。
send 方法返回一个 Result<T, E> 类型,所以如果接收端已经被丢弃了,将没有发送值的目标,所以发送操作会返回错误。
通道的接收端有两个有用的方法: recv 和 try_recv 。
这里,我们使用了 recv ,它是 receive 的缩写。
recv会阻塞主线程执行直到从通道中接收一个值,如果收不到值,会一直等待。一旦接收了一个值, recv 会在一个 Result<T, E> 中返回它。
当通道发送端关闭, recv 会返回一个错误表明不会再有新的值到来了。
try_recv 不会阻塞,相反它立刻返回一个 Result<T, E> : Ok 值包含可用的信息,而 Err 值代表此时没有任何消息。
如果线程在等待消息过程中还有其他工作时使用 try_recv 很有用。
可以编写一个循环来频繁调用 try_recv ,再有可用消息时进行处理,其余时候则处理一会其他工作知道再次检查。
如果使用try_recv,需要使用join等待 在有join的地方就等待子线程的结束,然后才会执行主线程的代码
use std::sync::mpsc;
use std::thread;
fn main() {
//多线程通信
let (sender, receiver) = mpsc::channel();
//创建个线程,发送数据,返回Result
let a = thread::spawn(move || {
//发送数据,发送失败会panic
sender.send("Hello from thread").unwrap();
});
a.join().unwrap();
//主线程接收数据,也是返回Result
let received = receiver.try_recv().unwrap();
println!("Got: {}", received);
}
1.3.3 通道与所有权转移
一旦将值发送到另一个线程后,那个线程可能会在我们再次使用它之前就将其修改或者丢弃。
其他线程对值可能的修改会由于不一致或不存在的数据而导致错误或意外的结果。
我们的并发错误会造成一个编译时错误。 send 函数获取其参数的所有权并移动这个值归接收者所有。
这个意味着不可能在发送后再次使用这个值;所有权系统检查一切是否合乎规则。
总结:不能在同一个线程中即发送数据,又使用数据。
use std::sync::mpsc;
use std::thread;
fn main() {
//多线程通信
let (sender, receiver) = mpsc::channel();
//创建个线程,发送数据,返回Result
thread::spawn(move || {
//发送数据,发送失败会panic
let data = String::from("Hello from thread");
sender.send(data).unwrap();
//此时data的所有权已经移动到通道中了,所以不能再次使用data
println!("data: {}", data);
});
// a.join().unwrap();
//主线程接收数据,也是返回Result
let received = receiver.recv().unwrap();
println!("Got: {}", received);
}
1.3.4 发送多个值并观察接收者的等待
在一个线程中发送多个消息,并在每个消息直接停顿1秒
use std::sync::mpsc;
use std::thread;
//发送多个值
fn main() {
//创建通道
let (tx, rx) = mpsc::channel();
//创建线程,在一个线程中发送多个数据
thread::spawn(move || {
let data = vec![String::from("Hello"), String::from("from"), String::from("thread")];
for value in data {
tx.send(value).unwrap();
thread::sleep(std::time::Duration::from_secs(1));
}
});
//主线程接收数据,直接循环接收者
for received in rx {
println!("Got: {}", received);
}
}
在主线程每秒接收一个
1.3.5 通过克隆发送者来创建多个生产者
可以通过克隆通道的发送端来创建多个生产者
发送端可以克隆,接收端不可以克隆,因为Sender实现了Clone,而Recevier没有实现Clone
//通过克隆发送端来创建多个生产者
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
//克隆发送端
let tx1 = mpsc::Sender::clone(&tx);
//创建两个生产者
thread::spawn(move || {
let vals = vec![
String::from("1 hi"),
String::from("1 from"),
String::from("1 the"),
String::from("1 thread")
];
for val in vals {
tx1.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
thread::spawn(move || {
let vals = vec![
String::from("2 more"),
String::from("2 messages"),
String::from("2 for"),
String::from("2 you")
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
//只有一个消费者,直接循环接收者
for received in rx {
println!("Got: {}", received);
}
}
在创建新线程之前,我们对通道的发送端调用了 clone 方法。这会给我们一个可以传递给第一个新建线程的发送端句柄。
我们会将原始的通道发送端传递给第二个新建线程。
这样就会有两个线程,每个线程将向通道的接收端发送不同的消息。
虽然你可能会看到这些值以不同的顺序出现;这依赖于你的系统。
这也就是并发既有趣又困难的原因。如果通过thread::sleep 做实验,在不同的线程中提供不同的值,就会发现他们的运行更加不确定并每次都会产生不同的输出。
1.4 共享状态并发
任何编程语言中的通道都类似于单所有权,因为一旦将一个值传送到通道中,将无法再使用这个值。
共享内存类似于多所有权:多个线程可以同时访问相同的内存位置。
智能指针使得多所有权成为可能,然而这会增加额外的复杂性,因为需要以某种方式管理这些不同的所有者。
1.4.1 互斥器一次只允许一个线程访问数据
互斥器(mutex)是 “mutual exclusion” 的缩写,也就是说,任意时刻,其只允许一个线程访问某些数据。
为了访问互斥器中的数据,线程首先需要通过获取互斥器的 锁(lock)来表明其希望访问数据。
锁是一个作为互斥器一部分的数据结构,它记录谁有数据的排他访问权。
因此,我们描述互斥器为通过锁系统 保护(guarding)其数据。
互斥器以难以使用著称,因为你不得不记住:
- 在使用数据之前尝试获取锁。如果这个锁被其他线程获取到了,就需要等待。
- 处理完被互斥器所保护的数据之后,必须解锁数据,这样其他线程才能够获取锁。
作为一个现实中互斥器的例子,想象一下在某个会议的一次小组座谈会中,只有一个麦克风。
如果一位成员要发言,他必须请求或表示希望使用麦克风。一旦得到了麦克风,他可以畅所欲言,然后将麦克风交给下一位希望讲话的成员。
如果一位成员结束发言后忘记将麦克风交还,其他人将无法发言。
如果对共享麦克风的管理出现了问题,座谈会将无法如期进行!
正确的管理互斥器异常复杂,这也是许多人之所以热衷于通道的原因。
然而,在 Rust 中,得益于类型系统和所有权,我们不会在上锁和解锁上出错。
Rust提供了Mutex和Arc来实现线程安全的共享状态。
1)Mutex
Mutex (互斥锁) 是 Rust 中用于线程间共享数据的主要同步机制之一。它通过强制每次只有一个线程可以访问数据来保证线程安全。
Mutex<T>
是一个智能指针
更准确的说, lock 调用 返回 一个叫做 MutexGuard 的智能指针。
这个智能指针实现了 Deref 来指向其内部数据;
其也提供了一个 Drop 实现当 MutexGuard 离开作用域时自动释放锁。
为此,我们不会冒忘记释放锁并阻塞互斥器为其它线程所用的风险,因为锁的释放是自动发生的。
//互斥锁
//Mutex<T>
use std::sync::Mutex;
fn main() {
//创建个互斥锁
let m = Mutex::new(5);
//查看互斥锁中的值
println!("m = {:?}", m);
//在局部作用域中修改互斥锁中的值
{
//lock方法返回一个MutexGuard智能指针,它实现了Deref trait,可以解引用
let mut num = m.lock().unwrap();
*num = 6; //修改互斥锁中的值
// MutexGuard 离开作用域时自动释放锁
}
println!("m = {:?}", m);
}
2)在线程间共享 Mutex<T>
使用 Mutex<T>
可以在多个线程间共享值。
我们将启动十个线程,并在各个线程中对同一个计数器值加一,这样计数器将从 0 变为 10。
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
//查看counter的值
println!("counter: {:?}", counter);
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 变量来存放内含 i32 的 Mutex<T>
。
接下来遍历 range 创建了 10 个线程。
使用了 thread::spawn 并对所有线程使用了相同的闭包:他们每一个都将调用 lock 方法来获取 Mutex<T>
上的锁,接着将互斥器中的值加一。
当一个线程结束执行, num 会离开闭包作用域并释放锁,这样另一个线程就可以获取它了。
在主线程中,我们收集了所有的 join 句柄,调用它们的 join 方法来确保所有线程都会结束。
之后,主线程会获取锁并打印出程序的结果。
报错
错误信息表明 counter 值被移动进了闭包并当调用 lock 时被捕获。
这听起来正是我们需要的,但是这是不允许的!
第一个错误信息中说, counter 被移动进了 handle 所代表线程的闭包中。
因此我们无法在第二个线程中对其调用 lock ,并将结果储存在 num2 中时捕获 counter !
所以 Rust 告诉我们不能将 counter 的所有权移动到多个线程中。
这在之前很难看出,因为我们在循环中创建了多个线程,而 Rust 无法在每次迭代中指明不同的线程。
因此,这种方式让数据在多个线程间共享,行不通。
3)结合Rc<T>
来实现共享所有权
上面不是锁的所有者只能被第一个线程使用吗,后面的线程无法获取锁的所有权。
我们就尝试通过使用智能指针 Rc<T>
来创建引用计数的值,以便拥有多所有者。
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Rc::new(Mutex::new(0));
//查看counter的值
println!("counter: {:?}", counter);
let mut handles = vec![];
for _ in 0..10 {
// 克隆counter,并将其传递给每个线程
// Arc::clone(&counter)
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());
}
报错
第一行错误表明 std::rc::Rc<std::sync::Mutex<i32>>
cannot be sent between threads safely 。
其原因是另一个值得注意的部分,经过提炼的错误信息表明 the trait bound Send
is not satisfied 。
不幸的是, Rc<T>
并不能安全的在线程间共享,Rc<T>
不是线程安全的。当 Rc<T>
管理引用计数时,它必须在每一个 clone 调用时增加计数,并在每一个克隆被丢弃时减少计数。
Rc<T>
并没有使用任何并发原语,来确保改变计数的操作不会被其他线程打断。
在计数出错时可能会导致诡异的 bug,比如可能会造成内存泄漏,或在使用结束之前就丢弃一个值。
我们所需要的是一个完全类似 Rc<T>
,又以一种线程安全的方式改变引用计数的类型。
4)原子引用计数 Arc<T>
Arc<T>
是原子引用计数的类型,可以安全的在线程之间共享。可以应用于并发环境
字母 “a” 代表 原子性(atomic),所以这是一个原子引用计数(atomically reference counted)类型。
你可能会好奇为什么不是所有的原始类型都是原子性的?为什么不是所有标准库中的类型都默认使用 Arc<T>
实现?
原因在于线程安全带有性能惩罚,我们希望只在必要时才为此买单。
如果只是在单线程中对值进行操作,原子性提供的保证并无必要,代码可以因此运行的更快。
Mutex<T>
是互斥锁,一次只允许一个线程访问数据
Mutex 通常与 Arc (原子引用计数) 一起用于多线程环境:
Arc<T>
和 Rc<T>
有着相同的 API
use std::sync::{ Arc, Mutex };
use std::thread;
fn main() {
//通过Arc和Mutex来共享数据
//Arc<T>是原子引用计数的类型,可以安全的在线程之间共享
//Mutex<T>是互斥锁,一次只允许一个线程访问数据
let counter = Arc::new(Mutex::new(0));
//查看counter的值
println!("counter: {:?}", counter);
let mut handles = vec![];
for _ in 0..10 {
// 克隆counter,并将其传递给每个线程
// Arc::clone(&counter)
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());
}
Arc(原子引用计数)允许数据有多个所有者,Mutex提供内部可变性并确保线程安全访问。
1.4.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) 的风险。
这发生于当一个操作需要锁住两个资源而两个线程各持一个锁,这会造成它们永远相互等待。
如果你对这个主题感兴趣,尝试编写一个带有死锁的 Rust 程序,接着研究任何其他语言中使用互斥器的死锁规避策略并尝试在 Rust 中实现他们。
标准库中 Mutex<T>
和 MutexGuard 的 API 文档会提供有用的信息。
1.5 使用 Sync 和 Send trait 的可扩展并发
Rust 的并发模型中一个有趣的方面是:语言本身对并发知之 甚少。
我们之前讨论的几乎所有内容,都属于标准库,而不是语言本身的内容。
由于不需要语言提供并发相关的基础设施,并发方案不受标准库或语言所限:我们可以编写自己的或使用别人编写的并发功能。
Rust有两个内嵌的并发包:std::marker 中的 Sync 和 Send trait。
1.5.1 通过 Send 允许在线程间转移所有权
Send 标记 trait 表明类型的所有权可以在线程间传递。
几乎所有的 Rust 类型都是 Send 的,不过有一些例外,比如Rc<T>
:这是不能 Send 的,
因为如果克隆了 Rc<T>
的值并尝试将克隆的所有权转移到另一个线程,这两个线程都可能同时更新引用计数。
为此, Rc<T>
被实现为用于单线程场景,这时不需要为拥有线程安全的引用计数而付出性能代价。
因此,Rust 类型系统和 trait bound 确保永远也不会意外的将不安全的 Rc<T>
在线程间发送。
当尝试在多线程中这么做的时候,会得到错误 the trait Send is not implemented for Rc<Mutex<_>> 。
而使用标记为 Send 的 Arc<T>
时,就没有问题了。
任何完全由 Send 的类型组成的类型也会自动被标记为 Send 。例如 struct A{a,b,c} 如果a,b,c都是Send的,那么A也是Send的
几乎所有基本类型都是 Send 的,除了裸指针(raw pointer)。
1.5.2 Sync 允许多线程访问
Sync 标记 trait 表明一个实现了 Sync 的类型可以安全的在多个线程中拥有其值的引用。
换一种方式来说,对于任意类型 T ,如果 &T ( T 的引用)是 Send 的话 T 就是 Sync 的,这意味着其引用就可以安全的发送到另一个线程。
类似于 Send 的情况,基本类型是 Sync 的,完全由 Sync 的类型组成的类型也是 Sync 的。
智能指针 Rc<T>
也不是 Sync 的,出于其不是 Send 相同的原因。
RefCell<T>
和 Cell<T>
系列类型也不是 Sync 的。
RefCell<T>
在运行时所进行的借用检查也不是线程安全的。
Mutex<T>
是 Sync 的,它可以被用来在多线程中共享访问。
1.5.3 手动实现 Send 和 Sync 是不安全的
通常并不需要手动实现 Send 和 Sync trait,因为由 Send 和 Sync 的类型组成的类型,自动就是 Send 和 Sync 的。
因为他们是标记 trait,甚至都不需要实现任何方法。他们只是用来加强并发相关的不可变性的。
手动实现这些标记 trait 涉及到编写不安全的 Rust 代码;
当前重要的是,在创建新的由不是 Send 和 Sync 的部分构成的并发类型时需要多加小心,以确保维持其安全保证。
The Nomicon 中有更多关于这些保证以及如何维持他们的信息。
二、高级并发模式
2.1 使用线程池处理任务
在实际应用中,频繁创建销毁线程会带来性能开销。线程池可以重用线程,提高性能。
//线程池
use std::sync::{ Arc, Mutex };
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
//定义一个结构体,表示线程池
#[allow(dead_code)]
struct ThreadPool {
workers: Vec<Worker>,
sender: mpsc::Sender<Job>,
}
//定义一个类型别名,表示任务
type Job = Box<dyn FnOnce() + Send + 'static>;
//为ThreadPool实现方法
impl ThreadPool {
// 创建新线程池
pub fn new(size: usize) -> ThreadPool {
//线程池的大小必须大于0
assert!(size > 0);
println!("Creating a thread pool of size {}", size);
//创建通道
let (sender, receiver) = mpsc::channel();
//将接收端放入互斥锁中,再放入Arc中,实现共享
let receiver = Arc::new(Mutex::new(receiver));
//创建线程池
let mut workers = Vec::with_capacity(size);
//创建工作线程
for id in 0..size {
workers.push(Worker::new(id, Arc::clone(&receiver)));
}
//返回线程池
ThreadPool { workers, sender }
}
// 执行任务
pub fn execute<F>(&self, f: F) where F: FnOnce() + Send + 'static {
let job = Box::new(f);
self.sender.send(job).unwrap();
}
}
//定义一个结构体,表示工作线程
#[allow(dead_code)]
struct Worker {
id: usize, //工作线程的id
thread: thread::JoinHandle<()>, //线程句柄
}
//为Worker实现方法
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
//创建工作线程
let thread = thread::spawn(move || {
//循环从通道中接收任务,并执行
loop {
//recv会阻塞线程,直到有数据可读
let job = receiver.lock().unwrap().recv().unwrap();
println!("Worker {} got a job; executing.", id);
job();
}
});
//返回工作线程
Worker { id, thread }
}
}
fn main() {
//创建线程池
let pool = ThreadPool::new(4);
//执行任务
for i in 0..8 {
pool.execute(move || {
println!("Executing task {}", i);
thread::sleep(Duration::from_secs(1));
});
}
}
2.2 无锁编程与原子类型
对于简单的共享数据,使用原子类型比互斥锁更高效。Rust提供了std::sync::atomic模块。
use std::sync::atomic::{ AtomicUsize, Ordering };
use std::sync::Arc;
use std::thread;
fn main() {
//使用AtomicUsize来实现线程安全的计数器
let counter = Arc::new(AtomicUsize::new(0));
//定义个线程句柄的集合
let mut handles = vec![];
for _ in 0..10 {
//克隆counter,并将其传递给每个线程
let counter = Arc::clone(&counter);
//使用move关键字将counter移动到线程中
let handle = thread::spawn(move || {
//每个线程循环1000次,每次将计数器加1
for _ in 0..1000 {
//使用fetch_add方法来原子的增加计数器的值
counter.fetch_add(1, Ordering::SeqCst);
}
});
//将线程句柄加入集合
handles.push(handle);
}
//等待所有线程完成
for handle in handles {
handle.join().unwrap();
}
//获取计数器的值
println!("Result: {}", counter.load(Ordering::SeqCst));
}
Ordering参数指定了内存顺序,SeqCst(顺序一致性)是最严格的,保证所有线程看到相同的操作顺序。
三、总结
Rust的并发编程模型提供了强大的安全保障,同时不牺牲性能。通过所有权系统、通道和智能指针等特性,Rust能够在编译期防止许多常见的并发错误。从简单的线程操作到复杂的异步编程,Rust提供了一系列工具来满足不同场景的需求。
在实际应用中,应根据具体需求选择合适的并发模型:对于CPU密集型任务,可以使用线程池或并行迭代;对于I/O密集型任务,异步编程可能是更好的选择;当需要线程间通信时,通道通常是首选方案。
Rust的并发编程虽然有一定的学习曲线,但其带来的安全性和性能优势使得这一投入非常值得。随着Rust生态系统的不断成熟,越来越多的库(如tokio、rayon、crossbeam等)正在使并发编程变得更加简单和高效。