并发安全性
在编程中经常遇到并发而产生的问题,那么应该怎么解决并发呢?什么情况下会产生并发以及为什么会有并发问题的产生?下面我将从宏观的角度讲解并发安全性问题。
什么是并发?
并发是指在同一时间间隔内,多个任务(线程、进程或活动)同时存在和执行的状态,在同一个时间点多个任务同时进行,当一个 CPU 在访问数据时,其他 CPU 可能同时在访问同一块数据或者相关的数据,如果其中一个 CPU 修改了数据并且写回了内存,其他 CPU 的缓存并不会立即更新,而是需要等待一定的时间或者触发特定的机制来使得缓存中的数据失效。这就可能导致其他 CPU 继续使用旧的数据,从而引发并发性问题,如数据不一致、程序错误等。
如何解决并发?
在go中,可以使用Goroutine
和Channel
来解决并发安全性,上者是基于共享通讯来解决并发安全性,亦或使用锁机制来解决并发安全,例如sync包下的atomic
和mutex
。其实mutex就是基于atomic实现的,等到下章节将会讲解mutex的源码分析。锁呢又分为悲观锁和乐观锁,atomic是乐观锁而mutex是悲观锁
-
悲观锁: 总是想到最坏的打算,只要它有发生意外的可能性,那么在访问共享资源之前
立即获取锁
,以确保同一时刻只有一个任务访问共享资源。悲观锁通常使用互斥锁和共享锁来实现,例如mutex,在Golang中除了atomic其他的都是悲观锁
,悲观的优点就是简单易懂,能够有效地防止并发访问导致的数据不一致问题。缺点就是在高并发下会导致系统性能瓶颈,因为每次访问都需要获取锁,会造成线程阻塞和资源竞争。 -
乐观锁: 乐观锁和悲观锁恰恰相反,它会认为并发访问不会导致数据冲突,所以在访问共享资源的时候
不会立即获取锁
,而是在更新操作的时候检查是否有其他事务对他进行了更改,乐观锁主要由CAS和版本号机制实现的,因为是无锁的,所以可以提高吞吐量。例如mysql中的MVCC
机制就是通过乐观锁实现的,go中atomic
也是乐观锁实现的一种方式
原子性的实现原理
一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为原子性(atomicity) 。原子性意为不可再分割,即最小单位,要么都执行,要么都不执行。Golang的atomic包的原子性操作是通过CPU指令实现的,Golang的atomic包的原子操作函数会将变量的地址转换为指针型的变量,并使用CPU指令对这个指针型的变量进行操作,原子操作支持的类型包括int32、int64、uint32、uint64、uintptr、unsafe.Pointer
atomic
了解了atomic是为了解决并发问题,官方并不建议使用atomic来保证并发安全性,除非在一些偏底层的操作中可以使用,一般使用mutex来保证并发安全性。下面开始正式讲解goalng下的atomic的使用和实现原理,他有5种原子性操作,
分别是
- swap操作
- compare-and-swap操作
- add操作
- load操作
- store操作
上面这五种操作,其实内部都是用汇编语言进行操作的,所以这个并不是我们的重点,重点就在value
中
swap
这个函数把addr 指针指向的内存里的值替换为新值new,然后返回旧值old,也就是把旧值修改成新值,然后把旧值返回出去。因为这里没有使用泛型,所以每个方法都要实现
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
这这并不是swap的具体实现,具体实现在汇编语言中即sync/atomic/asm.s,JMP指令被用来跳转到对应的Xchg函数,具体实现的方法在runtime∕internal∕atomic·Xchg中,需要注意的是每个cpu架构对应的指令也不一样,所以要先找到对应的架构文件,下面的例子都是基于x86实现的
TEXT ·SwapInt32(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xchg(SB)
TEXT ·SwapUint32(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xchg(SB)
TEXT ·SwapInt64(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xchg64(SB)
TEXT ·SwapUint64(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xchg64(SB)
TEXT ·SwapUintptr(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xchguintptr(SB)
在runtime∕internal∕atomic·Xchg中可以看到注释已经对这个汇编进行了解释,下面的例子都会基于int32类型进行讲解。Xchg 函数原子性地交换给定地址中的32位无符号整数的值,并返回原来的值,先把指针ptr指向的地址中的值读取并保存到临时变量中,在将参数 new 中的新值写入到指针 ptr 指向的地址中,即将旧值替换为新值,最后返回旧值
// uint32 Xchg(ptr *uint32, new uint32)
// Atomically:
// old := *ptr;
// *ptr = new;
// return old;
TEXT ·Xchg(SB), NOSPLIT, $0-20
MOVQ ptr+0(FP), BX
MOVL new+8(FP), AX
XCHGL AX, 0(BX)
MOVL AX, ret+16(FP)
RET
// uint64 Xchg64(ptr *uint64, new uint64)
// Atomically:
// old := *ptr;
// *ptr = new;
// return old;
eg:下面是对swap使用的例子
var value int32
func main() {
value = 100
old := swapInt32(300)
fmt.Printf("old:%d,value:%d", old, value)
}
func swapInt32(val int32) int32 {
old := atomic.SwapInt32(&value, val)
return old
}
输出结果:old:100,value:300
CompareAndSwap(CAS)
CAS 操作会比较内存中某个位置的值与预期值,如果相等,则使用新值替换原值,并返回操作是否成功,在 Golang中,CAS 操作由sync/atomic
包提供支持。atomic 包提供了一系列原子性操作函数,包括 CompareAndSwap、Add、Load、Store
等,用于对共享变量进行原子性操作。这些原子性操作函数可以在多线程环境下安全地对共享变量进行读写操作,而不需要显式地加锁
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
JMP指令被用来跳转到对应的Cas函数
TEXT ·CompareAndSwapInt32(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Cas(SB)
TEXT ·CompareAndSwapUint32(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Cas(SB)
TEXT ·CompareAndSwapUintptr(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Casuintptr(SB)
TEXT ·CompareAndSwapInt64(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Cas64(SB)
TEXT ·CompareAndSwapUint64(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Cas64(SB)
runtime/internal/atomic,这段汇编代码展示了Go语言中sync/atomic
包中的Cas
函数的底层实现。Cas
函数用于实现原子比较并交换操作,其功能为:如果给定地址中的值等于旧值,则将新值写入到该地址中,并返回真(true
);否则,返回假(false
)。先将指针 ptr
指向的地址中的值加载到寄存器 AX
中,并将参数 old
中的旧值加载到寄存器 CX
中。在将参数 new
中的新值加载到寄存器 BX
中。然后使用 LOCK CMPXCHGL
指令进行比较并交换操作
这个过程保证了在多线程环境下对给定地址中的值进行原子性的比较和交换操作,从而避免了竞态条件和数据不一致的问题。
// bool Cas(int32 *val, int32 old, int32 new)
// Atomically:
// if(*val == old){
// *val = new;
// return 1;
// } else
// return 0;
TEXT ·Cas(SB),NOSPLIT,$0-17
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
eg:
var value int32
func main() {
value = 100
ok := compareAndSwapInt32(300)
fmt.Printf("old:%v,value:%d", ok, value)
}
func compareAndSwapInt32(val int32) bool {
ok := atomic.CompareAndSwapInt32(&value, val, val+101)
return ok
}
只有value和val相等的时候才会替换value的值,这个和swap其实就多了一步判断
运行结果:old:false,value:100
Add(加或者减)
从源码可以看出,Add只支持五种类型,不支持unsafe.Pointer类型,add就是把addr 指针指向的内存里的值和delta做加法,然后返回新值,
func AddInt32(addr *int32, delta int32) (new int32)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
这些函数AddInt32
、AddUint32
、AddUintptr
、AddInt64
和AddUint64
,它们分别用于对32位整数、无符号32位整数、指针类型、64位整数和无符号64位整数进行原子加法操作。
这些Add
函数的实现都是通过调用名为runtime/internal/atomic
包中的Xadd
、Xadduintptr
和Xadd64
函数来实现的。这些函数会原子性地将给定地址中的值加上一个指定的增量,并返回增加前的值。
TEXT ·AddInt32(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xadd(SB)
TEXT ·AddUint32(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xadd(SB)
TEXT ·AddUintptr(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xadduintptr(SB)
TEXT ·AddInt64(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xadd64(SB)
TEXT ·AddUint64(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xadd64(SB)
注释解释了汇编语言,将给定地址中的值加上一个指定的增量,并返回增加前的值
// uint32 Xadd(uint32 volatile *val, int32 delta)
// Atomically:
// *val += delta;
// return *val;
TEXT ·Xadd(SB), NOSPLIT, $0-20
MOVQ ptr+0(FP), BX
MOVL delta+8(FP), AX
MOVL AX, CX
LOCK
XADDL AX, 0(BX)
ADDL CX, AX
MOVL AX, ret+16(FP)
RET
eg:
在下面的例子中,用go协程模拟并发安全性问题,其中unsafe是不安全的,因为sum += i 并不是原子性操作,所以在并发情况下会导致数据不一致,在safe中解决了此问题,把sum += i 换成了atomic.AddInt32(&sum, i),因为他是原子性操作
,所以不会有并发问题
,当然了也可以使用mutex互斥锁
来解决此问题,他保证在任意时刻只有一个 goroutine 可以访问临界区
var value int32
var wg sync.WaitGroup
func main() {
unsafe()
safe()
}
func unsafe() {
var sum int32 = 0
N := 100
wg.Add(N)
for i := 0; i < N; i++ {
go func(i int32) {
sum += i
wg.Done()
}(int32(i))
}
wg.Wait()
fmt.Printf("unsafe:%d以内所有的数字之和为:%d\n", N, sum)
}
func safe() {
var sum int32 = 0
N := 100
wg.Add(N)
for i := 0; i < N; i++ {
go func(i int32) {
atomic.AddInt32(&sum, i)
wg.Done()
}(int32(i))
}
wg.Wait()
fmt.Printf("safe:%d以内所有的数字之和为:%d", N, sum)
}
运行结果:unsafe的结果在多次操作后可能不是正确的数据,他的数据具有不确定性
unsafe:100以内所有的数字之和为:4882
safe:100以内所有的数字之和为:4950
Load(原子读取)
在进行大量的读写操作的时候,读取一个变量,很有可能这个变量正在被写入,有可能会读取到错误的数据,即这个数据刚写入一半,这时可以使用原子读取Load进行读取
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
下面是汇编代码,只需了解即可,大概意思就是对六种数据类型进行原子性读读取。
TEXT ·Loaduintptr(SB), NOSPLIT, $0-16
JMP ·Load64(SB)
TEXT ·Loaduint(SB), NOSPLIT, $0-16
JMP ·Load64(SB)
TEXT ·Loadint32(SB), NOSPLIT, $0-12
JMP ·Load(SB)
TEXT ·Loadint64(SB), NOSPLIT, $0-16
JMP ·Load64(SB)
eg:
var counter int32
var wg sync.WaitGroup
func main() {
for i := 0; i < 1000; i++ {
wg.Add(1)
go doTask()
}
wg.Wait()
fmt.Println("Counter Value:", atomic.LoadInt32(&counter))
}
运行结果: Counter Value: 1000
store操作
有读取肯定有写入操作,这个写入操作也是原子性的,直接操作的是硬件读取。
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
下面是汇编语言的源代码,这些函数通过汇编指令 XCHG
来实现原子性的存储操作。这样可以确保在多个goroutine
在同一时间内,并发访问同一个内存地址时,不会出现竞态条件和数据不一致的问题
TEXT ·StorepNoWB(SB), NOSPLIT, $0-16
MOVQ ptr+0(FP), BX
MOVQ val+8(FP), AX
XCHGQ AX, 0(BX)
RET
TEXT ·Store(SB), NOSPLIT, $0-12
MOVQ ptr+0(FP), BX
MOVL val+8(FP), AX
XCHGL AX, 0(BX)
RET
TEXT ·Store8(SB), NOSPLIT, $0-9
MOVQ ptr+0(FP), BX
MOVB val+8(FP), AX
XCHGB AX, 0(BX)
RET
TEXT ·Store64(SB), NOSPLIT, $0-16
MOVQ ptr+0(FP), BX
MOVQ val+8(FP), AX
XCHGQ AX, 0(BX)
RET
TEXT ·Storeint32(SB), NOSPLIT, $0-12
JMP ·Store(SB)
TEXT ·Storeint64(SB), NOSPLIT, $0-16
JMP ·Store64(SB)
TEXT ·Storeuintptr(SB), NOSPLIT, $0-16
JMP ·Store64(SB)
TEXT ·StoreRel(SB), NOSPLIT, $0-12
JMP ·Store(SB)
TEXT ·StoreRel64(SB), NOSPLIT, $0-16
JMP ·Store64(SB)
TEXT ·StoreReluintptr(SB), NOSPLIT, $0-16
JMP ·Store64(SB)
eg:下面是使用store的例子
var wg sync.WaitGroup
func main() {
var value int32
for i := 0; i < 100; i++ {
wg.Add(1)
go doAtomicStore(&value, int32(i))
}
wg.Wait()
loadedValue := atomic.LoadInt32(&value)
fmt.Println("Final value:", loadedValue)
}
func doAtomicStore(ptr *int32, val int32) {
// 使用原子存储函数将值存储到共享变量中
atomic.StoreInt32(ptr, val)
wg.Done()
}
运行结果:Final value: 98
Value的读操作
这个相信大家在go的标准库中经常见到,比如Golang的context库中done的实现,在上章节讲解的源码中有分析,context用atomic.value
来存储一个类型为 <-chan struct{} 的通道的原子值.
value 类型用来存储任意类型的数据相比之前的存储方式,非常的简单粗暴。需要注意的是,一但第一次store写入的类型就会确定,如果使用的类型不一致会导致panic,efaceWords
其实就是一个空interface,相当于eface想要深入了解,还需先了解一下unsafe.Pointer
unsafe.Pointer: 它是go的一个特殊类型,他可以表示任意类型的指针,需要注意的是,他在编译时并不会对数据类型进行检查,所以使用他的时候要特别小心。它可以用来进行类型转换,在转换时一定要检查是否具有相同的内存结构,下面的代码吧byte类型零值拷贝
成了string,这样的性能明显高于标准转化
即string(bytes)
func main() {
bytes := []byte{101, 102, 108, 108, 111}
a := unsafe.Pointer(&bytes)
str := *(*string)(a)
fmt.Println(str)
}
运行结果:Hello, Golang!
了解了unsafe.Pointer之后可以对Store进行了解了,文章来源:https://www.toymoban.com/news/detail-833883.html
type Value struct {
v any
}
type efaceWords struct {
typ unsafe.Pointer
data unsafe.Pointer
}
Srore操作
其实store还是基于LoadPointer实现的,所以他的性能略低于Load文章来源地址https://www.toymoban.com/news/detail-833883.html
func (v *Value) Store(val any) {
if val == nil {
panic("sync/atomic: store of nil value into Value")
}
vp := (*efaceWords)(unsafe.Pointer(v))
vlp := (*efaceWords)(unsafe.Pointer(&val))
for {
typ := LoadPointer(&vp.typ)
if typ == nil {
// Attempt to start first store.
// Disable preemption so that other goroutines can use
// active spin wait to wait for completion.
runtime_procPin()
if !CompareAndSwapPointer(&vp.typ, nil, unsafe.Pointer(&firstStoreInProgress)) {
runtime_procUnpin()
continue
}
// Complete first store.
StorePointer(&vp.data, vlp.data)
StorePointer(&vp.typ, vlp.typ)
runtime_procUnpin()
return
}
if typ == unsafe.Pointer(&firstStoreInProgress) {
// First store in progress. Wait.
// Since we disable preemption around the first store,
// we can wait with active spinning.
continue
}
// First store completed. Check type and overwrite data.
if typ != vlp.typ {
panic("sync/atomic: store of inconsistently typed value into Value")
}
StorePointer(&vp.data, vlp.data)
return
}
}
- 首先先判断val ,如果val为空直接panic,存的值类型不能为空。
- unsafe.Pointer将现有的和要写入的值分别转成ifaceWords类型,这样我们下一步就可以得到这两个interface{}的原始类型(typ)和真正的值(data)
- 通过一个无限for循环,配合CompareAndSwap来实现乐观锁,检查 vp 中存储的类型信息是否为 nil,如果是,则表示尚未进行任何存储操作,可以尝试开始第一次存储操作,后给type设置一个标识位,来表示有goroutine正在写入,然后退出
- runtime_procPin() 是gmp中的p,这个方法就是在这个过程中不允许其他m和g抢占
- 如果type不为nil,但是等于标识位,表示有正在写入的goroutine,然后继续循环
- 如果 vp 中存储的类型信息与 new 中存储的类型信息不一致,则抛出 panic。这是为了防止不一致类型的值被存储到 Value 中
- 最后类型已经写入,直接保存数据
Load操作
func (v *Value) Load() (val any) {
vp := (*efaceWords)(unsafe.Pointer(v))
typ := LoadPointer(&vp.typ)
if typ == nil || typ == unsafe.Pointer(&firstStoreInProgress) {
// First store not yet completed.
return nil
}
data := LoadPointer(&vp.data)
vlp := (*efaceWords)(unsafe.Pointer(&val))
vlp.typ = typ
vlp.data = data
return
}
- 读取也是基于Load实现的,将 v 转换为 efaceWords 结构体的指针 vp
- 如果 typ 为 nil 或者等于 firstStoreInProgress 标记,则表示尚未进行任何存储操作,直接返回 nil
- 如果 typ 不为空,则表示已经进行了存储操作,继续加载存储的数据
- 否则,根据当前看到的typ和data构造出一个新的interface{}返回出去
到了这里,关于深入了解Golang atomic原子操作的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!