一、基本概念
乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。
改变一个数值的三个步骤:
①把想修改的数值从某个地方取出来;
②在取出来的数值修改为期望值;
③把修改后的数值保存到原来的地方。
这里面有一个问题,把数值取出来进行修改的时候(做完了①步,正在做②步),如果有另一个过程(进程或线程)对同一个数值进行同样的操作(取值,修改),那么当两个过程都要做③的时候,就肯定有一个过程是白干活的。
- 悲观锁:悲观锁在操作数据时比较悲观,总认为会发生并发问题。
如果想修改一个数值,立马给这个数值上一把锁,标明这个数值正在被修改,谁也不能修改了;然后才开始三步走,在三步走的过程结束以后,再把锁解除。
当有其他过程想要修改同一个数值时,看到了锁就不进行三步走了,而是选择等待;当锁被解除了,自己在数值也加一把锁,然后开始三步走,在三个步骤走完了,也把锁解除。
上面的过程即是悲观锁,总是存在加锁和解除锁的动作。
- 乐观锁:乐观锁在操作数据时非常乐观,总认为不会发生并发问题。
如果想修改一个数值,不加锁,正常进行①②步,在进行③的时候,确认一下数值是否进行了修改,如果被修改过,放弃修改,重新走一遍①②③(或者放弃对数值进行修改)。
上面的过程即是乐观锁,不存在加锁和解除锁的动作。
二、实现方式
悲观锁的实现方式是加锁。
乐观锁的实现方式主要有两种:CAS机制和版本号机制。
- CAS(Compare And Swap)
CAS操作包括了3个操作数:
- 需要读写的内存位置(V)
- 进行比较的预期值(A)
- 拟写入的新值(B)
CAS操作逻辑如下:如果内存位置V的值等于预期的A值,则将该位置更新为新值B,否则不进行任何操作。许多CAS的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。
这里引出一个新的问题,既然CAS包含了Compare和Swap两个操作,它又如何保证原子性呢?答案是:CAS是由CPU支持的原子操作,其原子性是在硬件层面进行保证的。
CAS的缺点:
- ABA问题
假设有两个线程——线程1和线程2,两个线程按照顺序进行以下操作:
(1)线程1读取内存中数据为A;
(2)线程2将该数据修改为B;
(3)线程2将该数据修改为A;
(4)线程1对数据进行CAS操作
在第(4)步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。
在某些场景下,ABA会带来隐患,例如栈顶问题:一个栈的栈顶经过两次(或多次)变化又恢复了原值,但是栈可能已发生了变化。
对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。
高竞争下的开销问题
在并发冲突概率大的高竞争环境下,如果CAS一直失败,会一直重试,CPU开销较大。针对这个问题的一个思路是引入退出机制,如重试次数超过一定阈值后失败退出。当然,更重要的是避免在高竞争环境下使用乐观锁。功能限制
CAS的功能是比较受限的,例如CAS只能保证单个变量(或者说单个内存值)操作的原子性,这意味着:(1)原子性不一定能保证线程安全;(2)当涉及到多个变量(内存值)时,CAS也无能为力。
- 版本号机制
版本号机制的基本思路是在数据中增加一个字段version,表示该数据的版本号,每当数据被修改,版本号加1。当某个线程查询数据时,将该数据的版本号一起查出来;当该线程更新数据时,判断当前版本号与之前读取的版本号是否一致,如果一致才进行操作。
这里使用了版本号作为判断数据变化的标记,实际上可以根据实际情况选用其他能够标记数据版本的字段,如时间戳等。
三、golang中的乐观锁、悲观锁
- sync/atomic
Golang中有一个 atomic 包,可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作,这个包应用的便是乐观锁的原理。
不过这个包只支持int32/int64/uint32/uint64/uintptr这几种数据类型的一些基础操作(增减、交换、载入、存储等)
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var num int64
var l sync.Mutex
var wg sync.WaitGroup
// 普通版加函数
func add() {
num = num + 1
wg.Done()
}
// 互斥锁版加函数
func mutexAdd() {
l.Lock()
num = num + 1
l.Unlock()
wg.Done()
}
// 原子操作版加函数
func atomicAdd() {
atomic.AddInt64(&num, 1)
wg.Done()
}
func main() {
// 目的是 记录程序消耗时间
start := time.Now()
for i := 0; i < 20000; i++ {
wg.Add(1)
// go add() // 无锁的 add函数 不是并发安全的
// go mutexAdd() // 互斥锁的 add函数 是并发安全的,因为拿不到互斥锁会阻塞,所以加锁性能开销大
go atomicAdd() // 原子操作的 add函数 是并发安全,性能优于加锁的
}
// 等待子协程 退出
wg.Wait()
end := time.Now()
fmt.Println(num)
// 打印程序消耗时间
fmt.Println(end.Sub(start))
}
我们使用上述 demo 代码,模拟了3种情况下,程序的耗时以及计算结果对比
- 不加锁
无锁的 add函数 不是并发安全的
19379
8.8184ms
- 加互斥锁
互斥锁的 add函数 是并发安全的,因为拿不到互斥锁会阻塞,所以加锁性能开销大
20000
15.2841ms
- 使用原子操作
20000
7.9454ms
- sync
Golang中的sync包,提供了各种锁,如果使用了这个包,基本上就以悲观锁的工作模式了。
四、适用场景
乐观锁适用于多读的场景;
悲观锁适用于多写的场景。
参考:GO的锁和原子操作分享
乐观锁与悲观锁与Golang
【BAT面试题系列】面试官:你了解乐观锁和悲观锁吗?
乐观锁、悲观锁,这一篇就够了!