在编写C++多线程程序时,我们经常需要同时访问多个受互斥量保护的共享资源。如果各个线程以不同的顺序去锁定这些互斥量,就很可能发生死锁。标准库给出的解决方案是std::lock函数,它可以在不指定调用者顺序的情况下,一次性尝试锁定多个互斥量,并通过内部的锁顺序算法规避死锁风险。

为什么多个互斥量会导致死锁
假设有两个互斥量m1和m2,线程A先锁m1再尝试锁m2,而线程B先锁m2再尝试锁m1。如果调度时机恰好让A持有m1、B持有m2,双方都会等待对方释放另一个锁,程序就此卡死。这种因加锁顺序不一致造成的循环等待,是并发编程里最典型的死锁来源。
一种朴素的做法是规定所有线程都按固定顺序加锁,比如永远先锁m1后锁m2。但这种方式依赖团队纪律,且当互斥量数量变多、模块变复杂时,很容易出现遗漏。此外,若互斥量来自不同库,调用方很难统一顺序。std::lock正是为了从机制上解决这个问题而存在的。
std::lock的基本用法
std::lock定义在头文件<mutex>中,接受两个或多个互斥量引用,尝试同时锁定它们。调用成功时,所有互斥量都处于锁定状态;若其中某个锁定失败并抛异常,已经锁定的互斥量会被自动释放,保证不产生局部加锁残留。
通常我们会配合std::lock_guard和adopt_lock标志使用,把已锁定的互斥量交给guard管理,从而在作用域结束时安全释放。下面是一个典型的双资源转账示例:
#include <mutex>
#include <iostream>
struct Account {
std::mutex mtx;
int balance = 0;
};
void transfer(Account& a, Account& b, int amount) {
// 一次性锁定两个账户的互斥量,避免死锁
std::lock(a.mtx, b.mtx);
// adopt_lock表示互斥量已经锁定,guard仅负责释放
std::lock_guard<std::mutex> ga(a.mtx, std::adopt_lock);
std::lock_guard<std::mutex> gb(b.mtx, std::adopt_lock);
a.balance -= amount;
b.balance += amount;
std::cout << "transfer done" << std::endl;
}
在上面的代码中,即使transfer在多处被并发调用且账户对顺序不一,std::lock也能保证不会发生死锁。若不用std::lock而手动依次加锁,就必须由开发者保证全局顺序,否则隐患极大。
std::lock内部的锁顺序算法
标准并未规定std::lock的具体实现,但主流实现通常采用类似“死锁避免协议”的思路。一种常见策略是:对每个互斥量维护某种全局可比的标识(例如地址顺序),算法尝试按统一顺序去获取锁;当某个锁暂时不可得时,它不会一直阻塞,而是释放已持有的锁并稍后重试,从而打破循环等待。
更具体地,有些实现使用“尝试锁+回退”的方式:先对第一个互斥量加锁,然后以try_lock方式去拿后续互斥量;一旦try_lock失败,就解锁前面已持有的所有锁,并可能短暂让出CPU后再重来。通过这种“要么全拿,要么全放”的节奏,线程之间不会形成互相等待的闭环。下面的伪代码展示了该思想:
// 简化版伪代码,用于说明思路而非标准实现
template<typename M1, typename M2>
void lock_two(M1& m1, M2& m2) {
while (true) {
m1.lock();
if (m2.try_lock()) {
return; // 成功同时持有
}
m1.unlock(); // 拿不到第二个就释放第一个
// 可插入std::this_thread::yield()避免活锁
}
}
真实标准库实现会更复杂,需要处理两个以上互斥量、异常安全以及避免活锁等问题。但核心原则一致:不维持“部分锁定”的状态跨过等待期。只要算法保证任何时刻都不会有线程拿着部分锁去无限等待另一部分锁,死锁就不会发生。
与手动顺序加锁的对比
手动顺序加锁要求开发者按互斥量地址或编号排序后依次锁定,代码类似:
void transfer_manual(Account& a, Account& b, int amount) {
std::mutex* first = &a.mtx;
std::mutex* second = &b.mtx;
if (first > second) std::swap(first, second);
std::lock_guard<std::mutex> g1(*first);
std::lock_guard<std::mutex> g2(*second);
// 此时需注意a、b与first、second的对应关系
if (first == &a.mtx) {
a.balance -= amount;
b.balance += amount;
} else {
b.balance -= amount;
a.balance += amount;
}
}
这种做法虽然也能避免死锁,但容易因指针比较、交换逻辑而写错,尤其当资源超过两个时排序代码会变得繁琐。std::lock把顺序决策交给标准库,代码更短也更不易出错。不过要注意,std::lock本身不提供“已锁定”后的自动排序访问,业务逻辑里如果仍用错对象,数据竞争依然可能发生。
异常安全与注意事项
std::lock在锁定过程中若抛出异常,会确保已锁定的互斥量被释放,这是它相比裸调用mutex::lock的重要优势。配合adopt_lock的lock_guard,即使后续操作抛异常,析构函数也会释放全部锁,不会出现泄露。
使用时应注意:传入的互斥量类型必须都支持lock、unlock和try_lock成员函数;不要对同一互斥量重复传给std::lock,否则行为未定义;另外在持有多个锁的临界区内,操作应尽量短小,以减少并发度下降的影响。若业务允许,也可以考虑更细粒度的设计,比如用std::scoped_lock(C++17起)进一步简化写法,它内部正是基于std::lock实现。
小结
std::lock通过“一次性锁定多个互斥量并采用避免死锁的锁顺序算法”,让开发者不必手动规定加锁次序即可安全持有多把锁。结合lock_guard或scoped_lock,既能写出简洁的并发代码,又能获得异常安全保证。理解其回退与重试的底层思想,有助于在复杂多线程场景中规避死锁隐患。