互斥量让遵守同一把锁协议的线程轮流进入临界区,从而保护共享状态。它锁住的是互斥量对象,不是自动追踪某个变量;用两把互不相关的锁分别写同一个普通变量,仍可能发生数据竞争。
本页采用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_mutex | C++17读写锁,支持独占所有权或多个共享读者。 |
正确名称是 timed_mutex、recursive_mutex和 try_lock,不是 time_mutex、recursive_lock或 trylock。
尝试加锁允许虚假失败,因此返回false不证明另一个线程当前一定持锁。对普通 mutex在同一线程再次加锁违反前提,不能仅理解为“保证等待到解锁”。不同平台可能表现不同,程序不能依赖这种调用。C++工作草案:互斥量要求
2. 用RAII保持解锁完整
跳转到“2. 用RAII保持解锁完整”| 锁管理器 | 适用情况 |
|---|---|
std::lock_guard | 进入作用域就加锁,离开时解锁;逻辑简单。 |
std::unique_lock | 可暂不加锁、尝试加锁、移动所有权、显式解锁再上锁;条件变量等待常用。 |
std::scoped_lock | C++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()不同,不熟悉后续所有权处理时不要使用。
4. 同时更新两个对象
跳转到“4. 同时更新两个对象”分别按“先来源后目标”的顺序锁两个账户,如果另一线程做反向转账,就可能形成环形等待。下面使用 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或等待另一个线程,可能扩大依赖链。只有完成保护范围分析后才拆锁;把同一共享变量的读写随意分给不同锁会破坏保护,而不是优化。
上述例子用实际编译和有限并发执行检查计数、双向更新、非法金额和溢出边界;线程调度顺序不应被当作固定输出。更复杂的锁关系仍需独立检查对象寿命和所有加锁路径。