GO学习之路-原子操作「源码分析+案例」

最近在参加创作者训练营,把年前写的一些文章(原计划每周一篇)一起发了吧。本文虽然用 Go 写案例,但完全不影响大家对原子操作的理解,原子操作需要硬件提供支持,主流语言应该都支持原子操作语法糖的。

我列一些我写过的「Go 学习之路」文章,若有兴趣可以点链接看看。

深入Golang的Context「源码分析+详细案例」 - 掘金

GO singleflight 你真的会用吗?「源码分析+详细案例」 - 掘金

深入 GO unsafe.Pointer & uintptr - 掘金

深入GO之sync.Once你需要了解的快慢路径编程范式 - 掘金

写作背景

虽然 GO 提倡"不要通过共享内存来通信,而应该通过通信来共享内存",但是在日常开发中,使用共享内存方式来协调"协程"运行,解决临界区共享资源问题还挺常见的,所以锁和原子操作也非常值得大家深入学习的。如果研读过 GO 源码,GO 大佬们喜欢用锁+原子操作的套路实现高性能基础库(比如:sync.Mutex、sync.Map、sync.Once 等),大家可以看看这篇文章深入GO之sync.Once你需要了解的快慢路径编程范式 - 掘金

名称解释

原子操作:指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。来源于原子操作_百度百科。原子操作是依赖于硬件(CPU)支持,并不是靠软件就能实现的,在多核 CPU 中也是同样有效。

原子操作要比锁效率更高更快,因为它没有上下文切换,不涉及对共享资源的加锁。

汇编分析代码执行步骤

如果觉得文字抽象,举一个例子解释。假设有段代码如下

css 复制代码
func TestAdd(t *testing.T) {
	var (
		i = 0
	)
	i++
}

代码片段中 i++ 操作,在底层这个操作会被拆解成下面几步:

1、 加载变量值:编译器从内存中加载变量 i 的当前值到寄存器中。

2、 执行自增操作: 编译器会在寄存器中对变量的值进行自增操作,通过加法指令来实现。

3、 存储结果: 编译器将自增后的结果存储回内存中的变量 i。

来一段汇编,蓝色框内比较重要。

1、 MOVL $0, "".i+4(SP):将 0 移动到相对于栈指针 SP 偏移量为 4 的地址处(i 地址)。

2、 MOVL $1, "".i+4(SP): 将 1 移动到相对于栈指针 SP 偏移量为 4 的地址处(i 地址),覆盖了前面的 0。

3、 MOVQ 8(SP), BP:相对于栈指针 SP 偏移量为 8 的地址的值加载到基址寄存器 BP 中。通常用于函数返回之前,将函数调用前的基址恢复到基址寄存器中。

4、 ADDQ $16, SP:将栈指针 SP 增加 16,释放栈空间。

5、 RET:函数返回。

我们再贴一段用原子操作的代码

css 复制代码
func main() {
	var (
		i int32 = 0
	)
	atomic.AddInt32(&i, 1)
}

汇编如下

1、 LEAQ type.int32(SB), AX:将 type.int32 的地址加载到寄存器 AX 中。

2、 PCDATA <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 , 1, </math>1, 0:一种用于指示调试信息的指令,告诉编译器关于程序计数器(PC)和数据的信息。

3、 NOP:空操作指令,没有实际的操作,用于调整代码的对齐或者填充。

4、 CALL runtime.newobject(SB):调用 runtime.newobject 函数,用于分配 int32 类型的新对象,并将对象的地址返回到寄存器 AX 中。

5、 MOVQ AX, "".&i+16(SP):将寄存器 AX 中的值(即新对象的地址)存储到 i 的地址处,偏移量为 16。

6、 MOVL $0, (AX):将立即数 0 存储到地址 AX 指向的内存中,也就是将新分配的对象的值初始化为 0。

7、 MOVQ "".&i+16(SP), CX:将地址处于栈指针 SP 偏移量为 16 的位置处的值(即变量 i 的地址)加载到寄存器 CX 中。

8、 MOVL $1, DX:将立即数 1 存储到寄存器 DX 中。

9、 LOCK:接下来的指令是一个原子操作(这个很重要)。

10、 XADDL DX, (CX):将寄存器 DX 中的值(即 1)加到寄存器 CX 指向的内存地址中的值上,并将结果存储到寄存器 CX 中。

11、 ......后面省略

可以看出原子操作是加了一把锁 LOCK 的,整个自增过程将作为一个原子操作单元执行,保证线程安全性和数据一致性。意味着其他线程无法在自增操作期间读取或修改变量 i 的。

我再贴一段加锁的代码

css 复制代码
func main() {
	var (
		i  int32 = 0
		mu sync.Mutex
	)

	mu.Lock()
	i++
	mu.Unlock()
}

汇编如下

1、.....

2、CALL sync.(*Mutex).Lock(SB):调用Mutex的Lock方法,即对锁进行加锁操作。

3、....

4、CALL sync.(*Mutex).Unlock(SB):调用Mutex的Unlock方法,即对锁进行解锁操作

5、.....

go tool 生成汇编命令
go 复制代码
go tool compile -S main.go 

go 复制代码
go build main.go && go tool objdump ./main 

第一命令是编译,将源代码编译成 .o 目标文件,并输出汇编代码。

第二个命令是反汇编,即从可执行文件反编译成汇编,所以要先用 go build 命令编译出可执行文件。

我比较喜欢用第一个。

有些 GO SDK 版本执行命令会报错比如:"main.go:4:2: could not import sync (file not found)",我换了 SDK 版本就好了,怀疑是 SDK 版本有 bug。

原子操作支持下面这些操作类型

1、 原子加法操作: 共享内存中进行加法操作,一般是实现计数器等场景。

2、 比较并交换操作(Compare-and-Swap,CAS): CAS 操作是一种乐观并发控制机制,它检查某个内存位置的值是否等于预期值,如果是,则将该位置的值原子性的更新为新值。

3、 加载和存储(Load/Store): Load 操作指从内存中加载一个值,而 Store 操作指将一个值存储回内存。

第一个问题大家思考下,为啥 CPU 不提供更复杂的原子操作类型呢?文章末尾给我的思考。

源码解析

GO 逐步完善 SDK 提供能力也更丰富。1.9 版本前原子操作仅提供指针参数函数、和 atomic.Value;1.9 版本后提供了类型+方法方式执行原子操作。

GO 1.9 前版本

GO 提供的原子操作在 sync/atomic 包 doc.go 文件,仅提供通用函数,归纳 GO 提供的原子操作函数。提供的数据类型有:int32、int64、uint32、uint64、uintptr、unsafe 包中的 Pointer。unsafe.Pointer 类型不提供原子加法操作函数。

Swap 交换值

用于原子地交换指定内存地址的值,并返回原来的值。提供了多个变体,可以用于不同的数据类型,提供下面这几种数据类型:

用 int32 举例如下

go 复制代码
func TestAtomicSwap(t *testing.T) {
	var (
		v int32 = 3
	)
	old := atomic.SwapInt32(&v, 6)
	
	fmt.Printf("old=%v,new=%v\n", old, v)
}

结果输出如下

sql 复制代码
=== RUN   TestAtomicSwap
old=3,new=6
--- PASS: TestAtomicSwap (0.00s)
PASS
Add 加法

用于原子地对指定的内存地址的值进行加法操作,并返回原来的值。也有不同变体,提供的类型如下:

用 int32 举例如下

scss 复制代码
func TestAtomicAdd(t *testing.T) {
	var (
		v  int32 = 0
		wg sync.WaitGroup
	)

	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			time.Sleep(200)
			atomic.AddInt32(&v, 1)
		}()
	}

	wg.Wait()

	fmt.Printf("v=%v\n", v)
}

结果输出如下

diff 复制代码
=== RUN   TestAtomicAdd
v=200
--- PASS: TestAtomicAdd (0.00s)
PASS

那有同学要问了,如果对计数器做减法操作呢?第二个参数传负数就可以了。

第一个问题大家思考下,uint32, uint64 类型怎么做减法呢?文章末尾给答案。

go 复制代码
func TestAtomicAdd(t *testing.T) {
	var (
		v int32 = 10
	)
	out := atomic.AddInt32(&v, -2)

	fmt.Printf("v=%v,out=%v\n", v, out)
}

结果输出如下

diff 复制代码
=== RUN   TestAtomicAdd
v=8,out=8
--- PASS: TestAtomicAdd (0.00s)
PASS
CompareAndSwapInt32 比较和替换

用于执行比较并交换(compare-and-swap)操作。也有不同变体,提供的数据类型如下: 用 int64 举例如下

scss 复制代码
func TestAtomicCompareAndSwap(t *testing.T) {
	var (
		v  int64 = 0
		wg sync.WaitGroup
	)

	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			time.Sleep(200)
			if swapped := atomic.CompareAndSwapInt64(&v, 0, 3); swapped {
				fmt.Printf("CompareAndSwapInt64 success...")
			}
		}()
	}

	wg.Wait()

	fmt.Printf("v=%v\n", v)
}

结果输出如下

diff 复制代码
=== RUN   TestAtomicCompareAndSwap
CompareAndSwapInt64 success...v=3
--- PASS: TestAtomicCompareAndSwap (0.00s)
PASS
Load 和 Store 加载和存储

Load 函数从内存地址 addr 中读取一个 uint32 类型的值,并返回这个值。

Store 函数将一个 uint32 类型的值 val 存储到内存地址 addr 中。

用 int64 举例如下

scss 复制代码
func TestAtomicStoreAndLoad(t *testing.T) {
	var (
		v  int64 = 0
		wg sync.WaitGroup
		m  = sync.Mutex{}
	)

	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func() {
			defer func() {
				wg.Done()
			}()

			if val := atomic.LoadInt64(&v); val == 1 { // 快路径
				return
			}

			m.Lock() // 满路径需要加锁
			if val := atomic.LoadInt64(&v); val == 1 {
				return
			}
			atomic.StoreInt64(&v, 1) // 存储数据
			fmt.Printf("累加v=%v\n", v)
			m.Unlock()
		}()
	}

	wg.Wait()
}

结果输出如下

diff 复制代码
=== RUN   TestAtomicStoreAndLoad
累加v=1
--- PASS: TestAtomicStoreAndLoad (0.00s)
PASS

这几行代码跟 sync.Once 实现时一样的原理,快路径通过原子操作快速判断和返回,慢路径通过加锁的方案处理临界区。

第一个问题大家思考下,你们发现没?原子操作的第一个参数传的是 "addr *int64" 指针,为什么?文章末尾给答案。

GO 1.9 后版本

1.9 之后的版本增加了类型+方法使用方式,sync/atomic 包 type.go 文件。也仅支持这些类型:int32、int64、uint32、uint64、uintptr、unsafe.Pointer。我用 int32 和 unsafe.Pointer 举例,其他类型用法差异不大,自行研究。

atomic.Int32 类型

针对 int32 类型的原子操作

scss 复制代码
func TestInt32(t *testing.T) {
	var (
		n  atomic.Int32
		wg sync.WaitGroup
	)

	n.Store(2)
	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			time.Sleep(200)
			n.Add(1)
		}()
	}

	wg.Wait()
	fmt.Printf("累加v=%d\n", n.Load())
}

结果输出如下

diff 复制代码
=== RUN   TestName
累加v=202
--- PASS: TestName (0.00s)
PASS
atomic.Pointer 指针类型

用于执行针对指针类型的原子操作。它允许原子地读取和写入指针类型的值

css 复制代码
func TestPointer(t *testing.T) {
	type P struct{ x int }

	var (
		pT     atomic.Pointer[P]
		ta, tb = P{1}, P{2}
	)

	pT.Store(&ta)
	fmt.Println(pT.Load())

	pa1 := pT.Load()
	fmt.Println(pa1 == &ta)

	pa2 := pT.Swap(&tb)
	fmt.Println(pa2 == &ta)
	fmt.Println(pT.Load())

	b := pT.CompareAndSwap(&ta, &tb)
	fmt.Println(b)

	b = pT.CompareAndSwap(&tb, &ta)
	fmt.Println(b)
}

结果输出如下

lua 复制代码
=== RUN   TestPointer
&{1}
true
true
&{2}
false
true
--- PASS: TestPointer (0.00s)
PASS

atomic.Value 类型

在 sync/atomic 包 value.go 文件,允许原子地存储和加载任意类型的值,而不需要额外的锁。你也可以把它理解成一个容器,可以放任意值。

scss 复制代码
func TestValue(t *testing.T) {
	type T struct{ a, b, c int }
	var (
		x = T{1, 2, 3}
		y = T{4, 5, 6}
		z = T{7, 8, 9}
		v atomic.Value
	)

	v.Store(x)
	fmt.Println(v) 

	old := v.Swap(y)
	fmt.Println(v)       // {{4 5 6}}
	fmt.Println(old.(T)) // {1 2 3}
	swapped := v.CompareAndSwap(x, z)
	fmt.Println(swapped, v) // false {{4 5 6}}
	swapped = v.CompareAndSwap(y, z)
	fmt.Println(swapped, v) // true {{7 8 9}}
}

结果输出如下

lua 复制代码
=== RUN   TestValue
{{1 2 3}}
{{4 5 6}}
{1 2 3}
false {{4 5 6}}
true {{7 8 9}}
--- PASS: TestValue (0.00s)
PASS

使用 atomic.Value 有几个点要注意:

1、 不能存储 nil,否则会 Panic。(panic: sync/atomic: store of nil value into Value)

2、 我们向 atomic.Value 中第一次添加值后,后续添加的类型只能是第一次添加的类型,否则会 Panic。(panic: sync/atomic: store of inconsistently typed value into Value)

3、 尽量别向 atomic.Value 中存入引用类型,可能造成数据一致性问题。

go 复制代码
func TestValue(t *testing.T) {
	type m map[int]int

	var (
		v atomic.Value
	)
	v.Store(make(m))

	m1 := v.Load().(m)
	m1[1] = 200

	m2 := v.Load().(m)
	fmt.Printf("mlen=%d,val=%d\n", len(m2), m2[1])
}

结果输出如下

diff 复制代码
=== RUN   TestValue1
mlen=1,val=200
--- PASS: TestValue1 (0.00s)
PASS

如果要解决这个问题也很简单,GO 官方举另一个很有意思例子,写场景加同步锁就可以解决啦。

go 复制代码
func TestValue(t *testing.T) {
	type m map[int]int

	var (
		v  atomic.Value
		mu sync.Mutex // 用于写场景
	)
	v.Store(make(m))

	// 读场景
	read := func(key int) (val int) {
		m1 := v.Load().(m)
		return m1[key]
	}

	// 写场景
	write := func(key, val int) {
		mu.Lock()
		defer mu.Unlock()

		m1 := v.Load().(m)
		m2 := make(m) // 创建一个新的 map
		for k, v := range m1 {
			m2[k] = v // 复制所有数据到新的 map 里面
		}

		m2[key] = val // 更新数据
		v.Store(m2)   // 更新后的数据存储到新的 map 中
	}
	_ = read
	_ = write
}

总结

1、 原子指针函数和类型+方法的两种方式都可以执行原子操作,我更建议用类型+方法的方式,因为更简单更清晰。

2、 原子操作比互斥锁更轻便,但使用也是有限制的,原子操作仅提供有限的数据类型,使用时要珍酌清楚。

3、 atomic.Value 原子值中存储引用类型时,使用一定要注意否则会有安全性问题哈,最好别存引用类型。

4、 atomic.Value 不要存 nil,后续添加的类型一定要是第一次添加的类型,否则会 Panic。

5、 不要对外暴露原子变量(原子变量控制在包内访问)、不要将原子值及其指针值通过参数、通道等传递。

问题解答

1、 为啥 CPU 不提供更复杂的原子操作类型呢?

名词解释那部分我提到过原子操作是不会被中断的,肯定是越快越好。如果某一个操作一直没有完成,CPU 指令一直被占用影响是很大的,可能会导致性能下降。另外我猜测也有复杂性和通用性的考虑吧。

2、 为什么原子操作的第一个参数传的是 "addr *int64" 指针?

因为原子操作函数需要操作的是变量的地址,而不是变量的值本身。原子操作提供的可操作类型都比较简单,这些类型基本都是"非引用类型",如果传入的是值本身,修改后对原值是无效的。大家可能会问了,原子操作不是可以操作 "unsafe.Pointer"?它不是指针吗?这个问题不是很好理解。"unsafe.Pointer" 作为参数传入只有 CompareAndSwapPointer、LoadPointer、StorePointer、SwapPointer 这 4 个函数,并没有提供数学运算操作,所以并不是操作 "unsafe.Pointer" 对应的值,而是操作 "unsafe.Pointer",所以当然需要传入指针了。

3、 对于有符号整数类型T (int32或int64),调用AddT 函数的第二个参数可以是一个负值,以执行原子减操作。但是如何对 unsigned 类型 T 的值进行原子递减操作,例如 uint32, uint64 ?简单写几行代码

方案一:定义临时变量绕过编辑器检查

scss 复制代码
func TestUint32(t *testing.T) {
	var (
		n = atomic.Uint32{}
	)

	n.Add(30)
	// 方案一:定义临时变量绕过编辑器
	d := int32(-3)
	n.Add(uint32(d))
	fmt.Printf("n=%v\n", n.Load())
}

方案二:先把差量的绝对值减 1,再把得到的整数常量转换为 uint32 类型,在这个值之上做按位异或操作,就可以得到最终值。

go 复制代码
func TestUint32(t *testing.T) {
	var (
		n = atomic.Uint32{}
	)

	n.Add(30)
	// 方案二:取反
	var tt int64 = 5
	n.Add(^uint32(tt - 1))
	fmt.Printf("n=%v\n", n.Load())
}

参考资料

atomic package - sync/atomic - Go Packages

相关推荐
慕城南风4 小时前
Go语言中的defer,panic,recover 与错误处理
golang·go
刘大辉在路上4 小时前
突发!!!GitLab停止为中国大陆、港澳地区提供服务,60天内需迁移账号否则将被删除
git·后端·gitlab·版本管理·源代码管理
测试老哥6 小时前
外包干了两年,技术退步明显。。。。
自动化测试·软件测试·python·功能测试·测试工具·面试·职场和发展
追逐时光者6 小时前
免费、简单、直观的数据库设计工具和 SQL 生成器
后端·mysql
初晴~7 小时前
【Redis分布式锁】高并发场景下秒杀业务的实现思路(集群模式)
java·数据库·redis·分布式·后端·spring·
盖世英雄酱581367 小时前
InnoDB 的页分裂和页合并
数据库·后端
小_太_阳7 小时前
Scala_【2】变量和数据类型
开发语言·后端·scala·intellij-idea
直裾7 小时前
scala借阅图书保存记录(三)
开发语言·后端·scala
ThisIsClark7 小时前
【后端面试总结】深入解析进程和线程的区别
java·jvm·面试