乐观锁与悲观锁

[注]文档为学习整理,若有需要补充,请各位看官多多指点。

基本概念

乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。

  • 乐观锁:乐观锁在操作数据时非常乐观,认为鄙人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。

  • 悲观锁:悲观锁在操作数据时比较悲观,认为鄙人会同时修改数据。因此操作数据时直接把数据所著,直到操作完成后才释放锁;上锁期间其他人不能修改数据。

实现方式

乐观锁和悲观锁是两种思想,它们的使用非常广泛,不局限于某种编程语言或数据库。

悲观锁的实现方式是加锁,加锁既可以是对代码块加锁,也可以是对数据加锁。

乐观锁的实现方式主要两种:CAS机制和版本号机制

CAS(Compare And Swap)

CAS 操作包括了3个操作数:

  • 需要读写的内存位置 (V)

  • 进行比较的预期值 (A)

  • 拟写入的新值 (B)

CAS 操作逻辑如下:如果内存位置V的值等于预期的A值,则将该位置更新为新值B,否则不进行任何操作。许多CAS的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。

这里引入一个新的问题,既然CAS包含了Compare和Swap两个操作,它又如何保证原子性呢?答案是:CAS是由CPU支持的原子操作,其原子性是在硬件层面进行保证的。

下面以 Golang 中的自增操作为例,看一下悲观锁和 CAS 分别是如何保证线程安全的。

 package main
 
 import (
  "fmt"
  "sync"
  "sync/atomic"
 )
 
 var (
  value1 = 0
  value2 = 0
  value3 uint32 = 0
 )
 
 func main() {
  wg := sync.WaitGroup{}
  lock := sync.Mutex{}
  for i := 0; i < 1000; i++ {
  wg.Add(1)
 
  go func() {
  defer wg.Done()
  value1++
  lock.Lock()
  value2++
  lock.Unlock()
  atomic.AddUint32(&value3, 1)
  }()
  }
  wg.Wait()
  fmt.Println("value1: ", value1)
  fmt.Println("value2: ", value2)
  fmt.Println("value3: ", value3)
 }

在代码实例中:value1并没有进行任何线程安全方面的保护,value2使用了悲观锁(sync),value3使用了乐观锁(atomic)。运行程序,使用1000个协程同时对value1、value2、value3进行加1操作,可以发现:value2和value3的值总是等于1000,而value1的值尝尝小于1000。

版本号机制

除了CAS,版本号机制也可以用来实现乐观锁。版本号机制的基本思路是在数据中增加一个字段version,表示该数据的版本号,每当数据被修改,版本号加1。当某个线程查询数据时,将该数据的版本号一起查出来;当该线程更新数据时,判断当前版本号与之前读取的版本号是否一直,如果一致才进行操作。

需要注意的是,这里使用了版本号作为判断数据变化的标记,实际上可以根据实际情况选用其他能够标记数据版本的字段,如时间戳等。

是否加锁

乐观锁本身时不加锁的,只是在更新时判断一下数据是否被其他线程更新了。

优缺点和适用场景

乐观锁和悲观锁并没有优劣之分,它们有各自适合的场景;下面从两个方面进行说明。

功能限制

与悲观锁相比,乐观锁适用的场景受到了更多的限制,无论是CAS还是版本号机制。

例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而sync则可以通过对整个代码加锁来处理。再比如版本号机制,如果查询的时候针对数据A,而更新的时候针对数据B,也很难通过简单的版本号来实现乐观锁。

竞争激烈从程度

如果悲观锁和乐观锁都可以使用,那么选择就要考虑竞争的激烈程度:

  • 当竞争不激烈(出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,并且加锁和释放锁都需要消耗额外的资源。

  • 当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。

ABA问题

假设有两个线程——线程1和线程2,两个线程按照顺序进行以下操作:

  1. 线程1读取内存中数据为A;

  2. 线程2将该数据改为B;

  3. 线程2将该数据改为A;

  4. 线程1对数据进行CAS操作;

在第4步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。

在有些场景下,例如数字的增减,ABA似乎没有什么危害。但是在某些场景下,ABA却会带来隐患,例如栈顶问题:一个栈的栈顶经过两次(或多次)变化又恢复了原值,但是栈可能已经发生了变化。

对于ABA问题,比较有效的方案是引入版本号,内存中的值没发生一次变化,版本号都加1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。

参考资料

posted @ 2022-05-01 19:11  小禾先生  阅读(113)  评论(0编辑  收藏  举报