learn-tech/专栏/消息队列高手课/18如何用硬件同步原语(CAS)替代锁?.md
2024-10-16 06:37:41 +08:00

170 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
18 如何用硬件同步原语CAS替代锁
你好,我是李玥。上节课,我们一起学习了如何使用锁来保护共享资源,你也了解到,使用锁是有一定性能损失的,并且,如果发生了过多的锁等待,将会非常影响程序的性能。
在一些特定的情况下,我们可以使用硬件同步原语来替代锁,可以保证和锁一样的数据安全性,同时具有更好的性能。
在今年的 NSDINSDI 是 USENIX 组织开办的关于网络系统设计的著名学术会议伯克利大学发表了一篇论文《Confluo: Distributed Monitoring and Diagnosis Stack for High-speed Networks》这个论文中提到的 Confluo也是一个类似于消息队列的流数据存储它的吞吐量号称是 Kafka 的 410 倍。对于这个实验结论我个人不是很认同,因为它设计的实验条件对 Kafka 来说不太公平。但不可否认的是Confluo 它的这个设计思路是一个创新,并且实际上它的性能也非常好。
Confluo 是如何做到这么高的吞吐量的呢?这里面非常重要的一个创新的设计就是,它使用硬件同步原语来代替锁,在一个日志上(你可以理解为消息队列中的一个队列或者分区),保证严格顺序的前提下,实现了多线程并发写入。
今天我们就来学习一下如何用硬件同步原语CAS替代锁
什么是硬件同步原语?
为什么硬件同步原语可以替代锁呢?要理解这个问题,你要首先知道硬件同步原语是什么。
硬件同步原语Atomic Hardware Primitives是由计算机硬件提供的一组原子操作我们比较常用的原语主要是 CAS 和 FAA 这两种。
CASCompare 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 原语可以替代锁在保证安全性的同时提供比锁更好的性能
接下来我们用转账服务这个例子分别演示了 CAS FAA 这两个原语是如何替代锁来使用的对于类似:“先读取数据做计算然后再更新数据这样的业务逻辑可以使用 CAS 原语 + 反复重试的方式来保证数据安全前提是线程之间的碰撞不能太频繁否则太多重试会消耗大量的 CPU 资源反而得不偿失