47、Rust 并发编程:线程、错误处理、数据共享与工具使用

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[任务完成];
源码链接: https://pan.quark.cn/s/7b9e1590db2e 在本计划中,我们聚焦于一个基于数字逻辑的药片装瓶系统的构建,这构成了北京邮电大学(北邮)在小学期内向学生提供的一次课程设计课题。该系统致力于模拟实际药品包装的操作流程,借助电子操控和自动化技术达成药片的高效且精准的装瓶目标。以下是对该系统设计所涉及的关键知识领域的详尽阐述: 1. **数字逻辑**:数字逻辑是电子工程领域的核心学科,主要探究如何运用二进制数字进行信息的表征处理。在此项目中,数字逻辑用于构建和实现系统的控制机制,诸如计数器、编码器、解码器、触发器等,旨在保障药片装瓶过程的精确调控。 2. **硬件电路构建**:系统可能整合微控制器、传感器、执行机构等硬件单元。例如,微控制器作为系统的心脏,负责接收输入信号,处理数据,并指挥执行机构执行药片装填。传感器负责监测药片的数量和瓶装进度,而执行机构如电机则负责实际完成装瓶动作。 3. **计数器**:在药片装瓶的操作过程中,计数器用于追踪已装入瓶子的药片总数,确保达到预设的剂量标准。这可能需要设计同步计数器或异步计数器,以实现精确计数并触发装瓶操作。 4. **编码解码**:编码器将特定的信息(例如药片种类或剂量)转化为二进制编码,便于硬件设备进行处理;解码器则将这些编码解读为可执行的操作,如切换装瓶路径或启动封盖流程。 5. **触发器**:在系统中,触发器可用于在特定条件达成时启动或中止某个操作,例如当瓶子达到满载时关闭装填机制。 6. **传感器技术**:可能包含重量传感器、光电传感器或机械触碰开关,用于识别瓶子的存在、位置以及药片的数量。这些传感器的精确度直接关联到整个系统的性能水平。 7. **控制算法**...
内容概要:本文针对孤岛微电网在遭受拒绝服务(DoS)攻击下的安全稳定运行问题,提出了一种基于混合系统理论的弹性二次控制策略,创新性地将动态事件触发机制DoS攻击防御进行协同设计。该方法在保障微电网电压、频率恢复及有功功率精确均分的同时,有效应对通信链路被恶意阻塞的安全威胁,实现了控制性能通信资源利用效率的双重优化。通过Simulink仿真平台Matlab代码实现,验证了所提策略在复杂网络攻击场景下的鲁棒性有效性,深入分析了系统稳定性条件及攻击容忍边界,为电力信息物理系统(CPS)在面临网络安全挑战时的可靠控制提供了理论依据和技术路径。; 适合人群:具备电力系统自动化、分布式控制或网络安全等相关专业背景,熟悉Matlab/Simulink仿真环境,从事微电网控制、信息物理系统安全或弹性控制研究的研究生、科研人员及工程技术人员。; 使用场景及目标:① 提升高比例分布式能源接入背景下孤岛微电网在通信受限网络攻击耦合场景下的运行可靠性弹性恢复能力;② 实现低通信开销下的分布式协同控制,优化资源利用并增强系统抗干扰性能;③ 为电力系统中安全-控制联合设计提供可复现的仿真模型技术方案,推动安全防护从被动响应向主动容忍转变。; 阅读建议:读者应结合文中提供的Matlab代码Simulink模型开展仿真实验,重点理解动态事件触发机制的设计原理及其混合系统稳定性分析的融合方法,建议延伸学习DoS攻击建模、弹性控制理论及相关安全性证明技术,以全面掌握该协同设计框架的核心思想实现细节。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值