首页 > 编程笔记 > Go语言笔记 阅读:5

Go语言中的乐观锁(附带实例)

使用互斥锁解决并发问题时,每次操作数据都需要额外的两个步骤,即加锁和释放锁。这样程序性能会不会有所降低呢?理论上是的。

那有什么其他更好的办法既能解决并发问题,又不会对程序性能产生较大影响?其实,操作数据并不一定会有并发问题,只是为了避免这种可能性,所以才选择在操作数据之前加锁。

既然操作数据并不一定会有并发问题,那如果操作数据不进行加锁,只是在数据提交时验证有没有冲突,有冲突则不提交,是不是也可以解决并发问题呢?这就是乐观锁的思路。

顾名思义,乐观锁对并发问题持有乐观的态度,认为本次操作不一定存在并发问题。

如何基于乐观锁解决多协程操作(累加)同一个变量产生的并发问题呢?可以参考下面的代码:
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...)

相关文章