C++互斥量和锁的使用
概述
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 | std::mutex rm, lm; |
上面代码:
- 利用
std::lock()
同时锁住两个互斥量rm
,lm
.std::lock
是要么都成功, 要么都失败的策略. - 分别利用两个互斥量构造
std::lock_guard
实例, 通过std::adopt_lock
指明这两个互斥量已经被lock了,std::lock_guard
据此获取锁的归属权, 而不会重新加锁.
std::scoped_lock<>
对象, C++17新增
RAII类模板
接受各种互斥量类别作为模板参数类别, 以多个互斥量对象为构造函数参数. 当构造完毕, 这些互斥量对象都被加锁, 其机制和std::lock
相同.
在析构函数中一起被解锁
上面的代码可以改写为:
1 | void swap(...) |
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_flag
和std::call_once()
std::once_flag
是一个类std::call_once()
方法
演示:
1 | std::shared_ptr<resource> resource_ptr; |
比如我们有一个类, 它有一个初始化很耗时的资源, 又不能是静态资源, 我们又希望它在第一次被使用时才初始化, 而这个类有多个方法会用到这个资源, 并且会在多线程中被调用. 我们就可以将资源初始化代码用std::call_once()
保护起来, 在每个需要资源的地方做初始化, std::call_once
可以确保初始化代码只会被调用一次.
对于静态局部变量, C++11中规定了初始化指挥在某一个线程上发生. 就没有这个问题了. 比如, singleton的最经典的实现方式就是这样.
1 | class X |
3. std::shared_mutex
和std::shared_timed_mutex
std::shared_time_mutex
: C++14新提供的std::shared_mutex
: C++17新提供的, 性能稍微好一点.- 它们都可以用于
std::lock_guard
和std::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>
尽量不要使用. 考虑代码设计是否有问题.