【为什么需要锁呢?】
在单核时代,在不需要并行运算的情况下,锁确实是不需要的。
那么多核并行运算的时候,为什么需要锁呢?
我们先来看一个例子:
古代有一家钱庄,掌柜一个人记账,一个人更新钱庄的总账户余额,那么总账户余额的更新不存在并行运算的情况,也就不会有数据更新的问题。
而随着钱庄业务量的不断增加,掌柜一个人越来越忙不过来,于是请了2个伙计,这时候就有3个人一起来记账了。
这一天,来了两个客户,客户A找伙计A存50两银子,客户B找伙计B取50两银子。
伙计A和伙计B在自己的账本上分别记录上自己操作的这笔业务,然后同时去看了一下总账户余额是1200两。
伙计A算了下,1200+50=1250两,于是把1250更新为总账户余额了。
伙计B算了下,1200-50=1150两,于是把1150更新为总账户余额了。
大家是不是发现问题了,两笔业务,一个+50,一个-50,最后应该是不变,但是在伙计B更新完成后,总账户余额却少了50两。
上面就是因为并行运算导致数据更新异常的情况。
要怎么解决这个问题呢?
掌柜给总账户余额的本子上面加了一个锁。谁要看和写总账户余额之前都需要上锁,操作完成之后再解开锁。
于是上面的两个业务操作就有些变化了。如下:
伙计A去看查看总账户余额,先上锁,看到余额是1200,然后回来计算下1200+50=1250,把1250更新为总账户余额,然后解开锁。
伙计B也来查看总账户余额,一看上锁了,就只好先等着,等锁解开了,他才能去看余额是1250,回来计算下1250-50=1200,把1200更新为总账户余额,然后解开锁。
这样,数据更新也就安全了。
锁,是为了解决并行运算时,数据并发读写的安全性问题。
【增加锁的操作,带来的影响】
首先,增加锁的操作,伙计的工作流程中多了上锁解锁,虽然这个上锁解锁的速度很快,但毕竟是增加了操作和开销。
另外,伙计B看到已经上锁,意味着有冲突时,需要增加额外的等待,对总账户余额的更新变成串行化。
从上面可以看出来,如果工作流程中,有一个大循环,循环里面不断有对数据操作,大量的上锁解锁,也是很影响性能。
如果不只2个伙计,如果是20个伙计,非常频繁的去更新总账户余额,冲突太频繁,伙计的工作效率会急剧下降。
所以,我们知道了,在并发编程中,锁带来了安全性,同时也带来了性能瓶颈。
锁和并发,貌似有一种相克相生的关系。
【怎么减少锁的负面影响,同时保证数据安全呢】
下面几种方法大家可以考虑:
方法1:
对单个数据的更新,可以使用CAS(Compare-and-Swap)指令。
伙计们的操作变成下面这个过程:
伙计A看了下总账户余额是1200,然后记住这个数字,回来计算1200+50=1250,回去修改,一看总账户余额还是1200,于是成功修改为1250;
伙计B看了下总账户余额是1200,然后记住这个数字,回来计算1200-50=1150,回去修改,一看总账户余额是1250,不是原来的1200,说明数据被修改了,需要重新计算,于是记住新的值1250,回去重新计算1250-50=1200,在回去修改,一看总账户余额是1250,于是成功修改为1200。
上面的操作过程就是运用了CAS指令,修改之前先对比,数据没变化说明没有被修改过,这时候才能进行更新。
但是CAS操作时,还有一个ABA的问题。
例如:伙计A动作很快,改了一笔1200->1250,又改了一笔1250->1200;这时候伙计B回来改,看到的1200虽然数量没变,但是已经被改动两次了。
虽然上面的这种情况ABA问题不会有什么影响,但是有时候还是会出问题。
例如:总账户余额是每天记录一个数。
伙计A在今天的总账户余额上面改了一笔1200->1250,掌柜过来翻了一下总账户,翻到了昨天那页,而那页的总账户余额刚好是1200;
伙计B过来更新数据,一看还是1200,就改成了1150,这个改动是有问题的,因为伙计B改的是昨天的总账户余额。
要怎么解决这种ABA问题呢?
只需要在之前的数据基础上,再增加一个日期数据,检查的时候,需要同时检查总账户余额的数据和日期的数据,都没有变化才可以成功更新。
方法2:
引入队列,让一个任务专门来做数据的更新,避免并行运算。
这时候,就是让掌柜一个人来更新总账户余额,伙计们只需要把自己的每一笔业务结果记录在总账户下面。
伙计A有一笔业务是存50两,则在总账户下面记录上+50;
伙计B有一笔业务是取50两,则在总账户下面记录上-50;
而掌柜只需要从总账户里面,顺序的把+50,-50的操作更新到总账户余额就可以了。
这种方法的好处是,避免伙计并行更新总账户余额,发生冲突时的等待,既提高了伙计的效率也保证了数据更新的安全。
方法3:
还是需要有锁的操作,但是让运算的过程更快,减少锁冲突的频率和时间。
前面是伙计先上锁,然后看总账户余额,再回去运算,再回来更新和解锁。
把这个过程改一下,伙计带着算盘过来,先上锁,然后现场运算,更新后解锁,再回去。
这样一来,整个的读写时间变短了,锁的冲突时间也就减少了,效率和性能也就能有所提高。
方法4:
能否进一步减少锁的冲突时间,比如:将读、写的锁分开考虑,毕竟大部分业务中读的次数会远多于写的次数。
A 使用读写锁而不是互斥锁,可以提高并发读的效率,减少读时候的锁冲突。
例如:大量的业务都需要查看总账余额才可以做决定,那么就会有大量的读需求。而多个人一起读不冲突,只是在需要写的时候才独占总账余额的锁。
B 将大的数据分拆为多段的小数据,这样通过多个锁分别作用在小数据上,避免一个锁作用在大的数据中,减少冲突的概率。
例如:对总账余额的每一位设置单独的锁,而不是对整个数设置一个锁。这样的话,+50只需要得到十位数的锁,-3只需要得到个位的锁,如果是+55则要个位和十位两个锁。
C 读的时候不需要锁,而写入的时候串行化同时只能一个人更新
例如:总账余额对所有人公开的,大家可以随便看,但是修改的时候,只能由掌柜来操作。
【死锁是怎么造成的?怎么避免?】
锁有两个操作,一个是加锁,一个是解锁。
锁冲突的时候,所有需要加锁的人就要等待,而无限等待就是死锁的状态。
造成的情况有下面2种:
1 如果伙计A对总账余额加锁,但是被客户叫走了,就会导致伙计B陷入死锁,一直等待。
这种情况就是伙计A出现了异常,没能及时的操作完,进行解锁。
2 如果伙计A对今天的总账余额加锁,同时伙计B对昨天的总账余额加锁,然后伙计A又想要获取昨天总账余额的锁,同时伙计B要今天的总账余额锁。
那这个时候,伙计A、B就会因为今天、昨天的总账余额两个锁互相依赖,都无法成功完成操作,造成死锁。
要怎么避免呢?
针对情况1,就需要对异常情况做更周密的考虑,增加超时处理,锁占用时间最多1分钟,超时就自动解锁。
针对情况2,可以考虑把锁的粒度设置粗一些,锁的效率低些总比造成死锁的情况要好很多。
【单机的锁是怎么实现的?】
锁总线,只能有一个CPU核心可以通过数据总线访问主存,其他CPU等待,保证多CPU并行时数据读写的一致性。效率低,CPU闲置。
缓存锁,针对单个缓存行中的数据地址,缓存读写只能发生在一个CPU核心,其他CPU缓存全部失效。数据少,只能是一个缓存行的数据。
因此,只对一个变量做更新的时候,能用缓存锁就尽量不要使用锁总线的方式。
【分布式锁的实现】
多服务器多进程之间需要做到数据安全更新的时候,就需要用到分布式锁。
而分布式锁的实现,还是需要有独立的线程安全的服务来提供锁的实现。
比如:
redis,实现简单,吞吐量十分可观,对于高并发情况应付自如,自带超时保护,对于网络抖动的情况也可以利用超时删除策略保证不会阻塞所有流程。
zookeeper,实现分布式锁虽然是比较重量级的,但实现的锁功能十分健全,由于Zookeeper本身需要维护自己的一致性,所以性能上较Redis还是有一定差距的。
【写在最后】
并发编程是非常复杂,充满挑战的一项工作,可为什么大部分感觉不到它的难点呢?
互联网应用、Web应用,都是并发编程的典型应用,大家写着业务代码,却体会不到其中的难点呢?
我个人理解,是不是以为工作任务只是测试通过,功能跑通而已呢?
是不是以为几次几十次的操作,数据写入成功,返回正常就可以呢?
是不是以为偶尔出个错,排查后修正解决了就完事了呢?
我们的应用如果是作为一个系统,那么就不能是片面去看待和思考它,要有系统化的思维,从很小的点开始,认真面对这一切。
关于高并发和高性能的系列文章,到这算是一个阶段性尾篇,之前的文章希望大家能再翻出来仔细学习和了解。
大家有更多希望了解的内容,也可以联系我。
后续,我再针对性写一些关于高可用的文章,敬请期待~
在实战课程 《PHP秒杀系统 高并发高性能的极致挑战》中,也是针对这类高并发的业务场景做了特定的性能优化以及分布式方案,大家可以参考学习。