好的,没问题。
好的,让我们开始今天的讲座,主题是“C++ std::lock_guard
与 std::unique_lock
:RAII 风格的锁管理”。 这两个家伙是C++并发编程中管理互斥锁的利器,可以让你更安全、更方便地使用锁,避免手动加锁解锁带来的各种坑。
前言:锁,并发编程中的守护神
在并发编程的世界里,多线程就像一群熊孩子在抢玩具,如果没有人管着,那场面简直是一团糟。 锁就像一个严厉的家长,它确保每次只有一个熊孩子能拿到玩具(访问共享资源),其他熊孩子必须乖乖排队等着。 锁的存在是为了防止多个线程同时修改同一份数据,导致数据损坏或者程序行为异常。
RAII:资源获取即初始化
在深入std::lock_guard
和std::unique_lock
之前,我们需要了解一个重要的概念:RAII(Resource Acquisition Is Initialization,资源获取即初始化)。 RAII 是一种编程技术,它将资源的获取和释放与对象的生命周期绑定在一起。 简单来说,就是让对象在构造的时候获取资源,在析构的时候释放资源。 这样,即使程序因为异常提前退出,资源也能得到正确释放,避免资源泄漏。
在锁管理方面,RAII 意味着在创建锁对象的时候获取锁,在锁对象销毁的时候释放锁。 这样,无论程序如何执行,只要锁对象离开了作用域,锁就会自动释放,保证了程序的正确性。 std::lock_guard
和std::unique_lock
就是 RAII 风格的锁管理工具。
std::lock_guard
:简单粗暴的锁卫士
std::lock_guard
是一个非常简单的锁管理器。 它的主要功能就是在构造的时候获取锁,在析构的时候释放锁。 std::lock_guard
不提供任何额外的操作,比如手动解锁、尝试加锁等。 它就是一个简单粗暴的锁卫士,保证在作用域内始终持有锁。
代码示例:std::lock_guard
的基本用法
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // 声明一个互斥锁
int shared_data = 0;
void increment_data() {
std::lock_guard<std::mutex> lock(mtx); // 创建 lock_guard 对象,并获取锁
shared_data++;
std::cout << "Thread ID: " << std::this_thread::get_id() << ", shared_data: " << shared_data << std::endl;
} // lock_guard 对象销毁,自动释放锁
int main() {
std::thread t1(increment_data);
std::thread t2(increment_data);
t1.join();
t2.join();
std::cout << "Final shared_data: " << shared_data << std::endl;
return 0;
}
在这个例子中,我们创建了一个 std::lock_guard
对象 lock
,并将互斥锁 mtx
传递给它。 在 lock
对象构造的时候,它会自动调用 mtx.lock()
获取锁。 当 increment_data
函数执行完毕,lock
对象销毁的时候,它会自动调用 mtx.unlock()
释放锁。 这样,就保证了在 increment_data
函数执行期间,只有一个线程能够访问 shared_data
变量。
std::lock_guard
的特点:
- 简单易用: 构造即加锁,析构即解锁,无需手动管理锁的生命周期。
- 安全性高: 基于 RAII 机制,即使发生异常也能保证锁的释放。
- 功能有限: 不支持手动解锁、尝试加锁等操作。
std::lock_guard
适用场景:
- 需要独占访问共享资源,且不需要手动解锁的场景。
- 对锁的管理要求简单,不需要灵活控制锁的生命周期的场景。
std::unique_lock
:灵活多变的锁管家
std::unique_lock
是一个比 std::lock_guard
更加灵活的锁管理器。 它也基于 RAII 机制,但在构造的时候可以选择不获取锁,并且提供了手动解锁、尝试加锁、延迟加锁等功能。 std::unique_lock
就像一个灵活多变的锁管家,可以根据不同的需求来管理锁的生命周期。
代码示例:std::unique_lock
的基本用法
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // 声明一个互斥锁
int shared_data = 0;
void increment_data() {
std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 创建 unique_lock 对象,但不获取锁
//... 做一些准备工作 ...
lock.lock(); // 手动获取锁
shared_data++;
std::cout << "Thread ID: " << std::this_thread::get_id() << ", shared_data: " << shared_data << std::endl;
lock.unlock(); //手动释放锁
//... 做一些其他工作 ...
lock.lock(); // 再次获取锁
shared_data++;
std::cout << "Thread ID: " << std::this_thread::get_id() << ", shared_data: " << shared_data << std::endl;
} // lock_guard 对象销毁,自动释放锁
int main() {
std::thread t1(increment_data);
std::thread t2(increment_data);
t1.join();
t2.join();
std::cout << "Final shared_data: " << shared_data << std::endl;
return 0;
}
在这个例子中,我们创建了一个 std::unique_lock
对象 lock
,并将互斥锁 mtx
和 std::defer_lock
传递给它。 std::defer_lock
表示在构造 lock
对象的时候不获取锁。 然后,我们可以手动调用 lock.lock()
获取锁,调用 lock.unlock()
释放锁。 这样,就可以灵活地控制锁的生命周期。
std::unique_lock
的特点:
- 灵活性高: 支持手动解锁、尝试加锁、延迟加锁等操作。
- 功能强大: 可以与条件变量一起使用,实现更复杂的线程同步。
- 安全性高: 基于 RAII 机制,即使发生异常也能保证锁的释放。
- 开销稍大: 相比
std::lock_guard
,std::unique_lock
的开销稍微大一些。
std::unique_lock
的构造函数:
std::unique_lock
提供了多个构造函数,可以根据不同的需求来创建 unique_lock
对象。
构造函数 | 描述 |
---|---|
unique_lock(mutex_type& m) |
构造一个 unique_lock 对象,并获取互斥锁 m 。 |
unique_lock(mutex_type& m, std::defer_lock) |
构造一个 unique_lock 对象,但不获取互斥锁 m 。 需要手动调用 lock() 方法来获取锁。 |
unique_lock(mutex_type& m, std::try_to_lock) |
构造一个 unique_lock 对象,并尝试获取互斥锁 m 。 如果获取锁失败,则 owns_lock() 方法返回 false 。 |
unique_lock(mutex_type& m, std::adopt_lock) |
构造一个 unique_lock 对象,并假定调用线程已经拥有互斥锁 m 的所有权。 unique_lock 对象销毁时,会自动释放互斥锁 m 。 这个构造函数主要用于在已经持有锁的情况下,将锁的管理权交给 unique_lock 对象。 使用这个构造函数必须非常小心,确保调用线程确实拥有锁的所有权,否则可能会导致未定义行为。 |
std::unique_lock
的常用方法:
方法 | 描述 |
---|---|
lock() |
获取锁。 如果锁已经被其他线程持有,则当前线程会阻塞,直到获取锁为止。 |
unlock() |
释放锁。 |
try_lock() |
尝试获取锁。 如果获取锁成功,则返回 true ;否则返回 false 。 不会阻塞当前线程。 |
owns_lock() |
检查 unique_lock 对象是否拥有锁。 如果拥有锁,则返回 true ;否则返回 false 。 |
release() |
释放 unique_lock 对象对锁的所有权。 返回指向互斥锁的指针。 调用 release() 方法后,unique_lock 对象不再管理锁的生命周期,需要在其他地方手动释放锁。 这个方法主要用于将锁的管理权交给其他对象或函数。 |
std::unique_lock
适用场景:
- 需要灵活控制锁的生命周期的场景。
- 需要手动解锁、尝试加锁等操作的场景。
- 需要与条件变量一起使用的场景。
std::unique_lock
与条件变量:线程同步的黄金搭档
std::unique_lock
经常与条件变量(std::condition_variable
)一起使用,实现更复杂的线程同步。 条件变量允许线程在满足特定条件时才继续执行,否则就进入等待状态。 当其他线程修改了共享数据,使得条件满足时,可以通知等待的线程继续执行。
代码示例:std::unique_lock
与条件变量的使用
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;
bool data_ready = false;
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);
data_queue.push(i);
data_ready = true;
std::cout << "Producer: Produced data " << i << std::endl;
cv.notify_one(); // 通知等待的消费者线程
}
}
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return data_ready; }); // 等待数据准备好
while (!data_queue.empty()) {
int data = data_queue.front();
data_queue.pop();
std::cout << "Consumer: Consumed data " << data << std::endl;
}
data_ready = false; // 重置 data_ready 标志
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
在这个例子中,producer
线程生产数据,并将数据放入 data_queue
中。 然后,它设置 data_ready
标志为 true
,并调用 cv.notify_one()
通知等待的 consumer
线程。 consumer
线程等待 data_ready
标志为 true
,然后从 data_queue
中取出数据进行消费。 这里,std::unique_lock
提供了 cv.wait()
方法需要的锁对象,保证了在等待期间锁的正确释放和获取。
std::lock_guard
vs std::unique_lock
:如何选择?
特性 | std::lock_guard |
std::unique_lock |
---|---|---|
灵活性 | 低 | 高 |
功能 | 简单加锁/解锁 | 手动加锁/解锁,尝试加锁,延迟加锁,与条件变量一起使用 |
开销 | 低 | 高 |
适用场景 | 简单互斥,不需要手动控制锁的生命周期 | 需要灵活控制锁的生命周期,需要与条件变量一起使用 |
使用方法 | 构造即加锁,析构即解锁 | 构造时可以选择是否加锁,需要手动调用 lock() 和 unlock() 方法 |
代码复杂度 | 低 | 高 |
总的来说,如果你的需求很简单,只需要在作用域内独占访问共享资源,那么 std::lock_guard
是一个不错的选择。 如果你需要更灵活地控制锁的生命周期,或者需要与条件变量一起使用,那么 std::unique_lock
更加适合你。
总结:RAII 锁管理的魅力
std::lock_guard
和 std::unique_lock
都采用了 RAII 机制,将锁的获取和释放与对象的生命周期绑定在一起,可以有效地避免手动加锁解锁带来的各种问题,提高程序的安全性和可靠性。 在并发编程中,选择合适的锁管理工具非常重要。 std::lock_guard
和 std::unique_lock
为我们提供了方便、安全、灵活的锁管理方案,让我们可以更加专注于业务逻辑的实现。
好了,今天的讲座就到这里。 希望大家能够掌握 std::lock_guard
和 std::unique_lock
的用法,并在实际的并发编程中灵活运用。 记住,锁是并发编程中的守护神,合理使用锁可以保护你的程序免受数据竞争的困扰。 感谢大家的聆听!