Golang Go语言中 一个线程写, 另外一个线程读, 为什么不能保证最终一致性?

发布于 1周前 作者 nodeper 来自 Go语言
package main

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

var lock sync.RWMutex var i = 0

func main() { runtime.GOMAXPROCS(2) go func() { for { fmt.Println(“i am here”, i) time.Sleep(time.Second) } }() for { i += 1 } }

结果始终是 0, 考虑 cpu cache 一致性的话, 过一段时间就会看到变量发生了变化啊?


Golang Go语言中 一个线程写, 另外一个线程读, 为什么不能保证最终一致性?

更多关于Golang Go语言中 一个线程写, 另外一个线程读, 为什么不能保证最终一致性?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html

43 回复

编译器是可以优化的, 并且没有 memory barrier

更多关于Golang Go语言中 一个线程写, 另外一个线程读, 为什么不能保证最终一致性?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


go run -race a.go 就能看到预期变化了

确实,还没这么用过

我看了一下汇编代码, 确实是被编译器编译掉了, 他可能认为i没有变化, 根本没有 Add 过.

没有 volatile , 这个怎么解决

再请教一下啊, 内存屏障和 cache 一致性有关系吗? 网上说写屏障会发消息让其他 cache 失效, 如果不设置屏障, 难道不会达成最终一致性吗?

go 用更上一层的读写锁

#6 在 i386 和 x86_64 上应该是会的 其它架构我就不清楚了 这里的主要原因其实还是没有屏障导致编译器优化掉了

请教一下,这里的“-race ”做了什么神奇的操作导致 i 发生了变化?

看不懂……有没有人详细解释一下

按按 go tour 的说法有两种方式
一种是 chan
一种是 把你的变量和锁放在一个结构体里面

需要一个 compiler 屏障就行了呗

没看懂这个 lock 定义在这干啥。。。

+1 同样没看懂


楼主发的代码包含 data race,-race 打开了 data race detector,用来检查这个错误,为了检查错误关了相关的编译器优化


变量和锁不用放在一个结构体里,随便怎么放都行

曾经有一个和这事相关的 bug
https://github.com/golang/go/issues/19182

在 i +=1 下面添加一行 runtime.Gosched() 结果就是你期望的

这个要从 memory model 说起。i += 1 其实是两个指令:
mov i, %eax
add %eax, 1
所以当你在 for { i += 1}的时候存在两个 instructions, 而另一个读取 print i 的时候可能在 mov 之后也可能在 add 之后。所以你这个一致性要是不增加 memory fence 基本无解。

解法有几种:
1、原子 add
2、chan 传递数据
3、mutex 或者 rwlock

gcc 有一个__sync_add_and_fetch 就主要用了 memory fence 和 instruction reorder 技术来保证 memory model 的一致性。


mov i, %eax
add %eax, 1
mov %eax, I

噗写漏了。

内存屏障是解决顺序一致性的问题,怎么到了楼上的说法怎么全是解决 cache 一致性了。

内存屏障不是解决 cache 一致性问题的

我的理解他这个问题就是一个顺序一致性问题,读 thread 读取 i 的时候,写 thread 可能正在进行一个非原子的+=1,这里就出现不一致。

有 race 的 Go 程序的行为是未定义行为,理论上出现什么情况都是正常的,你这个示例程序极好地显示了这一点。所以讨论为什么出现这种现象实际上没有任何意义,不要依赖这种行为。理论上这个程序一运行就自动打开一个游戏也是合理的,好像有一个版本的 GCC 对待未定义行为就是这样做的。

是对的。x86 的 mfence 只解决 read-after-write 可能出现的 speculative/reorder 的情景,用于保证 sequential consistency。至于 说的,跟 sequential consistency 没有关系,而且“另一个读取 print i 的时候可能在 mov 之后也可能在 add 之后”是完全合适也正确的访存行为。

看来我说的不够详细,内存屏障是解决 LOAD/STORE 乱序的问题。
例如这种情况:
a = (char *) melloc();
dev.buff = a;
mb();
dev.flag = 1;
很好理解吧,填 buff,置 flag。
另一个线程发现 dev.flag == 1 就开始取 buff。
但是 cpu 的执行单元是乱序的(注意:假定编译器得到的顺序是对的,这里还有个 Optimization Barrier 的问题),如果不加屏障就可能是这样:
dev.flag = 1;
dev.buff = a;
另一个线程发现 flag 置 1 了去读 buff,此时 buff 指针可能还没来得及填,直接一个段错误歇菜了。
内存屏障实际作用是:保证 MFENCE 指令前的 LOAD/STORE,一定在 MFENCE 指令之后的 LOAD/STORE 指令之前完成。
回到你的理解:
写 thread 可能正在进行一个非原子的+=1
首先他只有一个线程在加,就算不是原子加那也不会影响别人读数,最多读的不是准确值,但是绝不会一直是 0。
要是有多个线程再加同一个数,就算不是原子加,最后肯定有 core 会成功写到 cache 上的,也不会一直是 0。

题主说的真没错,不是什么高深的问题,就仅仅是编译器把
i += 1
给优化掉了。
仅此而已。

唉,前面还说你结论是对的。你的这个例子确实是错的,你这里两个都是 store,x86 的 TSO 是保证 store 顺序的,所以另一个线程看到了 flag==1 一定能看到 buff==a,因为 store buffer 是按顺序刷到 cache 里去的。正确的关于 mfence 的例子应该是:
Thread 1:
a = 1
// mfence
if (b == 0) {
enter_critical_section()
}

Thread 2:
b = 1
// mfence
if (a == 0) {
enter_critical_section()
}

如果不加 fence,则会出现两个线程同时进入 critical section 的情景,这是 Dijkstra 最早提出的 mutex 方法。


当然我们都走远了,题主的问题是一个简单的问题。

抱歉,应该说在 x86 下你的例子是不会跑错的,因为 TSO。在 ARM 下你的例子应该就是合适的。

是我考虑的不仔细,随手写的确实没考虑 x86 STORE 保序的问题。

你不说我还真记不起来 TSO 这个事,得好好谢谢你。

其实也不是记着 tso,因为 x86 的 tso 只允许 R-A-W 这一种 reorder,所以这种 sequential consistency violation 的例子是比较唯一的,就是各种 mutex 嘛。反倒是理解 store buffer 和 speculation execution 比较重要。


多谢回复,我再去看看相关资料。

是的, 我在 https://stackoverflow.com 也问了这个问题, 就被他们这么教育了, 我对未定义行为的认识不够, 不过了解一下到底为什么这种未定义之后, 到底发生了什么, 为什么会这样, 还是挺好玩的, 要不然难受的慌.

为什么都在关心 happens before…? happens before 发生在 i =0; x= i * 4; 值有依赖的情况,CRVV 发的 github 才是正解啊,修 bug 前这个 routine 直接没有被调度到

不是一码事啊老哥, 用 atomic 还打印 0 说明是程序 bug. 我没有用, 会出现竞态, 编译器直接把 Add 这个操作优化没了. 不是没有调度的问题

<br>package main<br><br>import (<br> "fmt"<br> "os"<br> "runtime"<br> "time"<br>)<br><br>var a uint64 = 0<br><br>func main() {<br> runtime.GOMAXPROCS(runtime.NumCPU())<br> fmt.Println(runtime.NumCPU(), runtime.GOMAXPROCS(0))<br><br> go func() {<br> for {<br> a += 1<br> // just do something<br> _ = make(chan os.Signal)<br> }<br> }()<br><br> for {<br> fmt.Println(a)<br> time.Sleep(time.Second)<br> }<br>}<br><br>
加一句 make, 就可以不是 0 了, 难道加了 make 就会解决调度问题?

Looking at the assembly, the increment (and in fact the whole for loop) has been (over-)optimized away.

for 循环当作 dead code 优化掉了

能贴个 stackoverflow 的链接吗?

在Golang(Go语言)中,如果一个线程写数据而另一个线程读数据,且未使用适当的同步机制,确实无法保证最终一致性。这主要归因于以下几个原因:

  1. 内存可见性问题:在没有同步的情况下,一个线程对共享内存的写操作可能不会立即对其他线程可见。Go语言的运行时和编译器可能会进行指令重排序以优化性能,这可能导致读线程读取到过时或不一致的数据。

  2. 竞态条件:竞态条件发生在两个或多个线程同时访问共享资源,且至少有一个线程在写入该资源时。这可能导致数据损坏或不一致的状态,因为读线程可能在写线程完成更新之前读取了数据。

  3. 缓存一致性:现代处理器使用缓存来加速内存访问。然而,这可能导致缓存中的数据与主内存中的数据不一致。在没有适当的同步机制(如锁或原子操作)的情况下,这种不一致性可能无法被及时发现和解决。

为了保证最终一致性,Go语言提供了多种同步机制,如互斥锁(sync.Mutex)、读写锁(sync.RWMutex)和通道(channel)等。这些机制可以确保在写操作完成之前,读操作不会开始,从而避免竞态条件和内存可见性问题。

因此,在Go语言中实现线程安全的读写操作,必须仔细考虑并使用适当的同步机制来确保数据的一致性和完整性。

回到顶部