6.2、std::mutex/std::lock_guard(互斥锁)
初识std::mutex:多线程的“门锁”
如果,你家有一台打印机,家里几个人同时想用它打印东西。如果大家一起抢着用,打印内容可能会乱成一团。这时候,你需要一把锁,只有拿到锁的人才能用打印机,其他人得排队等着。这就是std::mutex
(互斥锁)的核心作用——在多线程程序中,确保共享资源(如内存中的数据)在同一时刻只被一个线程访问。
在C++11中,std::mutex
是一个标准库类,提供了lock()
和unlock()
方法,分别用来“上锁”和“开锁”。当一个线程调用lock()
获取锁成功后,其他线程再尝试lock()
就会被阻塞,直到锁被释放。这就像是只有一个人能拿到打印机的钥匙,其他人只能干等着。
std::lock_guard:自动化的“锁管理员”
不过,手动调用lock()
和unlock()
有个大坑:如果代码中途抛出异常,或者程序员一不小心忘了调用unlock()
,锁就永远不会释放,其他线程就得无限等待,程序直接“卡死”。为了解决这个问题,C++11引入了std::lock_guard
,这是一个基于RAII(资源获取即初始化)理念的辅助类。它的原理很简单:构造时自动上锁,析构时自动开锁。这样,不管代码是否异常退出,锁都会被妥善释放。
举个例子,下面这段代码用std::lock_guard
保护了一段输出操作:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
void print_message(const std::string& msg) {
std::lock_guard<std::mutex> lock(mtx); // 构造时自动上锁
std::cout << msg << std::endl;
} // 析构时自动开锁
int main() {
std::thread t1(print_message, "线程1:你好!");
std::thread t2(print_message, "线程2:你也好!");
t1.join();
t2.join();
return 0;
}
这段代码中,std::lock_guard
确保了std::cout
不会被多个线程同时使用,避免输出混乱。关键在于,lock
对象的作用域结束时(函数返回或异常抛出),它会自动调用mtx.unlock()
,完全不用你操心。
深度案例:线程安全的任务调度器
为了让你更深刻地理解std::mutex
和std::lock_guard
,我设计了一个线程安全的任务调度器案例。这个调度器允许多个线程添加任务和执行任务,同时保证任务列表不会被并发访问搞乱。代码如下:
#include <iostream>
#include <thread>
#include <mutex>
#include <queue>
#include <string>
#include <chrono>
class TaskScheduler {
public:
// 添加任务到队列
void addTask(const std::string& task) {
std::lock_guard<std::mutex> lock(mtx_); // 保护队列操作
tasks_.push(task);
std::cout << "添加任务:" << task << std::endl;
}
// 从队列中获取并执行任务
bool executeTask() {
std::string task;
{
std::lock_guard<std::mutex> lock(mtx_); // 保护队列操作
if (tasks_.empty()) {
return false; // 队列为空,返回false
}
task = tasks_.front();
tasks_.pop();
}
// 模拟任务执行
std::cout << "执行任务:" << task << " (线程ID: " << std::this_thread::get_id() << ")" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return true;
}
private:
std::mutex mtx_; // 互斥锁,保护任务队列
std::queue<std::string> tasks_; // 任务队列
};
void producer(TaskScheduler& scheduler) {
for (int i = 0; i < 5; ++i) {
scheduler.addTask("任务" + std::to_string(i));
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
}
void consumer(TaskScheduler& scheduler) {
for (int i = 0; i < 5; ++i) {
while (!scheduler.executeTask()) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
}
int main() {
TaskScheduler scheduler;
std::thread prod(producer, std::ref(scheduler));
std::thread cons1(consumer, std::ref(scheduler));
std::thread cons2(consumer, std::ref(scheduler));
prod.join();
cons1.join();
cons2.join();
return 0;
}
案例解析与底层细节
这个任务调度器模拟了一个生产者 - 消费者模型:一个线程负责添加任务(生产者),两个线程负责执行任务(消费者)。std::mutex
和std::lock_guard
在这里起到了关键作用。
- • 锁的保护范围:在
addTask
和executeTask
方法中,std::lock_guard
保护了对tasks_
队列的操作。每次只有一个线程能进入被锁保护的代码块,其他线程必须等待。这确保了队列的push
和pop
操作不会同时发生,避免数据竞争。 - • 锁的生命周期:注意
executeTask
方法中,获取任务和执行任务是分开的。锁只在访问队列时持有,执行任务时已经释放锁。这是一个重要的设计理念——锁的持有时间要尽量短,避免不必要的阻塞,提高并发效率。 - • 底层机制:
std::lock_guard
在构造时调用mtx_.lock()
,如果锁已被其他线程持有,当前线程会被操作系统挂起(进入等待队列),直到锁被释放。析构时,std::lock_guard
调用mtx_.unlock()
,通知操作系统唤醒等待队列中的线程。这背后依赖于操作系统的线程调度和同步原语(如Windows的Critical Section或POSIX的pthread_mutex)。
这个案例的独到之处在于,它不仅展示了std::mutex
和std::lock_guard
的基本用法,还强调了锁粒度的控制——一个在实际开发中容易被忽视但至关重要的点。过大的锁范围会降低程序性能,而过小的锁范围可能导致数据竞争,找到平衡是多线程编程的艺术。
常见错误使用:别踩这些坑
在实际开发中,std::mutex
和std::lock_guard
的使用看似简单,但隐藏了不少陷阱。以下是我总结的一些常见错误:
- • 忘记释放锁:如果你直接用
std::mutex
的lock()
和unlock()
,一旦忘记调用unlock()
(比如异常抛出),其他线程将永远无法获取锁。解决办法就是始终使用std::lock_guard
,让RAII帮你管理锁。 - • 重复获取锁:同一个线程试图多次获取同一个
std::mutex
会导致未定义行为(通常是死锁)。std::mutex
不支持递归锁,如果需要这种功能,可以用std::recursive_mutex
,但更推荐重新设计代码,避免递归锁需求。 - • 死锁风险:当多个线程以不同顺序获取多个锁时,可能出现死锁。例如,线程A持有锁1等待锁2,线程B持有锁2等待锁1,双方都无法前进。解决方法是统一锁的获取顺序,或者使用
std::lock()
函数一次性获取多个锁,避免中间状态。 - • 锁范围过大:锁保护的代码块如果包含耗时操作(如I/O或复杂计算),会严重影响并发性能。应该尽量缩小锁范围,只保护真正需要同步的部分。
面试热点:你可能会被问到什么
在C++面试中,std::mutex
和std::lock_guard
是多线程编程的常考点。以下是一些高频问题,供你准备:
- •
std::lock_guard
和std::unique_lock
的区别是什么?前者简单,只能构造时上锁、析构时开锁;后者更灵活,支持延迟锁、定时锁和手动解锁,适合复杂场景。 - • 如何避免死锁?统一锁获取顺序、使用
std::lock()
一次性获取多个锁、尽量减少锁嵌套。 - •
std::mutex
的底层实现原理是什么?依赖操作系统提供的同步原语,如Windows的Critical Section或Linux的futex,涉及线程挂起和唤醒机制。 - • 为什么不直接用
std::mutex
而要用std::lock_guard
?后者基于RAII,自动管理锁生命周期,避免手动管理导致的死锁风险。
总结
std::mutex
和std::lock_guard
是C++11为多线程编程引入的强大工具。std::mutex
提供了互斥访问的原始机制,而std::lock_guard
通过RAII理念简化了锁管理,避免了手动操作的常见错误。通过任务调度器的案例,我们不仅看到了它们的基本用法,还深入理解了锁粒度控制的重要性。避开常见误区,掌握面试要点,你就能在多线程编程中游刃有余。希望这篇文章能成为你学习C++11并发特性的起点,未来在实践中不断精进。
本文首发于【讳疾忌医-note】公众号,未经授权,不得转载。
(加入我的知识星球,免费获取账号,解锁所有文章。)