引子
多核处理器的出现解决了单核处理器在性能提升上的瓶颈问题,通过在一个芯片上集成多个处理器核心,可以提供更高的计算性能。然而,多核处理器的引入也带来了新的问题:数据竞争。即多核同时操作「至少有一个写操作」内存上的同一地址,且不同操作之间没有进行同步控制「指定对内存的读写顺序」,从而导致数据异常的问题。
下面从一个计数器来说明数据竞争的问题,输出的 c 是一个小于等于 1000 的不确定数值。
go
package main
import (
"fmt"
"sync"
)
// go tool compile -N -l -S main.go
func main() {
var c = 0
wg := &sync.WaitGroup{}
for i := 0; i < 100000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c++
}()
}
wg.Wait()
fmt.Println(c) // < 1000
}
在上面的程序中,创建了 1000 个 gorotuine,每个 goroutine 分别执行一次 c++
操作,结果却小于 1000。通过 go tool compile -N -l -S main.go
命令,查看 c++ 对应的汇编代码:
scss
// 将变量 c 的地址加载到 R0 中
MOVD "".&c+8(FP), R0
// 将 R0 的指向的值加载到 R0 中(解引用)
MOVD (R0), R0
// 将变量 c 的地址加载到 R0 中
MOVD "".&c+8(FP), R1
// 将 R0 值执行 +1 操作
ADD $1, R0, R0
// 将 R0 值存储到 R1 指向的内存地址
MOVD R0, (R1)
可以看到 c++
操作被拆成了五条指令。按照 GMP 调度模型,会将 goroutine 分配到不同 cpu 并行的 c++
操作,那么就会出现下面这种情况:两个 goroutine 同时读到 c 的值 x,并各自执行 +1 操作,分别将结果存储到 c = x+1
中。虽然执行了两次 +1 操作,最终结果却只增加了 1。
竞争检测
在 go 中,我们可以将数据竞争定义为两个或两个以上的 goroutine 操作内存中的同一块地址,至少有一个 goroutine 执行了写操作,且没有进行同步控制。go 提供了 -race
命令来进行竞争检测。对于上面的例子,可以执行 go run -race main.go
。从提示信息可以得知程序发生了数据竞争,goroutine 8
对内存地址 0x00c00013c018
有读取操作,同时 goroutine 7
对这块内存有写操作,读写之间没有做同步控制,从而导致了数据竞争。并指出了 goroutine 7
和 goroutine 8
是在哪一行创建的。
bash
==================
WARNING: DATA RACE
Read at 0x00c00013c018 by goroutine 8:
main.main.func1()
/Users/bytedance/Desktop/code/tetris/concurrency/atomic/main.go:16 +0x60
Previous write at 0x00c00013c018 by goroutine 7:
main.main.func1()
/Users/bytedance/Desktop/code/tetris/concurrency/atomic/main.go:16 +0x74
Goroutine 8 (running) created at:
main.main()
/Users/bytedance/Desktop/code/tetris/concurrency/atomic/main.go:14 +0xc4
Goroutine 7 (finished) created at:
main.main()
/Users/bytedance/Desktop/code/tetris/concurrency/atomic/main.go:14 +0xc4
==================
100000
Found 1 data race(s)
exit status 66
从上面的 case 可以看到,即使开启了 race
检测,也只能在程序真实运行时进行检测。如果程序运行时没有触发数据竞争相关代码,则无法发现问题,例如:满足特定条件下才执行对应的竞争操作。此外,-race
参数会在编译时插入额外的指令,影响程序运行的性能。
原子操作
数据竞争检测只能用来帮我们发现和排查数据竞争问题,那我们如何解决数据竞争问题呢?在上面的计数 case 中,问题发生在多个 goroutine 对内存进行读写时没有进行同步控制,从而导致计数是基于旧值。最简单的方法就是不进行并发读写,直接使用 1 个 CPU。即在上面的代码上增加 runtime.GOMAXPROCS(1)
。所有的 goroutine 将变成串行执行。显然,这种处理方式虽然解决了问题,但是造成了资源的浪费,无法发挥多核的性能。
在上面的程序中,由于 c++
操作由五个指令组成,并发场景下导致五个指令乱序执行,造成数据自增失效。那么,如果将五个指令放到一个原子操作中是不是就能解决问题?原子操作:一个不可被其他线程中断的操作。原子操作在执行过程中,要么全部完成,要么全部不完成,没有中间状态。这意味着,如果多个线程同时尝试执行原子操作,那么在任何特定时刻,只有一个线程能进行该操作,而其他线程必须等待该操作完成。即相同的原子操作始终是串行执行的。
只需要将 c++
操作变成一个原子操作,就能够保证计数器的准确性。下面我们修改一下计数器代码:
go
func main() {
var c int64 = 0
wg := &sync.WaitGroup{}
for i := 0; i < 100000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&c, 1)
}()
}
wg.Wait()
fmt.Println(c)
}
同样的,通过 go tool compile -N -l -S main.go
命令,查看对应的汇编指令。这里有一个命令 LDADDALD R1, (R0), R2
,这是由 CPU 提供的一个原子命令:从地址 R0 加载一个值,将它与寄存器 R1 中的值相加,然后把结果存回地址 R0,同时将原来的值(加载之前的值)放入寄存器 R2。加载「load」、相加「add」、存储「store」三个操放到一个原子操作的,执行过程中不会被中断,且任意时刻只有一个 CPU 在执行这个原子操作。
scss
MOVD "".&c+8(FP), R0:将参数c的地址加载到寄存器R0。
MOVBU runtime.arm64HasATOMICS(SB), R1:检查当前的ARM64硬件是否支持原子操作。
CBNZ R1, 104:如果支持原子操作,跳转到偏移104的指令。
JMP 120:如果不支持原子操作,跳转到偏移120的指令。
MOVD $1, R1:将1加载到寄存器R1。
LDADDALD R1, (R0), R2:如果硬件支持原子操作,将R1和R0指向的值相加并将结果存储在R0指向的地方,同时将原值加载到R2。
ADD R1, R2:如果硬件支持原子操作,将R1和R2的值相加。
JMP 144:无条件跳转到偏移144的指令。
LDAXR (R0), R2:如果硬件不支持原子操作,将R0指向的值原子性地加载到R2。
ADD R1, R2:如果硬件不支持原子操作,将R1和R2的值相加。
STLXR R2, (R0), R27:如果硬件不支持原子操作,尝试将R2的值原子性地存储到R0指向的地方,如果成功,R27被设置为0,否则非0。
CBNZ R27, 124:如果R27不为0(即上面的存储操作失败),则跳转到偏移124的指令重新尝试加载、增加和存储操作。
则整体的执行过程如下,由于c++
操作是原子的,保证了每次递增基于最新值完成的。
atomic 函数
原子操作底层是由 CPU 硬件支持的,不同的架构有不同的实现。go 对不同的 CPU 实现进行屏蔽,对外暴露一个 sync/atomic
包,里面提供对应的原子操作。
ADD
go
func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
给指定的类型增加 delta 并返回增加后的结果。如果想增加一个负数,则可以利用补码规则转为加法。例如:对于一个 unit32,需要减 c,则可以使用下面的方式实现。
scss
AddUint32(&x, ^uint32(c-1)).
CAS
比较交换,比较 addr 和 old 的值是否相同,相同则更新 add 为 new, 同时返回 true;否则,不更新 addr,返回 false。CAS 会用于实现同步原语 mutex,后续会介绍。
go
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (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)
Load
原子读数据,数据读取期间不会有其他操作。
go
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)
Store
更新数据,数据更新期间不会有其他操作。
go
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)
Swap
将 addr 直接设置为 new,并返回 addr 的值。
go
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
atomic 类型
Type
在 atomic 定义了一些原子类型「Bool、Int32、Int64、Pointer、Uint32、Uint64、Uintptr」,方便直接执行相关的原子操作。
go
func main() {
var c atomic.Int64
wg := &sync.WaitGroup{}
for i := 0; i < 100000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Add(1)
}()
}
wg.Wait()
fmt.Println(c.Load())
}
Value
Value 可以存储任意一种类型,支持对应的 Store、Load、Swap、ComareAndSwap 操作。下面是一个定时更新配置的例子,保证工作线程可以读取到更新的配置。
go
func main() {
var config atomic.Value
// 存储配置,每次存储时必须使用相同的数据类型
config.Store(loadConfig())
go func() {
for {
// 定时更细配置
time.Sleep(time.Second)
config.Store(loadConfig())
}
}()
// 创建 worker goroutines 处理请求
for i := 0; i < 10; i++ {
go func() {
for r := range requests() {
// 加载配置
c := config.Load()
// 处理请求
_, _ = r, c
}
}()
}
select {}
}
总结
在本文中,通过一个并发计数例子,引入数据竞争问题。即在并发场景下,多个 goroutine 操作「至少有一个写操作」同一块内存,且没有进行同步控制,从而导致数据异常。为了解决数据竞争问题,CPU 提供了原子操作,保证任意时刻只有一个 CPU 执行原子命令且不会被打断,中间结果也无法被外部感知到。go 对不同类型 CPU 的原子命令进行封装,暴露一个 atomic 包,方便开发者使用原子操作。