多线程程序里如果直接对std::mutex调用lock和unlock,一旦函数有多个return路径或者中间抛出异常,漏掉unlock就会让后续线程全部卡死。C++11提供的RAII锁std::lock_guard和std::unique_lock正是为解决这类问题而设计。本文先说明一个简单互斥锁封装应该暴露哪些接口,再分别剖析lock_guard和unique_lock的行为与适用场景,最后给出选型建议和容易踩到的细节。

一、裸mutex的问题与简单封装思路
直接使用std::mutex时,开发者必须保证每一条执行路径都能正确解锁。比如一个写配置的函数,在检查参数失败时会提前返回,如果返回前忘记调用unlock,锁就永远不会释放。更隐蔽的是,当临界区内抛出异常时,栈展开会跳过解锁语句,同样造成死锁。这类问题在代码评审中很难完全发现,因为错误路径往往不是高频执行路径。
一个自然的改进是让锁的释放与对象生命周期绑定,也就是采用RAII封装。下面是一个简单的互斥锁封装,它把原生std::mutex包在内部,并提供一个锁对象。锁对象构造时自动上锁,析构时自动解锁,同时禁用拷贝。
#include <mutex>
class SimpleMutex {
public:
void lock() { mtx_.lock(); }
void unlock() { mtx_.unlock(); }
bool try_lock() { return mtx_.try_lock(); }
private:
std::mutex mtx_;
};
class SimpleLock {
public:
explicit SimpleLock(SimpleMutex& m)
: pm_(&m), owns_(true) {
pm_->lock();
}
~SimpleLock() {
if (owns_) {
pm_->unlock();
}
}
SimpleLock(const SimpleLock&) = delete;
SimpleLock& operator=(const SimpleLock&) = delete;
SimpleLock(SimpleLock&& other) noexcept
: pm_(other.pm_), owns_(other.owns_) {
other.pm_ = nullptr;
other.owns_ = false;
}
SimpleLock& operator=(SimpleLock&& other) noexcept {
if (this != &other) {
if (owns_) {
pm_->unlock();
}
pm_ = other.pm_;
owns_ = other.owns_;
other.pm_ = nullptr;
other.owns_ = false;
}
return *this;
}
private:
SimpleMutex* pm_;
bool owns_;
};
这个封装的基本思路是:SimpleLock的构造函数锁定互斥量,析构函数在仍然持有锁时解锁。拷贝构造和拷贝赋值被删除,因为锁的拷贝会破坏独占语义;移动构造则转移指针和持有状态,源对象不再持有锁,避免重复解锁。虽然这段代码可以工作,但C++11标准库已经把这些逻辑封装得更加完善,std::lock_guard就是最直接的替代。
需要说明的是,简单封装中的移动语义只是为了模拟unique_lock的一部分行为。实际项目中如果只需要普通临界区,不必自己实现移动,使用std::lock_guard即可;如果需要转移锁或与条件变量配合,应直接使用std::unique_lock。
二、lock_guard的定位与代码实践
std::lock_guard是标准库提供的轻量级RAII锁模板。它在构造函数中调用互斥量的lock,在析构函数中调用unlock。因为没有unlock成员函数,所以不能提前释放,也不能延迟锁定。它也没有lock和try_lock方法,使用方式非常单一:构造即锁定,析构即解锁。
下面是一个使用std::lock_guard保护账户余额的例子。无论函数正常返回还是抛出异常,锁都会在离开作用域时释放。
#include <mutex>
class Account {
public:
void deposit(int amount) {
std::lock_guard<std::mutex> guard(mtx_);
balance_ += amount;
}
int balance() const {
std::lock_guard<std::mutex> guard(mtx_);
return balance_;
}
private:
mutable std::mutex mtx_;
int balance_ = 0;
};
这里把mtx_声明为mutable,是因为balance被标记为const,但内部又需要修改互斥锁状态。C++中mutable允许在const成员函数中修改成员。如果去掉mutable,编译会报错。
std::lock_guard很适合临界区短、逻辑简单、进入后没有必要中途解锁的场景。由于它没有额外的状态检查,开销通常非常小,编译器也可能做更好的优化。但它的局限也很明显:如果临界区内包含耗时I/O,而其他线程只需要短暂访问另一段数据,lock_guard无法在I/O前提前解锁。此时继续使用lock_guard会放大锁的争用时间。
另一个典型限制是它不能直接用于std::condition_variable。条件变量的wait需要临时解锁并让出线程,等待条件满足后再重新上锁,这个过程只能由std::unique_lock完成。
三、unique_lock的能力与条件变量配合
std::unique_lock同样是RAII锁,但提供了更多控制接口。它支持lock、unlock、try_lock,还允许使用std::defer_lock表示构造时不锁定,使用std::try_to_lock表示构造时尝试锁定但不阻塞,使用std::adopt_lock表示接管已经锁定的互斥量。这些标签使得unique_lock能适应更多同步需求。
例如某些场景需要先尝试获取锁,拿不到就执行其他任务,可以使用try_to_lock:
#include <mutex>
#include <iostream>
void try_process(std::mutex& mtx) {
std::unique_lock<std::mutex> locker(mtx, std::try_to_lock);
if (locker.owns_lock()) {
std::cout << "got lock\n";
} else {
std::cout << "lock busy\n";
}
}
这段代码中owns_lock返回当前是否持有锁。如果使用std::lock_guard就没有这种非阻塞尝试能力。需要注意的是,std::unique_lock的析构函数内部也会检查是否持有锁,只有持有才会调用unlock,因此手动unlock后退出作用域是安全的。
unique_lock最常见的用途是与条件变量搭配实现生产者消费者模型。条件变量在等待时会先解锁互斥量,让其他线程进入临界区;被通知唤醒后会重新锁定。这个解锁再锁定动作必须依赖可解锁的unique_lock。
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> tasks;
void produce(int value) {
{
std::unique_lock<std::mutex> lock(mtx);
tasks.push(value);
}
cv.notify_one();
}
void consume() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return !tasks.empty(); });
int value = tasks.front();
tasks.pop();
lock.unlock();
// 后续处理 value,此时锁已经释放
}
在cv.wait中传入一个谓词可以防止虚假唤醒。线程只有在谓词为真时才会继续运行,否则继续等待。如果这里把unique_lock换成lock_guard,代码无法通过编译,因为wait需要临时解锁。
除了条件变量,unique_lock还支持移动语义。一个函数可以返回std::unique_lock对象,将锁的所有权转移给调用方。这在需要延迟提交锁或实现更高层同步原语时非常有用。lock_guard不支持移动,只能在一个局部作用域使用。
四、两种锁的选型与常见误区
在实际开发中,选型可以从几个维度判断。如果临界区逻辑简单,进入后不需要解锁,也不与条件变量交互,优先使用std::lock_guard。如果需要在等待期间释放锁、尝试锁定、转移锁所有权,或者与条件变量配合,就必须使用std::unique_lock。下表列出了两者的核心差异。
| 能力 | lock_guard | unique_lock |
|---|---|---|
| 构造时锁定 | 支持 | 支持 |
| 延迟锁定 | 不支持 | 支持 |
| 提前解锁 | 不支持 | 支持 |
| 尝试锁定 | 不支持 | 支持 |
| 条件变量 | 不适用 | 适用 |
| 复制 | 禁止 | 禁止 |
| 移动 | 禁止 | 支持 |
| 性能开销 | 极小 | 略高 |
unique_lock比lock_guard多维护一个持有状态标志,构造和析构通常多一次判断,性能差异在纳秒级别。只有在锁竞争极其频繁的热路径上才需要特别关注,大部分业务代码不必为了极小性能牺牲可维护性。
常见误区之一是误以为lock_guard有unlock方法。它没有,所以在确定性释放锁的场景只能改用unique_lock。另一个误区是在unique_lock提前unlock后继续修改受保护的数据。这会把并发访问重新暴露在未加锁状态下,正确做法是先拷贝或完成数据访问,再解锁。
还有一点容易忽略:保护const成员函数内部状态时,互斥量必须声明为mutable。不加mutable会导致在const函数里无法锁定互斥量。对于线程安全的只读接口,这个细节非常关键。最后,锁对象本身不要放在全局或静态区间中跨过长生命周期使用,作用域越小越不容易出现锁顺序问题。
回到封装角度,简单互斥锁封装的价值在于隐藏原生std::mutex的手动调用,但标准库的lock_guard和unique_lock已经覆盖了绝大多数需求。理解它们的差异后,再根据临界区是否需要中途解锁、是否配合条件变量,就能快速写出正确且易读的加锁代码。
C++互斥锁lock_guardunique_lock修改时间:2026-09-27 14:05:14