std::scoped_lock 的源码实现与解析
std::scoped_lock
的源码实现与解析
本单章专门介绍标准库在 C++17 引入的类模板 std::scoped_lock
的实现,让你对它再无疑问。
这会涉及到不少的模板技术,这没办法,就如同我们先前聊 std::thread
的构造与源码分析最后说的:“不会模板,你阅读标准库源码,是无稽之谈”。建议学习现代C++模板教程。
我们还是一样的,以 MSVC STL 实现的 std::scoped_lock
代码进行讲解,不用担心,我们也查看了 libstdc++
、libc++
的实现,并没有太多区别,更多的是一些风格上的。而且个人觉得 MSVC 的实现是最简单直观的。
std::scoped_lock
的数据成员
std::scoped_lock
是一个类模板,它有两个特化,也就是有三个版本,其中的数据成员也是不同的。并且它们都不可移动不可复制,“管理类”应该如此。
主模板,是一个可变参数类模板,声明了一个类型形参包
_Mutexes
,存储了一个std::tuple
,具体类型根据类型形参包决定。_EXPORT_STD template <class... _Mutexes> class _NODISCARD_LOCK scoped_lock { // class with destructor that unlocks mutexes public: explicit scoped_lock(_Mutexes&... _Mtxes) : _MyMutexes(_Mtxes...) { // construct and lock _STD lock(_Mtxes...); } explicit scoped_lock(adopt_lock_t, _Mutexes&... _Mtxes) noexcept // strengthened : _MyMutexes(_Mtxes...) {} // construct but don't lock ~scoped_lock() noexcept { _STD apply([](_Mutexes&... _Mtxes) { (..., (void) _Mtxes.unlock()); }, _MyMutexes); } scoped_lock(const scoped_lock&) = delete; scoped_lock& operator=(const scoped_lock&) = delete; private: tuple<_Mutexes&...> _MyMutexes; };
对模板类型形参包只有一个类型情况的偏特化,是不是很熟悉,和
lock_guard
几乎没有任何区别,保有一个互斥量的引用,构造上锁,析构解锁,提供一个额外的构造函数让构造的时候不上锁。所以用scoped_lock
替代lock_guard
不会造成任何额外开销。template <class _Mutex> class _NODISCARD_LOCK scoped_lock<_Mutex> { public: using mutex_type = _Mutex; explicit scoped_lock(_Mutex& _Mtx) : _MyMutex(_Mtx) { // construct and lock _MyMutex.lock(); } explicit scoped_lock(adopt_lock_t, _Mutex& _Mtx) noexcept // strengthened : _MyMutex(_Mtx) {} // construct but don't lock ~scoped_lock() noexcept { _MyMutex.unlock(); } scoped_lock(const scoped_lock&) = delete; scoped_lock& operator=(const scoped_lock&) = delete; private: _Mutex& _MyMutex; };
对类型形参包为空的情况的全特化,没有数据成员。
template <> class scoped_lock<> { public: explicit scoped_lock() = default; explicit scoped_lock(adopt_lock_t) noexcept /* strengthened */ {} scoped_lock(const scoped_lock&) = delete; scoped_lock& operator=(const scoped_lock&) = delete; };
std::mutex m1,m2;
std::scoped_lock<std::mutex> lc{ m1 }; // 匹配到偏特化版本 保有一个 std::mutex&
std::scoped_lock<std::mutex, std::mutex> lc2{ m1,m2 }; // 匹配到主模板 保有一个 std::tuple<std::mutex&,std::mutex&>
std::scoped_lock<> lc3; // 匹配到全特化版本 空
std::scoped_lock
的构造与析构
在上一节讲 scoped_lock
的数据成员的时候已经把这个模板类的全部源码,三个版本的代码都展示了,就不再重复。
这三个版本中,只有两个版本需要介绍,也就是
- 形参包元素数量为一的偏特化,只管理一个互斥量的。
- 主模板,可以管理任意个数的互斥量。
那这两个的共同点是什么呢?构造上锁,析构解锁。这很明显,明确这一点我们就开始讲吧。
std::mutex m;
void f(){
m.lock();
std::lock_guard<std::mutex> lc{ m, std::adopt_lock };
}
void f2(){
m.lock();
std::scoped_lock<std::mutex>sp{ std::adopt_lock,m };
}
这段代码为你展示了 std::lock_guard
和 std::scoped_lock
形参包元素数量为一的偏特化的唯一区别:调用不会上锁的构造函数的参数顺序不同。那么到此也就够了。
接下来我们进入 std::scoped_lock
主模板的讲解:
explicit scoped_lock(_Mutexes&... _Mtxes) : _MyMutexes(_Mtxes...) { // construct and lock
_STD lock(_Mtxes...);
}
这个构造函数做了两件事情,初始化数据成员 _MyMutexes
让它保有这些互斥量的引用,以及给所有互斥量上锁,使用了 std::lock
帮助我们完成这件事情。
explicit scoped_lock(adopt_lock_t, _Mutexes&... _Mtxes) noexcept // strengthened
: _MyMutexes(_Mtxes...) {} // construct but don't lock
这个构造函数不上锁,只是初始化数据成员 _MyMutexes
让它保有这些互斥量的引用。
~scoped_lock() noexcept {
_STD apply([](_Mutexes&... _Mtxes) { (..., (void) _Mtxes.unlock()); }, _MyMutexes);
}
析构函数就要稍微聊一下了,主要是用 std::apply
去遍历 std::tuple
,让元组保有的互斥量引用都进行解锁。简单来说是 std::apply
可以将元组存储的参数全部拿出,用于调用这个可变参数的可调用对象,我们就能利用折叠表达式展开形参包并对其调用 unlock()
。
不在乎其返回类型只用来实施它的副作用,显式转换为
(void)
也就是弃值表达式。在我们之前讲的std::thread
源码中也有这种用法。不过你可能有疑问:“我们的标准库的那些互斥量
unlock()
返回类型都是void
呀,为什么要这样?”的确,这是个好问题,libstdc++ 和 libc++ 都没这样做,或许 MSVC STL 想着会有人设计的互斥量让它的
unlock()
返回类型不为void
,毕竟 互斥体 (Mutex) 没有要求unlock()
的返回类型。
template< class F, class Tuple >
constexpr decltype(auto) apply( F&& f, Tuple&& t );
这个函数模板接受两个参数,一个可调用 (Callable)对象 f,以及一个元组 t,用做调用 f 。我们可以自己简单实现一下它,其实不算难,这种遍历元组的方式在之前讲 std::thread
的源码的时候也提到过。
template<class Callable, class Tuple, std::size_t...index>
constexpr decltype(auto) Apply_impl(Callable&& obj,Tuple&& tuple,std::index_sequence<index...>){
return std::invoke(std::forward<Callable>(obj), std::get<index>(std::forward<Tuple>(tuple))...);
}
template<class Callable, class Tuple>
constexpr decltype(auto) apply(Callable&& obj, Tuple&& tuple){
return Apply_impl(std::forward<Callable>(obj), std::forward<Tuple>(tuple),
std::make_index_sequence<std::tuple_size_v<std::remove_reference_t<Tuple>>>{});
}
其实就是把元组给解包了,利用了 std::index_sequence
+ std::make_index_sequence
然后就用 std::get
形参包展开用 std::invoke
调用可调用对象即可,非常经典的处理可变参数做法,这个非常重要,一定要会使用。
举一个简单的调用例子:
std::tuple<int, std::string, char> tuple{ 66,"😅",'c' };
::apply([](const auto&... t) { ((std::cout << t << ' '), ...); }, tuple);
运行测试。
使用了折叠表达式展开形参包,打印了元组所有的元素。
总结
如你所见,其实这很简单。至少使用与了解其设计原理是很简单的。唯一的难度或许只有那点源码,处理可变参数,这会涉及不少模板技术,既常见也通用。还是那句话:“不会模板,你阅读标准库源码,是无稽之谈”。
相对于 std::thread
的源码解析,std::scoped_lock
还是简单的多。