Posts for: #Rust

KAIST-CS431: Lock Based API

标准库中的并发API

Rust 标准库中基于锁的 API 主要围绕几个核心原语构建,这些原语提供了不同级别的并发控制和用途。它们都包含在 std::sync 模块中。以下是一些最常用的基于锁的 API:

  1. std::sync::Mutex<T> (互斥锁)
    • 用途: 最常见的互斥锁,用于保护共享数据,确保一次只有一个线程可以访问该数据。
    • 特点:
      • 当线程尝试获取已被锁定的 Mutex 时,它会阻塞直到锁被释放。
      • 提供内部可变性(&T -> &mut T)通过 RAII (Resource Acquisition Is Initialization) 机制,即 MutexGuard。当 MutexGuard 离开作用域时,锁会自动释放。
      • 是“poisoning”感知的:如果持有锁的线程在锁被释放前发生 panic,Mutex 会被标记为 poisoned。后续尝试获取锁的线程会得到一个 PoisonError,其中包含原始的 MutexGuard,允许它们决定如何处理被中断的数据。
    • 何时使用: 当你需要独占访问某个共享资源时,例如全局计数器、数据结构或配置设置。
  2. std::sync::RwLock<T> (读写锁)
    • 用途: 允许多个读取者同时访问共享数据,但只允许一个写入者独占访问数据。
    • 特点:
      • 读取者(read()): 允许多个线程并行获取读锁。只要没有写入者持有锁,所有读锁请求都会成功。
      • 写入者(write()): 只允许一个线程获取写锁。当有写入者持有锁时,所有读锁和写锁请求都会阻塞。
      • 也提供 RAII 机制,通过 RwLockReadGuard 和 RwLockWriteGuard
      • 同样是“poisoning”感知的。
    • 何时使用: 当你的数据被频繁读取但很少写入时,RwLock 可以提供比 Mutex 更好的并发性能。例如,一个缓存系统或一个配置对象。
  3. std::sync::Once (只运行一次)
    • 用途: 确保某个代码块(一个初始化函数)在程序生命周期中只被执行一次,即使有多个线程同时尝试触发它。
    • 特点:
      • call_once() 方法会执行一个闭包。第一次调用会实际执行闭包,后续的调用会等待第一次调用完成,但不会再次执行闭包。
      • 通常用于惰性初始化全局数据或单例模式。
    • 何时使用: 初始化全局静态变量(例如日志系统、配置加载器)或实现单例模式。通常与 lazy_static crate (在稳定版 Rust 中) 或 std::sync::OnceLock (在 1.70+ 版本中,见下文) 结合使用。
  4. std::sync::Barrier (屏障)
    • 用途: 用于同步一组线程,确保所有线程都到达某个预定义点后才能继续执行。
    • 特点:
      • 通过 wait() 方法实现等待。当调用 wait() 的线程数量达到预设值时,所有等待的线程都会同时被释放。
      • wait() 返回一个 BarrierWaitResult,指示当前线程是否是最后一个到达屏障的。
    • 何时使用: 需要协调多个并行任务的执行,例如在某个阶段结束后开始下一阶段,或者在所有子任务完成后进行汇总。

Rust 1.70+ 中引入的更现代的基于锁的 API:

KAIST-CS431: Lock

  • Pros & cons: simple & possibly inefficient

Low-Level Lock API

常用的low-level锁的API有:

  • Lock.acquire(): 阻塞,直到获取到锁
  • Lock.try_acquire(): 返回锁是否已经被占用了,如果是,返回false,否,占用锁并返回true。不阻塞
  • Lock.release(): 释放锁
L.acquire();
r1 = X;
X = r1 + 1;
L.release();

L.acquire();
r2 = X;
X = r2 + 1;
L.release();

但是,这些API给用户在使用时造成了很多挑战(心智负担):

  • Relating lock and resource: 用户只有在拿到锁时才能访问被保护的变量;
  • Matching acquire/release: 用户只能释放已经拿到的锁。

如果锁没有得到正确的处理,会造成很多潜在的问题,并且,这些问题很难发现。因此,在并发编程时,low-level的锁API存在如下问题:

  • High cost:程序员需要始终关注API的使用;
  • Potential bugs:不正确的使用容易造成很多潜在的bugs。

High-Level Lock API

  • 想要一个易用地,始终能保证安全地high-level API。
    • Acquire/release自动匹配;
    • Lock和resource显式关联。

C++中,这种API被称作RAIIResource Acquisition Is Initialization

#include <string>
#include <mutex>
#include <iostream>
#include <fstream>
#include <stdexcept>

void write_to_file(const std::string & message)
{
    // 创建关于文件的互斥锁
    static std::mutex mutex;

    // 在访问文件前进行加锁
    std::lock_guard<std::mutex> lock(mutex);

    // 尝试打开文件
    std::ofstream file("example.txt");
    if (!file.is_open())
        throw std::runtime_error("unable to open file");

    // 输出文件内容
    file << message << std::endl;

    // 当离开作用域时,文件句柄会被首先析构 (不管是否抛出了异常)
    // 互斥锁也会被析构 (同样地,不管是否抛出了异常)
}

RAII要求,资源的有效期与持有资源的对象的生命期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄露问题。

Rust: Concurrency

为什么Rust不做green threading

Send 和 Sync

Rust中几乎所有的并发特性都是标准库或者第三方库提供的,真正由Rust语言本身提供的很少。而std::marker中的traits SendSync算是其中一个。

**实现Send的类型值的所有权可以在线程间传递。**Rust中绝大多数类型都实现了Send,但也有些例外,如Rc<T>:因为如果将Rc<T>的拷贝值的所有权在多个线程中传递,Rust无法保证Rc<T>引用值的正确性。

实现Sync的类型表示该类型值可以在多个线程中被引用。也就是说,如果&T实现了Send,那么类型T就是Sync

SendSync markers其实就是将其他语言中的一些潜规则显式地标明出来,让编译器提前检查出代码中的隐患。

线程原语

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", v);
    });

    handle.join().unwrap();
}

thread::spawn新建一个线程,执行传递的闭包函数,返回一个JoinHandler,可以在主线程中调用join等待子线程结束。move用于强制闭包获取它使用的变量的所有权。

我们可以看看thread::spawn的实现:

#[stable(feature = "rust1", since = "1.0.0")]
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T,
    F: Send + 'static,
    T: Send + 'static,
{
    Builder::new().spawn(f).expect("failed to spawn thread")
}

spawn的入参和返回值都实现Send,同时其生命周期为'static。这是因为在多线程中,每个线程的执行周期不是同步的。父线程或者子线程的结束都会导致入参或者返回值的生命周期不满足Rust的约束条件。

Rust:Generic and Traits

泛型

Rust 泛型会在编译时根据参数将泛型单态化(Monomorphization ),因此,Rust 泛型在运行时是没有任何损耗的。

泛型在函数定义

fn largest<T: std::cmp::PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 43, 15];
    println!("the largest number is {}", largest(&number_list));

    let char_list = vec!['y', 'm', 'c', 'd'];
    println!("the largest char is {}", largest(&char_list));
}

泛型在结构体定义

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let p = Point{x: 5, y: 3};
    println!("x is {}, y is {}", p.x, p.y);

    let p = Point{x: 5.0, y: 3.0};
    println!("x is {}, y is {}", p.x, p.y)
}

需要注意,这里表示结构体内x和y是同一种类型,如果需要x和y类型不同,需要定义两个泛型参数。

Rust:Ownership

所有权(Ownership)是Rust中最独特的特性之一。正是因为所有权,Rust才能够在不需要GC的情况下保证内存安全。

我们常用的语言中,内存管理一般分为两种:

  • 自管理,谁分配的谁负责回收,如果没有回收,就会导致内存泄漏。如C、C++。
  • 由统一的垃圾回收器管理,GC负责追踪和管理已分配内存,如果内存不再被使用,则由GC进行回收。

Rust选择了第三种,内存会在拥有它的变量离开作用域时回收。因此,理解Rust的内存管理,就需要理解Rust中的所有权、借用规则、生命周期等概念。Rust会在编译过程中校验变量是否符合借用规则,从而保证运行时的内存安全,而且没有GC的额外开销。

在C++中,这种模式也被称作Resource Acquisition Is Initialization (RAII)

所有权基本规则

所有权有三条基本规则:

  • Rust中每个值都有一个对应的变量称之为所有者(owner);
  • 同一时刻,一个值只能存在一个所有者;
  • 当所有者离开作用域时,其拥有的值会被丢弃。

copy和move

因为同一时刻,一个值只能存在一个所有者,对于所有权的转让,Rust根据对象类型分为两种。

一种是Copy。即将值做拷贝,拷贝后,原变量拥有原值的所有权,新变量拥有新值的所有权,没有所有权的转移,两者可以同时使用。

let x = 5;
let y = x;
println!("x is {}, y is {}", x, y); // valid

支持Copy操作常见的类型通常是只存储在栈上的数据类型,如:

  • 所有的integer类型,如u32;
  • bool值;
  • char值;
  • 所有的float类型,如f64;
  • 元组,如果其包含的类型也都是支持Copy的。

这些类型都实现了Copy特型。如果一种类型实现了Copy特型,在分配新变量后,原变量仍是可用的。

另外一种是Move。对于类似String这种在栈上存储了元数据,实际数据存储在堆上的数据类型来说,将原变量赋给新变量实际上是将堆上数据的所有权转移给新变量,原变量在赋值后不再使用。

let s1 = String::from("hello!!!");
let s2 = s1;
println!("s2 is {}, s1 is {}", s2, s1); // invalid, s1 is no longer valid any more.

image-20210623093004432