手动管理 std::mutex 互斥锁时,每次调用 lock() 后都必须配对写上 unlock(),一旦函数分支较多就很容易漏写。为了解决这个问题,C++ 在 <mutex> 头文件中提供了 std::lock_guard 类模板,帮我们自动完成加锁与解锁。
它的工作方式遵循前面学过的 RAII 机制。创建 std::lock_guard 对象时,构造函数会自动对互斥锁调用 lock() 加锁。当对象离开花括号 {} 作用域销毁时,析构函数又会自动调用 unlock() 解锁,用起来省心又安全。
C++ lock_guard 基本用法
在 C++ 中,只要是 “进入作用域加锁、离开作用域解锁” 的常规场景,我们都优先使用 std::lock_guard,不再手动配对编写 lock() 和 unlock()。
语法:
#include <mutex>
std::lock_guard<std::mutex> 锁管理对象名(互斥锁名);说明:
std::lock_guard 是一个类模板,尖括号 <> 里填入 std::mutex 等互斥锁类型,小括号 () 里传入需要管理的互斥锁对象。从定义这一行代码往下,一直到它所在的花括号 {} 结束,都属于受保护的临界区。
示例 1:保护共享计数器
#include <iostream>
#include <mutex>
#include <thread>
int counter = 0;
std::mutex mtx;
void addCount() {
for (int i = 0; i < 100000; i++) {
std::lock_guard<std::mutex> lock(mtx); // 构造加锁,出括号解锁
counter++;
}
}
int main() {
std::thread t1(addCount);
std::thread t2(addCount);
t1.join();
t2.join();
std::cout << "最终计数:" << counter << "\n";
return 0;
}运行结果如下。
最终计数:200000分析:
std::lock_guard<std::mutex> lock(mtx); 定义在 for 循环的花括号内部。因为循环体的花括号本身就是一个独立作用域,每一轮循环开始时都会创建 lock 并锁住 mtx,本轮结束走出右花括号时 lock 就会销毁并自动解锁。
这里小伙伴们可以留意一下定义 lock 的位置。把它放在循环内部时,每累加 1 次就释放一次锁,两个线程有机会交替执行。如果把它提到循环外面的函数开头,先抢到锁的线程就会一口气跑完 100000 次循环才放锁,另一个线程只能全程干等。
此外,从 C++17 开始,编译器可以根据传入的 mtx 自动推导出模板参数类型。因此对于这个例子来说,下面 2 种方式是等价的。
// 方式 1
std::lock_guard<std::mutex> lock(mtx);
// 方式 2
std::lock_guard lock(mtx);提示 C++17 还在 <mutex> 头文件中新增了 std::scoped_lock。它在管理单把锁时可以直接写成 std::scoped_lock lock(mtx);,同时还支持一次性锁住多把互斥锁避免死锁,这里简单了解一下即可。
为什么推荐使用 lock_guard?
如果一个函数内部包含多个条件分支,手动调用 unlock() 很容易顾此失彼。一旦改用 std::lock_guard,不管函数是正常执行到末尾,还是中途遇到 return 或抛出异常提前退出,局部对象 lock 都会在离开作用域时自动释放锁。下面我们来看一个例子。
示例 2:提前 return 时自动解锁
#include <iostream>
#include <mutex>
#include <thread>
int balance = 100;
std::mutex mtx;
void withdraw(int amount) {
std::lock_guard<std::mutex> lock(mtx);
if (amount > balance) {
std::cout << "取款 " << amount << " 失败:余额不足\n";
return; // 提前返回时自动解锁
}
balance -= amount;
std::cout << "取款成功,剩余:" << balance << "\n";
}
int main() {
std::thread t1(withdraw, 150); // 触发提前 return
t1.join();
std::thread t2(withdraw, 60); // 锁已释放,正常取款
t2.join();
return 0;
}运行结果如下。
取款 150 失败:余额不足
取款成功,剩余:40分析:
在这个例子中,线程 t1 尝试取款 150,满足 amount > balance 条件后直接执行 return; 提前退出了函数。
由于函数开头定义了 std::lock_guard<std::mutex> lock(mtx);,离开 withdraw() 作用域时,lock 会自动析构并解开 mtx。因此后面的线程 t2 依然能够顺利拿到锁并完成取款。对比下面这两种写法,小伙伴们就能一眼看出自动解锁的优势了。
// 正确方式
void withdrawSafe(int amount) {
std::lock_guard<std::mutex> lock(mtx);
if (amount > balance) {
return;
}
balance -= amount;
}
// 错误方式
void withdrawBad(int amount) {
mtx.lock();
if (amount > balance) {
return;
}
balance -= amount;
mtx.unlock();
}在上面的 withdrawBad() 中,一旦满足条件提前 return,程序就会直接跳过末尾的 mtx.unlock(),导致后续所有等待这把锁的线程一直卡死。
使用局部花括号缩小持锁范围
为了让常规加锁尽量简单可靠,std::lock_guard 被设计得非常精简,内部没有提供 unlock() 成员函数。也就是说,只要 lock_guard 对象还存活着,我们就无法手动提前把锁解开。
那如果一个函数里只有前面两行代码需要修改共享数据,后面还有一段耗时的本地操作,不想一直占着锁应该怎么办呢?做法其实很简单:我们直接使用一对局部花括号 {} 把需要加锁的那几行代码单独框起来就可以了。
示例 3:局部花括号缩小锁范围
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
int count = 0;
std::mutex mtx;
void processTask(int id, int workMs) {
{
std::lock_guard<std::mutex> lock(mtx);
count++;
std::cout << "线程 " << id << ":已登记,总数 = " << count << "\n";
} // 走出花括号自动解锁
// 模拟无需加锁的本地耗时操作
std::this_thread::sleep_for(std::chrono::milliseconds(workMs));
std::cout << "线程 " << id << ":本地操作完成\n";
}
int main() {
std::thread t1(processTask, 1, 100);
std::this_thread::sleep_for(std::chrono::milliseconds(20));
std::thread t2(processTask, 2, 10);
t1.join();
t2.join();
return 0;
}运行结果如下。
线程 1:已登记,总数 = 1
线程 2:已登记,总数 = 2
线程 2:本地操作完成
线程 1:本地操作完成分析:
在 processTask() 函数内部,我们专门写了一对局部花括号 {},并通过前面学过的 sleep_for() 模拟一段无需加锁的本地耗时操作。
从运行结果可以看出,线程 1 登记完走出右花括号 } 的那一刻,lock 就立刻销毁并释放了互斥锁。哪怕线程 1 后面的本地操作还要耗时 100 毫秒,线程 2 也不用干等,马上就能拿到锁完成登记并率先结束。小伙伴们可以试着把那对局部花括号去掉,看看输出顺序会发生什么变化。
注意 std::lock_guard 不支持中途手动调用 unlock() 解锁后再重新加锁,也不支持复制或移动。如果在复杂业务中确实需要 “创建时先不加锁”、“中途临时解锁再加锁” 或配合条件变量等待通知,我们可以使用更灵活的 std::unique_lock。
