并发编程(一) - atomic

引子

多核处理器的出现解决了单核处理器在性能提升上的瓶颈问题,通过在一个芯片上集成多个处理器核心,可以提供更高的计算性能。然而,多核处理器的引入也带来了新的问题:数据竞争。即多核同时操作「至少有一个写操作」内存上的同一地址,且不同操作之间没有进行同步控制「指定对内存的读写顺序」,从而导致数据异常的问题。

下面从一个计数器来说明数据竞争的问题,输出的 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 包,方便开发者使用原子操作。

相关推荐
AskHarries几秒前
Java字节码增强库ByteBuddy
java·后端
许野平1 小时前
Rust: 利用 chrono 库实现日期和字符串互相转换
开发语言·后端·rust·字符串·转换·日期·chrono
齐 飞3 小时前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb
童先生3 小时前
Go 项目中实现类似 Java Shiro 的权限控制中间件?
开发语言·go
LunarCod3 小时前
WorkFlow源码剖析——Communicator之TCPServer(中)
后端·workflow·c/c++·网络框架·源码剖析·高性能高并发
码农派大星。4 小时前
Spring Boot 配置文件
java·spring boot·后端
杜杜的man5 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*5 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu5 小时前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s5 小时前
Golang--协程和管道
开发语言·后端·golang