本文解决如下问题:
- 使用锁时要注意哪些问题。
- 死锁常见的两个例子以及如何避免死锁的两个简单方法。
- 条件变量的使用注意问题。
- 单例模式的问题与写法。
- 条件变量与锁的使用场景;
- 条件变量中的虚假唤醒原理是什么?
- 如何避免把类当做函数调用这种问题?
- 如何减少锁争用?(锁的延迟的主要占用点)
1. 使用锁时要注意哪些问题。
- 不直接使用std::mutex的lock和unlock函数,一切交给unique_lock等封装对象来完成;
- 使用unique_lock对象时,要考虑当前调用栈上是否已经持有了这个锁,避免死锁。
- 不使用跨进程的mutex,进程间通信只使用TCP sockets;
- 加锁和解锁必须在同一线程;(RAII自动保证)
- 不使用递归锁和读写锁、信号量。
对第五点的解释:读写锁在读优先的情况下会阻塞写操作,甚至会造成饥饿现象;写优先的情况下,会阻塞读,对读效率很敏感的程序来说很不友好。而普通锁在设计良好的情况下,临界区很小,效率是很高的,没有这些问题。
信号量的实现完全可以通过互斥器与条件变量来实现。另外信号量有自己的计数值,通常我们的数据结构也会保存一个计数值,两个计数值需要保持一致,增加了出错的可能。
2. 死锁常见的两个例子。
1. 同一线程发生死锁
同一个类中有锁的一个函数辗转调用了另一个有锁的函数。func1和func2调用时都持有锁,然后func2调用了func3,func3反过来调用了func1。调用关系如图1所示,此时存在死锁现象。
func2直接调用func1,没有func3也会发生死锁。
2. 两个线程发生死锁
两个类中持有锁的两对函数相互调用。类1中包含func1,func2;类2中包含func3,func4,然后func1调用func3,func4调用func2。(反向相互调用)如图2所示。
func1和func2可以重叠;func3和func4可以重叠。
3. 死锁的检测与预防
预防:
- 严格控制锁的调用顺序,用锁之前需要想调用栈上都有了哪些锁;
- 将锁内调用的函数同时定义一个无锁版,锁内调用那个无锁版;
检测:死锁之后,打开core文件或者使用如下gdb命令查看:
thread apply all bt
看到线程阻塞在一个锁上,或者某个非条件变量或者epoll_wait函数上,就是发生了死锁。
3. 条件变量的使用注意问题。
条件变量一般用于等待某个条件成立,即等待某个bool表达式为真。
- wait端
(1)必须与mutex一起使用;wait函数和bool表达式的判断需要在mutex的保护下;
(2)把bool表达式的判断和wait放到while循环中。
std::unique_lock lock(mtx);
while(queue.empty()) {
cond.wait();
}
//在锁的保护下,从queue中获取变量。
- signal端
(1)notify、notifyAll函数不一定在已上锁的情况下调用;
(2)调用notify之前一般要修改bool表达式;(修改bool表达式要有锁保护)
std::unique_lock lock(mtx);
queue.push_back(x);
cond.notify();
4. 单例模式的问题与写法。
1. double check locking(DCL)实现单例模式的问题
DCL实现方法:
if(instance == null) {
lock();
if(instance == null) {
instance = new Instance();
}
}
在对instance的初始化的时候,编译器会按如下步骤:
(1)分配内存空间;
(2)初始化内存;
(3)将内存的地址赋值给instance。
编译器对(2)(3)步没有顺序限制。结果就是如果先(3),就会instance没有初始化就有值了,外部线程到了第一个if,检测有值,就去使用了,发生了未定义行为。
2. 使用static来完成单例模式
class SingleInstance {
private:
public:
get() {
static instance;
return instance;
}
}
- 函数内的static对象是懒惰初始化,什么时候用什么时候初始化。
- 类静态变量的初始化则是饿汉初始化,即使不用类对象,也会在main函数开始执行之前进行初始化,不好。
- 除了使用函数内static初始化,还可以使用std::thread_once来保证只调用了一次。
5. 条件变量与锁的使用场景
- 锁是为了访问共享数据;
- 条件变量是为了等待事件发生。等待事件发生严禁使用sleep函数。
- 条件变量的notify通常代表资源可用(生产者模式);notifyall通常代表状态变化。(比如倒计时系统,可以开始做事了的那种)
这两种用法都很常见。所以都要会。
6. 条件变量中的虚假唤醒原理是什么?
1. 基本原因
虚假唤醒是因为条件变量的wait函数调用的是futex系统调用,这个系统调用在阻塞状态下,被信号打断的时候会返回-1。即线程被唤醒了。
2. 验证
将一个线程阻塞在cond.wait()函数上,然后调用kill向这个线程发送信号,然后就会发现阻塞的线程被唤醒了。
3. 解决
虚假唤醒代表的是等待条件变量唤醒的线程可能不是因为bool表达式为正而唤醒,所以需要将wait放到一个循环中,而不是一个if中。
C++11提供的条件变量的wait函数可以使用第二个参数来实现自动while,这样就不用放到while循环了。
7. 如何避免把类当做函数调用这种问题?
比如定义了GuardLock类来封装std::mutex的使用。但是使用时不小心这样使用了:
GuardLock(std::mutex);
问题:
定义了一个临时变量,紧接着就析构掉了。锁没有加上。
解决:
#define GuardLock(x) static_assert("false", "hehe");
编译期找到这个错误。
8. 如何减少锁争用?(锁的延迟的主要占用点)
真正影响性能的不是锁,而是锁争用。
上一小节,有介绍如何减少锁争用:
- 对于拷贝代价比较小的共享变量来说:
读操作,临界区内拷贝出来,临界区外使用副本读取;
写操作,临界区外定义副本,完成要完成的操作,临界区内直接赋值或者swap;
读和写都使用副本,而不是使用共享变量,这样就不会出现竞争。当然,如果共享变量是指针,要拷贝的是指针指向的值。
问题:
多个进程的写操作还是会相互覆盖,且临界区定义副本,这个副本的初始值无法界定。所以这种方法不宜使用。(有缺点就不宜使用)
- 将无关操作移出临界区。
写操作时析构移除临界区:一般指的是shared_ptr在临界区内析构的问题
为了防止其在临界区内析构,我们可以使用栈上的临时变量来增加引用计数,然后在临界区外析构临时变量。
避免临界区内的循环。
避免出现一边遍历一边决定是否对共享变量进行更改的情况;应该在临界区外遍历,然后记住该改哪些元素,最后在临界区内统一修改。shared_ptr来实现copy_on_write
有几个条件:
(1)共享变量使用shared_ptr来管理。使用shared_ptr来管理其实和指针是一样的;
(2)读和写都要获取到锁,同一时间只能有一个线程拥有锁。copy_on_write的关键在于减少锁争用。
(3)读操作,放心读,抢到锁就读,但是需要在读之前定义一个临时变量,来增加引用计数;
(4)写操作,无法放心写,抢到锁之后,得先判断引用计数是否有别的线程在读,如果没有就可以直接写,如果有,为了减少写等待,需要深拷贝动作。
如图3所示:
读操作:
(1)抢到锁之后,先定义出来ptr1的副本ptr2,但是都是指向的共享变量,然后就可以退出临界区了。
(2)临界区外使用ptr2来访问临界区。因为ptr1可能被写。
(3)每个读都会创建一个ptr2副本,所以可能同一时间ptr的副本有超多个。
(4)可以在临界区外访问共享变量是因为只要有读,写不会改变这个共享变量,只会改变共享变量的副本。
写操作:
(1)抢到锁之后,判断有没有读(根据ptr的引用计数值是否为1);
(2)没有读的话,可以直接修改共享变量;
(3)有读的话,二话不说,把共享内存拷贝出来一份,然后在拷贝出来的内存中进行修改,最后把ptr3赋值给ptr。
(4)所有的写操作都要在临界区内部。
(5)如果这里的共享变量仍然是一个指针,拷贝共享变量使用深拷贝就可以。
问题:
- 由于存在拷贝操作,共享变量无法太大。
- 共享变量太大也可以,不要频繁写就可以。(比如一段时间更新一次数据)
- 如果 单线程写 && 写时获取到的是全部数据,(不是共享变量的一部分),则退化到第一点,无需拷贝,直接临界区内swap就可以了。