V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
rbaloatiw
V2EX  ›  Go 编程语言

请教一个竞争问题

  •  
  •   rbaloatiw · 104 天前 · 1695 次点击
    这是一个创建于 104 天前的主题,其中的信息可能已经有所发展或是发生改变。

    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
    

    这是为什么?

    15 条回复    2024-09-09 18:55:27 +08:00
    zhouyin
        1
    zhouyin  
       104 天前 via Android
    string 底层表示不是一个 byte 不是原子操作 换成 byte 试试
    kk2syc
        2
    kk2syc  
       104 天前
    没 panic 啊
    iceheart
        3
    iceheart  
       103 天前 via Android
    sizeof A = 16
    Trim21
        4
    Trim21  
       103 天前
    并没有 panic

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

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

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

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


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

    一个简单的解决办法是把 `a` 换成 `atomic.Value` 来进行同步.
    kingcanfish
        15
    kingcanfish  
       100 天前
    a := &A{data: "b"} 这条语句其实是两个动作
    一个是初始化 A 之后在复制给 a (此时 data 已经有值);
    另一种是先初始化了个空的 A 地址,赋值给 a, 然后再给 data 赋值;
    第二种情况就会发生 panic (在 data 赋值之前,另一个协程就已经对 data 的值进行检查了)
    这两种情况和架构上的指令重排应该有关系,arm 内存模型比 amd 宽松 所以理论上遇到的概率更大
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2702 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 22ms · UTC 15:12 · PVG 23:12 · LAX 07:12 · JFK 10:12
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.