C++多线程:锁管理(lock)
对于多线程,无法避免要使用到锁对共享资源的保护,这一节我们就来学习现代C++对于锁的管理(lock),上一节我们已经学习了现代C++对应的mutex,直到C++17,一共有六种类型。而今天学习的锁管理,与mutex息息相关,它们都是使用RAII风格来进行锁管理,主要有下面几种:
-
std::lock_guard
(C++11) -
std::unique_lock
(C++11) -
std::share_lock
(C++14) -
std::scoped_lock
(C++17)
首先来简单解释一下RAII这个名称的意思:
❝「RAII,全称为Resource Acquisition Is Initialization,汉语是“资源获取即初始化”。简单说来就是,在资源获取的时候将其封装在某类的object中,利用"栈资源会在相应object的生命周期结束时自动销毁"来自动释放资源,即,构造函数创建时初始化获取资源,并将资源释放写在析构函数中。所以这个RAII其实就是和智能指针的实现是类似的。」
❞
用lock_guard举个例子,如下:
std::mutex mut;
{
std::lock_guard<std::mutex> lockGuard(mut); // lock in lock_guard 构造函数
sharedVariable++;
} // unlock in lock_guard 析构函数
对比std::mutex
没有使用RAII管理用法:
mutex mut;
mut.lock();
sharedVariable++;
mut.unlock();
std::lock_guard
类lock_guard
是互斥量包装器,为在作用域块期间占有互斥提供便利RAII 风格机制。创建lock_guard
对象时,它试图接收给定互斥的所有权。离开创建lock_guard
对象的作用域时,销毁lock_guard
并释放互斥。lock_guard
类不可复制。
「构造函数」
explicit lock_guard(mutex_type& m);
lock_guard(mutex_type& m, std::adopt_lock_t t); // adopt_lock_t 后面讲到
lock_guard(const lock_guard&) = delete;
「析构函数」
~lock_guard();
没错,lock_guard使用起来很简单,就只有上面的构造和析构函数。不单能完美管理我们上一篇内容讲到的mutex,还可以管理自定义的类:
#include <iostream>
#include <mutex>
class A {
public:
void lock() { std::cout << "lock" << std::endl; }
void unlock() { std::cout << "unlock" << std::endl; }
};
int main() {
A a;
{
std::cout << "before lock_guard" << std::endl;
std::lock_guard<A> l(a); // lock
std::cout << "after lock_guard" << std::endl;
} // unlock
}
输出:
before lock_guard
lock
after lock_guard
unlock
std::unique_lock
类 unique_lock 也是通用互斥包装器,也有lock_guard一样的RAII机制,但它有更多功能,允许延迟锁定、锁定的有时限尝试、递归锁定、所有权转移和与条件变量一同使用。它可移动,但不可复制。它有类似std::mutex一样的接口,更加灵活方便.
「构造函数」
// 1 构造无关联互斥的 unique_lock 。
unique_lock() noexcept;
// 2移动构造函数。以 other 的内容初始化 unique_lock 。令 other 无关联互斥
unique_lock( unique_lock&& other ) noexcept;
// 3 构造以 m 为关联互斥的 unique_lock, 通过调用 m.lock() 锁定关联互斥。
// 若当前线程已占有互斥则行为未定义,除非互斥是递归的。
explicit unique_lock( mutex_type& m );
// 4 构造以 m 为关联互斥的 unique_lock, 不锁定关联互斥
unique_lock( mutex_type& m, std::defer_lock_t t ) noexcept;
// 5 构造以 m 为关联互斥的 unique_lock,
// 通过调用 m.try_lock() 尝试锁定关联互斥而不阻塞。若当前线程已占有互斥则行为未定义,除非互斥是递归的。
unique_lock( mutex_type& m, std::try_to_lock_t t );
// 6 构造以 m 为关联互斥的 unique_lock, 假定调用方线程已占有 m
unique_lock( mutex_type& m, std::adopt_lock_t t );
// 7 构造以 m 为关联互斥的 unique_lock, 通过调用 m.try_lock_for(timeout_duration) 尝试锁定关联互斥。
template< class Rep, class Period >
unique_lock( mutex_type& m,
const std::chrono::duration<Rep,Period>& timeout_duration );
// 8 构造以 m 为关联互斥的 unique_lock, 通过调用 m.try_lock_until(timeout_time) 尝试锁定关联互斥。
template< class Clock, class Duration >
unique_lock( mutex_type& m,
const std::chrono::time_point<Clock,Duration>& timeout_time );
这里讲一下上面第4,5,6三个构造函数的第二个参数,它们分别对应std::defer_lock
, std::try_to_lock
, std::adopt_lock
.
-
defer_lock
不获得互斥的所有权,也就是使用m创建unique_lock对象,但是没有调用lock。 -
try_to_lock
尝试获得互斥的所有权而不阻塞,也就是使用m创建unique_lock对象,调用try_lock。 -
adopt_lock
假设调用方线程已拥有互斥的所有权,使用m创建unique_lock对象,认为m创建之前已经调用了lock。
std::mutex mut;
// exsample 1
{
std::unique_lock<std::mutex> unilock(mut, std::defer_lock); // 只创建 unique_lock 对象
unilock.lock(); // 这里需要手动lock
sharedVariable++;
} // 如果里面没有调unlock,这里就unlock
// exsample 2
{
mut.lock();
std::unique_lock<std::mutex> unilock(mut, std::adopt_lock); // 只创建 unique_lock 对象,已经调用了lock
sharedVariable++;
unilock.unlock(); // 这里可以直接解锁,也可以析构解锁
}
「其他函数」
void lock();
bool try_lock();
void unlock();
template< class Rep, class Period >
bool try_lock_for( const std::chrono::duration<Rep,Period>& timeout_duration );
template< class Clock, class Duration >
bool try_lock_until( const std::chrono::time_point<Clock,Duration>& timeout_time);
unique_lock不单单能使用RAII这样构造上锁,析构释放锁,还可以调用上述接口做更灵活操作。看下面一个简单例子:
std::mutex mut,
{
std::unique_lock<std::mutex> unilock(mut); // lock in unique_lock 构造函数
sharedVariable++;
unilock.unlock(); // 这里可以直接解锁,更灵活了
// do other time consuming thing
if (unilock.try_lock_for(1s)) {
// read share data
}
} // 如果里面没有调unlock,这里就unlock
对比unique_lock 和 lock_guard
二者都是自释放锁;lock_guard
在时间和空间上都比unique_lock
要快;lock_guard
功能单一,只能用作自释放锁;unique_lock
具备lock_guard
的所有能力,同时提供更多的能力,比如锁的成员函数都会被封装后导出,同时不会引入double lock和 double unlock;
「那么为什么有时候需要unlock()?」因为lock()锁住的代码段越少,执行越快,整个程序运行效率越高。锁头锁住的代码的多少称为锁的粒度,粒度一般用粗细来描述。锁住的代码少,这个粒度叫细,执行效率高。锁住的代码多,粒度叫粗,执行效率就低。要学会尽量选择合适粒度的代码进行保护,力度太细,可能漏掉共享数据的保护,粒度太粗,影响效率。选择合适的粒度,是高级程序员的能力和实力的体现。
「注意」
如果确定使用unique_lock了,就不要再直接使用 mutex 的 lock 和 unlock ,可以直接使用 unique_lock 的 lock 和 unlock,混合使用会导致程序异常,原因是unique_lock 内部会维护一个标识用来记录自己管理的 锁 当前处于何种状态,如果直接使用 mutex的成员函数,unique_lock无法更新自己的状态,从而导致 double lock 和 double unlock(因为unique_lock一定会在析构的时候unlock),这两种情况都会导致崩溃。
std::lock
锁定给定的可锁定 对象lock1
、 lock2
、 ...
、 lockn
,可以避免死锁。它使用一种避免死锁的算法对多个待加锁对象进行lock操作。当待加锁的对象中有不可获得锁的对象时std::lock会阻塞当前线程知道所有对象都可用。
我们看个例子,以下代码运行时有可能出现死锁的情况:
std::mutex mt1, mt2;
// thread 1
void fun1()
{
std::lock_guard<std::mutex> lck1(mt1);
std::lock_guard<std::mutex> lck2(mt2);
// do something
}
// thread 2
void fun2()
{
std::lock_guard<std::mutex> lck2(mt2); //先lock mt2
std::lock_guard<std::mutex> lck1(mt1);
// do something
}
int main ()
{
// 两个线程的互斥量锁定顺序不同,可能造成死锁
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
return 0;
}
为了避免发生这类死锁,对于任意两个互斥对象,在多个线程中进行加锁时应保证其先后顺序是一致。前面的代码应修改成:
std::mutex mt1, mt2;
// thread 1
void fun1()
{
std::lock_guard<std::mutex> lck1(mt1);
std::lock_guard<std::mutex> lck2(mt2);
// do something
}
// thread 2
void fun2()
{
std::lock_guard<std::mutex> lck1(mt1); // 与线程1一致,先lock mt1,再lock mt2
std::lock_guard<std::mutex> lck2(mt2);
// do something
}
更好的做法是使用标准库中的std::lock
函数来对多个Lockable对象加锁。std::lock(或std::try_lock)会使用一种避免死锁的算法对多个待加锁对象进行lock操作,当待加锁的对象中有不可用(无法获得锁)对象时std::lock
会阻塞当前线程直到所有对象都可用。使用std::lock改写前面的代码:
std::mutex mt1, mt2;
// thread 1
void fun1()
{
std::unique_lock<std::mutex> lck1(mt1, std::defer_lock);
std::unique_lock<std::mutex> lck2(mt2, std::defer_lock);
std::lock(lck1, lck2); // lck1和lck2顺序可以任意
// do something
}
// thread 2
void fun2()
{
std::unique_lock<std::mutex> lck1(mt1, std::defer_lock);
std::unique_lock<std::mutex> lck2(mt2, std::defer_lock);
std::lock(lck2, lck1); // lck1和lck2顺序可以任意
// do something
}
std::try_lock
尝试锁定每个给定的可锁定 对象lock1
、 lock2
、 ...
、 lockn
,通过以从头开始的顺序调用try_lock
。若调用try_lock
失败,则不再进一步调用try_lock
,并对任何已锁对象调用unlock
,返回锁定失败对象的底下标。若调用try_lock
抛出异常,则在重抛前对任何已锁对象调用 unlock
。如果所有的try_lock调用都成功则返回-1
。
int main ()
{
std::mutex mtx1;
std::mutex mtx2;
if (-1 == std::try_lock(mtx1, mtx2))
{
std::cout << "locked" << std::endl;
mtx1.unlock();
mtx2.unlock();
}
return 0;
}
std::share_lock
C++14增加,类 shared_lock
是通用共享互斥所有权包装器,允许延迟锁定、定时锁定和锁所有权的转移。shared_lock
,会以共享模式锁定关联的共享互斥,所有接口与std::unique_lock
一样,区别就是std::unique_lock
属于排他性模式锁定。一个共享一个排他。
读写锁也叫做“共享-独占锁”,当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。当读写锁处于写加锁状态时,在其解锁之前,所有尝试对其加锁的线程都会被阻塞;当读写锁处于读加锁状态时,所有试图以读模式对其加锁的线程都可以得到访问权,但是如果想以写模式对其加锁,线程将阻塞。C++14 提供了std::shared_timed_mutex
,C++17 提供了接口更少性能更高的std::shared_mutex
就是专门为“读写”这种情况而设计的,如果多个线程调用 shared_mutex.lock_shared()
,多个线程可以同时读,如果此时有一个写线程调用shared_mutex.lock()
,则读线程均会等待该写线程调用shared_mutex.unlock()
。
std::shared_lock
,是专门针对std::lock_share
的锁管理,它在构造时接受一个mutex,并会调用 mutex.lock_shared()
,析构时会调用 mutex.unlock_shared()
,具体就是:
-
std::shared_lock<Mutex>::lock
以共享模式锁定关联互斥。等效于调用 mutex()->lock_shared(); -
std::shared_lock<Mutex>::try_lock
尝试以共享模式锁定关联互斥而不阻塞。等效于调用 mutex()->try_lock_shared()。若无关联互斥,或互斥已被锁定,则抛出 std::system_error 。 -
std::shared_lock<Mutex>::unlock
从共享模式解锁关联互斥。等效于调用 mutex()->unlock_shared()
下面简单模拟一个例子:
#include <iostream>
#include <shared_mutex>
class A {
public:
void lock_shared() { std::cout << "lock_shared" << std::endl; }
void unlock_shared() { std::cout << "unlock_shared" << std::endl; }
};
int main() {
A a;
{
std::shared_lock l(a); // lock_shared
} // unlock_shared
}
输出:
lock_shared
unlock_shared
另外,下面这个例子展示如何使用share_lock进行读写操作:
class A {
public:
int read() const {
std::shared_lock<std::shared_mutex> l(m_); // 可以重复获取
return n_;
}
int write() {
std::unique_lock<std::shared_mutex> l(m_); // 唯一性
return ++n_;
}
private:
mutable std::shared_mutex m_;
int n_ = 0;
};
std::scoped_lock
C++17 增加的,类 scoped_lock
是提供便利RAII 风格机制的互斥包装器,它在作用域块的存在期间占有一或多个互斥。创建 scoped_lock
对象时,它试图取得给定互斥的所有权。控制离开创建 scoped_lock
对象的作用域时,析构 scoped_lock
并以逆序释放互斥。若给出数个互斥,则使用免死锁算法,如同以std::lock
。
std::scoped_lock
可以接受任意数量的 mutex,并将这些 mutex 传给std::lock
来同时上锁,它会对其中一个 mutex 调用 lock(),对其他调用 try_lock(),若 try_lock() 返回 false 则对已经上锁的 mutex 调用 unlock(),然后重新进行下一轮上锁,标准未规定下一轮的上锁顺序,可能不一致,重复此过程直到所有 mutex 上锁,从而达到同时上锁的效果。
下面模拟scoped_lock接受多个mutex类时的调用顺序,可以简单理解如何实现避免死锁算法:
#include <iostream>
#include <mutex>
class A {
public:
void lock() { std::cout << "A lock" << std::endl; }
void unlock() { std::cout << "A unlock" << std::endl; }
bool try_lock() {
std::cout << "A try_lock" << std::endl;
return true;
}
};
class B {
public:
void lock() { std::cout << "B lock" << std::endl; }
void unlock() { std::cout << "B unlock" << std::endl; }
bool try_lock() {
std::cout << "B try_lock" << std::endl;
return true;
}
};
int main() {
A a;
B b;
{
std::scoped_lock l(a, b); // 16
std::cout << "intv" << std::endl;
} // 23
}
输出:
A lock
B try_lock
intv
A unlock
B unlock
其实scoped_lock的增加就是对lock_guard的一种补充或者说扩展更合适,scoped_lock更多情况适用于多个mutex同时锁的情况,看下面的例子,使用scoped_lock更简洁:
friend void swap(X& lhs, X& rhs)
{
if (&lhs == & rhs)
return;
std::lock(lhs.m, rhs.m);
std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock);
std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock);
swap(lhs.some_detail, rhs.some_detail);
}
vs
friend void swap(X& lhs, X& rhs)
{
if (&lhs == &rhs)
return;
std::scoped_lock guard(lhs.m, rhs.m); // C++17 支持类模板实参推断,可以省略模板参数
swap(lhs.some_detail, rhs.some_detail);
}
总结
现代C++里提供了一系列的关于mutex
和lock
相关的接口。但是mutex
和lock
是不同的。mutex type
可以是mutex
,shared_mutex
等,而lock type
则可以是unique_lock
,shared_lock
,lock_guard
等。mutex
的生命周期和他保护的data
是一致的,可以被不同线程访问。而lock
则是在一段代码中用来封装管理mutex
的(What's the difference between "mutex" and "lock"?)。
-
lock_guard (1)创建即加锁,作用域结束自动析构解锁,无需手工解锁。(2)且不能中途解锁,必须等作用域结束才能解锁。(3)缺点在于在定义lock_guard的地方会调用构造函数加锁,在离开定义域时lock_guard就会被销毁,调用析构函数解锁。如果定义域范围很大的话,锁的粒度就会很大,影响效率。
-
unique_lock 当一个函数内部有两段代码需要保护时,这个时候使用lock_guard就需要创建两个局部对象来管理一个同斥锁,修改方法是使用unique_lock,它提供lock()和unlock()接口,能记录现在是处于上锁还是未上锁状态。
std::unique_lock<std:mutex>guard(_mu);
guard.unlock();//临时解锁
guard.lock(); //临时上锁而unique_lock在析构的时候会判断当前锁的状态来决定是否解锁,如果已经是解锁状态了,就不会再次解锁了,效率较慢。unique_lock是write lock。被锁后不允许其他线程执行被shared_lock或unique_lock的代码。
-
shared_lock 可用于保护共享数据不被多个线程同时访问。
std::shared_lock::lock
以共享模式锁定关联互斥。等效于调用 mutex.lock_shared();用于获得互斥的共享所有权。若另一线程以排他性所有权保有互斥,则到lock_shared
的调用将阻塞执行,直到能取得共享所有权。shared_lock
是read lock。被锁后仍允许其他线程执行同样被shared_lock
的代码。这是一般做读操作时的需要。 -
scoped_lock
在C++17中添加,其工作原理与lock_guard
和unique_lock
一样:其构造函数会进行上锁操作,并且析构函数会对互斥量进行解锁操作。scoped_lock
特别之处是,可以指定多个互斥量,同时锁定多个互斥量而不死锁。
总结一下,lock_guard是unique_lock的缩略版,适用于大多数场景,在任何情况下都会unlock保证了RAII。除非你需要用conditional variable,或者非要在一个scope里提前unlock,或者构建这个锁的时候非要不上锁,否则都可以用lock_guard。scoped_lock则是c++17里对于lock_guard的升级,可以一口气lock任意个mutex,保证不会死锁。
「如何选择」
对于一个基本的互斥场景来说。可以有一个mutex,然后每次使用的时候用unique_lock/lock_guard来封装mutex。对于读写场景来说,可以有一个shared_mutex,读的时候用shared_lock来封装,写的时候用unique_lock/lock_guard来封装这个shared_mutex。
简单来说,如果要用conditional variable,就用unique_lock。如果有多个mutex要同时lock,用scoped_lock。如果只要lock一个mutex,可以用lock_guard,不过更建议统一用升级过后的scoped_lock。
「对于死锁的建议」
C++17 最优的同时上锁方法是使用std::scoped_lock
,解决死锁并不简单,std::lock
和 std::scoped_lock
无法获取其中的锁,此时解决死锁更依赖于开发者的能力。避免死锁有四个建议:
-
第一个避免死锁的建议是,一个线程已经获取一个锁时就不要获取第二个。如果每个线程只有一个锁,锁上就不会产生死锁(但除了互斥锁,其他方面也可能造成死锁,比如即使无锁,线程间相互等待也可能造成死锁) -
第二个建议是,持有锁时避免调用用户提供的代码。用户提供的代码可能做任何时,包括获取锁,如果持有锁时调用用户代码获取锁,就会违反第一个建议,并造成死锁。但有时调用用户代码是无法避免的 -
第三个建议是,按固定顺序获取锁。如果必须获取多个锁且不能用 std::lock 同时获取,最好在每个线程上用固定顺序获取。上面的例子虽然是按固定顺序获取锁,但如果不同时加锁就会出现死锁,对于这种情况的建议是规定固定的调用顺序 -
第四个建议是使用层级锁,如果一个锁被低层持有,就不允许在高层再上锁
这篇文章就写这么多看了!
「参考」
https://www.apiref.com/cpp-zh/cpp/thread.html