线程与同步原语

本页目录

线程提供可并行的执行上下文,同步原语约束哪些操作可以同时发生、哪些结果必须先于其他操作可见。Rust 的类型系统能阻止许多非法共享,但锁顺序、终止协议和多步业务原子性仍需设计。

普通线程与作用域线程

thread::spawn 的闭包通常需要 Send + 'static,返回 JoinHandle;join 等待并观察结果,丢弃 handle 不会自动等待或取消线程。线程栈大小的默认值与平台实现有关,需要时用 Builder::stack_size 配置,不作为跨平台固定常数。

如果线程必定在当前作用域内结束,可以借用外层对象:

fn main() {
    let values = [1_u32, 2, 3];
    let sum = std::thread::scope(|scope| {
        let worker = scope.spawn(|| values.iter().sum::<u32>());
        worker.join().unwrap()
    });
    assert_eq!(sum, 6);
}

scope 确保相关线程在作用域返回前完成必要的 join,因此可以使用非 'static 借用。仍要处理线程 panic,不能用作用域形式绕过 Send 与借用规则。thread::scope

Mutex 与 RwLock 保护的是访问区间

MutexGuard 存活时保持锁,正常析构或展开时释放;panic=abort、进程被强制终止等不会保证运行所有析构。尽量让临界区边界清楚,避免持锁执行回调、长 I/O 或等待另一个反向依赖的锁。

标准 Mutex 的 poisoning 是提示可能存在未完成更新,而非不可绕过的安全屏障。PoisonError 可取回 guard,但恢复前要评估不变量;panic 检测也不是完整的,所以 unsafe 抽象不能仅依赖 poison 防止 UB。Mutex

RwLock 允许多个 reader 或一个 writer,公平性与平台实现有关,不保证读多写少就一定比 Mutex 快。持读锁时等待升级、或已有 writer 等待时再递归取读锁,都可能造成难以预期的阻塞,应采用明确的操作顺序。

通道:传递所有权,也要设计关闭

std::sync::mpsc 是阻塞接口,多生产者、单消费者。channel 提供无界排队语义,sync_channel 提供有界缓冲,容量零可用于会合。异步任务中等待应使用合适的异步通道,避免阻塞 worker。

发送最后一条消息不等于关闭。所有 sender 被释放后,接收方先读完已排队消息,之后 recv 才返回断开错误;留下一个不用的 sender 克隆也会让等待无法结束。接收方关闭时发送可能失败,应回收工作而不是假定永远有消费者。

消息传递并不意味着实现内部“完全不共享内存”。它提供的是让应用按值与所有权组织协作的接口,底层仍可能使用共享队列和同步。

发送成功不表示业务处理完成

use std::{sync::mpsc, thread};
fn main() {
    let (work_tx, work_rx) = mpsc::sync_channel(0);
    let (done_tx, done_rx) = mpsc::channel();
    let worker = thread::spawn(move || {
        let value: String = work_rx.recv().unwrap();
        let length = value.len();
        done_tx.send(length).unwrap();
    });
    work_tx.send(String::from("job")).unwrap();
    // 会合只确认 recv 已配对;处理结果通过另一个通道返回。
    assert_eq!(done_rx.recv().unwrap(), 3);
    worker.join().unwrap();
    let unsent = work_tx.send(String::from("next")).unwrap_err().0;
    assert_eq!(unsent, "next");
}

容量零的 send 会与接收操作会合,但接收之后仍有处理工作。若需要知道业务完成,必须返回确认或结果;有缓冲通道的 send 更不能当成处理成功。SendError 交回未能发送的 T,让调用方可以保存、重路由或报告,避免所有权在失败路径不明不白地丢失。sync_channel

有界通道也可能参与死锁:如果发送者在 send 等待空位时持有消费者处理消息所需的锁,消费者无法释放队列压力。同步设计必须一起查看锁与通道依赖,不能只检查每把锁的获取顺序。

Condvar:先有谓词,再有通知

持锁检查谓词例如 ready 是否为 true1谓词不满足:wait原子释放锁并进入等待,返回前重新加锁2其他线程改变状态持同一把锁更新谓词,发出通知3等待者醒来再次检查虚假唤醒或竞争可能让条件仍不满足4notify 不保存未来可消费的许可;状态谓词才决定能否继续。条件变量等待的是受锁保护的状态,而不是一张通知票
use std::sync::{Condvar, Mutex};
use std::thread;

fn main() {
    let shared = (Mutex::new(false), Condvar::new());
    thread::scope(|scope| {
        let waiter = scope.spawn(|| {
            let (lock, condition) = &shared;
            let guard = lock.lock().unwrap();
            let ready = condition.wait_while(guard, |ready| !*ready).unwrap();
            assert!(*ready);
        });
        let (lock, condition) = &shared;
        *lock.lock().unwrap() = true;
        condition.notify_one();
        waiter.join().unwrap();
    });
}

即使通知发生在等待线程正式睡眠前,它之后检查到 true 就不会等待,所以不会依赖“恰好收到那次通知”。wait_while 把循环检查写出来,既应对虚假唤醒,也应对其他等待者先消费状态的竞争。Condvar

通知不是信号量许可,不会为未来调用 wait 的线程保存次数。一个 Condvar 应按接口要求与同一把关联锁配合,状态检查与修改必须遵循共同协议。

把“以后不会再有结果”也放进谓词

只有 ready 标志的等待程序,如果生产者提前失败,消费者可能永远等不到 true。下面用三个状态区分等待、完成和关闭;关闭本身也要通知等待者。

use std::{sync::{Condvar, Mutex}, thread};
#[derive(Debug)]
enum State { Waiting, Ready(u32), Closed }
fn receive(shared: &(Mutex<State>, Condvar)) -> Option<u32> {
    let (lock, condition) = shared;
    let mut state = condition.wait_while(lock.lock().unwrap(),
        |state| matches!(state, State::Waiting)).unwrap();
    match std::mem::replace(&mut *state, State::Closed) {
        State::Ready(value) => Some(value),
        State::Closed => None,
        State::Waiting => unreachable!("谓词已不满足等待条件"),
    }
}
fn run(result: Option<u32>) -> Option<u32> {
    let shared = (Mutex::new(State::Waiting), Condvar::new());
    thread::scope(|scope| {
        let receiver = scope.spawn(|| receive(&shared));
        *shared.0.lock().unwrap() = match result {
            Some(value) => State::Ready(value), None => State::Closed,
        };
        shared.1.notify_one();
        receiver.join().unwrap()
    })
}
fn main() {
    assert_eq!(run(Some(42)), Some(42));
    assert_eq!(run(None), None);
}

这是单生产者、单消费者、一次交付的局部模型,不是通用 channel。多等待者的关闭通常需要 notify_all,并在状态转移后重新推导谁还可能等待;生产者 panic 也不会自动变成 Closed,需要拥有者负责关闭或采用具备断开语义的通道。

超时后仍然检查受锁保护的状态

wait_timeout 返回时重新取得锁,唤醒可能来自通知、超时或虚假唤醒。若手写循环,每次都重新等待完整的 5 秒,连续唤醒可能把总等待延长很多;可以使用 wait_timeout_while 管理总时长,或保存固定截止时间并计算剩余预算。

use std::{sync::{Condvar, Mutex}, time::Duration};
fn main() {
    let ready = Mutex::new(false);
    let condition = Condvar::new();
    let (state, timeout) = condition.wait_timeout_while(
        ready.lock().unwrap(), Duration::ZERO, |ready| !*ready,
    ).unwrap();
    assert!(!*state);
    assert!(timeout.timed_out());
}

这里用零预算验证未满足条件的超时分支,不依赖线程调度速度。真实超时也不是精确的返回时刻保证:等待线程还要重新获取锁并获得 CPU。最终是谓词决定结果是否可用,而不是单凭“收到通知”或“曾经到了截止时间”。Condvar 超时

原子变量:原子性不自动发布其他数据

Relaxed 保证该原子操作本身,不建立所需的跨对象发布关系。Acquire 只有观察到相应 Release 写入或其适用释放序列时,才与其建立同步;仅在两个线程里分别写上 Acquire/Release 关键词不够。

下面用两个原子变量展示一次发布,payload 本身也用原子类型,以避免教学代码包含未同步的普通内存访问:

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

fn main() {
    let payload = AtomicUsize::new(0);
    let ready = AtomicBool::new(false);
    thread::scope(|scope| {
        scope.spawn(|| {
            payload.store(42, Ordering::Relaxed);
            ready.store(true, Ordering::Release);
        });
        while !ready.load(Ordering::Acquire) {
            thread::yield_now();
        }
        assert_eq!(payload.load(Ordering::Relaxed), 42);
    });
}

读到 ready=true 的 Acquire 与发布它的 Release 配对,之前的 payload 写入因此先于后续读取。示例只有一次发布;反复复用同一标志需要代际或其他协议,不能简单 reset 后就假定没有竞争。

SeqCst 为相应顺序一致原子操作增加单一总序要求,不把任意普通变量自动变成原子,也不让多次操作组成事务。compare_exchange 的成功与失败内存序还不同:失败只是读取,不能任意使用写入方向的顺序。Ordering

线程 Aload 读取 0store 写入 1线程 Bload 读取 0store 写入 1Barrier:确保两边先完成读取没有未同步的普通内存访问;这是可由安全 Rust 表达的逻辑竞争。即使每次访问都 SeqCst,load + store 仍会丢失一次递增最终值 = 1;单次 fetch_add 才把读、加、写合为一个原子操作

顺序一致不等于复合操作不可分割

use std::{sync::{Barrier, atomic::{AtomicUsize, Ordering}}, thread};
fn main() {
    let count = AtomicUsize::new(0);
    let barrier = Barrier::new(2);
    thread::scope(|scope| {
        for _ in 0..2 {
            scope.spawn(|| {
                let old = count.load(Ordering::SeqCst);
                barrier.wait();
                count.store(old + 1, Ordering::SeqCst);
            });
        }
    });
    assert_eq!(count.load(Ordering::SeqCst), 1);
    count.store(0, Ordering::Relaxed);
    thread::scope(|scope| {
        for _ in 0..2 {
            scope.spawn(|| { count.fetch_add(1, Ordering::Relaxed); });
        }
    });
    assert_eq!(count.load(Ordering::Relaxed), 2);
}

Barrier 保证两边在写入前都已读取,因此丢失更新是确定的,不靠重复运行碰运气。第二段 fetch_add 将递增作为一个读改写原子操作;这里计数不用于发布其他数据,且最终观察在作用域线程完成之后,因此 Relaxed 足够表达所需的计数原子性。fetch_add 的整数溢出按回绕处理;如果计数不能回绕,应使用带检查的更新协议。AtomicUsize

Barrier 只适合参与者数量和阶段清楚的场景。某个参与者在抵达前提前返回或 panic,其他线程可能一直等,Barrier 本身不提供业务关闭恢复。它也不是互斥锁:通过屏障后,各线程仍可同时继续。

每次操作都原子,为何仍丢一次更新?

分离模式下,每个读写本身都是 SeqCst 原子操作,却仍允许两个线程先读到同一个旧值。安排一次这样的交错,再换成 fetch_add:修复来自把递增变成单个读改写操作,而不是简单提高内存序。这里的计数不承担发布其他数据的职责。

正在呈现知识画面
每次操作都原子,为何仍丢一次更新?

load 加 store 是两步;两个线程都读到 0 后各写 1,最终仍是 1。

Acquire 到底读到了哪次写入?

“用了 Acquire”还不够,必须追踪它读到哪个写入。这个单次发布例子把同步边单独画出来:读到 false 时没有进入业务分支,读到 true 却缺少 Release 或 Acquire 时没有本例所需保证。payload 使用原子类型,使无同步的对照仍是可讨论的定义内行为。

正在呈现知识画面
Acquire 到底读到了哪次写入?

只有 Acquire 观察到对应 Release 写入时,才建立此处 payload 的发布关系。

选择同步方式时先问等待与所有权

只需计数且不发布其他状态,可考虑 Relaxed 原子;维护多个字段共同不变量,Mutex 往往更容易证明;等待一个复杂谓词,用锁加 Condvar;把资源独占交给一个执行者,使用通道。

自旋示例用于展示顺序,不是通用等待策略。长等待应使用合适的阻塞或异步通知机制。验证并发程序除正常输出外,还要检查提前退出、关闭、错误、任务回收与锁依赖。

让停止条件成为协议的一部分

等待点正常推进条件必须设计的停止条件
接收通道收到工作所有发送端关闭、显式终止消息或截止时间
Condvar受锁保护的谓词改变关闭/失败也进入谓词,并通知等待者
Barrier足够参与者抵达参与者不会在中途无协调地退出
join目标线程结束工作循环能观察停止状态,阻塞等待有退出路径

一个 AtomicBool 停止标志不能唤醒正在 recv 或 Condvar 上阻塞的线程。除了写标志,还需要关闭通道、发通知或使用带超时的等待,使线程有机会重新检查停止条件。join 则只是等待退出,不会替线程发出取消请求。

排查卡住时先画出等待关系:谁等待谁提供数据或释放资源,谁又持有对方所需资源。重复 sleep、增加线程数或换更强内存序,通常不能修复这种协议层循环依赖。