Posts for: #Concurrency

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