C++11中的互斥锁,条件变量,生产者-消费者示例
目录
C++11 互斥锁
RALL介绍
lock_guard(最简单互斥锁封装)
unique_lock(灵活、强大的锁管理)
C++11 条件变量
为什么需要条件变量?
wait函数(礼貌的排队者)
wait 函数原型
wait 函数的作用(重点理解)
wait使用&不使用谓词的写法对比
notify 唤醒函数
使用unique_lock互斥锁+条件变量的线程执行流程
生产者-消费者模型示例
执行流程详解
生产者线程(producer)
消费者线程(consumer)
C++11 互斥锁
RALL介绍
互斥锁(std::mutex)核心思想:
- 上锁(lock()):一个线程在访问共享资源前,需要先获取(上锁)互斥锁。如果锁已经被其他线程持有,当前线程就会被阻塞,直到锁被释放。
- 解锁(unlock()):线程在使用完共享资源后,必须释放(解锁)互斥锁,让其他等待的线程有机会获取它。
在实际使用中,我们不推荐直接调用 mutex.lock() 和 mutex.unlock(),因为手动管理容易忘记解锁,并且如果lock() 和 unlock() 之间发生异常,unlock() 将永远不会被调用,从而导致死锁。
为了确保即使在代码抛出异常时也能正确解锁,C++11引入了智能锁机制(使用 std::unique_lock 或 std::lock_guard),不仅更加安全,也避免了我们手动lock()与unlock()。
智能锁的设计是RAII(资源获取即初始化)机制的体现,当它们被创建时自动锁定互斥锁,当它们离开作用域时(无论是正常结束还是发生异常),会自动调用析构函数解锁。
RALL的介绍:
RAII是一种在 C++ 中非常重要的编程思想或设计模式,它的核心理念是:将资源的生命周期与对象的生命周期绑定,自动化地管理互斥锁的获取和释放,避免手动lock/unlock。
简单来说,当你在一个对象的构造函数中获取资源(比如打开文件、分配内存、加锁等),然后在它的析构函数中释放这些资源。
简单来说,它的核心思想是:把对资源的“管理”操作,封装到一个对象的构造函数和析构函数中。
这样一来,这个对象只要被创建,资源就会被自动“获取”(比如加锁、打开文件、分配内存);而当这个对象被销毁时,资源就会被自动“释放”(比如解锁、关闭文件、释放内存)。
C++11的互斥锁中引入了 RAII 机制的封装类,比如 std::unique_lock 和 std::lock_guard。
std::lock_guard或std::unique_lock对象的作用是:
- 构造时加锁:当对象被创建时,其构造函数会自动调用互斥锁的lock()方法。
- 析构时解锁:当对象超出作用域被销毁时,其析构函数会自动调用互斥锁的unlock()方法。
优点:这样,你就不需要手动去管理互斥锁的解锁了。无论代码如何执行(比如函数正常返回、抛出异常等),当对象超出作用域时,它的析构函数都会被自动调用,从而保证互斥锁都能被安全释放,从而有效防止死锁。
补充:RAII(资源获取即初始化)思想在C++中应用非常广泛,比如智能指针是RAII最典型的应用之一。
下面来对比一下 不使用RALL(原始手动加锁解锁)和 使用RALL封装类管理互斥锁
传统手动管理方式 (非 RAII)
std::mutex m;void do_something() {m.lock(); // 1. 手动加锁// ... 执行一些操作 ...m.unlock(); // 2. 手动解锁
}
这种方式的问题在于,如果“执行一些操作”的过程中抛出了异常,m.unlock()
这行代码就不会被执行,导致锁永远不会被释放,其他线程会一直被阻塞,这就是死锁。
RAII 方式
C++标准库为我们提供了std::lock_guard和std::unique_lock类,它们是 RAII 思想的完美体现。
std::mutex m;void do_something_raii() {std::lock_guard<std::mutex> lock(m); // 1. 构造函数中自动加锁// ... 执行一些操作 ...
} // 2. 对象离开作用域时,析构函数自动调用,自动解锁
在这个例子中:
- 当 lock 对象被创建时,它的构造函数会自动调用 m.lock()。
- 当 do_something_raii() 函数执行完毕,无论它是正常返回还是因为异常而退出,lock 对象都会被销毁。
- lock 对象的析构函数会被自动调用,从而执行 m.unlock()。
这样就保证了锁总能被释放,从根本上解决了死锁的隐患。
lock_guard(最简单互斥锁封装)
std::lock_guard 是最简单、最轻量级的互斥锁封装,没有额外的灵活性。自动加锁与解锁是它唯一的功能。
工作方式:
-
构造时:自动调用它所管理的互斥锁的
lock()
方法。 -
析构时:自动调用互斥锁的
unlock()
方法。
适用场景:
当你需要一个很简单的、全作用域的自动锁,且不需要任何额外的控制(比如提前解锁、条件变量等待)时,std::lock_guard 是最佳选择。
std::mutex m;
void critical_section() {std::lock_guard<std::mutex> lock(m); // 构造时自动加锁// 临界区代码...// 无论是正常退出还是异常,都会自动解锁
} // 离开作用域时,lock_guard 析构,自动解锁
unique_lock(灵活、强大的锁管理)
unique_lock在构造时可以选择是否立即加锁,并且提供了更多的成员函数来管理锁的状态,提供了对互斥锁更精细的控制。
主要特点和优点:
- 延迟加锁:你可以创建一个不立即加锁的 std::unique_lock 对象,然后在需要时手动调用 lock() 方法。
- 所有权转移:unique_lock 是可移动的(Moveable),这意味着你可以将锁的所有权从一个函数传递到另一个函数。
- 与条件变量协同工作:这是 std::unique_lock 最重要的功能之一。std::condition_variable 的 wait() 方法要求传入一个 std::unique_lock 对象作为参数,因为它需要在等待时自动释放锁,被唤醒时再重新加锁。
std::mutex m;
std::condition_variable cv;
std::queue<int> tasks;void consumer() {std::unique_lock<std::mutex> lock(m); // 构造时加锁cv.wait(lock, []{ return !tasks.empty(); }); // 等待时自动解锁,唤醒后自动加锁int task = tasks.front();tasks.pop();
} // 离开作用域时,unique_lock 析构,自动解锁
C++11 条件变量
为什么需要条件变量?
条件变量是一种同步原语,用于线程间的通信和协调。它本身不能保护数据,但它能让一个或多个线程等待某个特定条件的发生。
条件变量必须配合互斥锁使用!!!
条件变量必须配合互斥锁使用!!!
条件变量必须配合互斥锁使用!!!
我们在项目中绝对不可能出现单独使用条件变量的情况,条件变量的使用前置条件就是互斥锁!
因此,当你使用条件变量时,记住这个黄金法则:条件变量 + unique_lock。
在多线程编程中,互斥锁可以解决共享资源的并发访问问题,但它无法解决线程间的同步和协作问题。举个例子,一个生产者线程需要等待消费者线程处理完任务,或者一个消费者线程需要等待生产者线程生产出数据。在这种“等待某个条件发生”的场景下,单纯的互斥锁就不够用了。
条件变量就是用来解决这类问题的。它的核心思想是:让一个或多个线程等待某个条件达成,同时避免繁忙等待。
条件变量使用场景示例:
想象一个“生产者-消费者”模型:一个线程(生产者)负责生成数据,另一个线程(消费者)负责处理数据。使用条件变量:消费者线程可以“订阅”一个条件,然后进入休眠状态。
当生产者线程准备好数据后,它会“发布”一个通知,唤醒等待中的消费者线程。这大大节省了 CPU 资源,因为线程在等待时不会占用 CPU。
wait函数(礼貌的排队者)
wait()函数的核心作用就是:
当一个线程发现它不能继续执行(即“条件不满足”)时,它会主动地、优雅地释放它已经持有的互斥锁,然后进入睡眠状态,等待其他线程来改变这个条件并唤醒它。
整个过程就像一个礼貌的排队者:当发现轮到自己但条件不符时,他会暂时离开队伍,并且把自己占用的资源拿走(释放互斥锁),方便其他人使用资源。直到有人通知他“你的条件已经满足了”,他才会重新回到队伍中,重新尝试获取之前释放的互斥锁,获取成功的话才开始执行自己的任务。
啊啊啊,对于wait函数,我哭死!!!太无私了!!!
wait 函数原型
wait() 方法主要分为两种形式,一种是带谓词的,另一种不带谓词,最常用的是带一个互斥锁和一个谓词(lambda函数)的:
不带谓词的 wait() 函数:
void wait(std::unique_lock<std::mutex>& lock);
这个函数会原子性地释放互斥锁 lock,然后阻塞当前线程,直到条件变量被通知(notify_one() 或 notify_all())。当线程被唤醒后,它会重新尝试获取互斥锁 lock。
带谓词的 wait() 函数:template< class Predicate > void wait( std::unique_lock<std::mutex>& lock, Predicate pred );
这个函数接受一个额外的参数 pred,它是一个可调用对象(如Lambda表达式、函数对象等),作为“谓词”。这个函数在内部等价于一个 while 循环:
while (!pred()) {wait(lock); }
谓词 本质上就是一个返回布尔值 (bool) 的可调用对象(比如函数、函数对象或者 lambda 表达式)。
std::condition_variable::wait() 函数的第二个参数就是一个谓词。它的作用是:
- 当谓词返回 false 时,表示条件不满足,线程需要进入等待状态。
- 当谓词返回 true 时,表示条件已经满足,线程可以继续执行,不再需要等待。
也就是说,wait 函数会先检查谓词 pred。
- 如果谓词返回 true,wait 函数会立即返回,不会阻塞线程。
- 如果谓词返回 false,它会释放互斥锁并阻塞线程。
当线程被唤醒后,它会重新获取锁,然后再次检查谓词。只有当谓词返回 true 时,wait 函数才会结束。
为什么需要这个谓词 pred
? 因为线程被唤醒后,条件可能仍然不满足。这种情况被称为虚假唤醒(Spurious Wakeup)。wait()
方法中的谓词可以帮助你防止虚假唤醒,确保只有在条件真正满足时,线程才会继续执行。这是一个非常重要的设计,所以总是推荐使用带谓词的 wait
函数。
如果使用不带谓词的 wait(),你需要自己手动写一个 while 循环来检查条件,以防虚假唤醒,而使用带谓词的 wait(),这个 while 循环已经内置在函数内部,代码会更简洁、更安全。
使用带谓词的 wait() 是一个最佳实践,它可以有效避免虚假唤醒问题。
虚假唤醒指的就是 等待在条件变量上的线程,在没有任何显式 notify_one / notify_all,并且条件其实还没有满足的情况下,却被意外地唤醒。
虚假唤醒不是 bug:标准库和操作系统允许线程在条件变量上“偶尔自己醒来”,即使没人通知。
原因:
操作系统实现条件变量时的优化,可能导致额外的唤醒。
内核或硬件中断也可能带来“假信号”。
如果没有谓词,你就需要手动编写一个 while 循环来检查条件。
有了谓词,这个 while 循环就不需要你手动写了,wait() 内部会帮你自动完成这个检查和循环过程。所以使用谓词的版本看起来更简洁。
谓词将“检查条件”的逻辑封装起来,并确保线程在每次被唤醒时都会重新确认条件是否真的满足,从而优雅地处理了虚假唤醒。
下面对这里的谓词来进行更详细的解释:
wait() 中的谓词是条件变量一个非常实用的特性。它的作用就是自动处理虚假唤醒,让你的代码更健壮、更简洁。
当你调用
wait()
并传入一个谓词时,它内部的执行流程是这样的:
在进入等待状态前,先检查谓词:
wait()
函数首先会检查你传入的谓词。如果谓词返回true
(条件已满足),wait()
函数会立即返回,不会让线程进入等待状态。
如果谓词返回
false
,则进入等待状态: 如果谓词返回false
(条件不满足),wait()
就会像没有谓词的wait()
一样,释放互斥锁并阻塞当前线程。
被唤醒后,重新检查谓词: 当线程被
notify_one()
或notify_all()
唤醒时,wait()
函数会自动重新获取互斥锁,然后再次检查谓词。
如果谓词现在返回
true
,wait()
函数就会返回,线程继续执行。如果谓词仍然返回
false
,线程会再次释放互斥锁并进入阻塞状态,等待下一次唤醒。这个“被唤醒后自动重新检查条件”的机制,正是谓词最大的优势所在。
wait 函数的作用(重点理解)
wait() 的作用可以分为两个阶段:
阶段1. 原子地释放互斥锁并阻塞线程:
- 调用 wait() 的线程首先必须已经持有(lock)一个std::unique_lock互斥锁。
- 当调用 wait() 时,它会原子地执行两个动作:释放传入的互斥锁,并让当前线程进入睡眠(阻塞)状态。
- 这个原子操作至关重要,它保证了在释放锁和线程进入阻塞状态之间不会有其他线程获取到锁,从而避免了死锁的发生。
阶段2. 被唤醒后重新获取互斥锁并继续执行:
- 当另一个线程通过 notify_one() 或 notify_all() 唤醒等待的线程时,这个线程会被操作系统调度。
- 在真正恢复执行前,wait() 函数会自动地重新尝试获取之前释放的互斥锁。只有成功获取锁后,线程才会从 wait() 调用中返回,继续向下执行。
wait使用&不使用谓词的写法对比
为了更直观地理解wait中带有谓词和不带有谓词写法的区别,体会这里的谓词便捷性,所以我们来分别看一下这两种写法。
写法二(带谓词,更简洁、推荐):
// 消费者线程
std::unique_lock<std::mutex> lock(mtx);
// 等价于上面的 while 循环
cv.wait(lock, []{ return !data_queue.empty(); });
// 现在可以安全地消费数据
int data = data_queue.front();
带谓词的版本不仅代码量更少,更重要的是它清晰地表达了你的意图
写法一(不带谓词,手动循环):
如果没有谓词,必须手动编写循环在 C++11 中,如果使用 std::condition_variable::wait() 的重载版本,也就是不带谓词的 cv.wait(lock),那么你必须手动在外部编写一个 while 循环来检查条件,以防止虚假唤醒。
wait
在没有谓词的情况下,正确的使用方式就是在它外面套一个 while
循环来持续检查你等待的条件。用来确保线程只在条件真正满足时才继续执行,从而安全地避免了“虚假唤醒”带来的问题。
// 消费者线程
std::unique_lock<std::mutex> lock(mtx);
while (data_queue.empty()) {cv.wait(lock);
}
// 现在可以安全地消费数据
int data = data_queue.front();
对于QT线程库中的条件变量以及Linux中的posix的条件变量来说,不像C++11标准库的wait一样,它们都没有提供谓词机制,所以为了避免虚假唤醒的话,就需要我们手动在wait外面嵌套一个while循环,嗯嗯,也比较简单。
notify 唤醒函数
notify_one():唤醒一个线程
这个方法用于唤醒一个正在等待的线程。如果当前没有线程在等待,那么这个调用就没有效果。
notify_all():唤醒所有线程
这个方法用于唤醒所有正在等待的线程。通常用于一个事件会影响多个线程的场景。
使用unique_lock互斥锁+条件变量的线程执行流程
在一个线程的执行流程中,关于逻辑执行、获取互斥锁、以及信号量或条件变量的等待与释放,其先后关系是至关重要的,它直接决定了程序的同步行为和正确性。
生产者-消费者模型示例
#include <iostream>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>std::queue<int> data_queue; // 共享数据队列
std::mutex mtx; // 保护队列的互斥量
std::condition_variable cv; // 条件变量,用于线程间通信// 生产者线程的执行函数
void producer() {for (int i = 0; i < 5; ++i) {std::cout << "生产者:正在生产数据 " << i << "..." << std::endl;// --- 1. 获取互斥锁 ---// 进入临界区,保护共享资源 data_queuestd::unique_lock<std::mutex> lock(mtx);// --- 2. 核心业务逻辑 ---// 将数据放入队列data_queue.push(i);std::cout << "生产者:数据 " << i << " 已放入队列。" << std::endl;// lock 在这里仍然有效,但生产者已经完成了它的工作,可以通知消费者了。// --- 3. 通知等待的线程 ---// 唤醒一个等待的消费者线程cv.notify_one();} // lock 在这里离开作用域,自动释放互斥锁
}// 消费者线程的执行函数
void consumer() {for (int i = 0; i < 5; ++i) {// --- 1. 获取互斥锁 ---// 必须使用 unique_lock 来配合条件变量std::unique_lock<std::mutex> lock(mtx);std::cout << "消费者:正在等待数据..." << std::endl;// --- 2. 等待条件满足并原子性地释放互斥锁 ---// wait() 检查 lambda 函数。如果队列为空,它会:// a. 原子性地释放 `lock` 持有的互斥量// b. 阻塞线程,进入休眠// c. 当被通知后,重新获取互斥量cv.wait(lock, []{ return !data_queue.empty(); });// --- 3. 被唤醒并重新获得锁后,执行核心业务逻辑 ---// 从队列中取出数据int data = data_queue.front();data_queue.pop();std::cout << "消费者:已消费数据 " << data << std::endl;} // lock 在这里离开作用域,自动释放互斥锁
}int main() {std::thread producer_thread(producer);std::thread consumer_thread(consumer);producer_thread.join();consumer_thread.join();std::cout << "所有任务完成。" << std::endl;return 0;
}
场景A:消费者先到 → 队列为空 → wait 休眠 → 生产者 push → notify_one → 消费者被唤醒
场景B:生产者先到 → 队列已有数据 → 消费者不阻塞(谓词立即为真)
执行流程详解
生产者线程(producer
)
-
逻辑执行:线程开始运行,执行
for
循环,准备生产数据。
-
获取互斥锁:通过
std::unique_lock<std::mutex> lock(mtx);
加锁。这保证了在生产者操作共享队列时,其他线程无法访问。
-
核心业务逻辑:将数据推入队列。
-
发出通知:调用
cv.notify_one();
。注意,生产者在发出通知时仍然持有锁。
-
释放互斥锁:当
lock
对象离开其作用域时,它的析构函数会自动解锁。这个操作发生在通知之后,因此可以保证消费者在被唤醒后可以立即尝试获取锁。
消费者线程(consumer
)
-
逻辑执行:线程开始运行,进入
for
循环。
-
获取互斥锁:通过
std::unique_lock<std::mutex> lock(mtx);
加锁,准备进入临界区。
-
检查条件并等待:调用
cv.wait(lock, ...)
。-
检查条件:
wait
首先检查队列是否为空。如果为空(return !data_queue.empty()
返回false
),它会进入等待。 -
原子操作:
wait
函数会原子性地执行两件事:释放mtx
,然后使线程休眠。这让生产者有机会获取锁、修改队列并发出通知。
-
-
被唤醒并重新获取互斥锁:当生产者调用
notify_one()
后,消费者线程被唤醒。wait
函数会尝试重新获取mtx
的所有权。一旦成功,wait
就会返回。
-
核心业务逻辑:在重新获得锁后,消费者安全地从队列中取出数据并进行处理。
-
释放互斥锁:当
lock
对象离开其作用域时,它会自动解锁mtx
,允许其他线程(如另一个消费者)访问队列。
通过这个例子,你可以清楚地看到互斥锁、条件变量和逻辑执行是如何协同工作的,确保线程在正确的时间加锁、等待和释放锁,从而实现安全的同步。