跳转到内容
新建笔记

C++ 互斥量:RAII、尝试加锁与多锁更新

互斥量让遵守同一把锁协议的线程轮流进入临界区,从而保护共享状态。它锁住的是互斥量对象,不是自动追踪某个变量;用两把互不相关的锁分别写同一个普通变量,仍可能发生数据竞争。

本页采用C++17,先掌握 std::mutex和RAII锁,再处理多把锁、尝试加锁和超时。

1. 基本操作与正确名称

跳转到“1. 基本操作与正确名称”
接口/类型含义
mutex.lock()取得所有权,必要时阻塞。当前线程不得已经拥有这把非递归互斥量。
mutex.try_lock()尝试加锁,成功为true,失败为false;不会等待锁变为空闲。
mutex.unlock()由当前拥有者释放。
std::mutex基本非递归互斥量。
std::recursive_mutex同一线程可递归取得,每次成功加锁都需对应解锁。
std::timed_mutex增加 try_lock_for、try_lock_until。
std::recursive_timed_mutex递归与超时两种能力。
std::shared_mutexC++17读写锁,支持独占所有权或多个共享读者。

正确名称是 timed_mutex、recursive_mutex和 try_lock,不是 time_mutex、recursive_lock或 trylock。

尝试加锁允许虚假失败,因此返回false不证明另一个线程当前一定持锁。对普通 mutex在同一线程再次加锁违反前提,不能仅理解为“保证等待到解锁”。不同平台可能表现不同,程序不能依赖这种调用。C++工作草案:互斥量要求

锁管理器适用情况
std::lock_guard进入作用域就加锁,离开时解锁;逻辑简单。
std::unique_lock可暂不加锁、尝试加锁、移动所有权、显式解锁再上锁;条件变量等待常用。
std::scoped_lockC++17,可一次管理多把锁,并使用避免死锁的加锁算法。
std::shared_lock管理读写锁的共享读者所有权。

这些管理器不会延长互斥量或受保护数据的寿命,相关对象必须活到所有使用结束。

#include <cassert>
#include <mutex>
#include <thread>
int main()
{
std::mutex mutex;
int count = 0;
auto add = [&] {
for (int i = 0; i != 10000; ++i) {
std::lock_guard<std::mutex> lock(mutex);
++count;
}
};
std::thread worker(add);
add();
worker.join();
assert(count == 20000);
}

两个执行路径都使用同一把 mutex。临界区内的 ++count虽然不是原子变量操作,但不会被另一持锁路径同时执行;最终读取发生在 join之后。若读者会在工作期间读取 count,该读取也必须纳入相同的同步协议。

手写 lock(); operation(); unlock();在 operation抛异常时可能漏掉解锁。RAII对象的析构会在正常离开和异常展开时执行,所以通常不用手动配对。

3. unique_lock的构造并不都相同

跳转到“3. unique_lock的构造并不都相同”
#include <cassert>
#include <mutex>
int main()
{
std::mutex mutex;
std::unique_lock<std::mutex> empty;
assert(!empty.owns_lock() && empty.mutex() == nullptr);
std::unique_lock<std::mutex> lock(mutex, std::defer_lock);
assert(!lock.owns_lock());
lock.lock();
assert(lock.owns_lock());
lock.unlock();
std::unique_lock<std::mutex> attempted(mutex, std::try_to_lock);
if (attempted.owns_lock()) {
// 在这个分支中,当前线程拥有mutex。
}
}

不带参数的默认构造不关联互斥量;unique_lock(mutex)会立即调用加锁;defer_lock只关联、暂不加锁;try_to_lock尝试取得。adopt_lock则要求调用者已经拥有锁,用来接管已有所有权,不能用它跳过加锁。

调用 unique_lock::release()会让管理器放弃关联,不会解锁底层互斥量。它与 unlock()不同,不熟悉后续所有权处理时不要使用。

分别按“先来源后目标”的顺序锁两个账户,如果另一线程做反向转账,就可能形成环形等待。下面使用 scoped_lock一次取得两把锁,并在持锁期间检查余额和溢出。

#include <cassert>
#include <cstdint>
#include <limits>
#include <mutex>
#include <stdexcept>
#include <thread>
class Box {
public:
explicit Box(std::int64_t amount) : amount_(amount)
{
if (amount < 0)
throw std::invalid_argument("negative initial amount");
}
std::int64_t amount() const
{
std::lock_guard<std::mutex> lock(mutex_);
return amount_;
}
friend bool transfer(Box& from, Box& to, std::int64_t amount)
{
// 本例将转给自身、非正金额定义为无效请求。
if (&from == &to || amount <= 0)
return false;
std::scoped_lock lock(from.mutex_, to.mutex_);
if (from.amount_ < amount ||
to.amount_ > std::numeric_limits<std::int64_t>::max() - amount)
return false;
from.amount_ -= amount;
to.amount_ += amount;
return true;
}
private:
mutable std::mutex mutex_;
std::int64_t amount_;
};
int main()
{
Box a(100000), b(100000);
std::thread worker([&] {
for (int i = 0; i != 10000; ++i) {
[[maybe_unused]] const bool moved = transfer(a, b, 1);
assert(moved);
}
});
for (int i = 0; i != 10000; ++i) {
[[maybe_unused]] const bool moved = transfer(b, a, 1);
assert(moved);
}
worker.join();
assert(a.amount() == 100000 && b.amount() == 100000);
[[maybe_unused]] const bool sameBox = transfer(a, a, 1);
[[maybe_unused]] const bool negative = transfer(a, b, -1);
[[maybe_unused]] const bool insufficient = transfer(a, b, 100001);
assert(!sameBox && !negative && !insufficient);
Box full(std::numeric_limits<std::int64_t>::max());
[[maybe_unused]] const bool overflow = transfer(a, full, 1);
assert(!overflow);
}

先排除同一对象很关键:同一个非递归互斥量不能被当作两把不同锁传入。旧写法也可以用 std::lock(a, b)配合两个 adopt_lock守卫,但更容易遗漏边界;此处用C++17的组合管理器表达同一个目的。

这个例子使用整数单位,不涉及浮点货币。两次单独调用 amount()并不构成跨对象的一致快照;例子在两个更新线程都结束后才比较最终值。如果业务要在更新期间读取总额,仍需同时持有相关锁。

5. 超时、递归锁和锁粒度

跳转到“5. 超时、递归锁和锁粒度”

timed_mutex::try_lock_for等待相对时长,try_lock_until按指定时钟的目标时刻等待;返回时必须检查是否成功。实际返回可能晚于请求时限,也允许失败,不能将它们当作精确实时定时器。使用 unique_lock<timed_mutex>管理成功取得的锁,避免不同分支漏解锁。

递归锁可支持已有代码中同线程的嵌套进入,但不会消除跨线程死锁,也不会修复过长临界区。先考虑把“外层加锁”和“内层已持锁操作”分开设计。

临界区应覆盖完整不变量,又尽量短。在持锁期间执行未知回调、阻塞I/O或等待另一个线程,可能扩大依赖链。只有完成保护范围分析后才拆锁;把同一共享变量的读写随意分给不同锁会破坏保护,而不是优化。

上述例子用实际编译和有限并发执行检查计数、双向更新、非法金额和溢出边界;线程调度顺序不应被当作固定输出。更复杂的锁关系仍需独立检查对象寿命和所有加锁路径。