并发,是指在宏观意义上同一时间处理多个任务。并发的方式一般包含为三种:多进程、多线程以及最近几年刚刚火起来的协程。
线程(thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
Rust使用标准库中的thread模块创建线程:
use std::thread;
use std::time::Duration;
// 线程函数
fn thread_fn() {
for i in 0..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_secs(1));
}
}
fn main() {
// 创建子线程
let t = thread::spawn(thread_fn);
// 等待子线程结束
t.join();
}
与创建进程不同,thread::spawn返回的不再是Result,不需要unwrap。难道创建线程不会失败吗?
上面的例子中,线程函数每秒打印一次,循环10次后线程结束。主线程等待子线程结束后退出。
这时候,若想让循环10次变成参数传入,从而控制循环次数,应该怎么做呢?很遗憾,thread::spawn没有办法给线程函数传递参数。不过,有一种特殊的函数可以解决这个问题,那就是闭包。我前面写过一篇关于闭包的文章,感兴趣的朋友可以看一下:https://blog.csdn.net/zhmh326/article/details/108443322。
use std::thread;
use std::time::Duration;
fn main() {
let count = 5;
// 使用闭包创建线程
let t = thread::spawn(move || {
// 闭包捕获环境变量count
for i in 0..count {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_secs(1));
}
});
// 等待线程结束
t.join();
}
闭包可以捕获环境中的变量count,细心的朋友可能发现闭包使用了move关键字,这是因为多线程并发有着许多不确定性。如果以借用的方法使用count,那主线程中count被销毁了怎么办?所以,需要添加move关键字,让Rust将count复制一份到闭包中,这样就不会引发问题。
如果线程函数很长,这么写并不优雅,可以把上面两种方式结合起来:
use std::thread;
use std::time::Duration;
// 线程函数
fn thread_fn(count: i32) {
for i in 1..count {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_secs(1));
}
}
fn main() {
let count = 5;
// 创建线程
let t = thread::spawn(move || { thread_fn(count) });
// 等待线程结束
t.join();
}
通道( channel)是个很时髦的词汇, Go和Rust都用通道来解决线程间通信的问题。与进程的管道类似,都是分为发送端和接收端,不同的是,通道的发送端可以不只一个,但接收端只能是一个。这在Rust中被称为mpsc(multiple producer, single consumer )。
use std::thread;
use std::sync::mpsc;
fn main() {
// 创建通道
let (tx, rx) = mpsc::channel();
// 启动子线程
let t = thread::spawn(move || {
// 阻塞的接收消息
let msg = rx.recv().unwrap();
println!("sub thread recv: {}", msg);
});
// 向通道发送消息
tx.send(String::from("hello")).unwrap();
// 等待子线程结束
t.join().unwrap();
}
recv()是阻塞的,与之相对的是非阻塞的try_recv:
use std::thread;
use std::sync::mpsc;
use std::time::Duration;
fn main() {
// 创建通道
let (tx, rx) = mpsc::channel();
// 启动子线程
let t = thread::spawn(move || {
// 阻塞的接收消息
loop {
match rx.try_recv() {
Ok(msg) => println!("sub thread recv: {}", msg),
_ => {
println!("sub thread recv nothing");
thread::sleep(Duration::from_secs_f32(0.5))
}
}
}
});
thread::sleep(Duration::from_secs_f32(3.5));
// 向通道发送消息
tx.send(String::from("hello")).unwrap();
// 等待子线程结束
t.join().unwrap();
}
由于tx的所有权只能被一个线程拥有,在多个线程同时使用时,需要clone为多分使用:
use std::thread;
use std::sync::mpsc;
fn main() {
// 创建通道
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
let tx2 = tx.clone();
// 启动子线程
let t1 = thread::spawn(move || {
tx1.send(String::from("hello. I'm thread1.")).unwrap();
});
let t2 = thread::spawn(move || {
tx2.clone().send(String::from("hello. I'm thread2.")).unwrap();
});
let msg1 = rx.recv().unwrap();
println!("main thread recv: {}", msg1);
let msg2 = rx.recv().unwrap();
println!("main thread recv: {}", msg2);
}
rx实现了迭代器特性,可以直接用于for循化。上面的代码可以简化为:
use std::thread;
use std::sync::mpsc;
fn main() {
// 创建通道
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
let tx2 = tx.clone();
// 启动子线程
let t1 = thread::spawn(move || {
tx1.send(String::from("hello. I'm thread1.")).unwrap();
});
let t2 = thread::spawn(move || {
tx2.clone().send(String::from("hello. I'm thread2.")).unwrap();
});
for msg in rx {
println!("main thread recv: {}", msg);
}
}
通道还有一个特性,如果发送的消息被保存在某变量中的话,消息发送后,这个变量不能再被使用。
use std::thread;
use std::sync::mpsc;
fn main() {
// 创建通道
let (tx, rx) = mpsc::channel();
// 启动子线程
let t = thread::spawn(move || {
// 阻塞的接收消息
let msg = rx.recv().unwrap();
println!("sub thread recv: {}", msg);
});
// 向通道发送消息
let msg = String::from("hello");
tx.send(msg).unwrap();
println!("main thread send: {}", msg);
// 等待子线程结束
t.join().unwrap();
}
这段代码看上去感觉没有问题,但事实上并非如此。因为并发,Rust担心子线程接收到以后会修改甚至drop掉msg变量,这时候主线程再使用它就会引发错误。因此,发送以后,msg的所有权被移动给了接收者,发送者不能再次使用它。
在没有通道之前,大多数语言都是通过共享内存的方式实现线程间的数据共享。共享内存固然好,但使用过程中有着诸多的问题,多个线程需要互斥的访问内存数据,需要与信号量结合进行加锁。加锁后又会出现新的问题,比如列锁等。Go语言直接明确的表示“不要通过共享内存来通讯 !”。
Rust依然保留了共享内存的实现方法,在允许使用共享内存的同时,将共享内存与信号量紧密的结合在了一起。如果你不使用信号量加锁,就得不到共享内存的所有权,共享内存使用完后会自动解锁等一系统的措施来保证共享内存的安全。
现在很多班级有问题都需要在群里进行接龙,接龙的过程是很多家长并发的过程,经常出现家长刚编辑完接龙的文字发出去就被其他家长覆盖的情况。我们使用共享内存来帮助家长解决一下这个头疼的问题。
首先,进行第一步尝试,在单线程中使用共享内存:
use std::sync::Mutex;
fn main() {
// 创建信号量,它的内部是一个动态数组
let m = Mutex::new(vec![]);
// 从信号量中取得共享内存
let mut names = m.lock().unwrap();
// 修改共享内存
names.push("main");
println!("{:?}", names);
}
m.lock不仅仅完成了对信号量的加锁,它还完成了从信号量中提取共享内存的的作用,因此,不用担心没有对共享内存加锁就使用它,因为不加锁你就得不到共享内存。虽然信号量是不可变的,但依然可以对其进行加锁,得到的共享内存也可以是可变的。
接下来,在循环中使用共享内存:
use std::sync::Mutex;
fn main() {
// 创建信号量,它的内部是一个动态数组
let m = Mutex::new(vec![]);
for i in 1..10 {
// 从信号量中取得共享内存
let mut names = m.lock().unwrap();
// 修改共享内存
names.push(format!("{}", i));
}
let names = m.lock().unwrap();
println!("{:?}", names);
}
这段代码也能够正常运行,为什么每次对信号量的加锁都能成功呢?因为在for循环的循环体内取得了共享内存,在共享内存的生命周期结束时(离开循环体时),信号量被自动解锁了。因此,不用担心忘记解锁,后面加锁时永远得不锁造成死锁的情况。
下面,将共享内存放到多线程的并发环境中:
use std::thread;
use std::sync::Mutex;
fn main() {
// 创建信号量,它的内部是一个动态数组
let m = Mutex::new(vec![]);
// 循环中创建多个线程
let mut threads = vec![];
for i in 1..10 {
let t = thread::spawn(move ||{
// 从信号量中取得共享内存
let mut names = m.lock().unwrap();
// 修改共享内存
names.push(format!("{}", i));
});
threads.push(t);
}
let names = m.lock().unwrap();
println!("{:?}", names);
}
然而,意想不到的是,这段代码是不能通过编译的,编译器提示:
error[E0382]: use of moved value: `m`
--> src\main.rs:11:31
|
6 | let m = Mutex::new(vec![]);
| - move occurs because `m` has type `std::sync::Mutex>`, which does not i
mplement the `Copy` trait
...
11 | let t = thread::spawn(move ||{
| ^^^^^^^ value moved into closure here, in previous iteration of loop
12 | // 从信号量中取得共享内存
13 | let mut names = m.lock().unwrap();
| - use occurs due to use in closure
error: aborting due to previous error
信号量没有实现Copy特性,同一个信号量是不能被多个线程使用。尝试对m进行clone操作,但发现m也没提供clone方法。这该如何是好。
事实上,Rust是允许多所有权的,这在Rust中被称为Rc(引用计数:Reference counted),Rc是一种智能指针,使用它,可以使变量被多个地方使用。在并发的环境中,Rust还提供了一种原子引用计数 :Arc(Atomically reference counted)的类型,使用它来操作变量是具有原子性的,因此,它可以保证并发的安全性。
use std::thread;
use std::sync::{Mutex, Arc};
fn main() {
// Arc,它的内部为之前的信号量
let m = Arc::new(Mutex::new(vec![]));
// 循环中创建多个线程
let mut threads = vec![];
for i in 1..10 {
// m具有了clone方法
let m = m.clone();
// 创建线程
let t = thread::spawn(move ||{
// Arc类型可以直接使用内部的值,从信号量中取得共享内存的方法与不使用Arc完全一致
let mut names = m.lock().unwrap();
// 修改共享内存
names.push(format!("thread{}", i));
});
threads.push(t);
}
// 等待所有线程结束
for t in threads {
t.join().unwrap();
}
// 打印接龙名单
let names = m.lock().unwrap();
println!("{:?}", names);
}
这个就可以正常运行了,最后结果的名单并不是按顺序的,也证明了并发的有效性。
虽然Rust对共享内存的安全进行了大量的工作,但是,它仍不能100%的避免死锁的问题,比如,有两个信号量,当同时拥有这两个信号量时才能进行某些工作,而这两个信号量却分别被两个线程加锁。这样的问题在于并发逻辑设计的不好,只能从逻辑上进行避免,无法在语言层面上根除,在进行并发编程中,需要小心又小心,谨慎再谨慎。