本篇是对Golang中的CAS原子操作 和 锁的学习与记录
概况:
在高并发场景下,对同一变量并发写操作时,需要使用 锁 或 CAS原子操作来保证最终结果的正确。此文比较二者的差异
比如起10000个协程,对同一变量进行+1操作。如果不采取任何措施(无锁&&无CAS操作),则会出现问题 (与预期结果不一致,且每次执行的结果都不同)
无锁&无CAS:
noLockandCas.go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| package main
import ( "fmt" "sync" )
func main() { var count int64 var wg sync.WaitGroup
for i := 0; i < 10000; i++ { wg.Add(1)
go func(wg *sync.WaitGroup) { defer wg.Done() count = count + 1 }(&wg) }
wg.Wait()
fmt.Println("count is:", count)
}
|
多次执行,结果为:
1 2 3
| count is: 8820 count is: 9336 count is: 8976
|
加锁的实现方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| package main
import ( "fmt" "sync" )
func main() { var count int64 var wg sync.WaitGroup var mu sync.Mutex
for i := 0; i < 10000; i++ { wg.Add(1)
go func(wg *sync.WaitGroup) { defer wg.Done() mu.Lock() count = count + 1 mu.Unlock() }(&wg) }
wg.Wait()
fmt.Println("count is:",count)
}
|
多次执行,结果为:
1 2 3
| count is: 10000 count is: 10000 count is: 10000
|
atomic CAS 原子操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| package main
import ( "fmt" "sync" "sync/atomic" )
func main() { var count int64 var wg sync.WaitGroup
for i := 0; i < 10000; i++ { wg.Add(1)
go func(wg *sync.WaitGroup) { defer wg.Done() for { old := atomic.LoadInt64(&count) if atomic.CompareAndSwapInt64(&count, old, old+1) { break } } }(&wg) }
wg.Wait()
fmt.Println("count is:", count)
}
|
多次执行,结果为:
1 2 3
| count is: 10000 count is: 10000 count is: 10000
|
性能差异:
可以看到,加锁或使用CAS,都可以达到目的。 来比较下二者的性能差异
lockandcat_test.go:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| package main
import ( "sync" "sync/atomic" "testing" )
func lockTest() { var count int64 var wg sync.WaitGroup var mu sync.Mutex
for i := 0; i < 10000; i++ { wg.Add(1) go func(wg *sync.WaitGroup) { defer wg.Done() mu.Lock() count = count + 1 mu.Unlock() }(&wg) } wg.Wait() }
func atomicTest() { var count int64 var wg sync.WaitGroup
for i := 0; i < 10000; i++ { wg.Add(1) go func(wg *sync.WaitGroup) { defer wg.Done() for { old := atomic.LoadInt64(&count) if atomic.CompareAndSwapInt64(&count, old, old+1) { break } } }(&wg) } wg.Wait() }
func BenchmarkLock(b *testing.B) { for i := 0; i < b.N; i++ { lockTest() } }
func BenchmarkAtomic(b *testing.B) { for i := 0; i < b.N; i++ { atomicTest() } }
|
单个CPU
go test -bench=".*" -v -benchmem -cpu=1
1 2 3 4 5 6 7 8 9
| goos: darwin goarch: arm64 pkg: xxxxx BenchmarkLock BenchmarkLock 548 2002265 ns/op 32 B/op 3 allocs/op BenchmarkAtomic BenchmarkAtomic 608 2003293 ns/op 24 B/op 2 allocs/op PASS ok ben 3.239s
|
单个CPU情况下,每次操作内存分配, atomic 比 lock 少了1/3,但总的看差别不太大
不同写法的性能差异
Go测试
两个CPU
go test -bench=".*" -v -benchmem -cpu=2
1 2 3 4 5 6 7 8 9
| goos: darwin goarch: arm64 pkg: xxxxx BenchmarkLock BenchmarkLock-2 825 1363475 ns/op 441 B/op 3 allocs/op BenchmarkAtomic BenchmarkAtomic-2 831 1440089 ns/op 24 B/op 2 allocs/op PASS ok ben 2.711s
|
相比单个CPU,每次操作耗时有所减少
四个CPU
go test -bench=".*" -v -benchmem -cpu=4
1 2 3 4 5 6 7 8 9
| goos: darwin goarch: arm64 pkg: xxxxx BenchmarkLock BenchmarkLock-4 567 2020170 ns/op 442 B/op 4 allocs/op BenchmarkAtomic BenchmarkAtomic-4 589 2011578 ns/op 33 B/op 2 allocs/op PASS ok ben 2.841s
|
在arm架构的机器上,改变也不太大
总结
从结果看,当使用多个CPU时,差距较为明显的是分配内存次数(如 4 allocs/op)和每次操作分配的内存大小(如 442B/op)。
总体来看使用 atomic 要比使用 lock 的性能要好
所以在只修改一个变量值的场景下,优先使用 atomic ,而不是 lock 锁
原理
原子操作一般是由 硬件底层 支持的,而锁则是由操作系统层面来实现的。比起使用锁,使用 CAS原子操作这个过程不会形成临界区和创建临界区,大大减少了同步对程序性能的影响,所以性能要高效一些。
但原子操作也有一定的弊端,在被操作值频繁变更的情况下,很可能失败,需要一直重试直到成功为止,这种重试行为也可以理解为自旋spinning,长时间处于spinning将浪费CPU
【os浅尝】实现自旋锁
原子操作
硬件层面实现。几乎所有的现代CPU指令都支持CAS的原子操作(X86下对应的是 CMPXCHG 汇编指令)
有了这个原子操作,就可以用其来实现各种无锁(lock free)的数据结构,如最常见的 无锁队列
CAS 在Golang中是以共享内存的方式来实现的一种同步机制,它是一个原子操作,一般格式如下:
1 2 3 4 5 6 7 8
| func addValue(delta int32){ for{ oldValue := atomic.LoadInt32(&addr) if atomic.CompareAndSwapInt32(&addr, oldValue, oldValue+delta){ break; } } }
|
“先从一个内存地址 &addr 读取出来当前存储的值,假如读取完以后,没有其它线程对此变量 进行修改的话,则下面的 atomic.CompareAndSwapInt32 语句会在执行时先再判断一次它的值是否与上次的相等,这时必须是相等的,则直接更新它的值;如果在读取值后,有其它线程对变量值进行了修改,发现值不相等,这时就再重新开始下一轮的判断,直到修改成功为止。
对于 atomic.CompareAndSwapIntxx() 之类函数,其声明在sync/atomic/doc.go ,实现在 src/sync/atomic/asm.s,真正对应的汇编文件位于 src/runtime/internal/atomic/*.s,如64架构对应的文件为 asm_amd64.s”
arm架构下atomic.CompareAndSwapInt64 和 atomic.CompareAndSwapUint64 对应的汇编是 runtime∕internal∕atomic·Cas64;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
TEXT runtime∕internal∕atomic·Cas64(SB), NOSPLIT, $0-25 MOVD ptr+0(FP), R0 MOVD old+8(FP), R1 MOVD new+16(FP), R2 again: LDAXR (R0), R3 CMP R1, R3 BNE ok STLXR R2, (R0), R3 CBNZ R3, again ok: CSET EQ, R0 MOVB R0, ret+24(FP) RET
|
锁
可以保证一段代码的原子性,由操作系统来实现。锁的粒度越小越好
对比
mutex 由操作系统实现,而 atomic 包中的原子操作则由底层硬件直接提供支持。在 CPU 实现的指令集里,有一些指令被封装进了 atomic 包,这些指令在执行的过程中是不允许中断(interrupt)的,因此原子操作可以在 lock-free 的情况下保证并发安全,并且它的性能也能做到随 CPU 个数的增多而线性扩展。
若实现相同的功能,后者通常会更有效率,并且更能利用计算机多核的优势。所以,以后当我们想并发安全的更新一些变量的时候,我们应该优先选择用 atomic 来实现。
到底啥是原子性–Mutex vs Atomic
更多参考:
sync/atomic - 原子操作
Go是如何实现乐观锁(CAS理论)
原文链接: https://dashen.tech/2017/08/25/Mutex-vs-Atomic/
版权声明: 转载请注明出处.