Go语言中的乐观锁(附带实例)
使用互斥锁解决并发问题时,每次操作数据都需要额外的两个步骤,即加锁和释放锁。这样程序性能会不会有所降低呢?理论上是的。
那有什么其他更好的办法既能解决并发问题,又不会对程序性能产生较大影响?其实,操作数据并不一定会有并发问题,只是为了避免这种可能性,所以才选择在操作数据之前加锁。
既然操作数据并不一定会有并发问题,那如果操作数据不进行加锁,只是在数据提交时验证有没有冲突,有冲突则不提交,是不是也可以解决并发问题呢?这就是乐观锁的思路。
顾名思义,乐观锁对并发问题持有乐观的态度,认为本次操作不一定存在并发问题。
如何基于乐观锁解决多协程操作(累加)同一个变量产生的并发问题呢?可以参考下面的代码:
所以上面代码的含义是,在将变量 sum 的值赋值为 sum+1 时,需要确保变量 sum 的值仍然等于当前协程读取到的值,即变量 sum 没有被其他协程修改;否则,不执行修改操作并返回 false。
另外,基于乐观锁实现的累加操作是一个循环,因为只有当函数 atomic.CompareAndSwapInt32 返回 true 时,才说明当前累加操作执行成功了。
不知道你有没有想过,比较交换明明是一个复杂操作,编译成汇编程序后,应该会生成好几条汇编指令,为什么这个函数是一个原子操作呢?其实现代计算机还提供了一些我们不知道的汇编指令,虽然这些指令语义上比较复杂,但实际上是一条指令。比如比较交换指令的定义如下:
所以比较交换操作实际上可以用一条汇编指令来实现。那么高速缓存的问题该如何解决呢?假设内存 m 的数据同时被缓存在 CPU0 和 CPU1 中,某一时刻 CPU0 执行 cmpxchgl 指令修改了内存 m 的数据,而此时 CPU1 不一定能立即读取到 CPU0 修改后的数据。为了解答这个问题,我们先看一下函数 atomic.CompareAndSwapInt32 的实现逻辑。该函数由汇编指令实现,代码如下所示:
注意,在执行 CMPXCHGL 指令之前,汇编程序 Cas 还执行了 LOCK 指令,该指令可以锁住总线,这样其他 CPU 对内存的读写请求都会被阻塞,直到锁释放。当然,目前计算机通常采用锁缓存方案代替锁总线(锁总线的开销比较大),即 LOCK 指令会锁定一个缓存行(高速缓存的最小缓存单元,默认 64 字节)。当某个 CPU 发出 LOCK 信号锁定某个缓存行时,其他 CPU 都无法读写该缓存行(使该缓存行失效),同时还会检测是否对该缓存行中的数据进行了修改,如果进行了修改,还会将已修改的数据写回内存。也就是说,是 LOCK 指令帮助我们解决了高速缓存导致的并发问题。
Go 语言大量使用了乐观锁,比如在修改协程 G 的状态时,修改逻辑处理器 P 的状态时等,如下所示:
最后补充一下,一些原子操作函数的实现与乐观锁实现非常类似。有兴趣的读者可以自己研究。另外,这些函数同样通过 alias 函数定义了别名,举例如下:
那有什么其他更好的办法既能解决并发问题,又不会对程序性能产生较大影响?其实,操作数据并不一定会有并发问题,只是为了避免这种可能性,所以才选择在操作数据之前加锁。
既然操作数据并不一定会有并发问题,那如果操作数据不进行加锁,只是在数据提交时验证有没有冲突,有冲突则不提交,是不是也可以解决并发问题呢?这就是乐观锁的思路。
顾名思义,乐观锁对并发问题持有乐观的态度,认为本次操作不一定存在并发问题。
如何基于乐观锁解决多协程操作(累加)同一个变量产生的并发问题呢?可以参考下面的代码:
package main import ( "fmt" "sync/atomic" "time" ) func main() { var sum int32 = 0 for i := 0; i < 10; i++ { go func() { for j := 0; j < 1000; j++ { for !atomic.CompareAndSwapInt32(&sum, sum, sum+1) {} } }() } time.Sleep(time.Second * 3) fmt.Println(sum) }参考上面的代码,函数 atomic.CompareAndSwapInt32 就是乐观锁(CAS)的一种实现。函数名称的含义是比较交换,即如果第一个指针变量指向的整型数据的值等于第二个数值,则将第一个指针变量指向的整型数据赋值为第三个数值,如果执行成功,则函数返回 true。
所以上面代码的含义是,在将变量 sum 的值赋值为 sum+1 时,需要确保变量 sum 的值仍然等于当前协程读取到的值,即变量 sum 没有被其他协程修改;否则,不执行修改操作并返回 false。
另外,基于乐观锁实现的累加操作是一个循环,因为只有当函数 atomic.CompareAndSwapInt32 返回 true 时,才说明当前累加操作执行成功了。
不知道你有没有想过,比较交换明明是一个复杂操作,编译成汇编程序后,应该会生成好几条汇编指令,为什么这个函数是一个原子操作呢?其实现代计算机还提供了一些我们不知道的汇编指令,虽然这些指令语义上比较复杂,但实际上是一条指令。比如比较交换指令的定义如下:
/* cmpxchgl 就是比较交换指令,其含义如下: "cmpxchgl r, [m]": if (eax == [m]) zf = 1; [m] = r; } else { zf = 0; eax = [m]; } */cmpxchgl 就是比较交换指令,其中 m 代表内存地址,eax 寄存器存储旧数据,r 就是要赋值的新数据。该指令的含义是,如果内存 m 存储的数据等于 eax 寄存器存储的旧数据,则将内存 m 赋值为新数据 r。当赋值成功时,会设置标志位 zf 等于 1;否则设置标志位 zf 等于 0,即通过标志位 zf 可以判断比较交换操作是否执行成功。
所以比较交换操作实际上可以用一条汇编指令来实现。那么高速缓存的问题该如何解决呢?假设内存 m 的数据同时被缓存在 CPU0 和 CPU1 中,某一时刻 CPU0 执行 cmpxchgl 指令修改了内存 m 的数据,而此时 CPU1 不一定能立即读取到 CPU0 修改后的数据。为了解答这个问题,我们先看一下函数 atomic.CompareAndSwapInt32 的实现逻辑。该函数由汇编指令实现,代码如下所示:
// 函数定义 func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool) // alias 定义了函数的别名 alias("sync/atomic", "CompareAndSwapInt32", "runtime/internal/atomic", "Cas", all...) // 比较交换函数的汇编代码 TEXT ·Cas(SB) MOVQ ptr+0(FP), BX MOVL old+8(FP), AX MOVL new+12(FP), CX LOCK CMPXCHGL CX, 0(BX) SETEQ ret+16(FP) RET在上面的代码中,Go 语言通过 alias 函数定义了 atomic.CompareAndSwapInt32 的别名,所以比较交换函数的底层其实是由汇编程序 Cas 实现的。该函数的第一个参数是一个内存地址,第二个以及第三个参数都是整型数据。返回值由指令 SETEQ 设置,设置的是什么呢?就是执行 CMPXCHGL 指令之后的 zf 标志位。
注意,在执行 CMPXCHGL 指令之前,汇编程序 Cas 还执行了 LOCK 指令,该指令可以锁住总线,这样其他 CPU 对内存的读写请求都会被阻塞,直到锁释放。当然,目前计算机通常采用锁缓存方案代替锁总线(锁总线的开销比较大),即 LOCK 指令会锁定一个缓存行(高速缓存的最小缓存单元,默认 64 字节)。当某个 CPU 发出 LOCK 信号锁定某个缓存行时,其他 CPU 都无法读写该缓存行(使该缓存行失效),同时还会检测是否对该缓存行中的数据进行了修改,如果进行了修改,还会将已修改的数据写回内存。也就是说,是 LOCK 指令帮助我们解决了高速缓存导致的并发问题。
Go 语言大量使用了乐观锁,比如在修改协程 G 的状态时,修改逻辑处理器 P 的状态时等,如下所示:
atomic.Cas(&gp.atomicstatus, oldval, newval) atomic.Cas(&_p_.status, S, _Pidle) ......
最后补充一下,一些原子操作函数的实现与乐观锁实现非常类似。有兴趣的读者可以自己研究。另外,这些函数同样通过 alias 函数定义了别名,举例如下:
alias("sync/atomic", "LoadInt32", "runtime/internal/atomic", "Load", all...) alias("sync/atomic", "StoreInt32", "runtime/internal/atomic", "Store", all...) alias("sync/atomic", "AddInt32", "runtime/internal/atomic", "Xadd", all...)