Golang Go语言中 Runtime: atomic 及内存一致模型初步
atomic
抛开 Memory Consistency Model 不谈, sync/atomic 的实现是简单而直观的.
我们首先构造一个案例, 基于 linux/amd64 编译后, 使用 objdump 查看汇编结果.
import (
"fmt"
"sync/atomic"
)
var x atomic.Int64
func main() {
x.Store(32)
x.Add(1)
x.CompareAndSwap(33, 34)
i := x.Load()
fmt.Println(i)
}
~ GOOS=linux GOARCH=amd64 go build main.go
~ x86_64-linux-gnu-objdump -D -S main | grep -A 65 "main.main>:" | cat -n -
1 000000000047ad60 <main.main>:
2 "sync/atomic"
3 )
4
5 var x atomic.Int64
6
7 func main() {
8 47ad60: 49 3b 66 10 cmp 0x10(%r14),%rsp
9 47ad64: 0f 86 81 00 00 00 jbe 47adeb <main.main+0x8b>
10 47ad6a: 55 push %rbp
11 47ad6b: 48 89 e5 mov %rsp,%rbp
12 47ad6e: 48 83 ec 38 sub $0x38,%rsp
13 x.Store(32)
14 47ad72: 90 nop
15
16 // Load atomically loads and returns the value stored in x.
17 func (x *Int64) Load() int64 { return LoadInt64(&x.v) }
18
19 // Store atomically stores val into x.
20 func (x *Int64) Store(val int64) { StoreInt64(&x.v, val) }
21 47ad73: b9 20 00 00 00 mov $0x20,%ecx
22 47ad78: 48 8d 15 89 16 0d 00 lea 0xd1689(%rip),%rdx # 54c408 <main.x>
23 47ad7f: 48 87 0a xchg %rcx,(%rdx)
24 x.Add(1)
25 47ad82: 90 nop
26 func (x *Int64) CompareAndSwap(old, new int64) (swapped bool) {
27 return CompareAndSwapInt64(&x.v, old, new)
28 }
29
30 // Add atomically adds delta to x and returns the new value.
31 func (x *Int64) Add(delta int64) (new int64) { return AddInt64(&x.v, delta) }
32 47ad83: b9 01 00 00 00 mov $0x1,%ecx
33 47ad88: f0 48 0f c1 0a lock xadd %rcx,(%rdx)
34 x.CompareAndSwap(33, 34)
35 47ad8d: 90 nop
36 return CompareAndSwapInt64(&x.v, old, new)
37 47ad8e: b8 21 00 00 00 mov $0x21,%eax
38 47ad93: b9 22 00 00 00 mov $0x22,%ecx
39 47ad98: f0 48 0f b1 0a lock cmpxchg %rcx,(%rdx)
40 47ad9d: 0f 94 c1 sete %cl
41 i := x.Load()
42 47ada0: 90 nop
43 func (x *Int64) Load() int64 { return LoadInt64(&x.v) }
44 47ada1: 48 8b 05 60 16 0d 00 mov 0xd1660(%rip),%rax # 54c408 <main.x>
45 fmt.Println(i)
46 47ada8: 44 0f 11 7c 24 28 movups %xmm15,0x28(%rsp)
47 47adae: e8 ed e9 f8 ff call 4097a0 <runtime.convT64>
48 47adb3: 48 8d 0d 06 70 00 00 lea 0x7006(%rip),%rcx # 481dc0 <type:*+0x6dc0>
49 47adba: 48 89 4c 24 28 mov %rcx,0x28(%rsp)
50 47adbf: 48 89 44 24 30 mov %rax,0x30(%rsp)
51 return Fprintln(os.Stdout, a...)
52 47adc4: 48 8b 1d 5d 37 0a 00 mov 0xa375d(%rip),%rbx # 51e528 <os.Stdout>
53 47adcb: 48 8d 05 36 75 03 00 lea 0x37536(%rip),%rax # 4b2308 <go:itab.*os.File,io.Writer>
54 47add2: 48 8d 4c 24 28 lea 0x28(%rsp),%rcx
55 47add7: bf 01 00 00 00 mov $0x1,%edi
56 47addc: 48 89 fe mov %rdi,%rsi
57 47addf: 90 nop
58 47ade0: e8 7b ae ff ff call 475c60 <fmt.Fprintln>
59 }
60 47ade5: 48 83 c4 38 add $0x38,%rsp
61 47ade9: 5d pop %rbp
62 47adea: c3 ret
63 func main() {
64 47adeb: e8 10 fc fd ff call 45aa00 <runtime.morestack_noctxt.abi0>
65 47adf0: e9 6b ff ff ff jmp 47ad60 <main.main>
66
runtime/internal/atomic 中的实现通过 inline 的形式直接嵌入到了 main.main 中, 稍显杂乱, 但是并不妨碍阅读. Store/Add/CompareAndSwap/Load 分别依赖 CPU 指令 XCHG/LOCK XADD/LOCK CMPXCHG/MOV 实现.
LOCK 是加在特定指令前的前缀, 用于将对应指令转化为原子指令. 例如, XADD 将第一个参数和第二个参数相加后的值保存到第一个参数, 添加 LOCK 前缀后可以保证整个过程是排他且原子的.
XCHG 用于交换两个寄存器的值. 也可以用于交换寄存器和内存地址的值, 此时自带 LOCK 效果.
CMPXCHG 借用寄存器 RAX 实现 CAS 效果. 如果 RAX 和第一个参数相等, 则将第二个参数的值赋给第一个参数. 否则将第一个参数赋给第二个参数.
Intel® 64 and IA-32 Architectures Software Developer’s Manual 中 Strengthening or Weakening the Memory Ordering Model 相关章节指明了 XCHG/LOCK 等相关指令会刷新缓存的写指令.
Synchronization mechanisms in multiple-processor systems may depend upon a strong memory-ordering model. Here, a program can use a locking instruction such as the XCHG instruction or the LOCK prefix to insure that a read-modify-write operation on memory is carried out atomically. Locking operations typically operate like I/O operations in that they wait for all previous instructions to complete and for all buffered writes to drain to memory (see Section 7.1.2, “Bus Locking”).
因此 Store/Add/CompareAndSwap 等操作修改后直接可以被其他处理器看到. 同时, Go 也保证了内存数据的对齐. 那么, Load 操作就可以直接用 MOV 来实现.
Memory Model
虽然 Memory Model 是计算机中的一个重要概念, 但可能真的是绝大多数程序员无需关心的领域. 同时因其针对的主要是硬件(CPU)和编译器, 我们也很难彻底理解, 但是花时间树立一个大体正确的概念依然是一件很有价值的事情. Russ Cox 为 Memory Model 写的三篇文章是一个不错的切入点.
首先我们要注意, Memory Model 并不是指如何管理内存, 其定义的是多处理系统中, 不同处理器之间如何共享和同步内存. 在下述例子中, 我们假设两个处理器分别运行如下的代码:
// Thread 1 // Thread 2
x = 1; r1 = x;
y = 1; r2 = y;
那么 (r1, r2) 可能的结果是 (0, 0), (1, 0), (1, 1), (0, 1). 在没有明确 CPU 和编译器的 Memory Model 时, (0, 1) 也是有可能的. CUP/编译器的重排指令都可能导致最终结果为 (0, 1).
而当我们约定 Memory Model 为 Sequential Consistency(SC) 时, (0, 1) 就是一种不可能的结果. 因为 SC 要求在所有处理器上观察到的内存操作顺序与单个处理器上的执行顺序一致.
现实世界中, CPU 为了更高的性能, 会选择比 SC 更宽松的内存模型, 比如 Total Store Order(TSO). 相对 SC, TSO 依然保证所有的写操作(Store)在所有处理器上观察到的顺序是一致的. 但 TSO 并不保证, 读(Load)和写(Store)在不同处理器上观察到的顺序是一致的. 在下面的例子中, r1=1&&r2=0 在 SC 中是不被允许的, 但在 TSO 中是可能出现的.
// Thread 1 // Thread 2
x = 1; y = 1;
r1 = y; r2 = x;
x86 等架构在实现 TSO 时, 基本都会为每个处理器维护一个 write buffer, 避免每个写操作都需要直接和内存交互, 进而提高 CPU 执行效率. 同时, x86 也提供了 FENCH 等内存屏障指令, 允许用户主动清空 write buffer, 确保指令前的写操作对所有处理器可见.
诸如 Java/C++/Go 等高级语言也会定义自己的的内存模型. Go 在没有数据竞争的情况下保证 SC, 即 data-race-free sequential-consistency(DRF-SC).
数据竞争是指同时有两个以上的处理器访问同一个变量, 其中至少有一个是写操作. 在下述的代码中, 就包括 race:
package main
import “time”
var x int
func load() int {
return x
}
func store(i int) {
x = i
}
func main() {
go store(32)
go load()
time.Sleep(1)
}
~ go build -race main.go
~ ./main
~ go build -race main.go
~ ./main
WARNING: DATA RACE Read at 0x000100a128a0 by goroutine 6: main.load() /Users/j2gg0s/go/src/github.com/j2gg0s/j2gg0s/examples/go-race/main.go:8 +0x28 main.main.func2() /Users/j2gg0s/go/src/github.com/j2gg0s/j2gg0s/examples/go-race/main.go:17 +0x28
Previous write at 0x000100a128a0 by goroutine 5: main.store() /Users/j2gg0s/go/src/github.com/j2gg0s/j2gg0s/examples/go-race/main.go:12 +0x2c main.main.func1() /Users/j2gg0s/go/src/github.com/j2gg0s/j2gg0s/examples/go-race/main.go:16 +0x2c
Goroutine 6 (running) created at: main.main() /Users/j2gg0s/go/src/github.com/j2gg0s/j2gg0s/examples/go-race/main.go:17 +0x34
Goroutine 5 (finished) created at: main.main() /Users/j2gg0s/go/src/github.com/j2gg0s/j2gg0s/examples/go-race/main.go:16 +0x28
Found 1 data race(s)
当存在数据竞争时, Go 并不提供任何保证, 我们需要通过使用 lock/chan/atomic 等机制避免数据竞争. 一种方式是加锁保护数据.
package main
import (
“sync”
“time”
)
var x int
var mu sync.Mutex
func load() int {
mu.Lock()
defer mu.Unlock()
return x
}
func store(i int) {
mu.Lock()
defer mu.Unlock()
x = i
}
func main() {
go store(32)
go load()
time.Sleep(1)
}
另一种方式使用 atomic.
package main
import (
“sync/atomic”
“time”
)
var x atomic.Int64
func load() int64 {
return x.Load()
}
func store(i int) {
x.Store(int64(i))
}
func main() {
go store(32)
go load()
time.Sleep(1)
}
Golang Go语言中 Runtime: atomic 及内存一致模型初步
更多关于Golang Go语言中 Runtime: atomic 及内存一致模型初步的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
更多关于Golang Go语言中 Runtime: atomic 及内存一致模型初步的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
在Golang(Go语言)中,runtime: atomic
提供了底层的原子操作支持,这些操作是并发编程中确保数据一致性和避免竞态条件的重要工具。原子操作是不可分割的操作,它们在执行过程中不会被线程调度机制打断,从而保证了在多线程环境下的安全执行。
Go语言的sync/atomic
包封装了一系列原子操作,如加减、比较并交换(CAS)、加载和存储等,这些操作作用于整型、指针和未对齐的整型等数据类型。原子操作常用于实现锁、计数器、自旋锁等并发控制结构。
内存一致模型(Memory Consistency Model)定义了并发系统中内存访问的行为。Go语言遵循的是“Happens-Before”一致性模型,该模型通过一系列规则来确保多线程程序在并发执行时的正确性。在Go中,通过通道(channel)和原子操作等手段,可以显式地控制内存访问的顺序和可见性,从而避免数据竞争和不一致的问题。
值得注意的是,虽然原子操作提供了并发安全的数据访问方式,但过度使用原子操作可能会影响性能,因为原子操作通常比普通操作更昂贵。因此,在设计并发程序时,应综合考虑数据一致性和性能需求,合理使用原子操作和其他并发控制手段。
总之,在Go语言中,runtime: atomic
和内存一致模型是并发编程中的重要概念,深入理解这些概念对于编写高效、正确的并发程序至关重要。