Rust 并发编程:线程、错误处理、数据共享与工具使用
1. 线程创建与移动
在 Rust 中,
spawn
函数会将闭包(包括工作列表向量)移动到新的子线程中。这种移动操作成本很低,就像之前讨论的
Vec<String>
移动一样,
String
不会被克隆,实际上也没有进行内存分配或释放操作,仅移动了
Vec
本身的三个机器字。
多数线程启动时都需要代码和数据,而 Rust 闭包恰好可以包含所需的代码和数据。创建线程后,需要等待所有线程完成工作,可使用
join
方法:
// Join: Wait for all threads to finish.
for handle in thread_handles {
handle.join().unwrap()?;
}
这里使用之前收集的
JoinHandle
的
.join()
方法来等待所有线程完成。加入线程对于程序的正确性很重要,因为 Rust 程序在
main
函数返回时就会退出,即使其他线程仍在运行,析构函数也不会被调用,多余的线程会被直接终止。若不希望出现这种情况,需在
main
函数返回前加入关心的线程。若能顺利通过这个循环,说明所有子线程都成功完成,函数最终返回
Ok(())
。
2. 跨线程错误处理
handle.join().unwrap()?;
这行代码在错误处理方面比看起来更复杂。
.join()
方法有两个重要作用:
-
处理线程恐慌
:
handle.join()
返回一个
std::thread::Result
,若子线程发生恐慌,它会返回错误。这使得 Rust 中的线程比 C++ 更健壮,在 C++ 中,越界数组访问是未定义行为,无法保护系统其他部分不受影响;而在 Rust 中,恐慌是安全的且是按线程隔离的,线程间的边界就像防火墙,恐慌不会自动从一个线程传播到依赖它的线程,而是在其他线程中以错误结果的形式报告,程序整体可以轻松恢复。在示例程序中,直接对这个
Result
使用
.unwrap()
,断言它是
Ok
结果而非
Err
结果,若子线程发生恐慌,这个断言会失败,父线程也会恐慌,即显式地将子线程的恐慌传播到父线程。
-
传递返回值
:
handle.join()
将子线程的返回值传递回父线程。传递给
spawn
的闭包返回类型为
io::Result<()>
,这是
process_files
的返回类型。子线程完成后,其返回值会被保存,
JoinHandle::join()
将该值传递回父线程。在这个程序中,
handle.join()
返回的完整类型是
std::thread::Result<std::io::Result<()>>
,其中
thread::Result
是
spawn/join
API 的一部分,
io::Result
是应用程序的一部分。解包
thread::Result
后,对
io::Result
使用
?
运算符,显式地将子线程的 I/O 错误传播到父线程。
与其他语言相比,Java 和 C# 中,子线程的异常默认会被输出到终端然后被遗忘;C++ 中,默认会终止进程;而在 Rust 中,错误是
Result
值(数据)而非异常(控制流),它们像其他值一样在线程间传递。使用底层线程 API 时,通常需要编写仔细的错误处理代码,而
Result
类型让这一过程变得更方便。
3. 跨线程共享不可变数据
假设分析需要一个大型的英语单词和短语数据库,原本的
process_files
函数:
// before
fn process_files(filenames: Vec<String>)
修改后:
// after
fn process_files(filenames: Vec<String>, glossary: &GigabyteMap)
由于词汇表很大,通过引用传递。若要更新
process_files_in_parallel
函数将词汇表传递给工作线程,直接修改会出现问题:
fn process_files_in_parallel(filenames: Vec<String>,
glossary: &GigabyteMap)
-> io::Result<()>
{
...
for worklist in worklists {
thread_handles.push(
spawn(move || process_files(worklist, glossary)) // error
);
}
...
}
Rust 会报错,提示
glossary
类型需要显式的生命周期。这是因为
spawn
启动的是独立线程,Rust 无法知道子线程会运行多久,它假设最坏的情况,即子线程可能在父线程结束且父线程中的所有值都被释放后仍在运行。而传递给
spawn
的闭包依赖于
glossary
引用,引用的生命周期是有限的,所以这种代码是不安全的。
为了解决这个问题,可以使用原子引用计数(
Arc
):
use std::sync::Arc;
fn process_files_in_parallel(filenames: Vec<String>,
glossary: Arc<GigabyteMap>)
-> io::Result<()>
{
...
for worklist in worklists {
// This call to .clone() only clones the Arc and bumps the
// reference count. It does not clone the GigabyteMap.
let glossary_for_child = glossary.clone();
thread_handles.push(
spawn(move || process_files(worklist, &glossary_for_child))
);
}
...
}
这里将
glossary
的类型改为
Arc<GigabyteMap>
,调用
glossary.clone()
只是复制了
Arc
智能指针,增加了引用计数,而不会克隆整个
GigabyteMap
。这样修改后,程序可以编译并运行,因为它不再依赖引用的生命周期,只要有线程持有
Arc<GigabyteMap>
,映射就会保持存活,且由于
Arc
中的数据是不可变的,不会出现数据竞争。
4. Rayon 库的使用
标准库的
spawn
函数是重要的原语,但并非专门为分治并行设计,基于它构建了更好的分治 API,例如 Rayon 库。Rayon 提供了两种并发运行任务的方式:
use rayon::prelude::*;
// "do 2 things in parallel"
let (v1, v2) = rayon::join(fn1, fn2);
// "do N things in parallel"
giant_vector.par_iter().for_each(|value| {
do_thing_with_value(value);
});
rayon::join(fn1, fn2)
会调用两个函数并返回两个结果;
.par_iter()
方法创建一个
ParallelIterator
,它有
map
、
filter
等方法,类似于 Rust 的
Iterator
。在这两种情况下,Rayon 会使用自己的工作线程池尽可能地分散工作,只需告诉 Rayon 哪些任务可以并行执行,它会管理线程并尽可能好地分配工作。
使用 Rayon 重写
process_files_in_parallel
函数:
use rayon::prelude::*;
fn process_files_in_parallel(filenames: Vec<String>, glossary: &GigabyteMap)
-> io::Result<()>
{
filenames.par_iter()
.map(|filename| process_file(filename, glossary))
.reduce_with(|r1, r2| {
if r1.is_err() { r1 } else { r2 }
})
.unwrap_or(Ok(()))
}
这段代码比使用
std::thread::spawn
的版本更短且更简单,具体步骤如下:
1. 使用
filenames.par_iter()
创建一个并行迭代器。
2. 使用
.map()
对每个文件名调用
process_file
,生成一个
ParallelIterator
,其元素是
io::Result<()>
类型的值。
3. 使用
.reduce_with()
合并结果,这里保留第一个错误(如果有),丢弃其余的。若想累积所有错误或将它们打印出来,也可以在这里实现。
4.
reduce_with
返回一个
Option
,只有当
filenames
为空时才为
None
,使用
Option
的
.unwrap_or()
方法在这种情况下使结果为
Ok(())
。
Rayon 会使用工作窃取技术动态平衡线程间的工作负载,通常比手动提前划分工作能更好地让所有 CPU 保持忙碌。此外,Rayon 支持跨线程共享引用,在
reduce_with
返回时,幕后的并行处理保证已经完成,这解释了为什么可以将
glossary
传递给
process_file
,即使该闭包会在多个线程上被调用。
5. 重新审视曼德勃罗集渲染
之前使用分治并发渲染曼德勃罗集,虽然速度提高了四倍,但由于工作负载分配不均,效果并不理想。计算图像的一个像素相当于运行一个循环,图像中浅灰色部分的循环很快退出,渲染速度比黑色部分快得多,因为黑色部分的循环要运行完整的 255 次迭代。所以,即使将区域划分为大小相等的水平带,也会产生不均衡的工作负载。
使用 Rayon 可以轻松解决这个问题,为输出图像的每一行像素启动一个并行任务,创建数百个任务,Rayon 可以将这些任务分配到其线程中。由于工作窃取技术,任务大小的差异不会影响工作平衡。以下是修改后的代码:
let mut pixels = vec![0; bounds.0 * bounds.1];
// Scope of slicing up `pixels` into horizontal bands.
{
let bands: Vec<(usize, &mut [u8])> = pixels
.chunks_mut(bounds.0)
.enumerate()
.collect();
bands.into_par_iter()
.for_each(|(i, band)| {
let top = i;
let band_bounds = (bounds.0, 1);
let band_upper_left = pixel_to_point(bounds, (0, top),
upper_left, lower_right);
let band_lower_right = pixel_to_point(bounds, (bounds.0, top + 1),
upper_left, lower_right);
render(band, band_bounds, band_upper_left, band_lower_right);
});
}
write_image(&args[1], &pixels, bounds).expect("error writing PNG file");
具体步骤如下:
1. 创建
bands
,这是要传递给 Rayon 的任务集合,每个任务是一个
(usize, &mut [u8])
类型的元组,包含行号和要填充的像素切片。使用
chunks_mut
方法将图像缓冲区拆分为行,使用
enumerate
为每行添加行号,使用
collect
将所有编号 - 切片对收集到一个向量中(因为 Rayon 只从数组和向量创建并行迭代器)。
2. 将
bands
转换为并行迭代器,使用
.for_each()
方法告诉 Rayon 要执行的工作。
使用 Rayon 时,需要在
main.rs
中添加
use rayon::prelude::*;
,并在
Cargo.toml
中添加:
[dependencies]
rayon = "1"
修改后,程序在 8 核机器上大约使用 7.75 个核心,比之前手动划分工作时快 75%,且代码更短,体现了让 crate 处理工作分配的好处。
6. 通道的使用
通道是一种从一个线程向另一个线程发送值的单向管道,即线程安全的队列。它类似于 Unix 管道,一端用于发送数据,另一端用于接收,两端通常由不同的线程拥有。与 Unix 管道不同的是,通道用于发送 Rust 值。
sender.send(item)
将单个值放入通道,
receiver.recv()
从通道中移除一个值,所有权从发送线程转移到接收线程。如果通道为空,
receiver.recv()
会阻塞,直到有值被发送。
使用通道,线程可以通过相互传递值进行通信,这是一种简单的线程协作方式,无需使用锁或共享内存。这种技术并非新事物,Erlang 已经有独立进程和消息传递 30 年了,Unix 管道也存在了近 50 年。Rust 通道比 Unix 管道更快,发送值时是移动而非复制,即使移动包含数兆字节数据的数据结构,移动操作也很快。
7. 使用通道构建并发程序
接下来将使用通道构建一个并发程序来创建倒排索引,这是搜索引擎的关键组成部分。程序结构为管道,使用五个线程,每个线程执行不同的任务,且在程序生命周期内持续产生输出。第一个线程从磁盘读取源文档到内存,每次读取一个文档,其输出是每个文档的一个长字符串,因此该线程通过一个
String
通道与下一个线程相连。
以下是启动文件读取线程的代码:
use std::{fs, thread};
use std::sync::mpsc;
let (sender, receiver) = mpsc::channel();
let handle = thread::spawn(move || {
for filename in documents {
let text = fs::read_to_string(filename)?;
if sender.send(text).is_err() {
break;
}
}
Ok(())
});
这里使用
std::sync::mpsc
模块的
channel()
函数创建一个通道,它返回一个发送者和一个接收者。通道是有类型的,这里要发送每个文件的文本,所以发送者类型为
Sender<String>
,接收者类型为
Receiver<String>
,可以显式指定通道类型,也可以让 Rust 的类型推断来确定。
使用
std::thread::spawn
启动一个线程,通过移动闭包将发送者的所有权转移到新线程。线程中的代码会逐个读取文件,成功读取文件后,将其文本发送到通道中。
sender.send(text)
将
text
值移动到通道中,最终会再次移动到接收该值的线程。
send
和
recv
方法都返回
Result
,只有当通道的另一端被丢弃时,这些方法才会失败。在代码中,
sender.send(text)
只有在接收者线程提前退出时才会失败,此时读取线程可以安静地关闭自己。当线程完成读取所有文档或遇到错误时,返回
Ok(())
。
错误处理方面,Rust 提供了多种选择,可以简单地打印错误并继续处理下一个文件,也可以通过同一个通道传递错误,或者创建一个单独的通道用于传递错误。示例中选择的方法既轻量级又负责,使用
?
运算符,避免了大量样板代码,也不会让错误无声地通过。
总结
本文介绍了 Rust 并发编程的多个方面,包括线程创建与移动、跨线程错误处理、跨线程共享不可变数据、使用 Rayon 库进行并行处理、重新审视曼德勃罗集渲染以及通道的使用和基于通道构建并发程序。通过这些技术和工具,Rust 为开发者提供了强大而安全的并发编程能力。
相关流程总结
线程创建与等待流程
graph TD;
A[创建线程] --> B[线程执行任务];
B --> C[等待线程完成];
C --> D[所有线程完成,程序继续];
通道使用流程
graph LR;
A[创建通道] --> B[发送者发送值];
B --> C[接收者接收值];
C --> D[所有权转移];
不同语言错误处理对比
| 语言 | 子线程错误处理方式 |
|---|---|
| Rust |
以
Result
值形式传递,可显式处理
|
| Java | 异常输出到终端后被遗忘 |
| C# | 异常输出到终端后被遗忘 |
| C++ | 默认终止进程 |
Rust 并发编程:线程、错误处理、数据共享与工具使用
8. 错误发生时的不同处理方式对比
在使用通道构建并发程序时,遇到错误有多种处理方式,下面详细对比不同方式的特点:
-
立即终止线程
:在示例代码中,当
sender.send(text).is_err()
时,线程会立即
break
并返回
Ok(())
。这种方式适用于当某个线程的输出对后续处理非常关键,一旦出现问题就没必要继续执行后续任务的情况。例如在构建倒排索引程序中,如果接收者线程提前退出,继续发送数据就没有意义,此时读取线程可以安静地关闭。
-
打印错误并继续
:可以在遇到错误时使用
println!
打印错误信息,然后继续处理下一个文件。这种方式简单直接,适用于错误不影响整体程序继续执行,只是需要记录错误发生情况的场景。例如在读取文件时,某个文件可能损坏,但不希望因为这一个文件的问题而停止整个文件读取过程。示例代码如下:
use std::{fs, thread};
use std::sync::mpsc;
let (sender, receiver) = mpsc::channel();
let handle = thread::spawn(move || {
for filename in documents {
match fs::read_to_string(filename) {
Ok(text) => {
if sender.send(text).is_err() {
break;
}
},
Err(e) => {
println!("Error reading file {}: {}", filename.display(), e);
}
}
}
Ok(())
});
- 通过通道传递错误 :可以将错误通过同一个通道传递,或者创建一个单独的通道用于传递错误。这种方式可以让主线程或其他线程统一处理错误,便于对错误进行集中管理和分析。例如创建一个单独的错误通道:
use std::{fs, thread};
use std::sync::mpsc;
let (data_sender, data_receiver) = mpsc::channel();
let (error_sender, error_receiver) = mpsc::channel();
let handle = thread::spawn(move || {
for filename in documents {
match fs::read_to_string(filename) {
Ok(text) => {
if data_sender.send(text).is_err() {
break;
}
},
Err(e) => {
if error_sender.send(e).is_err() {
break;
}
}
}
}
Ok(())
});
9. 不同并行工具对比
在 Rust 中,有多种并行工具可供选择,下面对
std::thread::spawn
和 Rayon 库进行对比:
| 工具 | 特点 | 适用场景 |
| ---- | ---- | ---- |
|
std::thread::spawn
| 是标准库的基础线程创建函数,灵活性高,可以手动控制线程的创建和管理,但需要开发者自己处理线程间的同步和错误处理等问题。 | 适用于对线程控制要求较高,需要精细管理线程行为的场景,例如需要自定义线程池大小、线程优先级等。 |
| Rayon 库 | 提供了简单易用的并行 API,能够自动管理线程池和工作负载平衡,支持跨线程共享引用,代码简洁。 | 适用于需要快速实现并行处理,对代码简洁性和开发效率要求较高的场景,例如对大量数据进行并行计算。 |
10. 并发编程中的性能优化
在 Rust 并发编程中,有一些性能优化的技巧:
-
合理分配工作负载
:如在曼德勃罗集渲染的例子中,使用 Rayon 为每个像素行创建并行任务,利用工作窃取技术平衡工作负载,避免了手动划分工作时出现的负载不均问题,提高了 CPU 的利用率。
-
减少数据复制
:在传递数据时,尽量使用移动语义而非复制语义。例如在使用通道发送数据时,
sender.send(text)
是将
text
移动到通道中,而不是复制,即使数据量很大,移动操作也很快。
-
选择合适的并行工具
:根据具体需求选择
std::thread::spawn
或 Rayon 库等并行工具。如果对线程控制要求高,选择
std::thread::spawn
;如果追求开发效率和代码简洁性,选择 Rayon 库。
11. 并发编程中的注意事项
在进行 Rust 并发编程时,需要注意以下几点:
-
生命周期管理
:在跨线程共享数据时,要注意数据的生命周期。如在传递引用时,Rust 会严格检查引用的生命周期,避免出现悬垂引用。可以使用
Arc
等智能指针来解决生命周期问题。
-
错误处理
:并发编程中错误处理尤为重要,要确保错误能够被正确捕获和处理,避免程序崩溃或出现未定义行为。可以使用
Result
类型来显式处理错误。
-
数据竞争
:在多线程环境中,要避免数据竞争。可以使用不可变数据或同步机制(如锁)来保证数据的一致性。例如使用
Arc
共享不可变数据,避免多个线程同时修改数据。
总结与展望
本文全面介绍了 Rust 并发编程的多个方面,包括线程创建与移动、错误处理、数据共享、并行工具使用以及通道的应用等。通过这些技术和工具,Rust 为开发者提供了强大而安全的并发编程能力。
未来,随着 Rust 社区的不断发展,可能会有更多高效、易用的并发编程工具和库出现。同时,对于复杂的并发场景,如分布式系统中的并发处理,Rust 也有望发挥更大的作用,为开发者提供更完善的解决方案。
关键技术点总结
| 技术点 | 描述 | 代码示例 |
|---|---|---|
| 线程创建与等待 |
使用
spawn
创建线程,使用
join
等待线程完成
|
rust for handle in thread_handles { handle.join().unwrap()?; }
|
| 跨线程错误处理 |
使用
Result
类型处理线程恐慌和返回值传递
|
rust handle.join().unwrap()?;
|
| 跨线程共享不可变数据 |
使用
Arc
实现数据的共享所有权
|
rust use std::sync::Arc; let glossary_for_child = glossary.clone();
|
| Rayon 库使用 | 提供简单的并行 API,支持并行迭代和任务合并 |
rust filenames.par_iter().map(...).reduce_with(...).unwrap_or(Ok(()));
|
| 通道使用 |
创建通道进行线程间通信,使用
send
和
recv
方法传递数据
|
rust let (sender, receiver) = mpsc::channel(); sender.send(text); receiver.recv();
|
相关流程总结
并行处理流程(使用 Rayon)
graph LR;
A[创建并行迭代器] --> B[执行并行任务];
B --> C[合并任务结果];
C --> D[返回最终结果];
并发编程错误处理流程
graph TD;
A[线程执行任务] --> B{是否发生错误};
B -- 是 --> C[捕获错误];
C --> D{错误处理方式};
D -- 立即终止线程 --> E[线程退出];
D -- 打印错误继续 --> F[继续执行任务];
D -- 通过通道传递错误 --> G[将错误发送到错误通道];
B -- 否 --> H[任务完成];
超级会员免费看

1276

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



