Golang Go语言中的竞争问题请教

go memory model 中说:

...each read of a single-word-sized or sub-word-sized memory location must observe a value actually written to that location (perhaps by a concurrent executing goroutine) and not yet overwritten.

这句话是否可以理解为读一个字长以下的数据, 总是会读到某一次写入的数据, 而不会读到某个中间状态?

如果上述理解是正确的, 那么对于下面的程序:

package main

import ( “fmt” “sync” “time” )

type A struct { data string }

func main() { a := &A{data: “b”}

go func() {
	for {
		if a.data == "a" {
			a = &A{data: "b"}
		} else {
			a = &A{data: "a"}
		}
	}
}()

var wg sync.WaitGroup
for i := 0; i < 100; i++ {
	wg.Add(1)
	go func() {
		for i := 0; i < 100000; i++ {
			// 复制 a 的指针, aa 在接下来的使用中应该指向同一个 A
			aa := a
			if aa.data != "a" && aa.data != "b" {
				panic(aa.data)
			}
		}
		wg.Done()
	}()
}

start := time.Now()
wg.Wait()
fmt.Println(time.Since(start))

}

由于指针 *A 是一个字长, 那么读取变量 a 总是会读到某一个 A 地址, 所以 panic 不会发生, 但实际上会出现:

panic: 

goroutine 6 [running]: main.main.func2() /Users/a/test/test.go:44 +0xa0 created by main.main in goroutine 1 /Users/a/test/test.go:40 +0x44 exit status 2

这是为什么?


Golang Go语言中的竞争问题请教
16 回复

string 底层表示不是一个 byte 不是原子操作 换成 byte 试试

更多关于Golang Go语言中的竞争问题请教的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


并没有 panic

顺便前面#1 和#3 理解错了,这里操作的 a 是个*A ,跟 string 和 A 的大小没关系。

我理解的跟你一样,这种情况下虽然 go 的 race 检查会报错但是实际上是安全的


我的理解和 #4 是一样的, 操作的 a 是个指针, 所以大小为 8B (64 位机器)

#5 #2 主楼是在 apple silicon arm 下跑的, 我又去 intel x86 下跑了一下, 试了好几次也都没有 panic, 莫非和架构有关…

Intel ,同没有 panic 。

建议研究一下生成的汇编代码,看看具体是怎么运行的。

首先从理论上来说,aa.data != "a" && aa.data != "b" 这一行代码不是原子的,有可能出现这种情况:
在判断 aa.data != "a"时,aa.data="b"
随后在判断 aa.data != "b"时,aa.data 被修改为了"a"

这种情况下是可能触发 panic 的


然而这不是唯一的原因,因为你的代码 panic 出来的信息 aa.data 是空,因此还有其他方面的原因

#8 我并没有修改 a.data 的操作, 在写入线程中都是新建一个结构体赋值给 a, 而下面 aa := a 复制了指针 a, 这时候即使 a 被赋了新值 aa 也不会改变. 所以应该不会出现在判断 aa.data != "a" && aa.data != "b" 时 aa 指向的结构体变化了的情况.

Panic 堆栈的代码行数和你这个对不上吧?

确实,是我草率了😄

先把 A{data: “a”}和 A{data: “b”}构造好, 循环里直接换它们的指针就不会有错,
我猜测顺序是 alloc 内存 -> 更新指针 -> 给 string 赋值, 所以出现了不是 a 或 b 的情况.

CPU 乱序执行。

> 在 x86-64 (x64) 和 ARM64 (AArch64) 处理器架构中,乱序执行( Out-of-Order Execution )是用于提高处理器性能的一种技术。两种架构在乱序执行和内存模型方面有所不同,其中 ARM64 的内存模型通常被认为比 x86-64 更加“激进”或更弱。

x86-64 和 ARM64 的内存模型对比
x86-64 (x64) 内存模型:

强内存模型:x86-64 处理器通常有一个较为强的一致性内存模型。这意味着大多数内存操作(特别是读写操作)的顺序与程序中的顺序是一致的。写入操作一般不能在读取操作之前发生,也不能跨越其他写入操作。这种强内存模型使得编写并发代码相对容易。
乱序执行限制:虽然 x86-64 处理器执行乱序执行,但它在内存操作的乱序方面受到限制。处理器会自动维护内存操作的一些顺序,特别是写-读依赖关系,不需要开发者过多使用内存屏障。
ARM64 (AArch64) 内存模型:

弱内存模型:与 x86-64 相比,ARM64 使用了更弱的内存模型。这意味着处理器可以以更加激进的方式重新排序内存操作。比如,写入操作可以跨越读取操作,甚至不同线程的内存操作顺序可能会被打乱,这在多线程编程中可能导致不可预期的结果。
乱序执行更激进:ARM64 的乱序执行在内存操作上更为激进,需要更多地依赖于显式的内存屏障来确保内存操作的顺序。这使得 ARM64 的性能可能更高,但也增加了并发编程的复杂性。开发者必须通过 dmb 、dsb 等指令或使用内存屏障来控制内存操作的顺序。
总结
x86-64 的内存模型更强,乱序执行更保守:在大多数情况下,x86-64 处理器会确保内存操作顺序与程序代码顺序大致一致,使得并发编程相对简单。
ARM64 的内存模型更弱,乱序执行更激进:ARM64 处理器允许更多的内存操作乱序执行,因此在并发编程中需要更加注意内存屏障的使用,以避免数据一致性问题。
因此,ARM64 的乱序执行比 x86-64 更加激进,也更依赖于显式的同步操作来确保内存操作的正确性。

#13 应该是对的. 这个例子应该非常类似 go memory model 中"不正确的同步"一节中的例子. 更详细的解释可以看 rsc 的 [Hardware Memory Models]( https://research.swtch.com/hwmm) 这篇博客.

一个简单的解决办法是把 a 换成 atomic.Value 来进行同步.

a := &A{data: “b”} 这条语句其实是两个动作
一个是初始化 A 之后在复制给 a (此时 data 已经有值);
另一种是先初始化了个空的 A 地址,赋值给 a, 然后再给 data 赋值;
第二种情况就会发生 panic (在 data 赋值之前,另一个协程就已经对 data 的值进行检查了)
这两种情况和架构上的指令重排应该有关系,arm 内存模型比 amd 宽松 所以理论上遇到的概率更大

在Go语言中,竞争问题(Race Condition)通常发生在多个goroutine并发访问共享资源时,如果没有适当的同步机制,就可能导致数据不一致或程序崩溃。

要解决Go语言中的竞争问题,你可以采取以下几种方法:

  1. 使用互斥锁(Mutex):通过sync.Mutexsync.RWMutex来保护对共享资源的访问。在访问共享资源前,先加锁;访问完成后,再解锁。这样可以确保同一时间只有一个goroutine能访问共享资源。

  2. 使用通道(Channel):Go的通道是一种强大的并发原语,可以用来在goroutine之间传递数据。通过通道,你可以实现goroutine之间的同步和通信,从而避免竞争问题。

  3. 使用原子操作:对于简单的计数器或标志位等,可以使用sync/atomic包提供的原子操作来避免竞争问题。原子操作可以确保在多goroutine环境下,对变量的读写是原子的,不会被打断。

  4. 避免全局变量:尽量减少全局变量的使用,将共享数据封装在结构体中,并通过方法访问。这样可以更容易地控制对共享数据的访问,并应用上述同步机制。

总之,解决Go语言中的竞争问题需要你仔细设计并发访问策略,并选择合适的同步机制。在实际开发中,你还可以使用Go的竞态检测工具(如go run -race)来检测潜在的竞争问题。

回到顶部
AI 助手
你好,我是IT营的 AI 助手
您可以尝试点击下方的快捷入口开启体验!