🚀死锁概念
如果现在有两个线程thread1和thread2,整个体系之中存在两把锁,mutex1和mutex2,现在出现这这样一种情况:

thread1已经持有锁1,thread2已经持有锁2,而此时thread1需要mutex2锁才能继续执行,那么thread1就等待mutex2释放,而thread2这时也需要mutex1才能继续执行,所以thread2也需要等待thread1释放mutex1,这样两个线程相互等待对方释放锁才能继续运行后续代码。
其实很简单,张三来到一个小吃摊前面,跟老板说,xxx好吃吗?我先来尝尝,好吃我就买。老板这时就急眼了,小本生意,不买就别吃。张三又说,我不吃怎么知道好不好吃,好吃我才买。总之张三跟老板谁也不让。这就是一种死锁,虽然现实当中这种事很荒唐,但是在计算机当中这种问题是闲荡重要的。
这样的情形我们称为死锁,也是最常见的死锁情况,死锁会导致占用公共资源不释放,甚至可能会造成饥饿问题,所以程序员需要避免死锁的情况。
死锁产生的四个必要条件:
而我们想要避免掉死锁,只需要破坏死锁产生条件的任意一条或者多条,就可以避免死锁。
破坏互斥条件,一句话:能不加锁就不加锁,有些代码不需要加锁我们就尽量不加锁,避免死锁问题最有效的方式就是不加锁。不过需要限制资源访问以及并发问题时还是需要加锁的,这个时候我们只能破坏另外的三个条件了。
破坏请求与保持条件,这个条件简单来说就是,我不仅要吃自己的食物,我还要吃你的食物。即申请一个锁之后申请另外一个锁,比如一个线程需要申请锁1和锁2,此时已经拥有锁1,但还需要申请锁2,这个时候很可能会发生死锁的情况(别的线程刚好持有锁2而恰好又需要锁1, 导致互相等待对方释放)。所以我们可以对锁2申请的时候进行判断,如果申请失败则释放锁1,申请成功继续执行。
破坏不剥夺条件:这个条件的破坏情况比较复杂,如果一些特殊的场景确实需要几把锁,我们实际上不能随意剥夺。当然,我们可以想办法来剥夺,我们可以像进程那样,给线程设置优先级,优先级高的可以剥夺优先级低的线程的锁。
破坏循环等待条件:建议如果线程需要申请多把锁,每个线程申请的锁的顺序一致,尽量避免,这样就可以避免环路等待,从而避免死锁。
STL容器不能保证线程安全。原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响.而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶)。因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全。
对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题。对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数。
我们前面学习的不论是互斥锁还是二元信号量都属于悲观锁的范畴,担心数据被其线程修改所以提前加锁。
如果你学过设计模式,那么一定知道有一种常见的单例模式叫做懒汉模式,不知道也没关系,我来简单介绍一下:
什么是单例模式?顾名思义,单例模式实际上就是在整个进程当中,这个类的对象有且只有一个,当然,仅限于当前进程。而单例模式分为饿汉模式与懒汉模式。
懒汉模式相当于一个人想要吃饭了再去做饭。以下的代码就是一个简单的懒汉式单例模式:
template <typename T>
class Singleton {
static T* inst;
public:
static T* GetInstance()
{
if (inst == NULL)
{
inst = new T();
}
return inst;
}
};当代码中第一次调用GetInstance时,会创建单例对象,而后每次调用的单例对象都是最初创建的单例对象。这样就能保证创建出来的一定是单例。但是引入了线程,如果多个线程恰好并发初次执行GetInstance()接口,这样就会创建出多个类对象,也就不能称之为单例了。
那么创建单例的过程就变为了临界资源,所以在懒汉模式下,我们需要给GetInstance()内的临界区加锁保证同一时刻只有一个线程在创建单例:
template <typename T>
class Singleton {
static T* inst;
public:
static T* GetInstance()
{
if(nullptr == inst)// 如果第二次及以后获取单例我们不再加锁,直接返回即可
{
lock();// 第一次获取单例需要加锁
if (inst == NULL)
{
inst = new T();
}
unlock();
}
return inst;
}
};我们从来没有讨论过线程在临界区内执行时间长短的问题:

有一天阿熊11点钟去小美楼下给小美发消息一起吃饭,小美一看手机才11点,而且目前自己还有事情,所以小美就跟阿熊说:“这才几点钟,我还要好一会才去吃饭,你要是急你先自己去吧”。阿熊收到消息后,毅然决然的给小美发消息:“那我先去网吧打会游戏,你好了再叫我”。于是到了1点钟,小美就叫阿熊一起去吃饭了。 第二天,阿熊这次12点来找小美共进午餐,这个时候小美刚好饿了,眼下也没事情,于是小美给阿熊回电话:“我等下就下来”。于是阿熊就一直在楼下等待,果不其然,没几分钟小美就下来了。
这是一个很平常的故事,映射到线程,其中阿熊和小美是两个线程,阿熊看小美还要很久才会下来,于是就去网吧打游戏去了。实际上这种行为就是线程在临界区内执行时间比较长,所以其他线程会阻塞挂起,相当于阿熊等小美。
而第二次阿熊叫小美去吃饭,小美说马上就下楼来一起去吃饭,于是阿熊就一直在宿舍楼下等小美下楼共进午餐,这种行为实际上就是其他线程在 自旋,那么不难看出,自旋实际上就是不断的对资源发出请求,当资源空闲就可以快速占有资源。
我们之前一直用的互斥锁都属于第一种情况的锁,而对于一些在临界资源访问时间比较短的线程来说,可以采用自旋锁来对线程进行加锁控制,而在Linux的pthread线程库当中,给我们提供了自旋锁的一些接口:

这种接口大家应该都不陌生,与互斥锁的使用除了名称不同几乎没什么差别。可以直接平替互斥锁加锁。而我们所说的自旋的过程在函数内部就已经帮助我们自旋。
线程中有两个经典的应用场景,一个是生产消费模型,另一个就是读者写者模型。
读者写者模型应用场景很多,以下几种仅为举例:在csdn发文章,杂志,出黑板报等等都是读者写者模型。读者写者模型常见情况是——读者众多,写者较少
读者写者模型也符合“321”原则,即:
写者和写者之间属于竞争,所以是互斥关系。读者与写者之间的关系一定有互斥,比如当一个人在写工作报告,但是没写完这时候被接收人看到直接把没写完的数据汇报,就会造成数据不一致的问题。而同时它们又要保持同步关系,因为工作报告写完需要提交给接收人,接收人需要读取工作报告获取进度信息。 而 读者和读者之间,实际上 没有关系,读者写者模型并不是生产消费者模型,因为消费者之间会存在消费行为,会消耗资源。而读者写者模型读者之间仅仅需要读取拷贝,并不需要对资源进行更改,所以读者与读者之间没有关系。
依照上述的321原则,再根据之前的生产消费者模型我们不难写出伪代码:

因为读写者模型有两个角色,并且工作不同,所以需要定义两把锁将其加锁处理,同时使用计数器记录读者是否正在读取。
读者在读取数据的时候需要加锁,如果是第一个读者,会分为两种情况,一种是写者正在写,而写者在写时,需要跟写者保持互斥关系,所以在访问数据时,也需要申请wlock。在申请成功后,将读者read_count+1,这样其他读者线程就进不来了。随后在进行常规的读取拷贝操作。、
读取完成之后,需要将reader_count自减,把读者状态置为0,让其他读者线程可以进行申请锁,申请数据资源。而在正常的开发中,pthread线程库里给我们提供了 读写锁的接口:
#include <pthread.h>
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);// 销毁读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restric attr);// 初始化读写锁
int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);// 读者加锁
int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);// 写者加锁
int pthread_rwlock_unlock(pthread_rwlock_t* rwlock);// 解锁读者优先与写者优先就是字面含义,是读者先读还是写者先写,是一种同步顺序问题。通常情况下,一般是都是读者优先,不过 读者优先有可能导致写者饥饿问题,因为读者读取时需要持有写者的锁,如果读者线程持续不断地抢占写者的锁,就会造成写者饥饿问题。
这个问题对于读写者模型并不是很严重,因为按照正常情况来说,一个作者写好作品,大部分时间就是读者在读,少许时间是写者在写。所以可以将其看作读写者模型的一个特点。
如果实在对这个问题感到厌恶,可以采用写者优先的方式来写代码,不过这样就必然会使得写者代码变得更加复杂,比如需要添加标记为,记录读者是否来了,添加等待队列等等。