资讯动态

C++多线程之unique_lock的使用详解

发布时间:2026/10/20 20:20:30 来源:尧图企业网站定制
前言std::lock_guard解决了「异常路径忘记解锁」这个最基本的问题但它的能力也就到此为止构造即加锁析构即解锁中间不能解锁不能延迟加锁不能尝试加锁不能转移所有权。一旦遇到条件变量、需要一次锁多个互斥量、或者要先做一段无锁准备再进入临界区lock_guard就不够用了。C11 提供的std::unique_lock正是为这些场景设计的。常见的误解有两个。第一把unique_lock当成「更高级所以更快的锁」。它不更轻反而通常比lock_guard多存一个「是否持有锁」的标志并因此支持移动和显式解锁在不需要这些能力的地方lock_guard或 C17 的scoped_lock更直接。第二以为unique_lock的析构一定会解锁。准确说法是析构时如果owns_lock()为真才解锁用defer_lock构造、从未加锁或者已经release()掉所有权析构就是空操作。还有一个与条件变量有关的误解「wait会释放锁所以调用前不用加锁」。事实相反std::condition_variable::wait要求传入一个已经持有锁的unique_lock它在内部原子地「解锁并阻塞」被唤醒后再重新加锁谓词也是在持有锁的状态下求值。传一个未加锁的unique_lock进去是未定义行为。本文围绕四件事展开unique_lock相对lock_guard多了什么、四种构造策略、所有权如何转移、以及它和条件变量的标准配合方式。代码以 C17 为基准只使用 C11 已有的设施。本机没有 C/C 工具链代码按标准语义推演不声称编译或运行过。一、unique_lock 到底比 lock_guard 多了什么std::lock_guard是一个极薄的 RAII 包装构造时调用互斥量的lock()析构时调用unlock()不可拷贝、不可移动没有成员函数可以查询状态。它的设计目标只有一个——让作用域退出时必定解锁。std::unique_lock则把「持有锁」变成一个可以查询、可以改变、可以转移的状态。它至少多出这些能力能力lock_guardunique_lock构造时自动加锁是是默认延迟加锁否是std::defer_lock尝试加锁否是std::try_to_lock或try_lock()接管已加锁的互斥量是std::adopt_lock是std::adopt_lock超时加锁否是需timed_mutex一类支持手动lock()/unlock()否是查询是否持有锁否是owns_lock()/operator bool移动所有权否是释放所有权而不解锁否是release()与condition_variable配合否是必须是unique_lockstd::mutex多出来的能力不是没有代价unique_lock需要保存互斥量指针和一个布尔标志移动时要正确转移状态析构时要判断是否解锁。因此在单纯的作用域加锁场景里没有理由用它替换lock_guard。选型原则很简单需要「锁的生命周期」和「对象的生命周期」不一致时才用unique_lock。unique_lock的模板参数是互斥量类型例如std::unique_lockstd::mutex。它要求该类型满足 Lockable有lock()、unlock()、try_lock()使用超时构造或try_lock_for/try_lock_until时还要求满足 TimedLockable。它对互斥量类型没有其他假设。二、四种构造策略默认、defer、try、adopt不传策略标签时构造函数会立即调用m.lock()。若此时lock()抛异常例如std::system_error对象不会构造成功也不持有锁异常继续向外传播。std::defer_lock表示「只关联不锁定」。构造后owns_lock()为 false之后可以在合适的时机调用lock()、try_lock()或交给std::lock统一加锁。这是避免死锁的标准做法先构造多个延迟锁再让std::lock以不会互相死锁的算法一次性锁住它们。// 适用于 C17#include mutexstruct Account {std::mutex m;int balance 0;};// 把两个账户的锁都延迟构造再交给 std::lock 统一加锁bool transfer(Account from, Account to, int amount) {std::unique_lockstd::mutex lock_from(from.m, std::defer_lock);std::unique_lockstd::mutex lock_to(to.m, std::defer_lock);std::lock(lock_from, lock_to); // 不会因为加锁顺序不同而死锁if (from.balance amount) {return false; // 两个 unique_lock 析构时各自解锁}from.balance - amount;to.balance amount;return true;}std::try_to_lock表示「尝试加锁失败不阻塞」。构造后通过owns_lock()判断是否成功失败时对象仍然有效只是不持有锁。它适合「拿不到锁就去做别的事」的场景但要注意忙等会把 CPU 打满通常需要配合退避或条件变量。std::adopt_lock表示「互斥量已经被当前线程锁住只接管所有权不再加锁」。典型用途是从旧代码或 C API 接过一个已经锁定的互斥量或者把一个lock_guard换成unique_lock。前提是确实已锁定对未锁定的互斥量使用adopt_lock析构时会解锁一个未锁定的互斥量属于未定义行为。超时构造接受一个时长或时间点内部调用try_lock_for/try_lock_until因此互斥量必须是std::timed_mutex或std::recursive_timed_mutex一类。若在时限内没有拿到锁构造完成但owns_lock()为 false不抛异常。// 适用于 C17#include chrono#include mutexstd::timed_mutex tm;void try_work() {// 最多等 100 毫秒超时后 owns_lock() 为 falsestd::unique_lockstd::timed_mutex lock(tm, std::chrono::milliseconds(100));if (!lock.owns_lock()) {return; // 没拿到锁做降级处理}// 持有锁期间的工作}四种策略的共同点是构造之后对象一定处于「关联某个互斥量」的状态默认构造除外区别只在是否持有锁。判断持有状态一律用owns_lock()不要靠「我是用哪个标签构造的」来推断因为后续的unlock()、release()、移动都会改变它。三、所有权移动、释放与显式解锁unique_lock不可拷贝但可以移动。移动构造和移动赋值把互斥量指针与持有标志一起转走源对象变为「不关联任何互斥量、不持有锁」的状态。这使得它可以作为工厂函数的返回值、放进容器例如std::vectorstd::unique_lockstd::mutex或者在线程之间传递「锁的所有权」。// 适用于 C17#include mutex#include utilitystd::mutex g_mutex;std::unique_lockstd::mutex acquire() {return std::unique_lockstd::mutex(g_mutex); // 返回值移动不发生拷贝}void use() {std::unique_lockstd::mutex a acquire();std::unique_lockstd::mutex b std::move(a); // 所有权转移// 此时 a.owns_lock() 为 falseb 持有锁if (b.owns_lock()) {b.unlock(); // 提前释放}}显式unlock()之后对象仍然关联着互斥量但owns_lock()变为 false之后还可以再次lock()。这正是「临界区只包住必要部分」的写法把耗时操作、I/O、回调放到锁外同时保留 RAII 的兜底能力。注意unlock()只能由持有锁的对象调用对不持有锁的对象调用是未定义行为标准通常要求抛std::system_error的实现可以检测但不能依赖。release()是另一回事它返回互斥量指针并把对象置为「不关联、不持有」不会解锁。调用者接管了「必须解锁」的责任。它主要用于把锁交给不遵循 RAII 的旧接口正常代码里很少需要一旦用错锁会永久保持后续线程全部阻塞。析构函数不会替你补救因为对象已经不再关联那个互斥量。mutex()返回关联的互斥量指针未关联时返回空指针。swap()交换两个锁的状态。这些成员在泛型代码里偶尔有用日常业务代码里出现往往意味着设计需要重新审视。需要强调生命周期unique_lock只保存指针不拥有互斥量。互斥量必须先于锁构造、后于锁销毁。把局部互斥量的锁返回出去或者在互斥量销毁后继续持有锁对象都是悬垂访问属于未定义行为。四、与条件变量配合完整示例std::condition_variable的wait系列只接受std::unique_lockstd::mutex这是它和lock_guard无法配合的根本原因等待期间必须能解锁被唤醒后必须能重新加锁而lock_guard两者都做不到。若互斥量不是std::mutex要用std::condition_variable_any它可以接受任意满足 BasicLockable 的锁类型代价是实现上通常需要额外的内部同步。等待必须放在循环里或者使用带谓词的重载。原因是条件变量存在虚假唤醒spurious wakeup即使没有通知等待也可能返回。带谓词的重载等价于while (!pred()) wait(lock);谓词在持有锁时求值避免了「检查条件」和「进入等待」之间的丢失唤醒窗口。下面是一个有界阻塞队列生产者与消费者各一个线程。它演示了unique_lock的三个关键动作等待时自动解锁、被唤醒后自动重新加锁、在通知前主动解锁。// 适用于 C17#include condition_variable#include cstddef#include iostream#include mutex#include queue#include threadclass BoundedQueue {public:explicit BoundedQueue(std::size_t capacity) : capacity_(capacity) {}bool push(int value) {std::unique_lockstd::mutex lock(mutex_);not_full_.wait(lock, [this] {return queue_.size() capacity_ || closed_;});if (closed_) {return false;}queue_.push(value);lock.unlock(); // 先解锁再通知减少被唤醒线程的空等not_empty_.notify_one();return true;}bool pop(int out) {std::unique_lockstd::mutex lock(mutex_);not_empty_.wait(lock, [this] {return !queue_.empty() || closed_;});if (queue_.empty()) {return false; // 已关闭且队列为空}out queue_.front();queue_.pop();lock.unlock();not_full_.notify_one();return true;}void close() {{std::lock_guardstd::mutex lock(mutex_);closed_ true;} // 先释放锁not_empty_.notify_all(); // 再唤醒所有等待者not_full_.notify_all();}private:std::size_t capacity_;std::queueint queue_;std::mutex mutex_;std::condition_variable not_empty_;std::condition_variable not_full_;bool closed_ false;};int main() {BoundedQueue queue(4);std::thread producer([queue] {for (int i 0; i 10; i) {if (!queue.push(i)) {return;}}queue.close();});std::thread consumer([queue] {int value 0;while (queue.pop(value)) {std::cout consumed value \n;}});producer.join();consumer.join();return 0;}这段代码里有几个细节值得逐行确认。push在等待谓词里同时检查「队列未满」和「已关闭」所以关闭后即使队列满等待也会返回返回后先判断closed_避免向已关闭队列继续写入。pop在关闭且队列为空时返回 false消费者循环因此自然结束。close()用lock_guard改标志在作用域结束后才notify_all避免通知时还持有锁。两个通知都使用notify_one/notify_all的语义差异队列有空位只需要唤醒一个生产者关闭则需要唤醒全部等待者。wait的谓词捕获了this因此在等待期间对象必须存活。这是另一个生命周期约束不要在线程还在等待时销毁队列。线程的join()提供了这个保证。常见坑点❌ 用std::defer_lock构造后直接访问共享数据忘了真正加锁。✅ 延迟锁只是「未加锁」状态访问前必须调用lock()、try_lock()或交给std::lock并用owns_lock()确认。❌ 对未锁定的互斥量使用std::adopt_lock。✅adopt_lock只接管已有锁若不确定改用defer_lock再显式加锁。❌ 把unique_lock传给std::condition_variable::wait时对象没有持有锁。✅wait要求锁已持有先正常构造unique_lock再调用带谓词的重载。❌ 用if而不是while判断条件只等待一次。✅ 虚假唤醒存在必须用wait(lock, pred)或while (!pred) wait(lock);。❌ 在持有锁时调用notify_one()并认为这样更安全。✅ 通知本身不需要锁先unlock()再通知可以避免被唤醒线程立刻又阻塞在锁上两种写法都正确但不要通知后再访问共享数据。❌ 调用release()后以为析构会兜底解锁。✅release()放弃所有权且不解锁调用者必须自己解锁否则所有后续线程永久阻塞。❌ 把unique_lock按值传给函数或从函数返回局部互斥量的锁。✅ 按值传参无法拷贝编译失败应传引用锁对象不能比互斥量活得更久。❌ 同一线程对std::mutex重复加锁指望像递归锁一样工作。✅ 非递归互斥量由同一线程重复加锁是未定义行为需要重入时明确改用std::recursive_mutex并重新审视设计。总结场景推荐工具理由简单作用域加锁std::lock_guard最薄、意图最清楚多个互斥量一起加锁std::scoped_lockC17或std::lock 延迟unique_lock内置死锁避免算法条件变量等待std::unique_lockstd::mutexwait的接口要求临界区中途需要解锁std::unique_lock支持unlock()/lock()尝试加锁或超时加锁std::unique_locktry_to_lock/ 时长构造lock_guard无此能力转移锁的所有权std::unique_lock可移动、不可拷贝unique_lock的价值不在「更安全」而在于把锁的所有权变成可管理的状态可以延迟、可以尝试、可以提前释放、可以转移。用它的判断标准始终是「锁的作用域是否需要和对象生命周期分离」不需要时lock_guard或scoped_lock是更准确的表达。把它和条件变量组合时牢记三件事——等待前必须持有锁、谓词在持锁时求值、通知不必持锁——绝大多数并发队列的骨架就稳了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑