Rust并发编程全解析
1. 并发与多线程基础
在并发编程里,程序代码通常是线程共享的。每个线程能够执行程序文本段中不同的代码部分,并且将局部变量和函数参数存储在各自的线程栈中。当轮到某个线程执行时,CPU会加载该线程的程序计数器(包含要执行指令的地址),进而执行该线程的一组指令。
例如,若任务A2因等待I/O而被阻塞,CPU就会将执行权切换到其他任务,像B1或者A1。
Rust实现了1:1的线程模型,也就是每个操作系统线程对应由Rust标准库创建的一个用户级线程。另一种模型是M:N(也叫绿色线程),其中有M个绿色线程(由运行时管理的用户级线程)映射到N个内核级线程。
2. 创建和配置线程
2.1 使用
thread::spawn
函数创建线程
使用Rust标准库创建新线程有两种方式,首先来看
thread::spawn
函数的示例:
use std::thread;
fn main() {
for _ in 1..5 {
thread::spawn(|| {
println!("Hi from thread id {:?}",
thread::current().id());
});
}
}
在这个程序里,使用了
std::thread
模块,
thread::spawn()
函数用于创建新线程。在主函数(在进程的主线程中运行)中创建了四个新的子线程。不过,运行这个程序时会发现,每次的输出结果都不一样,有时只打印一行,有时打印多行,有时甚至没有输出。这是因为线程的执行顺序是不确定的,而且如果
main()
函数在子线程执行之前就结束了,就无法看到预期的输出。
为了解决这个问题,需要将创建的子线程加入到主线程中,让主线程等待所有子线程执行完毕。修改后的代码如下:
use std::thread;
fn main() {
let mut child_threads = Vec::new();
for _ in 1..5 {
let handle = thread::spawn(|| {
println!("Hi from thread id {:?}",
thread::current().id());
});
child_threads.push(handle);
}
for i in child_threads {
i.join().unwrap();
}
}
修改后的程序确保了
main()
函数在所有子线程执行完毕后才退出,每次运行都会打印出四个线程的信息。但线程的执行顺序仍然是不确定的,这是多线程编程的一个特点,也是需要解决的挑战之一。
2.2 使用
thread::Builder
创建线程
thread::spawn
函数使用的是线程名称和栈大小的默认参数。如果想显式设置这些参数,可以使用
thread::Builder
。以下是使用
Builder
模式重写的示例:
use std::thread;
fn main() {
let mut child_threads = Vec::new();
for i in 1..5 {
let builder = thread::Builder::new().name(format!(
"mythread{}", i));
let handle = builder
.spawn(|| {
println!("Hi from thread id {:?}", thread::
current().name().unwrap());
})
.unwrap();
child_threads.push(handle);
}
for i in child_threads {
i.join().unwrap();
}
}
在这个代码中,首先使用
new()
函数创建一个新的构建器对象,然后使用
name()
方法配置线程的名称,最后使用
spawn()
方法创建线程。需要注意的是,
spawn()
方法返回的是
io::Result<JoinHandle<T>>
类型,所以要对返回值进行解包以获取子进程句柄。运行代码后,会在终端看到四个线程的名称。
3. 线程中的错误处理
3.1 基本错误处理
Rust标准库包含
std::thread::Result
类型,这是专门为线程设计的
Result
类型。以下是一个使用示例:
use std::fs;
use std::thread;
fn copy_file() -> thread::Result<()> {
thread::spawn(|| {
fs::copy("a.txt", "b.txt").expect("Error
occurred");
})
.join()
}
fn main() {
match copy_file() {
Ok(_) => println!("Ok. copied"),
Err(_) => println!("Error in copying file"),
}
}
在这个示例中,
copy_file()
函数将源文件复制到目标文件,并返回
thread::Result<()>
类型。在
main()
函数中使用
match
语句对返回值进行解包,如果
copy_file()
函数返回
Result::Err
变体,就会打印错误信息。
3.2 检测线程恐慌
如果想在错误传播到调用函数之前就识别当前线程是否正在恐慌,可以使用
thread::panicking()
函数。以下是修改后的示例:
use std::fs;
use std::thread;
struct Filenames {
source: String,
destination: String,
}
impl Drop for Filenames {
fn drop(&mut self) {
if thread::panicking() {
println!("dropped due to panic");
} else {
println!("dropped without panic");
}
}
}
fn copy_file(file_struct: Filenames) -> thread::Result<()> {
thread::spawn(move || {
fs::copy(&file_struct.source,
&file_struct.destination).expect(
"Error occurred");
})
.join()
}
fn main() {
let foo = Filenames {
source: "a1.txt".into(),
destination: "b.txt".into(),
};
match copy_file(foo) {
Ok(_) => println!("Ok. copied"),
Err(_) => println!("Error in copying file"),
}
}
在这个代码中,创建了一个
Filenames
结构体,用于存储要复制的源文件名和目标文件名。同时,为
Filenames
结构体实现了
Drop
trait,在
Drop
trait的实现中使用
thread::panicking()
函数检测当前线程是否正在恐慌,并根据情况打印相应的信息。运行程序时,如果源文件名无效,会看到以下输出:
dropped due to panic
Error in copying file
需要注意的是,在传递给
spawn()
函数的闭包中使用了
move
关键字,这是为了将
file_struct
数据结构的所有权从主线程转移到新创建的线程。
4. 线程间的消息传递
4.1 消息传递并发模型
并发编程虽然强大,但并发程序的执行和调试却很困难,因为其执行是不确定的。为了确保程序在不确定的线程执行顺序下也能正确运行,可以引入线程间同步机制。消息传递并发就是这样一种模型,Rust标准库实现了一种名为通道的消息传递并发解决方案。通道类似于管道,由生产者和消费者两部分组成,生产者将消息放入通道,消费者从通道中读取消息。
Rust的通道实现具有多生产者单消费者(mpsc)的特性,也就是说可以有多个发送端,但只有一个接收端。以下是一个逐步构建的示例:
use std::sync::mpsc;
use std::thread;
fn main() {
let (transmitter1, receiver) = mpsc::channel();
let transmitter2 = mpsc::Sender::clone(&transmitter1);
thread::spawn(move || {
let num_vec: Vec<String> = vec!["One".into(),
"two".into(), "three".into(),
"four".into()];
for num in num_vec {
transmitter1.send(num).unwrap();
}
});
thread::spawn(move || {
let num_vec: Vec<String> =
vec!["Five".into(), "Six".into(),
"Seven".into(), "eight".into()];
for num in num_vec {
transmitter2.send(num).unwrap();
}
});
for received_val in receiver {
println!("Received from thread: {}",
received_val);
}
}
4.2 操作步骤
-
导入标准库中的
mpsc和thread模块。 -
在
main()函数中创建一个新的mpsc通道。 - 克隆通道,以便有两个发送线程。
-
创建两个新线程,分别将
transmitter1和transmitter2移动到线程闭包中,并向通道发送一组值。 - 在主线程中使用接收句柄从通道中消费子线程发送的值。
运行这个程序时,会在主线程中看到子线程发送的值,但每次运行时接收到的值的顺序可能不同,这是因为线程的执行顺序是不确定的。mpsc通道提供了一种轻量级的线程间同步机制,适用于创建多个线程进行不同类型的计算,并让主线程汇总结果的场景。需要注意的是,一旦值被发送到通道中,发送线程就不再拥有该值的所有权。
4.3 流程图
graph LR
A[开始] --> B[创建mpsc通道]
B --> C[克隆通道]
C --> D[创建线程1并发送值]
C --> E[创建线程2并发送值]
D --> F[主线程接收值]
E --> F
F --> G[结束]
5. 通过共享状态实现并发
5.1 共享状态并发模型
在Rust标准库中,另一种支持的并发编程模型是共享状态或共享内存模型。由于进程中的所有线程共享相同的进程内存空间,所以可以利用这一点来实现线程间的通信。
实现共享状态并发的主要方式是结合使用
Mutex
和
Arc
。
Mutex
(互斥锁)是一种机制,它确保同一时间只有一个线程能够访问某块数据。首先,将数据值包装在
Mutex
类型中,它就像一个带有外部锁的盒子,保护着里面的重要数据。要访问盒子里的内容,必须先请求打开锁并获取盒子,使用完后再将盒子交回。
同样,要访问或修改受
Mutex
保护的值,必须先获取锁。请求
Mutex
对象的锁会返回一个
MutexGuard
类型,通过它可以访问内部值。在此期间,其他线程无法访问受
MutexGuard
保护的值。使用完后,
MutexGuard
会在超出作用域时自动释放锁。
为了支持一个值被多个线程拥有,Rust使用了引用计数智能指针
Rc
和
Arc
。
Rc
通过其
clone()
方法允许一个值有多个所有者,但
Rc
不能安全地跨线程使用,而
Arc
(原子引用计数)是
Rc
的线程安全版本。因此,需要将
Mutex
包装在
Arc
引用计数智能指针中,并将值的所有权在多个线程间传递。当受
Arc
保护的
Mutex
的所有权转移到另一个线程后,接收线程可以调用
Mutex
的
lock()
方法来获取对内部值的独占访问权。
5.2 示例程序
下面通过一个逐步示例来展示如何使用
Mutex
和
Arc
实现共享状态并发。我们将修改之前计算目录树中所有Rust文件源文件统计信息的示例,使其成为一个并发程序。
use std::ffi::OsStr;
use std::fs;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::thread;
#[derive(Debug)]
pub struct SrcStats {
pub number_of_files: u32,
pub loc: u32,
pub comments: u32,
pub blanks: u32,
}
fn main() {
let src_stats = SrcStats {
number_of_files: 0,
loc: 0,
comments: 0,
blanks: 0,
};
let stats_counter = Arc::new(
Mutex::new(src_stats));
let mut dir_list = File::open(
"./dirnames.txt").unwrap();
let reader = BufReader::new(&mut dir_list);
let dir_lines: Vec<_> = reader.lines().collect();
for dir in dir_lines {
let stats_counter = Arc::clone(&stats_counter);
thread::spawn(move || {
let dir_path = PathBuf::from(dir.unwrap());
// 遍历目录树并计算统计信息
// 更新stats_counter中的值
});
}
// 等待所有线程完成
}
5.3 操作步骤
- 导入必要的模块。
-
定义一个结构体
SrcStats来存储源文件统计信息。 -
在
main()函数中创建一个SrcStats实例,用Mutex保护它,并将其包装在Arc中。 -
读取
dirnames.txt文件,将每个目录名存储在一个向量中。 -
遍历目录名向量,为每个目录创建一个新线程。在每个线程中,递归遍历目录结构,计算Rust源文件的统计信息,并更新受
Mutex和Arc保护的共享数据结构。
5.4 流程图
graph LR
A[开始] --> B[创建SrcStats实例并包装]
B --> C[读取dirnames.txt文件]
C --> D[遍历目录名向量]
D --> E[创建线程]
E --> F[遍历目录树计算统计信息]
F --> G[更新共享数据]
D --> H{是否遍历完所有目录}
H -- 否 --> D
H -- 是 --> I[等待所有线程完成]
I --> J[结束]
通过以上内容,我们详细介绍了Rust中的并发编程,包括线程的创建和配置、错误处理、线程间的消息传递以及共享状态并发等方面。掌握这些知识,能够帮助我们编写高效、安全的并发程序。
6. 共享状态并发示例程序详解
6.1 程序结构概述
我们要实现的程序是接收一个目录列表作为输入,计算每个目录下所有Rust文件的源文件统计信息,并输出汇总的源代码统计数据。具体步骤如下:
1. 在Cargo项目的根文件夹中创建一个
dirnames.txt
文件,该文件包含完整路径的目录列表,每行一个目录。
2. 读取该文件的每个条目,并为每个目录创建一个单独的线程,用于计算该目录树中Rust文件的源文件统计信息。
3. 每个线程将计算得到的值累加到一个共享的数据结构中,我们使用
Mutex
和
Arc
来安全地保护对共享数据的访问和更新。
6.2 代码实现
use std::ffi::OsStr;
use std::fs;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::thread;
#[derive(Debug)]
pub struct SrcStats {
pub number_of_files: u32,
pub loc: u32,
pub comments: u32,
pub blanks: u32,
}
fn main() {
let src_stats = SrcStats {
number_of_files: 0,
loc: 0,
comments: 0,
blanks: 0,
};
let stats_counter = Arc::new(
Mutex::new(src_stats));
let mut dir_list = File::open(
"./dirnames.txt").unwrap();
let reader = BufReader::new(&mut dir_list);
let dir_lines: Vec<_> = reader.lines().collect();
let mut handles = vec![];
for dir in dir_lines {
let stats_counter = Arc::clone(&stats_counter);
let handle = thread::spawn(move || {
let dir_path = PathBuf::from(dir.unwrap());
let mut stats = SrcStats {
number_of_files: 0,
loc: 0,
comments: 0,
blanks: 0,
};
if let Ok(entries) = fs::read_dir(dir_path) {
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.extension() == Some(OsStr::new("rs")) {
if let Ok(file) = File::open(&path) {
let reader = BufReader::new(file);
for line in reader.lines() {
if let Ok(line) = line {
let trimmed = line.trim();
if trimmed.is_empty() {
stats.blanks += 1;
} else if trimmed.starts_with("//") {
stats.comments += 1;
} else {
stats.loc += 1;
}
}
}
stats.number_of_files += 1;
}
}
}
}
}
let mut locked_stats = stats_counter.lock().unwrap();
locked_stats.number_of_files += stats.number_of_files;
locked_stats.loc += stats.loc;
locked_stats.comments += stats.comments;
locked_stats.blanks += stats.blanks;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
let final_stats = stats_counter.lock().unwrap();
println!("Final Statistics: {:?}", final_stats);
}
6.3 代码解释
- 导入模块 :导入了处理文件系统、输入输出、路径、同步和线程相关的模块。
-
定义结构体
:
SrcStats结构体用于存储源文件的统计信息,包括文件数量、代码行数、注释行数和空行数。 -
初始化共享数据
:在
main函数中,创建SrcStats实例,用Mutex包装并使用Arc实现多线程共享。 -
读取目录列表
:打开
dirnames.txt文件,将每行的目录名存储在dir_lines向量中。 -
创建线程
:遍历
dir_lines,为每个目录创建一个线程。在每个线程中:-
初始化一个局部的
SrcStats结构体用于存储当前目录的统计信息。 - 遍历目录树,对于每个Rust文件,逐行读取文件内容,根据行的内容更新局部统计信息。
-
获取
Mutex锁,将局部统计信息累加到共享的stats_counter中。
-
初始化一个局部的
-
等待线程完成
:使用
join方法等待所有线程完成。 -
输出最终结果
:获取
Mutex锁,打印最终的统计信息。
6.4 注意事项
-
锁的使用
:在访问和修改受
Mutex保护的共享数据时,必须先获取锁,使用完后会自动释放。 -
所有权转移
:使用
Arc::clone来复制Arc指针,实现多线程间的数据共享,同时保证线程安全。
7. 并发编程的挑战与应对策略
7.1 线程执行顺序不确定性
在多线程编程中,线程的执行顺序是不确定的,这可能导致程序的输出结果每次运行都不同。例如,在使用
thread::spawn
创建多个线程时,无法保证哪个线程先执行。
应对策略
:
-
使用
join
方法
:确保主线程等待所有子线程执行完毕后再继续执行,如前面创建线程的示例中,将子线程的句柄存储在向量中,最后遍历向量调用
join
方法。
-
使用同步机制
:如
Mutex
、
Semaphore
等,控制线程对共享资源的访问顺序。
7.2 数据竞争
当多个线程同时访问和修改共享数据时,可能会发生数据竞争,导致数据不一致或程序崩溃。例如,多个线程同时对一个共享的计数器进行递增操作。
应对策略
:
-
使用
Mutex
:确保同一时间只有一个线程能够访问和修改共享数据,如共享状态并发示例中,使用
Mutex
保护
SrcStats
结构体。
-
使用原子类型
:对于简单的共享数据,如计数器,可以使用Rust标准库中的原子类型,如
AtomicU32
,它们提供了线程安全的操作。
7.3 死锁
死锁是指两个或多个线程相互等待对方释放锁,从而导致所有线程都无法继续执行的情况。例如,线程A持有锁L1并请求锁L2,而线程B持有锁L2并请求锁L1。
应对策略
:
-
避免嵌套锁
:尽量避免在持有一个锁的同时请求另一个锁,减少死锁的可能性。
-
统一锁的获取顺序
:所有线程按照相同的顺序获取锁,避免循环等待。
7.4 线程恐慌处理
线程恐慌可能会导致程序崩溃,因此需要在多线程程序中正确处理线程恐慌。
应对策略
:
-
使用
catch_unwind
:在创建线程时,可以使用
catch_unwind
来捕获线程中的恐慌,避免程序崩溃。
-
使用
thread::panicking
:在
Drop
trait的实现中使用
thread::panicking
函数,检测当前线程是否正在恐慌,并进行相应的处理。
8. 总结
8.1 并发编程要点回顾
-
线程创建与配置
:可以使用
thread::spawn和thread::Builder来创建和配置线程,使用join方法确保主线程等待子线程完成。 -
错误处理
:使用
std::thread::Result类型处理线程中的错误,使用thread::panicking检测线程恐慌。 -
消息传递
:通过
mpsc通道实现线程间的消息传递,支持多生产者单消费者模式。 -
共享状态并发
:结合使用
Mutex和Arc实现共享状态并发,确保线程安全地访问和修改共享数据。
8.2 并发编程的优势与应用场景
并发编程可以充分利用多核处理器的性能,提高程序的执行效率。在以下场景中,并发编程尤为有用:
-
I/O密集型任务
:如网络编程、文件读写等,使用多线程可以在等待I/O操作完成时执行其他任务,提高程序的响应速度。
-
计算密集型任务
:将复杂的计算任务分解为多个子任务,并行执行,加快计算速度。
8.3 未来展望
随着计算机硬件技术的不断发展,多核处理器将越来越普及,并发编程的重要性也将日益凸显。Rust作为一种安全、高效的系统编程语言,其强大的并发编程能力将在更多的领域得到应用,如分布式系统、人工智能等。掌握Rust的并发编程技术,将为开发者带来更多的机遇和挑战。
通过本文的学习,希望读者能够深入理解Rust中的并发编程概念和技术,掌握线程的创建、配置、错误处理、消息传递和共享状态并发等方面的知识,从而编写高效、安全的并发程序。
8.4 常见问题解答
| 问题 | 解答 |
|---|---|
| 为什么线程执行顺序不确定? | 操作系统的调度算法决定了线程的执行顺序,不同的调度策略会导致线程执行顺序的不确定性。 |
Mutex
和
Arc
的作用是什么?
|
Mutex
用于确保同一时间只有一个线程能够访问共享数据,
Arc
用于实现数据的多线程共享,保证线程安全。
|
| 如何避免死锁? | 避免嵌套锁,统一锁的获取顺序,确保线程不会相互等待对方释放锁。 |
mpsc
通道的优势是什么?
|
mpsc
通道提供了轻量级的线程间同步机制,支持多生产者单消费者模式,适用于线程间的消息传递。
|
超级会员免费看
83

被折叠的 条评论
为什么被折叠?



