跟我学C++中级篇—悲观和乐观锁
一、悲观锁和乐观锁
在前的“并发中的锁的类型和分析”中,对C++中的锁进行了一次比较全面的说明。通过分析可以得出,C++的并行锁与其它语言的锁机制还是有着一些不同的。它更倾向于从底层描述锁的机制,而非从上层抽象的说明锁的功能。
在其它的一些语言中,可能经常看两个术语“乐观锁”和“悲观锁”。
- 悲观锁
这个其实很好理解,所谓悲观就是看一切都不顺眼,不相信别人。体现在计算机中,就是所有的并发操作一定会产生冲突,要想不冲突,必须使用锁。这样才保证并发时访问数据的安全性。
使用悲观锁的优点在于数据的强一致性得到保证,不会出现数据并发冲突导致的脏数据或重复数据,实现相对简单。但由于必须使用锁,则导致性能会明显的降低,同时降低并发性。更有可能在严重时导致死锁。它一般适于读少写多的场景时。 - 乐观锁
乐观锁和悲观锁的想法正好相反,它认为世上还是好人多,出现冲突的可能是一个小概率事件。在计算的并发编程中,就是在操作数据不使用锁而只是在数据更新时检查数据是否被其它线程修改过。
乐观锁在工作时一般要获取数据的标签(如版本号、时间戳等),用来在提交更新时进行检测,根据检测结果来进行有针对性的数据处理。它的优势在于无锁的竞争,并发性能高。特别在读多写少的情况下更有优势。也从根本上解决了死锁的可能。但当并发冲突频繁时,反复重试的开销剧增。另外,它还引入了经典的ABA和伪共享的问题。
这里简单说明一下伪共享,伪共享指在多核或多CPU缓存中,两个没有共享(有可能冲突并发操作需要强制更新)的变量,被加载到了同一个CacheLine行中(没有共享但产生了共享,计算机认为会产生冲突,需要进行强制更新缓存操作),改变任一个后,导致其它核或CPU认为共享产生的情况。
二、C++中对应的锁
在C++中,一般来说,直接使用锁如mutex属于悲观锁,而使用CAS则被视为一种乐观锁。下面看简化后的主要代码:
悲观锁:
#include<iostream>#include<mutex>#include<thread>std::mutex mtx;intid=0;voidmutexLock(){mtx.lock();++id;mtx.unlock();}intmain(){std::threadt1(mutexLock);std::threadt2(mutexLock);t1.join();t2.join();return0;}乐观锁:
#include<iostream>#include<atomic>#include<thread>std::atomic<int>data(0);voidcasLock(intid){intoldValue=data.load();intnewValue;do{intnewValue=++oldValue;}while(!data.compare_exchange_weak(oldValue,newValue));}intmain(){std::threadt1(casLock,0);std::threadt2(casLock,1);t1.join();t2.join();return0;}代码非常简单,不再进行说明了。
三、应用分析
通过上面的分析和举例,已经基本明白了乐观锁和悲观锁及其在C++中的应用实现。而所谓的读多写少或读少写多,本质就是对应用哪类锁的一种偏向型分析。但实际情况中,读和写的情况经常会非常复杂。可能时多时少,也可能转换场景就有了不同的情况。而且还要考虑对错误后果忍受性及吞吐量等的业务需求限制。
但整体上,还是要把握二者性能的不同以及对数据安全性的不同为根本区别。可以动态的将二者结合起来,比如可以在金融行业中的底层操作操作使用悲观锁保证安全而上层业务使用乐观锁提高性能。另外也可以在某些场景下一开始使用乐观锁,如果检测到失败次数过多则切换为悲观锁。
常见的混合应用方法包括:
- 乐观锁处理性能,悲观锁处理最终确认。比如常见的秒杀就可以如此
- 优先使用乐观锁,发现频繁失败后转悲观锁(反之亦可)即自适应锁的升/降级。如监控系统处理系统
- 功能模块划分或不同阶段使用乐观锁和悲观锁。如多阶段任务处理中,前几个阶段用乐观锁而后几个阶段使用悲观锁
- 乐观锁尝试失败后后转悲观锁。比如用户积分等的处理
其实单纯使用乐观锁和悲观锁的应用场景也不少,比如悲观锁中的金融行业的转账、跨进程的处理等等。而乐观锁中最典型的是Redis数据库和其它一些新型数据库通过版本号等进行实时的更新处理。有过这方面开发经验的一定会非常容易理解。
四、总结
合适的就最好的。但合适本身就有不同的看法,这就需要设计和开发者仔细分析再进行取舍。不搞一言堂,不搞一刀切。哪个方法能解决问题就用哪个。从必然走向自由。
