4. 共享状态:Mutex与RwLock、Condvar条件变量、屏障(Barrier)、OnceCell与LazyLock

共享状态并发,说白了就是多个线程要读写同一块数据。这活儿看着简单,但坑特别多。我早年用C++写多线程服务时,就因为锁没用好,线上出了好几次死锁。到了Rust这里,编译器帮你管住了不少事,但底层逻辑你还得懂。

这一章,咱们把Rust里常用的共享状态工具捋一遍。从最基本的互斥锁,到读写锁,再到条件变量、屏障,最后聊聊延迟初始化的OnceCell和LazyLock。每个工具都有它的脾气,用对了地方,事半功倍。

4.1 Mutex:互斥锁

Mutex是最基础的同步原语。它保证同一时刻只有一个线程能访问被保护的数据。Rust的Mutex和别的语言不太一样——它直接包裹数据,而不是单独锁一个对象。

use std::sync::Mutex;

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

    // 获取锁,返回MutexGuard
    let mut num = counter.lock().unwrap();
    *num += 1;

    // 锁在MutexGuard离开作用域时自动释放
    println!("counter: {}", *num);
}

嗯,这里要注意:lock()返回的是一个Result。为什么?因为持有锁的线程可能panic了,这时候锁会进入"中毒"状态。我个人习惯是直接用unwrap(),但如果你的程序对错误敏感,最好用match处理一下。

⚠️ 死锁警告

Rust不会自动帮你避免死锁。如果你在持有锁A的情况下去获取锁B,而另一个线程正持有锁B等着锁A——完蛋,死锁了。我曾经在一个消息队列项目里踩过这个坑,排查了一下午才发现是两个锁的获取顺序不一致。

多线程场景下,Mutex需要配合Arc使用,因为多个线程需要共享所有权:

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

fn main() {
    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());
}

你想想看,如果没有Arc,所有权怎么分?每个线程都要一个Mutex的引用,但Rust的借用检查器不允许你这么干。Arc提供了线程安全的引用计数,完美解决这个问题。

4.2 RwLock:读写锁

Mutex有个问题:读操作和写操作互斥。但很多时候,读操作远多于写操作。这时候RwLock就派上用场了。它允许多个读者同时访问,但写者独占。

use std::sync::RwLock;

fn main() {
    let data = RwLock::new(vec![1, 2, 3]);

    // 多个读锁可以同时存在
    let read1 = data.read().unwrap();
    let read2 = data.read().unwrap();
    println!("{:?}, {:?}", read1, read2);

    // 写锁会阻塞所有读锁
    let mut write = data.write().unwrap();
    write.push(4);
}
💡 性能建议

读多写少的场景用RwLock,比如配置缓存、路由表。写多读少或者读写频率差不多的场景,直接用Mutex反而更简单高效。我在一个网关项目里用RwLock管理路由规则,读请求每秒几万次,写规则几分钟一次,性能比Mutex提升了将近3倍。

但RwLock有个潜在问题——写者饥饿。如果读线程一直来,写线程可能永远拿不到锁。标准库的RwLock不保证公平性,所以写操作频繁的场景要慎用。

4.3 Condvar:条件变量

条件变量用来让线程等待某个条件成立。它必须和Mutex配合使用。典型场景:生产者-消费者模型。

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

fn main() {
    let pair = Arc::new((Mutex::new(false), Condvar::new()));
    let pair_clone = Arc::clone(&pair);

    // 消费者线程
    thread::spawn(move || {
        let (lock, cvar) = &*pair_clone;
        let mut ready = lock.lock().unwrap();
        while !*ready {
            // 等待条件变量通知
            ready = cvar.wait(ready).unwrap();
        }
        println!("消费者:收到通知,开始干活");
    });

    // 主线程(生产者)
    let (lock, cvar) = &*pair;
    let mut ready = lock.lock().unwrap();
    *ready = true;
    cvar.notify_one();
    println!("生产者:发送通知");
}

为什么wait()要传入一个MutexGuard?因为线程在等待时会释放锁,让其他线程能修改条件。等被唤醒后,它会重新获取锁。这个机制保证了条件检查的原子性。

🔧 避坑指南

我曾经在条件变量上吃过一个亏:用if而不是while检查条件。结果发生了"虚假唤醒"——线程被唤醒了,但条件其实还没满足。标准做法是用while循环,每次被唤醒都重新检查条件。

4.4 Barrier:屏障

Barrier让多个线程在某个点上同步等待。所有线程都到达屏障后,才一起继续执行。这个工具在并行计算里特别常见。

use std::sync::{Arc, Barrier};
use std::thread;

fn main() {
    let mut handles = Vec::new();
    let barrier = Arc::new(Barrier::new(5));

    for id in 0..5 {
        let barrier = Arc::clone(&barrier);
        handles.push(thread::spawn(move || {
            println!("线程{}:到达屏障", id);
            barrier.wait();
            println!("线程{}:通过屏障", id);
        }));
    }

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

输出结果里,所有"到达屏障"的消息会先打印完,然后才打印"通过屏障"。Barrier内部用了一个计数器,每次wait()调用就减1,减到0时唤醒所有等待线程。

我在做图像处理项目时用过Barrier。把一张大图分成4块,4个线程各自处理一块,处理完后在屏障处等待,然后统一进行下一步合并操作。代码写起来特别清爽。

4.5 OnceCell与LazyLock

这两个工具解决的是"延迟初始化"问题。说白了就是:这个值我只初始化一次,而且是在第一次被用到的时候才初始化。

OnceCell是基础版本,LazyLock是它的语法糖版本(Rust 1.80+稳定)。

use std::sync::OnceLock;

static CONFIG: OnceLock<String> = OnceLock::new();

fn get_config() -> &'static str {
    CONFIG.get_or_init(|| {
        // 只执行一次
        println!("初始化配置...");
        std::fs::read_to_string("config.toml").unwrap()
    })
}

fn main() {
    println!("{}", get_config());
    println!("{}", get_config()); // 不会再次初始化
}

LazyLock更简洁:

use std::sync::LazyLock;

static CONFIG: LazyLock<String> = LazyLock::new(|| {
    println!("初始化配置...");
    std::fs::read_to_string("config.toml").unwrap()
});

fn main() {
    println!("{}", *CONFIG);
}
💡 使用场景
  • 全局配置:程序启动时加载,但不想在main函数里显式初始化
  • 单例模式:全局唯一的资源,比如数据库连接池
  • 计算密集型缓存:第一次使用时计算结果,后续直接复用

你可能会问:这和Mutex有什么区别?OnceCell保证初始化只执行一次,而且初始化完成后后续读取没有锁开销。而Mutex每次读写都要加锁。所以对于"写一次、读多次"的场景,OnceCell性能好得多。

知识体系总览

下面这张图把本章的核心概念串起来了。你可以看到每个工具的适用场景和核心特点:

共享状态并发工具全景图 共享状态 Mutex 互斥锁 · 读写互斥 RwLock 读写锁 · 读共享写独占 Condvar 条件变量 · 等待/通知 Barrier 屏障 · 线程同步点 OnceCell 延迟初始化 · 一次写入 🔒 独占访问 ⚠️ 可能死锁 📖 读多写少 ⚠️ 写者饥饿 📨 生产者-消费者 ⚠️ 虚假唤醒 🔄 并行计算同步 ✅ 无数据竞争 ⚡ 零开销读取 ✅ 线程安全 选择建议 简单互斥 → Mutex | 读多写少 → RwLock | 等待条件 → Condvar 多线程同步 → Barrier | 延迟初始化 → OnceCell / LazyLock

这张图把五个工具按"互斥访问"和"同步协作"两个维度做了分类。Mutex和RwLock解决的是"怎么安全地访问数据",Condvar和Barrier解决的是"线程之间怎么协调节奏",OnceCell和LazyLock解决的是"什么时候初始化数据"。搞清楚了这些,你写并发代码时就知道该用哪个了。


公众号:蓝海资料掘金营,微信deep3321