unique_lock 源码:灵活锁控的代价?析构 / 移动 / 锁状态管理的源码细节

0 阅读8分钟

0.引言

在《lock_guard 源码:RAII 思想的极简落地,为何它是 “不可变” 独占锁?》中我们了解了lock_guard的简洁方便以及其背后的局限性(不能延迟加锁,不能主动释放等)。为了解决上述局限性,C++引入std::unique_lock,它是继std::lock_guard 之后更为灵活的锁管理工具。它继承了 RAII(资源获取即初始化)的核心思想,自动管理互斥量的上锁与解锁,同时突破了 lock_guard “构造即上锁、析构即解锁”的固定逻辑,支持延迟锁定、手动解锁、超时锁定、所有权转移等灵活操作。但灵活性的背后,也伴随着额外的设计与性能开销。本文将基于标准库的源码实现,深入拆解 unique_lock 的析构机制、移动语义与锁状态管理细节,剖析其灵活锁控的代价,为多线程场景下的锁选择提供底层支撑。

1.设计目标:RAII 与灵活性的平衡

unique_lock 的核心目标是在保持 RAII 自动解锁安全性的基础上,赋予开发者更多控制权。它可以处于以下三种状态之一:

  • 无关联互斥量(空状态)
  • 已关联互斥量但未持有锁(延迟锁定)
  • 已关联互斥量且持有锁

为了实现这些状态,unique_lock 内部必须存储两个关键信息:指向互斥量的指针 和 一个布尔标志位。libstdc++ 的实现正是这样做的:

template<typename _Mutex>
class unique_lock {
private:
    mutex_type* _M_device;   // 指向管理的互斥量
    bool        _M_owns;     // 是否持有锁
};
  • _M_device:为 nullptr 时表示没有关联任何互斥量;非空时指向被管理的互斥量。
  • _M_ownstrue 表示当前对象拥有锁(即已经成功锁定),false 则表示未持有。

正是这个额外的布尔标志,支撑起了 unique_lock 的全部灵活性。

2.构造函数:奠定状态基石

unique_lock 提供了多个构造函数,以支持不同的初始化策略。每种构造函数都精确地设置 _M_device 和 _M_owns

1)默认构造函数:创建一个空的 unique_lock 对象,不关联任何互斥量,也不持有锁。这种对象可用于后续通过移动赋值获得所有权,或者直接作为占位符。

unique_lock() noexcept
    : _M_device(0), _M_owns(false)
{ }

2)立即锁定构造函数:关联互斥量后立即调用 lock() 进行锁定,并将 _M_owns 设为 true。这是最常用的形式。

explicit unique_lock(mutex_type& __m)
    : _M_device(std::__addressof(__m)), _M_owns(false)
{
    lock();
    _M_owns = true;
}

3)延迟锁定构造函数:关联互斥量,但 _M_owns 保持 false,不进行任何锁定操作。锁的获取需要后续手动调用 lock()

unique_lock(mutex_type& __m, defer_lock_t) noexcept
    : _M_device(std::__addressof(__m)), _M_owns(false)
{ }

4)尝试构造:直接调用互斥量的 try_lock(),将其返回值直接赋值给 _M_owns。如果 try_lock() 成功,_M_owns 为 true,否则为 false。对象可能持有锁,也可能不持有。

unique_lock(mutex_type& __m, try_to_lock_t)
    : _M_device(std::__addressof(__m)), _M_owns(_M_device->try_lock())
{ }

5)接管已有锁:假定调用线程已经锁定了互斥量,unique_lock 直接接管所有权。_M_owns 设为 true。此构造不尝试锁定,因此调用者必须确保锁已被持有,否则析构时可能错误解锁。

unique_lock(mutex_type& __m, adopt_lock_t) noexcept
    : _M_device(std::__addressof(__m)), _M_owns(true)
{ }

6)定时锁定:这两个构造函数分别调用互斥量的 try_lock_until 和 try_lock_for,并将结果赋值给 _M_owns。若超时未获得锁,则 _M_owns 为 false

template<typename _Clock, typename _Duration>
unique_lock(mutex_type& __m,
            const chrono::time_point<_Clock, _Duration>& __atime)
    : _M_device(std::__addressof(__m)),
      _M_owns(_M_device->try_lock_until(__atime))
{ }

template<typename _Rep, typename _Period>
unique_lock(mutex_type& __m,
            const chrono::duration<_Rep, _Period>& __rtime)
    : _M_device(std::__addressof(__m)),
      _M_owns(_M_device->try_lock_for(__rtime))
{ }

3.析构函数,安全的自动解锁

unique_lock 的析构函数是 RAII 的核心保障:它只做一件事:如果当前对象持有锁(_M_owns 为 true),则调用 unlock() 释放锁。对于空对象或未持有锁的对象,析构函数什么也不做,不会产生副作用。这保证了即使发生异常,锁也会在作用域结束时被正确释放。

~unique_lock()
{
    if (_M_owns)
        unlock();
}

4.移动语义,保证所有权安全转移

1)移动构造函数:移动构造函数直接从源对象“窃取”资源:将源对象的 _M_device 和 _M_owns 拷贝到新对象,然后将源对象重置为空状态。移动后,源对象不再关联任何互斥量,也不持有锁,其析构变为空操作。

unique_lock(unique_lock&& __u) noexcept
    : _M_device(__u._M_device), _M_owns(__u._M_owns)
{
    __u._M_device = 0;
    __u._M_owns = false;
}

2)移动赋值运算符:移动赋值运算符的逻辑更复杂,因为它需要处理当前对象可能持有的锁。步骤如下:

  • 如果当前对象持有锁,先调用 unlock() 释放它。
  • 通过 unique_lock(std::move(__u)) 创建一个临时对象,该临时对象通过移动构造函数获取 __u 的资源。
  • 调用 swap(*this),将临时对象与当前对象交换内部状态。此时,当前对象获得了 __u 的资源,而临时对象获得了原来 *this 的资源(但已经解锁,因为第一步已释放)。
  • 将 __u 显式置空(虽然移动构造已经将其置空,但此步保证安全)。
  • 函数返回后,临时对象析构,由于它持有的是已解锁的资源,析构函数不会执行任何操作。

这种实现保证了异常安全:如果第一步 unlock() 抛出异常,赋值操作会终止,*this 状态保持不变,不会执行后续的移动。

5.锁状态管理:细粒度的控制接口

unique_lock 提供了多个成员函数,允许开发者在对象生命周期内动态管理锁的状态。

1)lock() / try_lock() / unlock():这些函数不仅调用底层互斥量的对应方法,还会进行严格的状态检查,避免误用:

  • 如果 _M_device 为空(即对象未关联任何互斥量),抛出 operation_not_permitted
  • 如果 _M_owns 已经为 true(即已经持有锁),抛出 resource_deadlock_would_occur,防止递归锁定导致的死锁(除非互斥量本身支持递归,但 unique_lock 在这里做了通用保护)。
    try_lock() 的实现类似,只是将 _M_owns 赋值为 try_lock() 的返回值。
    unlock() 也进行了检查:
  • 如果 _M_owns 为 false(即未持有锁),抛出异常,防止对未锁定的互斥量解锁。
  • 调用 _M_device->unlock() 前确保指针非空。
    这些防御性检查使 unique_lock 在误用时能够快速失败,而不是导致未定义行为,极大提升了代码的安全性。
void lock()
{
    if (!_M_device)
        __throw_system_error(int(errc::operation_not_permitted));
    else if (_M_owns)
        __throw_system_error(int(errc::resource_deadlock_would_occur));
    else
    {
        _M_device->lock();
        _M_owns = true;
    }
}
void unlock()
{
    if (!_M_owns)
        __throw_system_error(int(errc::operation_not_permitted));
    else if (_M_device)
    {
        _M_device->unlock();
        _M_owns = false;
    }
}

2)release() —— 所有权移交

release() 切断 unique_lock 与互斥量的关联,并返回互斥量指针。注意:它不会解锁。调用后,unique_lock 对象变为空,而互斥量的锁仍然由调用线程持有,调用者必须负责后续的手动解锁。这个函数常用于将锁的所有权转移给其他机制(如 std::unique_lock 之外的管理器)。

mutex_type* release() noexcept
{
    mutex_type* __ret = _M_device;
    _M_device = 0;
    _M_owns = false;
    return __ret;
}

3)swap() 与全局 swap

交换两个 unique_lock 对象的内部状态。同时提供了全局 swap 重载,方便使用。

void swap(unique_lock& __u) noexcept
{
    std::swap(_M_device, __u._M_device);
    std::swap(_M_owns, __u._M_owns);
}

4)观察器函数:这些函数允许查询当前对象的状态,尤其在与条件变量配合时至关重要(std::condition_variable::wait 需要接受一个 unique_lock 并查询其内部的互斥量指针)。

mutex_type* mutex() const noexcept { return _M_device; }
bool owns_lock() const noexcept { return _M_owns; }
explicit operator bool() const noexcept { return owns_lock(); }

6.与条件变量的协作

std::condition_variable 的 wait 系列函数要求传入一个 unique_lock 对象,并在等待期间自动解锁,唤醒后重新锁定。unique_lock 提供的 mutex() 和 owns_lock() 接口使得条件变量能够安全地访问和管理互斥量,而无需了解 unique_lock 的具体实现细节。

7.总结:灵活性背后的代价与价值

从源码层面审视 std::unique_lock,我们可以清晰地看到其设计权衡:

  • 空间代价:相比 std::lock_guardunique_lock 多存储了一个布尔标志,以及可能存在的空指针状态。在大多数情况下,这个开销可以忽略不计。
  • 时间代价:每次加锁、解锁、尝试锁等操作都需要进行额外的状态检查(如空指针、重复锁等),带来轻微的性能损失。
  • 代码复杂性:实现需要精心处理移动语义、异常安全以及各种构造选项。

然而,这些代价换来了灵活性

  • 支持延迟锁定、定时锁定、尝试锁定等多种获取锁的方式。
  • 支持锁所有权的安全转移(移动语义)。
  • 可以在对象生命周期内动态解锁、重新锁定,实现细粒度的临界区控制。
  • 能够与条件变量无缝协作。
  • 通过 release() 可以将锁的管理权移交给其他代码。

对于简单的同步需求,std::lock_guard 可能更加轻量;但在需要更复杂控制逻辑的场景下,则需要std::unique_lock 提供的灵活性。理解其源码实现,不仅有助于我们正确使用它,也能在设计自己的 RAII 资源管理类时获得启发。

更多深入内容,欢迎了解(已更内容如下)C++/Linux/ 数据库内核 | 底层开发 + AI 实战圈——12 个月系统落地,从原理到工业级实战,搭建你的核心技术壁垒

image.png