问题入门

请想象一个场景,一个寝室内有两个独立的房间,但只有一个浴室,如果此时的你正在洗澡,但你发现你的好哥们也要使用浴室,那想必一定会是尴尬的场面。这时我想你会说浴室不是有门锁着吗,或者说把门锁着不久没人进得来了。恭喜你,你抓住了重点,!!!!!!,浴室就是公共资源,两个独立的房间就是独立的线程,你和你的好哥们在自己房间活动是不会影响到对方的,可以一旦你们要同时使用公共资源时,那么你们将存在竞争关系。而有了,就可以使得你们有次序的使用公共资源而不会出现混乱的局面。这时候有人会说了家里有两个浴室,此时确实没有使用锁的必要了,不过不使用锁不是本章的重点,就是用来处理公共资源有限或同步数据修改的场景的,比如两个浴室同时有三个人争用。

互斥问题

对公共数据如容器进行增加、删除操作时,在多线程环境下需要保证操作的原子性(某线程在修改过程中一气呵成,不会被其他线程打断),否则将会出现重大安全事故。例如线程A正在对容器进写入操作,但尚未写入完成,此时若有线程B抢占该资源也进行写入,完成后退出,紧接着线程A再次获得使用权并恢复上次的修改现场,再次写入,则极有可能在容器的同一位置进行重新写入,导致覆盖线程B的修改结果。出现该问题的本质就是正在修改的公共数据的行为会被打断,也就是说,只要保证该行为的原子性就避免此种问题的发生,而互斥锁就是解决此类问题的工具之一。(标准的说法是给互斥量加锁,为了易于理解功能为主要目的,后文会直接称为锁)

互斥锁

互斥锁在头文件<mutex>中,mutex对象的三个常用函数:

  • void lock() // 加锁
  • void unlock() // 开锁
  • bool try_lock() // 尝试加锁:失败为false

使用起来也非常的简单,只需要在修改公共资源的操作之前加锁,操作完毕后解锁即可,如下:

std::mutex mtx;

void Func()
{
   mtx.lock();
   // ......对公共资源进行修改
   mtx.unlock(); // 切记一定要开锁
}

加锁时也可以使用try_lock()函数进行加锁并获取其返回值判断加锁是否成功。当线程A第一次进入Func()函数后执行mtx.lock();,此时线程A获得了锁的归属,它可以继续向下执行;而若此时线程B也在访问该函数,当它执行到mtx.lock();时发现mtx已经上锁,无法拿到锁的归属,就不会继续向下执行,会一直在外等待,直到线程A执行完mtx.unlock();开锁之后,线程B才能够再次重新加锁,然后向下执行。

恭喜你!你已经会使用互斥锁了,快去试试吧!使用完后我们可以想想隐藏在其中的问题:

  1. lock()unlock()必须配套使用,若一个线程在加锁之后提前离开,并未开锁,那么结果就是之后的所有线程都被挡在所外,再也无法进入,造成死锁现象;
  2. 同一线程对相同的锁进行重复加锁会导致未定义行为(通常是程序直接死锁或崩溃);
问题1:记得开锁

请思考以下代码的问题:

std::mutex mutex;

void Func()
{
   mutex.lock(); // 加锁

   // 情况1:条件判断
   if (ptr == nullptr) 
      return;

      // 情况2:分支判断
   switch (0)
   {
   case 1:
      // 具体操作
      break;
   default:
      return;
   }

   // 情况3:抛出异常
   try
   {
      // ......抛出异常的代码
   }
   catch (const std::exception&)
   {
      return;
   }

   mutex.unlock(); // 解锁
}

以上三种判断情况都有可能导致函数提前退出,从而无法解锁,导致后面的线程永远无法继续执行,造成巨大的程序事故。
一般来说,这些分支情况需要各位大佬的小心防护,不过有好消息是,C++标准库提供了优雅的解决方式:std::lock_guard;具体用法如下:

std::mutex mutex;

void Func()
{
   std::lock_guard<std::mutex> guard(mutex);
   std::lock_guard guard(mutex); // 高版本C++支持自动推导
   // ......操作代码
}

不必再为了开锁的位置焦头烂额,特别是那种各种分支混杂的函数。我们可以来看看std::lock_guard大概原理(只是帮助了解大概原理,这并非标准库的真正实现,请注意!!!):

class lock_guard
{
   public:
   lock_guard(std::mutex &mutex)
   {
      m_mutex = mutex;
      m_mutex.lock(); // 加锁
   }

   ~lock_guard()
   {
      m_mutex.unlock();// 开锁
   }

   private:
   std::mutex m_mutex;
};

使用lock_guard类对象进行加锁生命周期的管理,在构造函数中自动加锁,无论发生什么情况,在退出函数的那一刻,利用局部变量的自动释放,在其析构函数中进行开锁,也就保证了一定存在解锁的行为,优雅,实在是太优雅了!!!(再提醒一句,这并非lock_guard类真正的实现)。

同时,lock_guard存在第二个参数std::adopt_lock,它是一个标志位,表示当前获取的锁已经被锁住,无需再在构造函数中进行加锁,只需要保证在析构函数中解锁就行,具体用法如下:

std::mutex mutex;

void Func()
{
   mutex.lock(); // 事先锁住
   // ......其他跟锁无关操作
   std::lock_guard<std::mutex> guard(mutex, std::adopt_lock);
   // ......其他操作
}

注意:使用该标志位时一定要保证目标锁已经上锁,否则轻则上锁失败,重则程序崩溃!

额外,在C++17中,引入了std::scoped_lock,它是std::lock_guard增强版。

更灵活的加锁

在标准库中,std::unique_lock也可以用于加锁管理,基本用法与std::lock_guard一致,灵活在于它是一个模板类型,也支持第二参数,含义如下:

  • std::adopt_lock:同上;
  • std::defer_lock:与std::adopt_lock刚好相反,它表示目标锁在初始化时不上锁,但在后续的使用中需要手动调用lock()函数进行加锁。(std::unique_lock模板类存在lock()unlock()try_lock()等函数)
  • std::try_to_lock:在构造函数中尝试加锁,但不阻塞。

std::unique_lockstd::lock_guard灵活的另一个功能是,它可以在不同作用域中转移互斥锁的归属权(人话:在函数中将锁返回),好处是函数调用者可以在同一个锁的保护下执行其他操作:

std::mutex mutex;

std::unique_lock<std::mutex> GetLock()
{
   std::unique_lock<std::mutex> lock(mutex);
   //...... 其他操作
   return lock;
}

可能会有读者疑问,上述代码中返回的是局部变量,使用时会不会已析构或不是同一个锁了,大可放心,该模板是只转移不复制的类型,具体可了解std::move()相关作用;

同时,值得注意的是,std::unique_lockstd::lock_guard更灵活的代价就是因为有标志位等的存在,所占内存要大一点,运行效率要低一点,所以请按需使用,优先使用std::lock_guard

同时加多个锁

在标准库中提供了std::lock()用于同时给多个互斥量加锁的操作,它保证了所有锁同时加锁成功,否则全部加锁失败;

std::mutex mutex1, mutex2, mutex3, mutex4;

void Func()
{
   std::lock(mutex1, mutex2);
   // 配合“lock_guard”使用提前加锁
   std::lock_guard<std::mutex> guard(mutex1, std::adopt_lock);
   std::lock_guard<std::mutex> guard(mutex2, std::adopt_lock);

   // 配合“unique_lock”使用延迟加锁
   std::unique_lock<std::mutex> lock1(mutex3, std::defer_lock);
   std::unique_lock<std::mutex> lock2(mutex4, std::defer_lock);
   std::lock(mutex3, mutex4);
}
问题2:重复加锁

为了解决同一线程对同一互斥锁进行多次加锁导致未定义的行为,标准库提供了递归锁std::recursive_mutex类型的mutex,其工作方式与std::mutex相似,但值得注意的是,对std::recursive_mutex类型的互斥量加多少次锁lock(),就必须调用相应次数的开锁unlock()。若非项目设计实在需要,一般不推荐使用该互斥量,所以这里不做详解。

注意事项

对于需要加锁访问的共享数据,我们不应该在函数中向外传递它的地址或引用,也不要在调用的外部函数中修改它的数据,不然就破坏了加锁的本意。
,对std::recursive_mutex类型的互斥量加多少次锁lock(),就必须调用相应次数的开锁unlock()。若非项目设计实在需要,一般不推荐使用该互斥量,所以这里不做详解。

注意事项

对于需要加锁访问的共享数据,我们不应该在函数中向外传递它的地址或引用,也不要在调用的外部函数中修改它的数据,不然就破坏了加锁的本意。

更多推荐