vlambda博客
学习文章列表

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_forconst std::chrono::duration<Rep,Period>& timeout_duration )
;

template< class Clock, class Duration >
bool try_lock_untilconst 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

锁定给定的可锁定 对象lock1lock2...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

尝试锁定每个给定的可锁定  对象lock1lock2...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++里提供了一系列的关于mutexlock相关的接口。但是mutexlock是不同的。mutex type可以是mutexshared_mutex等,而lock type则可以是unique_lockshared_locklock_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_guardunique_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::lockstd::scoped_lock 无法获取其中的锁,此时解决死锁更依赖于开发者的能力。避免死锁有四个建议:

  • 第一个避免死锁的建议是,一个线程已经获取一个锁时就不要获取第二个。如果每个线程只有一个锁,锁上就不会产生死锁(但除了互斥锁,其他方面也可能造成死锁,比如即使无锁,线程间相互等待也可能造成死锁)
  • 第二个建议是,持有锁时避免调用用户提供的代码。用户提供的代码可能做任何时,包括获取锁,如果持有锁时调用用户代码获取锁,就会违反第一个建议,并造成死锁。但有时调用用户代码是无法避免的
  • 第三个建议是,按固定顺序获取锁。如果必须获取多个锁且不能用 std::lock 同时获取,最好在每个线程上用固定顺序获取。上面的例子虽然是按固定顺序获取锁,但如果不同时加锁就会出现死锁,对于这种情况的建议是规定固定的调用顺序
  • 第四个建议是使用层级锁,如果一个锁被低层持有,就不允许在高层再上锁

这篇文章就写这么多看了!

「参考」

https://www.apiref.com/cpp-zh/cpp/thread.html