-
阻塞
- 当进程需要等待某个事件时,它会主动或被动地进入阻塞状态
- 阻塞的进程会释放 CPU,操作系统会将 CPU 分配给其他就绪的进程。
-
锁
- 打个比方。在Breaking Bad中,每位家庭成员坐在一起谈心。为了交谈的高效性,同一时刻只允许一位家庭成员发言(类比:进程互斥)。所以弄了个抱枕(类比:锁)放在桌子上,要发言的人只有拿到抱枕(获取锁)才能发言。发言完毕后,将抱枕放回到桌子上(释放锁)
- 锁的使用
std::mutex#include <iostream> #include <thread> #include <mutex> std::mutex mtx; // 全局互斥锁 int sharedData = 0; // 共享数据 void increment() { for (int i = 0; i < 100000; ++i) { mtx.lock(); // 加锁 ++sharedData; // 访问共享数据 mtx.unlock(); // 解锁 } } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout << "Final value: " << sharedData << std::endl; // 输出: 200000 return 0; }- 这种方式,类似于裸指针,每次必须手动调用
lock()和unlock(),否则可能导致死锁或资源泄漏。 - 如果临界区代码抛出异常,
unlock()可能不会被调用,导致死锁。(占着不释放)
- 这种方式,类似于裸指针,每次必须手动调用
std::lock_guard#include <iostream> #include <thread> #include <mutex> std::mutex mtx; // 全局互斥锁 int sharedData = 0; // 共享数据 void increment() { for (int i = 0; i < 100000; ++i) { std::lock_guard<std::mutex> lock(mtx); // 自动加锁和解锁 ++sharedData; // 访问共享数据 } } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout << "Final value: " << sharedData << std::endl; // 输出: 200000 return 0; }- 这种RAII风格的锁管理器,在构造时自动加锁,在析构时自动解锁,避免了手动管理锁的麻烦
std::unique_lock#include <iostream> #include <thread> #include <mutex> std::mutex mtx; // 全局互斥锁 int sharedData = 0; // 共享数据 void increment() { for (int i = 0; i < 100000; ++i) { std::unique_lock<std::mutex> lock(mtx); // 自动加锁和解锁 ++sharedData; // 访问共享数据 } } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout << "Final value: " << sharedData << std::endl; // 输出: 200000 return 0; }
-
在 C++ 多线程编程中,线程间的协调是一个经常需要考虑的问题。我们经常需要一个线程等待某个条件满足(例如,等待任务队列非空,或等待某个计算完成),而另一个线程则负责在条件满足时通知等待的线程。
- 这么说依然抽象,现在就假设在消费者-生产者问题下。引入锁机制只是解决了对临界资源(即产品队列)的互斥操作,使得同一时刻只有一个进程访问互斥资源。
- 假设现在一个消费者进程A拿到锁了并在CPU上运行,CPU分配给他的时间片有100ms。假设此时产品队列为空,那么进程A什么都干不了,只有空转。比如它花了10ms判断到产品队列为空,剩下90ms它将无所事事,相当于浪费了这个时间片90%的效率。这就是忙等待或自旋的劣势。
- 代码:
// --- 极简化的伪代码,仅用于说明概念 --- std::mutex queue_mutex; std::queue<Task> task_queue; bool running = true; void consumer_thread() { while (running) { std::lock_guard<std::mutex> lock(queue_mutex); // 锁住队列 if (!task_queue.empty()) { Task task = task_queue.front(); task_queue.pop(); // ... process task ... } else { // 队列为空,解锁并稍等片刻? // 这就是问题所在!我们不想空转浪费 CPU! } // lock_guard 在离开作用域时自动解锁 } }
-
一个很直白的思路就是,对于忙等待(占着cpu又无所事事)的进程,应该让它阻塞。再次回顾阻塞的含义:释放 CPU并进入阻塞进程队列,等待将来的唤醒。
- 怎样“释放 CPU并进入阻塞进程队列”:
wait函数 - 谁来唤醒:另一个进程调用
notify函数
- 怎样“释放 CPU并进入阻塞进程队列”:
-
void wait(std::unique_lock<std::mutex>& lock, Predicate pred):lock:一个std::unique_lock<std::mutex>对象,用于保护共享资源pred:一个可调用对象(如Lambda表达式),用于检查条件是否满足。如果谓词为 true,说明条件已经满足,wait函数直接返回。线程继续执行,lock仍然保持锁定状态。- 工作流程
- 持有锁检查谓词:
wait函数首先检查你提供的predicate(通常是一个lambda表达式)。此时,你传入的lock必须是锁定的状态。 - 如果谓词为
true: 说明条件已经满足,wait函数直接返回。线程继续执行,lock仍然保持锁定状态。 - 如果谓词为
false: 说明条件不满足,线程需要等待。此时wait执行一个关键的原子操作序列: a. 释放锁:wait自动地、原子地调用lock.unlock(),释放掉你传入的lock所管理的互斥锁 (queue_mutex)。这是至关重要的一步,它允许其他线程(比如生产者)能够获取这个锁,进而修改共享状态(队列)并最终满足条件。 b. 阻塞线程: 当前线程进入阻塞(睡眠)状态,等待被notify_one()或notify_all()唤醒。 - 被唤醒: 当线程被
notify或发生spurious wakeup(虚假唤醒,这是可能发生的)时,它会从阻塞状态醒来。 - 重新获取锁: 在唤醒后,
wait函数自动地、原子地尝试重新调用lock.lock()获取之前释放的互斥锁。线程可能会在这里再次阻塞,直到成功获取锁为止。 - 再次检查谓词: 成功重新获取锁后,
wait再次检查 predicate。 - 如果
predicate现在返回true,wait 函数返回,线程继续执行。lock 此时是锁定的。 - 如果
predicate仍然返回false(可能是虚假唤醒,或者条件被其他线程改变了),wait 不会返回,而是重复步骤 3a,再次释放锁并进入阻塞状态,等待下一次唤醒。
- 持有锁检查谓词:
-
notify_all- 唤醒所有等待的线程。
-
例子:考虑经典的消费者-生产者模型
#include <iostream> #include <thread> #include <queue> #include <mutex> #include <condition_variable> std::mutex mtx; std::condition_variable cv; std::queue<int> dataQueue; void producer() { for (int i = 0; i < 10; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟生产耗时 { std::unique_lock<std::mutex> lock(mtx); dataQueue.push(i); std::cout << "Produced: " << i << std::endl; } cv.notify_all(); // 通知消费者 } } void consumer() { while (true) { std::unique_lock<std::mutex> lock(mtx); cv.wait(lock, [] { return !dataQueue.empty(); }); // 等待队列不为空 int value = dataQueue.front(); dataQueue.pop(); std::cout << "Consumed: " << value << std::endl; if (value == 9) break; // 结束条件 } } int main() { std::thread t1(producer); std::thread t2(consumer); t1.join(); t2.join(); return 0; } ``` -
类比:所以你可以想象到这样一个场景:每位家庭成员尝试获取抱枕并发言,对于拿到抱枕却又支支吾吾的,他应该立即放下抱枕让给别人。
-
虚假唤醒:在不同的语言,甚至不同的操作系统上,条件锁都会产生虚假唤醒现象。所有语言的条件锁库都推荐用户把wait()放进循环里:
while (!cond) { lock.wait(); }- 由于线程调度的原因,被条件变量唤醒的线程在本线程内真正执行「加锁并返回」前,另一个线程插了进来,完整地进行了一套「拿锁、改条件、还锁」的操作。比如任务队列未空,多个消费者进程被唤醒,当一个消费者进程拿走了最后一个任务后,其他被唤醒的消费者如果不检查条件,就会出错。
- 这也是推荐使用wait带谓词的重载版本,这样就包含了条件判断,而不需要在外面包一个循环体。
-
参考资料
深入理解 C++ 条件变量:为何 wait 钟爱 std::unique_lock?
C++ std::condition_variable wait() wait_for() 区别 怎么用 实例
虚假唤醒
C++ 多线程 wait,notifty,mutex 的使用
于 2025-04-04 21:14:14 首次发布
3205

被折叠的 条评论
为什么被折叠?



