C++ lock_guard

手动管理 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。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号