关于后端:从底层理解CAS原语

51次阅读

共计 3400 个字符,预计需要花费 9 分钟才能阅读完成。

什么是硬件同步原语?

为什么硬件同步原语能够代替锁呢?要了解这个问题,你要首先晓得硬件同步原语是什么。

硬件同步原语(Atomic Hardware Primitives)是由计算机硬件提供的一组原子操作,咱们比拟罕用的原语次要是 CAS 和 FAA 这两种。

CAS(Compare and Swap),它的字面意思是:先比拟,再替换。咱们看一下 CAS 实现的伪代码:

<< atomic >>
function cas(p : pointer to int, old : int, new : int) returns bool {
    if *p ≠ old {return false}
    *p ← new
    return true
}

它的输出参数一共有三个,别离是:

  • p: 要批改的变量的指针。
  • old: 旧值。
  • new: 新值。

返回的是一个布尔值,标识是否赋值胜利。

通过这个伪代码,你就能够看出 CAS 原语的逻辑,非常简单,就是先比拟一下变量 p 以后的值是不是等于 old,如果等于,那就把变量 p 赋值为 new,并返回 true,否则就不扭转变量 p,并返回 false。

这是 CAS 这个原语的语义,接下来咱们看一下 FAA 原语(Fetch and Add):

<< atomic >>
function faa(p : pointer to int, inc : int) returns int {
    int value <- *location
    *p <- value + inc
    return value
}

FAA 原语的语义是,先获取变量 p 以后的值 value,而后给变量 p 减少 inc,最初返回变量 p 之前的值 value。

讲到这儿预计你会问,这两个原语到底有什么非凡的呢?

下面的这两段伪代码,如果咱们用编程语言来实现,必定是无奈保障原子性的。而原语的非凡之处就是,它们都是由计算机硬件,具体说就是 CPU 提供的实现,能够保障操作的原子性。

咱们晓得, 原子操作具备不可分割性,也就不存在并发的问题 。所以在某些状况下,原语能够用来代替锁,实现一些即平安又高效的并发操作。

CAS 和 FAA 在各种编程语言中,都有相应的实现,能够来间接应用,无论你是应用哪种编程语言,它们底层的实现是一样的,成果也是一样的。

接下来,还是拿咱们相熟的账户服务来举例说明一下,看看如何应用 CAS 原语来代替锁,实现同样的安全性。

CAS 版本的账户服务

假如咱们有一个共享变量 balance,它保留的是以后账户余额,而后咱们模仿多个线程并发转账的状况,看一下如何应用 CAS 原语来保证数据的安全性。

这次咱们应用 Go 语言来实现这个转账服务。先看一下应用锁实现的版本:

package main

import (
    "fmt"
    "sync"
)

func main() {
    // 账户初始值为 0 元
    var balance int32
    balance = int32(0)
    done := make(chan bool)
    // 执行 10000 次转账,每次转入 1 元
    count := 10000

    var lock sync.Mutex

    for i := 0; i < count; i++ {
        // 这里模仿异步并发转账
        go transfer(&balance, 1, done, &lock)
    }
    // 期待所有转账都实现
    for i := 0; i < count; i++ {<-done}
    // 打印账户余额
    fmt.Printf("balance = %d \n", balance)
}
// 转账服务
func transfer(balance *int32, amount int, done chan bool, lock *sync.Mutex) {lock.Lock()
    *balance = *balance + int32(amount)
    lock.Unlock()
    done <- true
}

这个例子中,咱们让账户的初始值为 0,而后启动多个协程来并发执行 10000 次转账,每次往账户中转入 1 元,全副转账执行实现后,账户中的余额应该正好是 10000 元。

如果你没接触过 Go 语言,不理解协程也没关系,你能够简略地把它了解为过程或者线程都能够,这里咱们只是心愿能异步并发执行转账,咱们并不关怀这几种“程”他们之间轻微的差异。

这个应用锁的版本,重复屡次执行,每次 balance 的后果都正好是 10000,那这段代码的安全性是没问题的。接下来咱们看一下,应用 CAS 原语的版本。

func transferCas(balance *int32, amount int, done chan bool) {
    for {old := atomic.LoadInt32(balance)
        new := old + int32(amount)
        if atomic.CompareAndSwapInt32(balance, old, new) {break}
    }
    done <- true
}

这个 CAS 版本的转账服务和下面应用锁的版本,程序的总体构造是一样的,次要的区别就在于,“异步给账户余额 +1”这一小块儿代码的实现。

那在应用锁的版本中,须要先获取锁,而后变更账户的值,最初开释锁,实现一次转账。咱们能够看一下应用 CAS 原语的实现:

首先,它用 for 来做了一个没有退出条件的循环。在这个循环的外部,重复地调用 CAS 原语,来尝试给账户的余额 +1。先获得账户以后的余额,临时寄存在变量 old 中,再计算转账之后的余额,保留在变量 new 中,而后调用 CAS 原语来尝试给变量 balance 赋值。咱们刚刚讲过,CAS 原语它的赋值操作是有前置条件的,只有变量 balance 的值等于 old 时,才会将 balance 赋值为 new。

咱们在 for 循环中执行了 3 条语句,在并发的环境中执行,这外面会有两种可能状况:

一种状况是,执行到第 3 条 CAS 原语时,没有其余线程同时扭转了账户余额,那咱们是能够平安变更账户余额的,这个时候执行 CAS 的返回值肯定是 true,转账胜利,就能够退出循环了。并且,CAS 这一条语句,它是一个原子操作,赋值的安全性是能够保障的。

另外一种状况,那就是在这个过程中,有其余线程扭转了账户余额,这个时候是无奈保障数据安全的,不能再进行赋值。执行 CAS 原语时,因为无奈通过比拟的步骤,所以不会执行赋值操作。本次尝试转账失败,以后线程并没有对账户余额做任何变更。因为返回值为 false,不会退出循环,所以会持续重试,直到转账胜利退出循环。

这样,每一次转账操作,都能够通过若干次重试,在保障安全性的前提下,实现并发转账操作。

其实,对于这个例子,还有更简略、性能更好的形式:那就是,间接应用 FAA 原语。

func transferFaa(balance *int32, amount int, done chan bool) {atomic.AddInt32(balance, int32(amount))
    done <- true
}

FAA 原语它的操作是,获取变量以后的值,而后把它做一个加法,并且保障这个操作的原子性,一行代码就能够搞定了。看到这儿,你可能会想,那 CAS 原语还有什么意义呢?

在这个例子外面,必定是应用 FAA 原语更适合,然而咱们下面介绍的,应用 CAS 原语的办法,它的适用范围更加宽泛一些。相似于这样的逻辑:先读取数据,做计算,而后更新数据,无论这个计算是什么样的,都能够应用 CAS 原语来爱护数据安全,然而 FAA 原语,这个计算的逻辑只能局限于简略的加减法。所以,咱们下面讲的这种应用 CAS 原语的办法并不是没有意义的。

另外,你须要晓得的是,这种应用 CAS 原语重复重试赋值的办法,它是比拟消耗 CPU 资源的,因为在 for 循环中,如果赋值不胜利,是会立刻进入下一次循环没有期待的。如果线程之间的碰撞十分频繁,经常性的重复重试,这个重试的线程会占用大量的 CPU 工夫,随之零碎的整体性能就会降落。

缓解这个问题的一个办法是应用 Yield(),大部分编程语言都反对 Yield() 这个零碎调用,Yield() 的作用是,通知操作系统,让出以后线程占用的 CPU 给其余线程应用。每次循环完结前调用一下 Yield() 办法,能够在肯定水平上缩小 CPU 的使用率,缓解这个问题。你也能够在每次循环完结之后,Sleep() 一小段时间,然而这样做的代价是,性能会重大降落。

所以,这种办法它只适宜于线程之间碰撞不太频繁,也就是说绝大部分状况下,执行 CAS 原语不须要重试这样的场景。

总结

本文讲述了 CAS 和 FAA 这两个原语。这些原语,是由 CPU 提供的原子操作,在并发环境中,独自应用这些原语不必放心数据安全问题。在特定的场景中,CAS 原语能够代替锁,在保障安全性的同时,提供比锁更好的性能。

本文由 mdnice 多平台公布

正文完
 0