并发编程(一) - 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 包,方便开发者使用原子操作。

相关推荐
不知更鸟5 小时前
Django 项目设置流程
后端·python·django
黄昏恋慕黎明6 小时前
spring MVC了解
java·后端·spring·mvc
G探险者8 小时前
为什么 VARCHAR(1000) 存不了 1000 个汉字? —— 详解主流数据库“字段长度”的底层差异
数据库·后端·mysql
百锦再8 小时前
第18章 高级特征
android·java·开发语言·后端·python·rust·django
Tony Bai8 小时前
Go 在 Web3 的统治力:2025 年架构与生态综述
开发语言·后端·架构·golang·web3
程序猿20238 小时前
项目结构深度解析:理解Spring Boot项目的标准布局和约定
java·spring boot·后端
RainbowSea9 小时前
内网穿透配置和使用
java·后端
掘金码甲哥9 小时前
网关上的限流器
后端
q***062910 小时前
搭建Golang gRPC环境:protoc、protoc-gen-go 和 protoc-gen-go-grpc 工具安装教程
开发语言·后端·golang
GOTXX10 小时前
用Rust实现一个简易的rsync(远程文件同步)工具
开发语言·后端·rust