乐观锁与悲观锁
乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。
-
乐观锁:乐观锁在操作数据时非常乐观,认为鄙人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
-
悲观锁:悲观锁在操作数据时比较悲观,认为鄙人会同时修改数据。因此操作数据时直接把数据所著,直到操作完成后才释放锁;上锁期间其他人不能修改数据。
实现方式
乐观锁和悲观锁是两种思想,它们的使用非常广泛,不局限于某种编程语言或数据库。
悲观锁的实现方式是加锁,加锁既可以是对代码块加锁,也可以是对数据加锁。
乐观锁的实现方式主要两种: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读取内存中数据为A;
-
线程2将该数据改为B;
-
线程2将该数据改为A;
-
线程1对数据进行CAS操作;
在第4步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。
在有些场景下,例如数字的增减,ABA似乎没有什么危害。但是在某些场景下,ABA却会带来隐患,例如栈顶问题:一个栈的栈顶经过两次(或多次)变化又恢复了原值,但是栈可能已经发生了变化。
对于ABA问题,比较有效的方案是引入版本号,内存中的值没发生一次变化,版本号都加1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。