Rust 条件变量(Condvar)详解:线程同步的高效方式

0 阅读8分钟

Rust 条件变量(Condvar)详解:线程同步的高效方式

在 Rust 并发编程中,线程同步是保证数据安全和逻辑正确的核心环节。条件变量(Condvar)专门用于解决“线程等待某个条件成立”的场景,与 Mutex 配合使用,能实现高效的线程协作,避免无效的忙等,提升程序性能。

为什么需要 Condvar?

在并发场景中,我们经常会遇到这样的需求:线程 A 需要等待某个条件满足后才能继续执行,比如等待一个队列不为空、等待某个数值达到阈值。如果没有 Condvar,我们可能会用“循环检查 + 睡眠”的方式实现:

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

fn main() {
    let shared_data = Arc::new(Mutex::new(0));
    let data_clone1 = Arc::clone(&shared_data);
    let data_clone2 = Arc::clone(&shared_data);

    // 线程1:等待数据达到 5(轮询检查,但合理释放锁)
    let handle1 = thread::spawn(move || {
        loop {
            // 获取锁
            let data = data_clone1.lock().unwrap();

            if *data >= 5 {
                println!("条件满足,数据:{}", *data);
                break;
            }

            // 检查完条件后立即释放锁,避免阻塞线程2修改
            drop(data);
            // 短暂睡眠后重新检查(降低 CPU 占用)
            thread::sleep(Duration::from_millis(100));
        }
    });

    // 线程2:修改数据(不持有锁时 sleep)
    let handle2 = thread::spawn(move || {
        for i in 1..=5 {
            // 获取锁并修改数据
            let mut data = data_clone2.lock().unwrap();
            *data = i;
            // 修改完成后立即释放锁,让线程1有机会检查
            drop(data);

            // 释放锁后再 sleep,不阻塞其他线程
            thread::sleep(Duration::from_millis(50));
        }
    });

    // 等待两个线程都完成
    handle2.join().unwrap();
    handle1.join().unwrap();
}

这种方式有两个明显的问题:

  • 效率低下:睡眠时间过长会导致线程响应延迟,过短则会频繁获取锁、检查条件,浪费 CPU 资源(忙等);
  • 逻辑冗余:需要手动管理锁的释放和重新获取,代码繁琐且容易出错。

而 Condvar 的出现,正是为了解决这个问题。它允许线程在条件不满足时,主动释放锁并进入等待状态,直到其他线程通知“条件可能成立”,再重新获取锁并检查条件。这种方式既避免了忙等,又简化了代码逻辑。

Condvar 的核心原理

Rust 中的 Condvar 定义在 std::sync::Condvar 中,它必须与 Mutex 配合使用,原因是条件的检查和修改必须在互斥锁的保护下进行,否则会出现数据竞争。

Condvar 的工作流程可以概括四步:

  1. 线程 A 获取 Mutex 锁,检查目标条件;
  2. 如果条件不满足,线程 A 调用 Condvar 的 wait 方法。此时会自动释放 Mutex 锁,并将线程 A 加入等待队列,进入阻塞状态;
  3. 线程 B 获取 Mutex 锁,修改共享数据,使条件可能成立,然后调用 Condvar 的 notify_one(唤醒一个等待线程)或 notify_all(唤醒所有等待线程);
  4. 线程 A 被唤醒后,会自动重新获取 Mutex 锁,再次检查条件(避免虚假唤醒),如果条件满足则继续执行,否则再次等待。

这里需要重点注意“虚假唤醒”(spurious wakeup),即使没有线程调用 notify,等待的线程也可能被系统唤醒。因此,必须在循环中检查条件,而不是 if 判断。

Condvar 的使用

核心 API

Condvar 提供了四个核心方法,重点掌握前三个:

  • wait(&self, guard: MutexGuard<'a, T>) -> LockResult<MutexGuard<'a, T>>:让当前线程释放 Mutex 锁并进入等待状态,唤醒后重新获取锁并返回;
  • notify_one(&self):唤醒等待队列中的一个线程;
  • notify_all(&self):唤醒等待队列中的所有线程;
  • wait_timeout(&self, guard: MutexGuard<'a, T>, dur: Duration) -> LockResult<(MutexGuard<'a, T>, bool)>:带超时的等待,返回值中的 bool 表示是否超时,true 为超时。

示例

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

fn main() {
    let pair = Arc::new((Mutex::new(0), Condvar::new()));
    let pair_clone1 = Arc::clone(&pair);
    let pair_clone2 = Arc::clone(&pair);

    // 线程1:等待数据 >= 5(阻塞等待而非轮询)
    let handle1 = thread::spawn(move || {
        let (lock, cvar) = &*pair_clone1;
        let mut data = lock.lock().unwrap();

        // 核心:while 循环检查条件(避免虚假唤醒)
        while *data < 5 {
            // wait() 自动释放锁并阻塞线程,被通知后重新获取锁
            data = cvar.wait(data).unwrap();
        }

        println!("条件满足,数据:{}", *data);
    });

    // 线程2:修改数据并通知等待线程
    let handle2 = thread::spawn(move || {
        let (lock, cvar) = &*pair_clone2;

        for i in 1..=5 {
            // 循环内获取锁:修改完立即释放,避免长期持有
            let mut data = lock.lock().unwrap();
            *data = i;
            println!("修改数据为:{}", *data);

            // 修改数据后通知等待的线程
            cvar.notify_one();

            // 释放锁后再 sleep,给线程1机会获取锁
            drop(data);
            thread::sleep(Duration::from_millis(50));
        }
    });

    // 等待两个线程都完成
    handle2.join().unwrap();
    handle1.join().unwrap();
}

这个示例中,我们解决了之前“忙等”的问题:线程1在条件不满足时,会释放锁并进入阻塞状态,直到线程2通知,才会重新检查条件,CPU 资源得到了有效利用。

进阶:生产者-消费者模型

Condvar 最典型的应用场景之一就是生产者-消费者模型,生产者往队列中添加数据,消费者从队列中取出数据,当队列为空时,消费者等待;当队列满时,生产者等待。

下面我们用 Condvar + Mutex + Vec 实现一个简单的生产者-消费者模型(为了简化,不限制队列大小,仅演示 Condvar 的用法):

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

const QUEUE_CAPACITY: usize = 5; // 限制队列容量,防止内存无限增长

fn main() {
    // 用 Arc 包裹 (Mutex + Condvar),实现多线程所有权共享
    let shared = Arc::new((
        Mutex::new(Vec::with_capacity(QUEUE_CAPACITY)),
        Condvar::new(),
    ));
    let mut consumer_handles = Vec::new();

    // 启动 2 个消费者线程
    for consumer_id in 1..=2 {
        let shared_clone = Arc::clone(&shared);
        let handle = thread::spawn(move || {
            let (queue_lock, cvar) = &*shared_clone;
            loop {
                // 用 let-else 处理锁中毒(PoisonError)
                let Ok(mut queue) = queue_lock.lock() else {
                    eprintln!("消费者 {}:队列锁损坏,退出", consumer_id);
                    return;
                };

                // 队列为空时等待(while 循环防止虚假唤醒)
                while queue.is_empty() {
                    println!("消费者 {}:队列为空,等待生产...", consumer_id);
                    let Ok(wait_result) = cvar.wait(queue) else {
                        eprintln!("消费者 {}:等待时锁损坏,退出", consumer_id);
                        return;
                    };
                    queue = wait_result;
                }

                // 取出数据
                let data = queue.remove(0);
                println!("消费者 {}:取出数据 {}", consumer_id, data);

                // 通知生产者
                cvar.notify_one();

                // 关键优化:先释放锁,再模拟处理耗时,避免持有锁时阻塞其他线程
                drop(queue);
                thread::sleep(Duration::from_millis(100));
            }
        });
        consumer_handles.push(handle);
    }

    // 启动生产者线程
    let shared_clone = Arc::clone(&shared);
    let producer_handle = thread::spawn(move || {
        let (queue_lock, cvar) = &*shared_clone;
        for i in 1..=10 {
            let Ok(mut queue) = queue_lock.lock() else {
                eprintln!("生产者:队列锁损坏,退出");
                return;
            };

            // 队列满时等待消费者
            while queue.len() >= QUEUE_CAPACITY {
                println!("生产者:队列已满(容量 {}),等待消费...", QUEUE_CAPACITY);
                let Ok(wait_result) = cvar.wait(queue) else {
                    eprintln!("生产者:等待时锁损坏,退出");
                    return;
                };
                queue = wait_result;
            }

            // 生产数据
            queue.push(i);
            println!("生产者:添加数据 {}", i);
            cvar.notify_one();

            // 释放锁后再模拟生产耗时
            drop(queue);
            thread::sleep(Duration::from_millis(50));
        }
        println!("生产者:完成所有数据生产");
    });

    // 等待生产者结束
    producer_handle.join().unwrap();
    // 等待消费者处理完剩余数据(实际项目建议用退出信号)
    thread::sleep(Duration::from_secs(2));
    println!("主线程:程序结束");
}

常见陷阱与注意事项

必须在循环中检查条件

如前所述,操作系统可能会随机唤醒等待的线程,即使没有线程调用 notify,也就是虚假唤醒。如果用 if 判断条件,线程被虚假唤醒后会直接继续执行,导致逻辑错误。

// 错误:用 if 判断,无法应对虚假唤醒
if *data < 5 {
    data = condvar.wait(data).unwrap();
}

// 正确:必须用 while
while *data < 5 {
    data = condvar.wait(data).unwrap();
}

Condvar 必须与 Mutex 配合使用

Condvar 的 ·wait· 方法必须接收一个 MutexGuard(互斥锁的守卫),目的是确保“条件检查-进入等待”的原子性。如果没有 Mutex 保护,可能会出现以下问题:

  • 线程 A 检查条件时,条件不满足,但还没来得及调用 wait,线程 B 就修改了条件并调用了 notify,此时线程 A 再调用 wait,会错过通知,陷入无限等待;
  • 条件的修改和检查没有互斥,会出现数据竞争,违反 Rust 的内存安全原则。

区分 notify_one 和 notify_all 的使用场景

  • notify_one:唤醒一个等待线程,适合“一个线程修改条件,只需要一个线程响应”的场景(如上面的基础示例、生产者-消费者模型),效率更高;
  • notify_all:唤醒所有等待线程,适合“一个线程修改条件,多个线程都需要响应”的场景(如多个线程等待同一个阈值),但会带来一定的性能开销,因为唤醒的线程需要重新竞争锁。

总结

Rust 的 Condvar 是一种高效的线程同步原语,它与 Mutex 配合使用,专门解决“线程等待某个条件成立”的场景,避免了无效的忙等,简化了并发代码的逻辑。

在实际开发中,可以结合 Barrier(屏障)等其他同步原语,实现更复杂的并发场景。