Skip to content

Rust 并行编程:并发实现更快的执行

Rust 并行性提供了一种安全、高效的处理并发任务的方法。在本文中,我们将探讨其核心概念、优势和实际应用。非常适合希望优化 Rust 代码性能的开发人员

关键见解

Rust 的并行方法通过同时执行多个任务来最大限度地提高 CPU 利用率,从而提高性能。

Rust 中的并发和并行是截然不同但又互补的概念,对于高效的多任务处理至关重要。

Rust 的线程库方便创建和管理线程,确保安全高效的并行执行。

Rust 中线程之间的数据共享通过互斥锁等同步技术进行管理,从而防止数据竞争。

Rust 并行性为并发编程提供了一种独特的方法,既确保了安全性,又确保了效率。作为开发人员,利用此功能可以显著优化性能。让我们一起探索这个概念的细微差别和实用性。

img

理解 Rust 中的并行性

Rust 中的并行性是一项强大的功能,它允许开发人员同时执行多个任务,从而最大限度地利用可用的 CPU 内核。Rust 的并行性方法是独一无二的,因为它强调内存安全性而不牺牲性能。

并发与并行

虽然这些术语经常互换使用,但它们的含义却截然不同。并发是指同时处理多个任务,而并行是指同时执行任务。Rust 为这两种情况都提供了工具。

https://marketsplash.com/rust-concurrency/

Rust 中的线程

Rust 提供了一个线程库,允许创建和管理线程。线程是 CPU 执行的最小单位,可以在多核系统上并行运行。

Rust
use std::thread;

fn main() {
    thread::spawn(|| {
        // some task
    });
}

这个简单的例子演示了如何在 Rust 中生成一个新线程。thread::spawn 函数接受一个闭包,其中包含要在新线程中执行的代码。

线程间数据共享

并行编程的挑战之一是线程之间共享数据。Rust 通过其所有权系统解决了这个问题,确保了数据安全。

Rust
use std::thread;
use std::sync::{Arc, Mutex};

fn main() {
    let counter = Arc::new(Mutex::new(0));

    let handle = thread::spawn({
        let counter = Arc::clone(&counter);
        move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        }
    });

    handle.join().unwrap();
    println!("Result: {}", *counter.lock().unwrap());
}

在此示例中,Arc(原子引用计数器)和 Mutex(互斥)用于跨线程安全地共享和改变数据。

https://marketsplash.com/rust-threads/

同步技术

确保线程可以安全地访问共享资源至关重要。Rust 提供了几个同步原语,例如MutexRwLock

PrimitiveDescription
Mutex(互斥锁)提供互斥,确保一次只有一个线程可以访问数据。
RwLock(读写锁)允许在任意时间点有多个读者或一个作者。

并发与并行

在计算领域,“并发”和“并行”这两个术语经常互换使用。然而,它们代表着每个开发人员都应该理解的不同概念,尤其是在使用 Rust 等为并发和并行提供强大工具的语言时。

定义并发

并发是指同时执行多个任务的概念。这并不一定意味着任务会在同一时间执行。

🧐相反,它是关于管理多个任务,给人一种它们同时运行的错觉。

例如,考虑一个单核 CPU。它可以通过快速切换来处理多个任务,给人同时执行的感觉。这就是并发性。

定义并行性

另一方面,并行是指同时执行多个任务或进程。多核 CPU 可以实现这一点,每个核心都可以运行单独的任务。

💡在真正的并行系统中,多个操作同时运行,从而缩短执行时间。

例如,假设一个多核 CPU 正在处理一个大型数据集。如果每个核心同时处理一部分数据,则任务完成的速度比单核按顺序处理整个数据集要快。

方面并发并行性
定义管理多项任务,给人同时进行的错觉。同时执行多个任务或进程。
执行任务可能不会同时执行。任务同时执行。
硬件可以在单核CPU上实现。需要多核 CPU 或多个 CPU。

Rust 中的并发性

Rust 提供了一套丰富的工具来处理并发。该语言的所有权系统确保多个任务可以安全地访问共享数据而不会引起数据争用。

Rust
use std::thread;

fn main() {
    let handles: Vec<_> = (0..10).map(|_| {
        thread::spawn(|| {
            // Task to be executed concurrently
        })
    }).collect();

    for handle in handles {
        handle.join().unwrap();
    }
}

☝️在此示例中,Rust 生成了十个线程,这些线程同时执行任务。所有权系统确保每个线程对其数据都有唯一的访问权限,从而防止潜在的冲突。

Rust 中的并行性

Rust 的标准库不提供内置的并行工具。但是,第三方包可以rayon轻松实现真正的并行执行。

Rust
use rayon::prelude::*;

let data = vec![1, 2, 3, 4, 5];
let results: Vec<_> = data.par_iter().map(|&x| x * 2).collect();

这里,rayoncrate 用于并行映射一个向量,将每个元素加倍。每个操作都在单独的核心上运行,实现真正的并行性。

在并发和并行之间进行选择

并发和并行之间的选择取决于手头的问题。对于 I/O 密集型任务,系统等待输入/输出操作,并发可能更合适。对于 CPU 密集型任务,CPU 是限制因素,并行可以显著提高速度。

标准并发并行性
定义同时处理多项任务同时执行多个任务
目标管理更多任务更快地执行任务
用例IO 绑定操作CPU 密集型操作
Rust 库async-std,tokiorayon
挑战死锁、饥饿数据竞争、线程安全

并行计算中的内存安全

并行计算彻底改变了我们处理数据的方式,通过将任务分布在多个内核或处理器上,显著提高了速度。然而,这些好处也带来了挑战,其中最关键的挑战之一就是确保内存安全

什么是内存安全?

内存安全是针对与内存访问相关的错误和漏洞的保护。这些可能包括缓冲区溢出、释放后使用错误和数据争用。在并行计算中,由于任务同时执行,确保内存安全变得更具挑战性。

https://marketsplash.com/rust-memory-safety/

不安全内存访问的风险

在并行环境中,多个线程或进程可能会尝试同时访问共享内存。如果没有适当的同步或安全机制,这可能会导致:

  • 数据竞争:当两个线程同时访问同一内存位置时,至少有一个线程会对其进行写入。
  • 竞争条件:由于并发线程的时间安排或交错而导致不可预测的结果。
  • 死锁:两个或多个线程无限期地等待对方持有的资源。

Rust 的内存安全方法

Rust 是一种现代系统编程语言,其设计充分考虑了内存安全。其所有权系统、借用机制和生命周期协同工作,可防止许多常见的内存相关错误。

Rust
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));

let handle = std::thread::spawn({
    let counter = Arc::clone(&counter);
    move || {
        let mut num = counter.lock().unwrap();
        *num += 1;
    }
});

☝️在这个 Rust 示例中,使用 Arc(原子引用计数器)和 Mutex(互斥)在线程之间安全地共享计数器。这确保一次只有一个线程可以改变计数器。

使用线程

在计算领域,线程是最小的执行单位。它是允许程序同时运行多个操作的途径,充分利用可用的 CPU 核心。使用线程时,您实际上是将程序拆分为多个可以同时运行的较小部分。

为什么要使用线程?

线程是提高 CPU 密集型应用程序性能的强大工具。通过将任务分配到多个线程,您可以实现更快的执行时间和更好的响应能力。例如,在图形应用程序中,一个线程可能处理用户输入,而另一个线程则渲染图形。

在 Rust 中创建线程

Rust 提供了一种直接的方式来生成线程。该std::thread模块包含所有必要的函数和结构:

Rust
use std::thread;

let handle = thread::spawn(|| {
    // Code to run in the new thread
});

handle.join().unwrap();

☝️在这个例子中,产生了一个新线程,主线程使用 join() 方法等待它完成。

线程通信

线程经常需要相互通信,尤其是在共享数据时。Rust 为此提供了几种机制,其中最常见的一种是通道:

Rust
use std::sync::mpsc;
use std::thread;

let (tx, rx) = mpsc::channel();

thread::spawn(move || {
    tx.send("Hello from the spawned thread!").unwrap();
});

let received = rx.recv().unwrap();
println!("Message: {}", received);

这里创建了发送者(tx)和接收者(rx)。生成的线程发送一条消息,主线程接收并打印该消息。

处理共享状态

多线程的挑战之一是管理共享状态。Rust 的所有权系统有助于防止数据竞争,但您仍然需要Mutex(互斥)之类的机制来确保安全访问:

Rust
use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));

let mut handles = vec![];

for _ in 0..10 {
    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());

此代码使用互斥锁安全地从多个线程增加计数器。

线程池

对于生成许多线程的应用程序,反复创建和销毁这些线程可能效率低下。线程池允许您维护一组可重复使用的线程:

Rust
use std::thread;
use std::sync::mpsc;

let (tx, rx) = mpsc::channel();
let mut handles = vec![];

for i in 0..10 {
    let tx = tx.clone();
    let handle = thread::spawn(move || {
        tx.send(i).unwrap();
    });
    handles.push(handle);
}

for _ in 0..10 {
    let result = rx.recv().unwrap();
    println!("Received: {}", result);
}

for handle in handles {
    handle.join().unwrap();
}

☝️本示例使用线程池来并发高效地处理数据。

数据共享策略

跨线程共享数据的最简单策略之一是使用不可变数据。数据一旦创建,就无法更改。这意味着多个线程可以读取数据,而不会出现数据争用的风险。

Rust
use std::thread;

let data = vec![1, 2, 3, 4, 5];

let guards: Vec<_> = (0..3).map(|_| {
    thread::spawn(move || {
        println!("{:?}", data);
    })
}).collect();

for guard in guards {
    guard.join().unwrap();
}

☝️在这个例子中,数据向量是不可变的,因此可以安全地在多个线程之间共享。

互斥锁

Mutex (互斥的缩写)是一种同步原,可确保在给定时间内只有一个线程可以访问某些数据。要访问数据,线程必须先获得 Mutex 上的锁。

Rust
use std::sync::{Arc, Mutex};
use std::thread;

let data = Arc::new(Mutex::new(vec![1, 2, 3]));

for _ in 0..3 {
    let data = Arc::clone(&data);
    thread::spawn(move || {
        let mut data = data.lock().unwrap();
        data[0] += 1;
    });
}

这里,数据受到互斥锁(Mutex)的保护,确保每次只有一个线程可以修改它。

Rust 的原子类型

Rust在模块中提供了一组原子类型std::sync::atomic,允许对共享数据进行无锁、线程安全的操作。

Rust
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

let var = AtomicUsize::new(0);

thread::spawn(move || {
    var.fetch_add(1, Ordering::SeqCst);
});

☝️在此代码中,var 是一个原子无符号整数。fetch_add 方法原子地将其值相加。

Channels

Channels是线程间共享数据的另一种有效方式。它们允许一个线程向另一个线程发送数据,从而确保通信安全。

Rust
use std::sync::mpsc;
use std::thread;

let (tx, rx) = mpsc::channel();

thread::spawn(move || {
    tx.send(10).unwrap();
});

let received = rx.recv().unwrap();
println!("Received: {}", received);

☝️此示例展示了一个简单的通道,其中一个线程发送一个数字,另一个线程接收该数字。

构建并发应用

Rc And Arc

Rc(引用计数)和Arc(原子引用计数)是智能指针类型,用于跟踪对某个值的引用次数。Arc是的线程安全版本Rc

Rust
use std::sync::{Arc, Mutex};
use std::thread;

let data = Arc::new(Mutex::new(0));

for _ in 0..10 {
    let data = Arc::clone(&data);
    thread::spawn(move || {
        let mut data = data.lock().unwrap();
        *data += 1;
    });
}

☝️在这个例子中,ArcGIS 确保数据可以跨线程安全地共享。

同步技术

锁是最基本的同步技术。它们确保一次只有一个线程可以访问特定部分的代码或数据。在 Rust 中,Mutex **(**互斥的缩写)是一种常见的锁定机制。

Rust
use std::sync::Mutex;

let m = Mutex::new(5);

{
    let mut num = m.lock().unwrap();
    *num = 6;
}

这里lock使用该方法获取 Mutex 上的锁,获取到锁之后,就可以安全地访问和修改数据了。

信号量

信号量是锁的泛化。它们维护一组许可;线程可以获得许可(如果有)或释放许可。

Rust
use std::sync::Semaphore;

let sem = Semaphore::new(2);

sem.acquire();
// critical section
sem.release();

☝️在这个例子中,信号量允许最多两个线程同时进入临界区。

屏障

屏障是允许多个线程相互等待的同步原语。屏障将阻塞线程,直到指定数量的线程到达屏障。

Rust
use std::sync::Barrier;
use std::thread;

let barrier = Barrier::new(2);
let _ = thread::spawn(move || {
    // do some work
    barrier.wait();
    // continue with other work
});

这里,该wait方法被阻塞直到两个线程都到达屏障。

条件变量

条件变量允许线程等待直到某个条件变为真。它们通常与互斥锁一起使用来保护共享数据。

Rust
use std::sync::{Mutex, Condvar};

let pair = (Mutex::new(false), Condvar::new());
let &(ref lock, ref cvar) = &pair;

let mut started = lock.lock().unwrap();
while !*started {
    started = cvar.wait(started).unwrap();
}

☝️在此示例中,线程等待启动条件变为真。

读写锁

读写锁(通常缩写为 RWLock)允许多个线程同时读取共享数据,但每次只能有一个线程写入数据

Rust
use std::sync::RwLock;

let lock = RwLock::new(5);

{
    let r1 = lock.read().unwrap();
    let r2 = lock.read().unwrap();
}

{
    let mut w = lock.write().unwrap();
    *w += 1;
}

这里,多个线程可以获取读锁,但只有一个线程可以获取写锁。

Spinlocks

自旋锁是一种在循环中反复检查条件(自旋)而不是阻塞线程的锁。它们在预计锁将保持较短时间的场景中很有用。

Rust
use spin::Spinlock;

let lock = Spinlock::new(0);

{
    let mut data = lock.lock();
    *data = 2;
}

☝️在这个例子中,线程将旋转直到它可以获取锁。

通过这些 Rust 指南继续学习

  1. Rust Threads:深入探究并发编程
  2. Rust 零成本抽象:概念与应用
  3. F# 与 Rust:每种语言的独特功能和应用
  4. Rust 游戏开发:其特性和用途之旅
  5. Rust 所有权实战:高效内存管理

前端知识体系 · wcrane