线程安全的单例模式

时间:2024-04-26 21:35:37

STL是不是线程安全的?

不是,STL只考虑高效

智能指针是不是线程安全的?

大部分智能指针是安全的。智能指针 说白了 就是 指针管理。

什么是单例呢?

单例模式在任何时候只允许类最终定义一个对象

为什么要提一提单例呢?
因为后面网络服务器希望它是单例的,一个服务器一旦启动了那就一个启动就行了,不需要再用它的类创建更多的对象了。
另外线程池我也不想过多创建,一会总结线程池优缺点,再来谈一谈为什么要把他改成单例模式。

什么是设计模式?
编代码也是有方式的,发展这么多年大神们最后大家的代码都很类似,最后总结出一套模板,
这套模板也叫作编码的方式,我们称之为设计模式。

什么叫做编程最后大家代码都类似了呢?
先描述在组织 本质就是一套面向对象的思想,大部分语言都支持面向对象,这就是语言设计者发现都 大家使用时都一样,都需要面向对象,他不是语言必要的特征,他是计算机发展必然的阶段,大家刚开始你是你我是我,二进制,汇编,C的,VB VC。

单例模式分为两种理念
饿汉
吃完饭,立刻洗完,下一次直接就能拿着碗就能吃饭。
懒汉
吃完饭,先放下碗,下一次用时再洗 延时加载

举个例子
你申请内存空间时,只是在地址空间上允许你去访问,当你真正去访问时我们OS才会帮你缺页中断然后在内存里申请空间。本质是当我们需要时才给你。这就是延时加载的应用。

延时加载的好处是什么?
当你调用时,调用速度变快,启动时,启动的速度更快。
加载的时间总是需要花费的,所以我在你首次使用的时候再加载。
它并没有在整体上提高效率,因为我需要时你还是得加载,但是好处是我们创建对象,申请内存在时间的配比上发生了改变,现在是申请是申请,使用是使用,在局部上加快启动速度。

单例模式其实是语言级创建对象的方式,我们只考虑语言,不考虑操作系统会做什么。
在这里插入图片描述

static T data 类内静态全局数据,因为是静态的,在类里只有一份而且属于类不属于对象。
不管再怎么用Singleton定义对象它里面的静态成员只有一份。
问题
在C++中,拿类定义变量时,static 变量是什么时候就已经存在 了?
static 是静态全局数据,也是全局的一种,处理时和全局数据一模一样。
代码编译好后,把代码运行刚变成进程这些全局变量/静态变量就一定已经有了,不像栈区你需要的时候才有。
一定把语言和系统能够打通。比如你有一个类,类编译时有属性和方法,方法就到代码区了。

你怎么证明啊?
对象Init t 是在main函数的内部,也就是在栈上开辟的对象,三秒过后才输出构造时的打印,三秒前没创建对象。
在这里插入图片描述

如果t 在全局创建,
程序一起动hello world直接就被打印出来了,说明全局对象在启动时开始时就一定要有了,构造就要在运行时给你做了。
在这里插入图片描述
如果定义了很多全局对象,每一个全局对象都在运行时帮我们就创建出来了,所以注定了对象如果比较大它会影响加载速度。
在这里插入图片描述
全局对象是加载时就创建了,整个进程的生命周期从来不释放,他们的生命周期叫随进程,进程启动就加载,进程退出才让他释放,这不就是语言上全局变量具有全局性。
本质上不就是进程启动创建地址空间,完了之后释放地址空间。

饿汉模式启动时,类内有静态成员,一定在加载时要把对象创建出来了。当我下次使用对象时拿着对象就能访问。
在这里插入图片描述
在这里插入图片描述
懒汉模式在创建对象时,内部成员是静态的,但是它只是一个指针并不创建对象,而是当我需要首次调用Getinstance时获取单例时,需要的时候我们才把单例new出来,所以他就可以让我们加载时不用花太多时间,而是当真正使用时再new创建对象。

其实饿汉不饿汉的你自己定义全局对象就完了。我们就不讲了。

我们想把对象设计成懒汉模式。把线程池直接改成单例,以懒汉为例。
该怎么改呢?
我们需要有一个指针指向当前线程池的单例,这个指针不需要暴露出去。
这个指针和对象我们都想让他只有一份,所以指针设置成静态的
静态成员类外初始化,类内初始化不了。
在这里插入图片描述
线程池对象我也不希望能被拷贝能被赋值,所以拷贝和赋值要设置为私有并去掉,凡是能够形成临时对象,或 可能会发生形成第二个对象的操作,都有私有并去掉。
但构造方法一定是要有的,因为你还要构建单例的。
在这里插入图片描述
下面就是得给外部提供获取单例的接口。我们要求这个方法是静态的(而不是返回指针是静态的),也就是与生俱来就存在的,因为他是类内静态方法没有this指针无法直接访问类内成员了,但是他可以访问类内的静态tp_指针。
在这里插入图片描述

如果整个类从来没有创建过对象,tp指针是空,那就new构造出一个对象,然后返回这个对象的指针。
所有人想访问我这个线程池对象都是通过Getinstance返回的线程池指针来访问其成员函数。
因为你定义不了对象,拷贝也考不,tp指针是私有的。

使用单例线程池

红框表示首次使用
在这里插入图片描述
即便每次while循环调用获取单例函数,但是单例构建打印只会打印一次,证明只有第一次创建获取的时候才获取单例。如果有需要可以把Getinstance()返回的tp指针,也就是线程池单例对象的地址打印出来看看,他们都是一样的

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

至此就完成了单例版的线程池。

问题来了
那如果获取单例本身也是多线程并发去访问的呢?你想用线程池放任务,我也想。
比如单例是并发式的有多线程都在用getinstance的单例,
好比main函数创建了一批线程,这批线程每一个线程都想获取单例,都在并发的Getinstance,
而大家都想获取tp_指针时,就会存在并发问题。
多线程如果都跑过来竞争式的申请Getinstance,有线程进来了,大家都来申请了,(tp指针只有一个)不就相当于多线程并发访问全局tp指针了。
在这里插入图片描述

可能线程 A Getinstance时进来判断tp指针为空,所以正准备往后执行时,线程A被切走了,线程B说我也要来,线程B他也判断这个指针为空,然后他也跑进来要进行创建了,线程C说你们都这样那我也要来,所以三个线程并发的同时对指针做判断的条件都成立,那么此时都进来new的话,那么此时这个对象就可能被new成了多份了!(这个有点像之前抢票,所有线程都在判断票数只剩一张>0时被切走,每次减减时会重新读取票数,最后票数被抢到了负数)。

所以单例要写的好一点该怎么写?
加锁

对于加锁这件事,一定行,多线程并发访问单例,不就是都通过getinstance获取吗, 这个判断就是使用静态tp指针,赋值也是,这个全局变量tp 被多个线程共享访问,所以tp就是传说中的共享资源啊, 所以需要把它加锁保护起来
在这里插入图片描述
我们什么都缺,就是不缺锁,又因为这把锁将来是要在Getinstance这样的静态方法里去用,注定了我这把锁也一定是静态的,普通成员变量定义的锁我在静态方法里访问不了啊。
锁也可以全局初始化 或 静态的 也一样。初始化了也不用destory了。
在这里插入图片描述
在这里插入图片描述
此时多线程访问tp指针就不怕了,我们加了锁
在这里插入图片描述
线程A说我要进来,先申请锁,申请到了之后判断条件tp为空,new一个对象,锁释放后,return走了。
线程B也会申请锁,从第二个线程开始,这个指针就不为空了,直接条件不满足不创建对象,直接解锁return。
我们已经能保证数据安全了,可是实际上要保护单例的安全也就在第一次申请对象时有并发问题,从此往后我其实不怎么担心,为什么呢?因为以后再去new对象时,这个对象指针已经创建了,所以后续判断if条件永远不会满足,后续所有线程的工作都是申请锁,判断一下不满足解锁然后返回tp。加锁保证了最开始创建对象时的安全,但也带来了新问题,后续每个线程最终判断总是会失败,当我们往后走时这货每次getinstance都要申请锁释放锁,最后带来的结果就是即便对象已经被创建了,剩下所以线程想申请单例都要串行申请,那系统就压力比较大了。
接下来问题不再是并发问题了,考虑的就是他的一个效率问题。

在这里插入图片描述

里面的if必做的,因为要判断是否是单例 ,加锁也是必做的因为要保证线程安全。
外面的if是什么鬼?
最开始ABCD四个线程一起进来了,大家可能一起判断指针是空的,每个线程都迫不及待去竞争锁了,可是临界区代码(加锁解锁之间的代码)只有一个线程可以进入,比如A线程,单例也创建出来然后解锁了,从此往后BCD这样的线程可能临时有一部分做了加锁解锁的工作,但时间线再往后延一点,后来单例对象一旦被创建出来后来的EFGXYZ线程进来了,他们都做判断时,在外层if条件永远都不成立了,那么就直接返回tp不会再执行解锁加锁的代码了。往后就可以并发获取单例了。

这就是单例中双重判断来保证线程安全

懒汉好处就是可以提高加载速度,当你需要对象时再给你创建。


悲观锁:

在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行
锁等),当其他线程想要访问数据时,被阻塞挂起。
我们之前用的那都是悲观锁。

乐观锁

挂起等待锁

线程执行临界区的时长非常长,你都知道要等很久,那就挂起等待吧
我们之前用的都是挂起等待锁
但如果时长很短,就不要挂起了,因为挂起也要花时间,要把执行流链入阻塞队列,当我把你唤醒时也要从阻塞队列断开再链入到CPU运行队列里去运行。
所以到底使用挂起等待锁 还是 自旋锁 完全看 其他线程在临界资源里要花多长时间来进行访问资源的。

自旋锁

什么是自旋锁?
自旋锁并不把自己挂起,而是周而复始的不断由线程去申请锁,申请锁成功则进入,失败则返回,返回之后立马重新再检测锁的状态,也就是一直在问。
这里就会比较浪费CPU资源了,但前提是进入临界区线程使用临界区资源时长非常短,一瞬间就释放了,此时我们不用挂起,这种情况称之为自旋。
实现简单自旋锁
trylock 再打一个死循环,trylock会出错返回,加锁失败不用管,失败就让他一直while循环,就判断加锁成功
在这里插入图片描述

但是pthread库帮我们做了一个自旋锁,他有自己的阻塞和非阻塞版本
在这里插入图片描述
不是自旋锁吗?怎么还有阻塞版本和非阻塞版?
lock当你申请锁时,成功就往后再,失败了在你看来它会阻塞住,但是它在库中的实现不像pthread_mutex_lock把你的线程挂起,而是让线程在底层一直在申请锁,这个spin-lock底层会帮我们封装while循环直到锁申请成功。所以你在调用时,申请锁如果没有成功给你的感觉不就是它阻塞住了吗。
那trylock什么意思呢?
你自旋的时候申请失败了,申请失败就失败了直接返回了,也就是说如果要自旋你自己加循环

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
所以自旋锁在我们看来他和互斥锁 唯一区别就是他会不会挂起,对应的策略就是我要不要再重新申请。

到底要不要用自旋锁,原则只有一个,就是线程在临界区待 的时长问题。

比如更改文件引用计数,就对整数做加加,那临界区时间待的就不长。
但牵扯到打印,访问文件了,计算了,对象拷贝了,那就比较长了。

读者写者问题

读者写者问题 在生活中的例子?
出黑板报,有人写有人看
这玩意有并发问题吗
有的,你想写我也想写,我们两个可能出现竞争问题。
还有我正在画,别人就来读了,说明消息正在写入时需要花时间才写完,没写完读者就来读了,此时读到的信息就是残缺的。

对读者写着进行建模
有1~n个写者,对缓冲区写入
有1~n个读者,从缓冲区读数据
该如何正确理解读者写者问题呢?
我们也遵守321原则。
3种关系
写写,读读,写读
写写什么关系?
互斥,我写的时候你就别来写了
写者和读者呢?
我正在写,你也就别读了你读的数据不完整。则有互斥。
我出完黑板报了就期望读者来读了,读完了就期望写者来写,则有同步关系。
读和读什么关系?
肯定是共享关系,你能读我也能读啊
为什么读者和读者是共享关系?为什么CP中,消费者和消费者却是互斥?读者写者 和 CP模型 的 区别?
因为读者写者模型中,写者把数据写进去了,读者在读他不会把数据拿走。
CP模型中消费者是会把数据拿走的,拿走了其他人就读不了了,你说你要拿我说我也要拿不就有并发问题吗,当然要互斥了。
这是读者写者模型和CP模型最朴素的区别。

2种角色
读者s (s表示多个) ,写者s , 读者写者角色最终由线程扮演承担
1个交易场所
黑板,数据交换的地点:各种数据结构

维护读者写者的321原则,写写互斥,读写互斥同步,读读关系我们不维护保证大家正常进行访问就行

实操

pthread库里已经有读者写者模型的锁已经准备好了
读写锁
在这里插入图片描述
以读者的身份进行加锁
在这里插入图片描述

写加锁
在这里插入图片描述
释放锁

在这里插入图片描述

rw模型理论的理解:

读者多并且不断在最多,写者少,读者竞争锁的概率非常高进而导致写者长时间的不到锁进而导致写者的饥饿问题。(竞争同一把读写锁)
聊读写之间的同步问题
rw模型,让写者饥饿的话,这种情况是中性特征还是偏贬义的问题
答:中性,他就是事实,因为读者本来就多,注定了写者饥饿是一个正常现象也是读写锁默认行为,我们称之为读者优先。
就像抢票纯互斥下同一个线程竞争锁能力很强,其他线程饥饿了这就代码有点问题,可是rw模型本来就是读者多,你写者等一等怎么了。
那让谁先跑让谁后跑不就是同步吗。
当然也可以有同步策略是写者优先。
假如写者和读者同时申请锁访问临界区,读者太多了此时写者要让读者先别进去我也不进去,写者会等正在访问临界区的读者读完后,写者再进去写,此时读者就不能打扰我了,等写者写完了让读者再读。
.读者优先
如果读者和写者同时来了,此时让读者先进入访问临界区,当所有读者读完了最后写者在进入临界区。这也是默认情况。

伪代码:读者优先的伪代码,理解rwlock实现原理
在这里插入图片描述
读者 和 写者 两把不同的锁
写写,加了锁,则有互斥
读读需要维护自己读者数量的互斥访问,则对共享变量进行加锁,读取是不需要加锁的,大家一起读
读写
如果第一个读者进来了,它会把写者进行加锁,不让写者访问临界资源了,加锁时写者只有两种情况,
如果此时写者没在临界区不持有锁,那写者就被加锁阻塞。如果写者在临界区,那读者加锁失败就会被阻塞在lock(&wlock)。
也就是说 写者在临界区,读者不访问,写者不再临界区,那写者被加锁,读者来访问,这不就是互斥吗。因为if是reader_count == 1后续所有读者都可以进来读取(写者被加锁的情况下)。

如果想要写者优先,可以设置写者的数量个数
你可以在读者加锁解锁之间判断当前有没有写者,如果有写者你让读者线程直接返回解锁释放掉,当有写者在你也就不往后走了。

用这伪代码帮助理解库里面的rwlock

千呼万唤始出来网络