请教一个竞争问题

15 天前
 rbaloatiw

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

这是为什么?

1423 次点击
所在节点    Go 编程语言
15 条回复
zhouyin
15 天前
string 底层表示不是一个 byte 不是原子操作 换成 byte 试试
kk2syc
15 天前
没 panic 啊
iceheart
15 天前
sizeof A = 16
Trim21
15 天前
并没有 panic

顺便前面#1 和#3 理解错了,这里操作的 a 是个*A ,跟 string 和 A 的大小没关系。
Trim21
15 天前
我理解的跟你一样,这种情况下虽然 go 的 race 检查会报错但是实际上是安全的
rbaloatiw
15 天前
@zhouyin
@iceheart 我的理解和 #4 是一样的, 操作的 a 是个指针, 所以大小为 8B (64 位机器)

@Trim21 #5 @kk2syc #2 主楼是在 apple silicon arm 下跑的, 我又去 intel x86 下跑了一下, 试了好几次也都没有 panic, 莫非和架构有关..
nagisaushio
15 天前
Intel ,同没有 panic 。

建议研究一下生成的汇编代码,看看具体是怎么运行的。
Orlion
15 天前
首先从理论上来说,`aa.data != "a" && aa.data != "b"` 这一行代码不是原子的,有可能出现这种情况:
在判断 aa.data != "a"时,aa.data="b"
随后在判断 aa.data != "b"时,aa.data 被修改为了"a"

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


然而这不是唯一的原因,因为你的代码 panic 出来的信息 aa.data 是空,因此还有其他方面的原因
rbaloatiw
15 天前
@Orlion #8 我并没有修改 a.data 的操作, 在写入线程中都是新建一个结构体赋值给 a, 而下面 `aa := a` 复制了指针 a, 这时候即使 a 被赋了新值 aa 也不会改变. 所以应该不会出现在判断 `aa.data != "a" && aa.data != "b"` 时 aa 指向的结构体变化了的情况.
zizon
15 天前
Panic 堆栈的代码行数和你这个对不上吧?
Orlion
14 天前
@rbaloatiw 确实,是我草率了😄
MoYi123
14 天前
先把 A{data: "a"}和 A{data: "b"}构造好, 循环里直接换它们的指针就不会有错,
我猜测顺序是 alloc 内存 -> 更新指针 -> 给 string 赋值, 所以出现了不是 a 或 b 的情况.
oaix
14 天前
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 更加激进,也更依赖于显式的同步操作来确保内存操作的正确性。
rbaloatiw
13 天前
#13 应该是对的. 这个例子应该非常类似 go memory model 中"不正确的同步"一节中的例子. 更详细的解释可以看 rsc 的 [Hardware Memory Models]( https://research.swtch.com/hwmm) 这篇博客.

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

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1070577

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX