C++ unique_lock

在实际开发中,有时我们需要在函数中间临时把锁解开去做其他耗时操作,等需要时再重新锁上,或者在创建锁管理对象时先不急着加锁。

遇到这类需要灵活控制持锁状态的场景,我们可以使用 <mutex> 头文件中的 std::unique_lock 类模板。它比持锁状态固定的 std::lock_guard 灵活得多,同时保留了离开作用域自动解锁的安全特性。

C++ unique_lock 基本用法

在默认情况下,std::unique_lock 同样用来管理 std::mutex 互斥锁:创建对象时自动加锁,离开作用域销毁时自动解锁。区别在于,unique_lock 额外提供了 unlock() 和 lock() 成员函数,方便我们在函数中间随时调整持锁状态。

语法:

#include <mutex>

std::unique_lock<std::mutex> lock(互斥锁名);
lock.unlock();    // 中途手动提前解锁
// 执行不需要加锁的操作
lock.lock();      // 需要时再次加锁

说明:

为什么中途手动调用过 unlock() 之后,函数结束时又不会发生重复解锁报错呢?这是因为 std::unique_lock 内部专门记录了当前的持锁状态,就像随身带着一张 “持锁状态卡”:

  • 离开作用域时仍持有锁:析构函数会自动调用 unlock() 把锁释放掉,保证不会漏写解锁代码。
  • 离开作用域前已手动解锁:如果我们中途调用了 lock.unlock() 且后面没有重新加锁,析构函数看到锁已经释放过了,就什么都不做,不会重复解锁。

示例 1:中途解锁与重新加锁

#include <iostream>
#include <mutex>
#include <thread>

int sharedScore = 60;
std::mutex mtx;

void updateScore(int bonus) {
    std::unique_lock<std::mutex> lock(mtx);    // 构造时自动加锁
    sharedScore += 10;
    std::cout << "第 1 阶段加分后:" << sharedScore << "\n";

    lock.unlock();                             // 临时解锁做本地计算
    int extra = bonus * 2;
    std::cout << "解锁期间算出额外加分:" << extra << "\n";

    lock.lock();                               // 重新加锁修改数据
    sharedScore += extra;
    std::cout << "第 2 阶段加分后:" << sharedScore << "\n";
}

int main() {
    std::thread worker(updateScore, 15);
    worker.join();
    return 0;
}

运行结果如下。

第 1 阶段加分后:70
解锁期间算出额外加分:30
第 2 阶段加分后:100

分析:

在 updateScore() 函数中,完成第 1 阶段加分后,接下来的 int extra = bonus * 2; 只涉及函数内部的局部变量,不需要占用互斥锁。因此我们先调用 lock.unlock() 把锁临时让出来,方便其他排队的线程趁这段时间拿到 mtx 处理工作。

等本地算好 extra 之后,我们再调用 lock.lock() 重新拿回互斥锁,安全地完成第 2 阶段修改。最后函数运行结束离开作用域时,不需要手写 unlock(),lock 的析构函数会自动完成兜底解锁。

需要注意的是,析构函数虽然不会重复解锁,但我们手动调用成员函数时一定要看清当前状态。如果已经持有锁却再次调用 lock.lock(),或者已经解开锁却再次调用 lock.unlock(),程序都会在运行时直接抛出异常。

延迟加锁与尝试加锁

除了在构造时立刻阻塞加锁,std::unique_lock 还支持在构造函数的第 2 个参数位置传入策略标签,用来改变创建对象时的加锁行为。常见的 3 种构造方式如下表所示。

常见构造方式
第 2 个参数 构造时的行为 构造后是否持锁
不传(默认) 调用 lock() 阻塞等待并加锁 是(true)
std::defer_lock 仅绑定互斥锁,构造时先不加锁 否(false)
std::try_to_lock 调用 try_lock() 非阻塞尝试加锁 抢到为 true,未抢到为 false

1. 使用 std::defer_lock 延迟加锁

有时我们需要先把锁管理对象定义好,等完成一些不需要加锁的前置准备工作后,再正式加锁进入临界区。这时可以在构造 std::unique_lock 时传入 std::defer_lock,其中 defer 是 “推迟、延迟” 的意思。

语法:

std::unique_lock<std::mutex> lock(互斥锁名, std::defer_lock);
// 执行不需要加锁的前置准备
lock.lock();    // 进入临界区前手动加锁

说明:

传入 std::defer_lock 后,unique_lock 只是先和这把互斥锁绑定了管理关系,构造时并不会加锁。我们可以通过 owns_lock() 成员函数随时检查当前对象手里是否拿着锁:持有锁返回 true;未持有锁返回 false。

示例 2:先做前置计算再加锁

#include <iostream>
#include <mutex>
#include <thread>

int total = 100;
std::mutex mtx;

void addBonus(int base) {
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
    std::cout << "初始是否持锁:" << lock.owns_lock() << "\n";

    int bonus = base * 2;    // 无锁状态下完成前置计算

    lock.lock();             // 修改共享变量前手动加锁
    std::cout << "加锁后是否持锁:" << lock.owns_lock() << "\n";
    total += bonus;
}

int main() {
    std::thread worker(addBonus, 25);
    worker.join();

    std::cout << "最终 total:" << total << "\n";
    return 0;
}

运行结果如下。

初始是否持锁:0
加锁后是否持锁:1
最终 total:150

分析:

从运行结果可以看出,刚创建 lock 对象时,lock.owns_lock() 输出 0(即 false)。这说明传入 std::defer_lock 后构造当下并没有占住锁,后面的 int bonus = base * 2; 是在无锁状态下完成的。

等到真正需要修改全局变量 total 时,我们调用 lock.lock() 手动加锁,lock.owns_lock() 随之变成 1(即 true)。小伙伴们要注意,std::defer_lock 并不会自动挑时间替我们加锁,读写共享数据前一定要记得手动调用 lock.lock()。

2. 使用 std::try_to_lock 尝试加锁

前面学习互斥锁时,我们接触过非阻塞的 try_lock() 函数,但当时一旦抢到了锁,还是得自己记着去写 unlock()。改用 std::unique_lock 后,只需要在构造时传入 std::try_to_lock,它就会以非阻塞方式尝试加锁,而且抢到锁后离开作用域同样会自动释放。

语法:

std::unique_lock<std::mutex> lock(互斥锁名, std::try_to_lock);
if (lock.owns_lock()) {
    // 抢到锁,操作共享数据
} else {
    // 未抢到锁,做其他事
}

说明:

使用 std::try_to_lock 构造对象后,当前线程不会在原地阻塞排队,但也不一定抢到了锁。因此接下来我们必须先通过 if (lock.owns_lock()) 或简写形式 if (lock) 判断是否成功持锁,确认拿到锁之后才能操作共享数据。

示例 3:非阻塞尝试抢锁

#include <iostream>
#include <mutex>
#include <thread>

std::mutex mtx;

void tryWork(int id) {
    std::unique_lock<std::mutex> lock(mtx, std::try_to_lock);
    if (lock.owns_lock()) {
        std::cout << "线程 " << id << ":抢到锁,执行任务\n";
    } else {
        std::cout << "线程 " << id << ":未抢到锁,直接跳过\n";
    }
}

int main() {
    {
        std::lock_guard<std::mutex> mainLock(mtx);    // 主线程先占住锁
        std::thread t1(tryWork, 1);
        t1.join();
    }

    std::thread t2(tryWork, 2);                       // 锁已空闲,再次尝试
    t2.join();
    return 0;
}

运行结果如下。

线程 1:未抢到锁,直接跳过
线程 2:抢到锁,执行任务

分析:

std::unique_lock<std::mutex> lock(mtx, std::try_to_lock); 表示在创建 lock 时以非阻塞方式尝试加锁。当线程 1 运行时,互斥锁正被主线程的 mainLock 占着,lock.owns_lock() 返回 false,因此直接进入 else 分支。

等主线程走出局部花括号释放锁后,线程 2 再次尝试就会顺利拿到锁,并在函数结束时自动解锁。小伙伴们传入 std::try_to_lock 后,千万不要漏掉 owns_lock() 判断就直接修改共享变量,下面这种写法就是错误的。

std::unique_lock<std::mutex> lock(mtx, std::try_to_lock);
sharedScore += 10;    // 未检查是否抢到锁

在上面代码中,一旦当前线程没抢到锁,下一行依然会强行修改 sharedScore,照样会引发数据竞争。

提示 和智能指针 std::unique_ptr 类似,std::unique_lock 也是独占所有权的,不支持直接复制。如果需要从函数内部返回一把已加锁的对象,或者把锁的管理权转交给另一个 unique_lock,我们可以通过 std::move() 来转移所有权。

lock_guard 与 unique_lock 如何选择?

学完这两个互斥锁管理工具后,在实际开发中,小伙伴们可以根据下面这 2 条标准来选择:

  • 常规加锁场景优先使用 std::lock_guard:如果我们只是想把一段代码锁起来,进入作用域加锁、离开作用域解锁,优先选择 std::lock_guard。它没有任何额外的状态开销,而且别人一读代码就能确定这把锁在作用域中途不会被解开。
  • 需要灵活控制或配合条件变量时使用 std::unique_lock:如果我们需要在函数中间调用 unlock() 临时放锁、使用 std::defer_lock 延迟加锁、使用 std::try_to_lock 尝试加锁,或者配合下一节的条件变量等待通知,就应该选择 std::unique_lock。

上一篇:

下一篇:

给站长反馈

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

邮箱:lvyenet@vip.qq.com

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