概述

  • C++标准库中的互斥量和锁

    • std::lock
    • std::scoped_lock
    • std::unique_lock
    • std::shared_lock
    • std::recursive_mutex
    • std::shared_mutex
    • std::shared_timed_mutex
    • std::shared_lock
  • Qt中的对应物

    • QMutex

    • QRecursiveMutex

    • QMutexLocker

    • QReadWriteLock

    • QReadLocker

    • QWriteLocker

接口设计

设计接口的时候, 要注意接口存在的固有的条件竞争, 设计的接口要注意这个情况. 避免相关的两个接口调用之间的数据状态发生变化.

例如, 一个队列, 如果定义了front()来查询队列首部元素, 提供pop()来修改队列. 这两个接口就会导致这种情况: 如果我们先调用front(), 再pop(), 在多线程情况下就容易出现不一致的情况, 除非我们在外部再做互斥保护.

1. 死锁和保护措施

1.1 基本原则:

如果能按照相同的顺序加锁解锁, 可以从根本上避免死锁

只要一个线程A可能会等待另一个线程B, 则线程B千万不能等待A.

1.2 当需要同时获取多个锁时的防死锁措施

利用C++11的std::lock和C++17提供的std::scoped_lock:

std::lock同时锁住多个互斥, 不会有死锁风险

1
2
3
4
5
6
7
8
9
10
std::mutex rm, lm;
...

void swap(... )
{
std::lock(lm, rm );
std::lock_guard<std::mutex> lock_r(rm, std::adopt_lock);
std::lock_guard<std::mutex> lock_l(lm, std::adopt_lock);
...
}

上面代码:

  1. 利用std::lock()同时锁住两个互斥量rm, lm. std::lock是要么都成功, 要么都失败的策略.
  2. 分别利用两个互斥量构造std::lock_guard实例, 通过std::adopt_lock指明这两个互斥量已经被lock了, std::lock_guard据此获取锁的归属权, 而不会重新加锁.

std::scoped_lock<>对象, C++17新增

RAII类模板
接受各种互斥量类别作为模板参数类别, 以多个互斥量对象为构造函数参数. 当构造完毕, 这些互斥量对象都被加锁, 其机制和std::lock相同.
在析构函数中一起被解锁

上面的代码可以改写为:

1
2
3
4
5
6
void swap(...)
{
...
std::scoped_lock guard(lm, rm);
...
}

std::unique_lock<>, C++17新增

  • 类似与std::lock_guard<>, 支持第二个参数, 指定创建的时候是否加锁:

    • std::adopt_lock, 指明由创建的std::unique_lock<>管理互斥的锁
    • std::defer_lock, 指明创建的对象完成构造后处于无锁状态, 后续可以在需要时再加锁. 有两种方式:
      • std::unique_lock对象调用lock()来加锁, 或者
      • std::unique_lock对象作为参数传递给std::lock()来加锁. 比如前面的例子可以重写为:
    1
    2
    3
    4
    5
    //...
    std::unique_lock<std::mutex> lock_a(lm, std::defer_lock);
    std::unique_lock<std::mutex> lock_b(rm, std::defer_lock);
    //...
    std::lock(lock_a, lock_b);
  • std::unique_lock<>和互斥量一样, 有lock(), try_lock(), unlock()成员函数.

  • 相对于std::lock_guard, 它占用更多的存储空间, 有少许性能损失.

  • std::unique_lock<>是可移动而不可复制的类型, 可以用于需要转移锁的归属权到其他作用域时
    这个意思是, 可以在函数中锁定互斥, 然后将互斥的归属权转移给函数的调用者, 在同一个锁的保护下执行其他的操作.

    下面的代码中, 在get_lock()中先锁定互斥, 对数据做处理, 然后返回, 将unique_lock对象返回给调用者继续处理:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17

    std::unique_lock<std::mutex> get_lock(...)
    {
    extern std::mutex some_mutex;
    ...
    std::unique_lock<std::mutex> lk(some_mutex);
    ...
    return lk;
    }

    void process()
    {
    ...
    auto lk{get_lock};
    ...
    }

  • std::unique_lock<>另一个好处是可以在不需要锁的时候暂时释放锁, 在需要的时候再重新加锁.
    –我认为这中做法又引入了死锁的风险或数据不一致的风险. 应该只在特定时刻使用.

避免死锁要注意的事项

  • 避免嵌套锁
  • 加锁后要尽量避免调用用户提供的接口, 避免发生不可知的影响
  • 依照固定的顺序获取锁
  • 划分应用层级, 按照层级加锁

2. 初始化过程中的保护: std::once_flagstd::call_once()

  • std::once_flag是一个类
  • std::call_once()方法

演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
std::shared_ptr<resource> resource_ptr;
std::once_flag resource_flag;
void init_resource()
{
resource_ptr.reset(new resource);
}

void foo()
{
std::call_once(resource_flag, init_resource);

resource_ptr->do_something();
}

比如我们有一个类, 它有一个初始化很耗时的资源, 又不能是静态资源, 我们又希望它在第一次被使用时才初始化, 而这个类有多个方法会用到这个资源, 并且会在多线程中被调用. 我们就可以将资源初始化代码用std::call_once()保护起来, 在每个需要资源的地方做初始化, std::call_once可以确保初始化代码只会被调用一次.

对于静态局部变量, C++11中规定了初始化指挥在某一个线程上发生. 就没有这个问题了. 比如, singleton的最经典的实现方式就是这样.

1
2
3
4
5
6
7
8
class X
{
static X& instance()
{
static X _instance;
return _instance;
}
}

3. std::shared_mutexstd::shared_timed_mutex

  • std::shared_time_mutex: C++14新提供的
  • std::shared_mutex: C++17新提供的, 性能稍微好一点.
  • 它们都可以用于std::lock_guardstd::unique_lock.
  • 还使用std::shared_lock<std::shared_mutex>实现对无需修改的共享资源的并发访问
  • 行为:
    • 若它已经被某些线程所持有, 若其他线程试图获取排他锁, 就会阻塞, 直到那些线程都释放了共享锁
    • 若任一线程持有排他锁,那么其他线程都无法获取共享锁或排他锁, 直到持锁线程释放排他锁为止.
    • 共享锁的意思: std::shared_lock<std::shared_mutex>
    • 排他锁的意思: std::lock_guard<std::shared_mutex>std::unique_lock<std::shared_mutex>

比如, 我们如果有共享资源, 允许共享读取, 但是修改只能排他, 那么就可以使用shared_lock<shared_mutex>在读取的时候做保护, 而使用lock_guard<shared_mutex>在修改的时候保护.

4. 递归锁std::recursive_mutex>

尽量不要使用. 考虑代码设计是否有问题.