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 的工作流程可以概括四步:
- 线程 A 获取 Mutex 锁,检查目标条件;
- 如果条件不满足,线程 A 调用 Condvar 的
wait方法。此时会自动释放 Mutex 锁,并将线程 A 加入等待队列,进入阻塞状态; - 线程 B 获取 Mutex 锁,修改共享数据,使条件可能成立,然后调用 Condvar 的
notify_one(唤醒一个等待线程)或notify_all(唤醒所有等待线程); - 线程 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(屏障)等其他同步原语,实现更复杂的并发场景。