Loading [MathJax]/jax/output/CommonHTML/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >专栏 >深入Go:并发迷思-消失的赋值语句

深入Go:并发迷思-消失的赋值语句

作者头像
wenxing
发布于 2021-12-14 03:23:31
发布于 2021-12-14 03:23:31
68500
代码可运行
举报
文章被收录于专栏:Frames of WenxingFrames of Wenxing
运行总次数:0
代码可运行

对全局变量的赋值,为何无缘无故消失?等候了千万个时钟周期的打印语句,为何发现变量没有一丝改变?意料之外的结果,却为何又是在情理之中?这究竟是编译器的背叛,还是随机的巧合——本篇文章将带您深入Go内存模型,一起走近并发。

热身

先看一个经典的问题,下列代码输出的结果可能是多少?

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
wg := sync.WaitGroup{}
x, y, r1, r2 := 0, 0, 0, 0
wg.Add(2)
go func() { // goroutine A
  y = 1 // line A1
  r1 = x // line A2
  wg.Done()
}()
go func() { // goroutine B
  x = 1 // line B1
  r2 = y // line B2
  wg.Done()
}
fmt.Println(r1 + r2)

输出当然可能是1,执行顺序可能是:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
A1: y = 1                    | B1: x = 1
A2: r1 = x // 0        | B2: r2 = y // 0
B1: x = 1                    | A1: y = 1
B2: r2 = y // 1        | A2: r1 = x // 1
------------- 1     | ------------- 1

输出也可能是2,因为执行顺序可能是:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
A1: y = 1
B1: x = 1
-- then --
A2: r1 = x // 1
B2: r2 = y // 1
------------ 2 --

但是运行10000000次,有9994463次结果为1,有23次结果为2,有5514次结果为0!

为什么结果为0,也就是执行顺序可能变成了:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
A2: r1 = x
B2: r2 = y
A1: y = 1
B1: x = 1

实际上,CPU的指令执行顺序是乱序执行的,因为但就一个协程执行的代码而言,两行语句是无关的,CPU完全可能会乱序执行;指令乱序执行也是现代CPU能运行如此之快的原因之一——否则,如果一个store指令需要等待写入,后面的load指令只能白白等待。

(也许不仅仅因为CPU的指令乱序导致迷思,后面我们可以看到。)

意料之外的迷思

再看另一段代码,请问输出应该是什么?

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
var isRunning = int32(1)

func fg1() {
    for {
        isRunning--
    }
}

func fg2() int {
    count := 1
    for isRunning > 0 {
        count++
    }
    return count
}

func main() {
    count := 0
    go fg1()
    go func() {
        count = fg2()
    }()
    time.Sleep(3 * time.Second)
  println(isRunning)
    println(count)
}

答案是:isRunning: 1, count: 0,也就是fg1中的isRunning--没有被执行,fg2根本没有返回。

这就很让人意外,足足等了3秒的时间,而fg1里的循环完全没有产生任何的效果。实际上,查看go汇编代码(go tool compile -S file.go > file.s),可以发现如下结果:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
"".fg1 STEXT nosplit size=3 args=0x0 locals=0x0
  ...
  // swap the value of $(AX) and $(AX) atomically, or NOP -- do nothing
    0x0000 00000 (pkg/main/file.go:10)    XCHGL    AX, AX 
    // jump to last line
    0x0001 00001 (pkg/main/file.go:1)    JMP    0        

即,fg1什么都没有做,不说等3秒了,等10年也没用!那,是不是Go的编译器背叛了这段代码?

情理之中的解答

最后再问一个问题,在Go当中,对一个变量的write在什么情况下才能保证被对该变量的read所感知到?虽然你可能有Go的编程经验,但很可能你也说不清楚这个问题。它实际上有官方的解答

我们一边举例子,一边来解释。

早于、晚于、并发于

首先我们要定义偏序关系(回想大学知识,偏序关系是非自反、反对称、传递的关系)“早于”(Happens Before)。为方便起见,我们记“A早于B”为A<BB>AA<BB > AA < BA > B

首先,单个goroutine中顺序执行的语句,在先的与在后的形成“早于”关系,例如下方代码中,A_1 < A_2

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
func f1() {
  y = 1 // A_1
    r1 = x // A_2
}

其次,包的init、goroutine的创建、channel交互、锁、once也定义了偏序关系;这里,我们选相关的goroutine创建、销毁与锁的使用进行介绍。

Goroutine的创建

创建goroutine的代码一定早于该goroutine中代码的执行。例如下面的代码中,由上面的规则有A < BB < CA < C

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
x = 1 // A
go func() { // B
  y = x // C
}()

对于任意sync.Mutex变量l和n < m \in \mathbb Nn次l.Unlock()的调用早于第m次调用l.Lock()返回,例如以下代码中,我们根据本规则有:V < C

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
var l sync.Mutex
var a string
func f() {
  a = "hello world" // U
  l.Unlock() // V
}

func main() {
  l.Lock() // A
  go f() // B
  l.Lock() // C
  print(a) //D
}

因此,我们有:A < B < U < VV < C < DA < B < U < V < C < D

sync.RWMutex有类似情况,不再赘述。

保证write能被read观察到的条件

回到第三个问题,对变量x的write w如何才能保证被read r观察到,Go内存模型规定了:

  1. w < r
  2. 其他对于x的写要么早于w,要么晚于r
注意

Go内存模型也说明了:

一,Goroutine代码的执行、销毁时间没有任何保证,甚至下方的代码行A可以被编译器直接删除:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
var a string
func hello() {
  go func() { a = "hello" }() // A
  print(a)
}

二,如果read r观察到了并发于r的write w,也不能保证任何晚于r的read能观察到任何早于w的write——这是因为r观察到w不能推出r晚于w

举个例子,下方代码中,我们只能得到C < D < EC < A < BA < ED退出,也不能保证能打印出hello。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
var a string
var done bool

func setup() {
    a = "hello" // A
    done = true // B
}

func main() {
    go setup() // C
    for !done { // D
    }
    print(a) // E
}
解答
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
var isRunning = int32(1)

func fg1() {
    for {
        isRunning-- // A
    }
}

func fg2() int {
    count := 1
    for isRunning > 0 { // B
        count++
    }
    return count
}

func main() {
    count := 0
    go fg1() // C
    go func() {
        count = fg2() // D
    }()
    time.Sleep(3 * time.Second)
  println(isRunning) // E
    println(count)
}

我们可以得出:

  • C < A
  • D < B
  • C < E

但我们不能得到ABAE之间的早于关系。因此,编译器完全可以优化掉fg1中的赋值语句。详细讨论还可以见码客与Google Groups - golang-nuts(至于为什么编译器在short circuit阶段优化掉该赋值,尚在讨论之中,后续会继续更新)。

讨论

再来看一段代码:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
var hasLoad = uint32(0)
var instance *T
var m sync.Mutex

func getInstance() *T {
  if hasLoad == 0 { // A
    m.Lock()
    if hasLoad == 0 {
      instance = &T{}
      hasLoad = 1
    }
    m.Unlock()
  }
}

这段代码究竟有没有问题?

运行go run -race来查看data race的情况,马上会得到在A处会有一个协程写一个协程读的情况,我们之前的做法都是,把hasLoad的读写都使用sync/atomic包进行操作。但真的需要吗?

Go Memory Model: Advice

Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access. To serialize access, protect the data with channel operations or other synchronization primitives such as those in the sync and sync/atomic packages. If you must read the rest of this document to understand the behavior of your program, you are being too clever. Don’t be clever.

被多个goroutines并发读写数据的程序必须串行化这样的读写。 为此,请使用channel操作或其他例如syncsync/atomic中的同步原语保护该类数据。 如果你一定要阅读本文(笔者注:即Go Memory Model)剩余部分以理解你程序的行为,那么你就是耍小聪明了。 别耍小聪明。

遇到并发读写变量的情况,请一定使用mutex或atomic操作;我们可以认为这段代码是存在问题的。

可以跳过的小聪明分析

其实这段代码严格意义上是没有问题的,我们再来分析(为方便起见,我们假设只有2个协程访问getInstance,所以我们可以把它分别命名为getInstance1getInstance2):

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
var hasLoad = uint32(0)
var instance *T
var m sync.Mutex

func getInstance1() *T { // 和getInstance完全相同
  if hasLoad == 0 { // A1
    m.Lock() // B1
    if hasLoad == 0 { // C1
      instance = &T{} // D1
      hasLoad = 1 // E1
    }
    m.Unlock() // F1
  }
}

func getInstance2() *T { // 和getInstance完全相同
  if hasLoad == 0 { // A2
    m.Lock() // B2
    if hasLoad == 0 { // C2
      instance = &T{} // D2
      hasLoad = 1 // E2
    }
    m.Unlock() // F2
  }
}

func main() {
  go getInstance1()
  go getInstance2()
}

我们假设B_2 > F_1

在getInstance1中,因为是串行执行,有D_1 < E_1 < F_1

在getInstance2中,也是因为串行执行,有B_2 < C_2

所以由假设B_2 > F_1D_1 < E_1 < F_1 < B_2 < C_2E_1中的hasLoad = 1可以被C_2中的if hasLoad == 0观察到,因此不会进入D_2E_2,不会导致instance被多次赋值——代码是正确的。而A_1A_2E_1E_2的data race其实无关紧要,因为有C_1C_2处的双重校验,第一次m.Unlock()之前的hasLoad = 1被观察到了。

实际上可以运行以上代码,每次用多个协程调用getInstance,重复1000000次,没有一次有发生instance重复赋值。

结论

  • 并发中意料之外的结果,总是有着情理之中的解释;
  • 虽然仔细地分析可以得到结论,但还是请合理使用mutexatomic操作。
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021年 03月12,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
《Go语言程序设计》读书笔记(七)基于共享变量的并发
上面的例子里Unlock会在return语句读取完balance的值之后执行,所以Balance函数是并发安全的。
KevinYan
2020/01/13
3770
GoLang内存模型
Go语言的内存模型规定了一个goroutine可以看到另外一个goroutine修改同一个变量的值的条件,这类似java内存模型中内存可见性问题(Java内存可见性问题可以参考拙作:Java并发编程之美一书)。
加多
2019/03/12
8700
Go基于共享变量的并发原理及实例 【Go语言圣经笔记】
前一章我们介绍了一些使用goroutine和channel这样直接而自然的方式来实现并发的方法。然而这样做我们实际上回避了在写并发代码时必须处理的一些重要而且细微的问题(笔者注:一谈到并发,就需要处理对共享变量等公共资源的访问问题,不合理的访问问题会造成一系列诸如丢失修改、读脏数据、重复读等常见并发问题)。
Steve Wang
2021/12/06
1K0
golang happens before内存模型
happens-before是一个术语,并不仅仅是Go语言才有的。假设A和B表示一个多线程的程序执行的两个操作。如果A happens-before B,那么A操作对内存的影响 将对执行B的线程(且执行B之前)可见。 内存模型描述的是 “在一个 groutine 中对变量进行读操作能够侦测到在其他 gorountine 中对改变量的写操作” 的条件。
golangLeetcode
2022/08/03
4870
Go语言同步(Synchronization)
Go语言同步(Synchronization) 1. 初始化 程序的初始化在一个独立的goroutine中执行。在初始化过程中创建的goroutine将在 第一个用于初始化goroutine执行完成后启动。 如果包p导入了包q,包q的init 初始化函数将在包p的初始化之前执行。 程序的入口函数 main.main 则是在所有的 init 函数执行完成 之后启动。 在任意init函数中新创建的goroutines,将在所有的init 函数完成后执行。 2. Goroutine的创建 用于启动goroutin
李海彬
2018/03/23
6500
条件变量Cond实现
下面是wikipedia对条件变量的定义,大体是说条件变量总的来说是等待特定条件的线程的容器。
数据小冰
2022/08/15
5800
条件变量Cond实现
Go 语言并发编程之互斥锁 sync.Mutex
Go 标准库 sync 提供互斥锁 Mutex。它的零值是未锁定的 Mutex,即未被任何 goroutine 所持有,它在被首次使用后,不可以复制。
frank.
2024/11/19
900
Go 语言并发编程之互斥锁 sync.Mutex
Golang 并发编程之同步原语
当提到并发编程、多线程编程时,我们往往都离不开『锁』这一概念,Go 语言作为一个原生支持用户态进程 Goroutine 的语言,也一定会为开发者提供这一功能,锁的主要作用就是保证多个线程或者 Goroutine 在访问同一片内存时不会出现混乱的问题,锁其实是一种并发编程中的同步原语(Synchronization Primitives)。
KevinYan
2020/05/20
1.2K0
go: 同步原语详解
同步原语是计算机科学中用于实现进程或线程之间同步的机制。它提供了一种方法来控制多个进程或线程的执行顺序,确保它们以一致的方式访问共享资源。
运维开发王义杰
2024/02/26
2810
go: 同步原语详解
Go语言并发常见问题:A-Study-of-Real-World-Data-Races-in-Golang
data race的检测可以通过go build中加入-race来进行。详情见此文所举的例子
千灵域
2022/06/17
6190
Go语言并发常见问题:A-Study-of-Real-World-Data-Races-in-Golang
并发编程,为什么选Go?
导语 | 代码的稳健、可读和高效是我们每一个coder的共同追求。本文将结合Go语言特性,为书写高效的代码,力争从并发方面给出相关建议。让我们一起学习Go高性能编程的技法吧~ 在上篇《再不Go就来不及了!Go高性能编程技法解读》中我们结合Go语言特性,为书写高效的代码,从常用数据结构、内存管理两个方面给出相关建议,本篇将深入并发这部分进行阐述。 一、并发编程 (一)关于锁 无锁化 加锁是为了避免在并发环境下,同时访问共享资源产生的安全问题。那么,在并发环境下,是否必须加锁?答案是否定的。并非所有的并发都需要
腾讯云开发者
2022/03/30
6670
Golang 基础:底层并发原语 Mutex RWMutex Cond WaitGroup Once等使用和基本实现
上一篇 《原生并发 goroutine channel 和 select 常见使用场景》 介绍了基于 CSP 模型的并发方式。
张拭心 shixinzhang
2022/05/10
4010
Golang 基础:底层并发原语 Mutex RWMutex Cond WaitGroup Once等使用和基本实现
2022-07-10:以下go语言代码输出什么?A:A,B;B:A,C:A,fatal error;D:fatal error...
2022-07-10:以下go语言代码输出什么?A:A,B;B:A,C:A,fatal error;D:fatal error...
福大大架构师每日一题
2022/07/10
2900
2022-07-10:以下go语言代码输出什么?A:A,B;B:A,C:A,fatal error;D:fatal error...
go进阶(1) -深入理解goroutine并发运行机制
并发指的是同时进行多个任务的程序,Web处理请求,读写处理操作,I/O操作都可以充分利用并发增长处理速度,随着网络的普及,并发操作逐渐不可或缺 
黄规速
2023/02/27
4.2K0
go进阶(1) -深入理解goroutine并发运行机制
浅谈Go并发原语
在操作系统中,往往设计一些完成特定功能的、不可中断的过程,这些不可中断的过程称为原语。
闫同学
2024/02/12
3770
GO 语言处理并发的时候我们是选择sync还是channel
以前写 C 的时候,我们一般是都通过共享内存来通信,对于并发去操作某一块数据时,为了保证数据安全,控制线程间同步,我们们会去使用互斥锁,加锁解锁来进行处理
阿兵云原生
2023/10/24
2340
GO 语言处理并发的时候我们是选择sync还是channel
互斥锁与读写锁:如何使用锁完成Go程同步?
这张图容易让人产生误解,容易让人误以为goroutine1获取的锁,只有goroutine1能释放,其实不是这样的。“秦失其鹿,天下共逐之”。在这张图中,goroutine1与goroutine2竞争的是一种互斥锁。goroutine1成功获取锁以后,锁变成锁定状态,此时goroutine2也可以解锁。
LIYI
2021/01/26
1.1K0
Go Mutex:保护并发访问共享资源的利器
Go 语言以 高并发 著称,其并发操作是重要特性之一。虽然并发可以提高程序性能和效率,但同时也可能带来 竞态条件 和 死锁 等问题。为了避免这些问题,Go 提供了许多 并发原语,例如 Mutex、RWMutex、WaitGroup、Channel 等,用于实现同步、协调和通信等操作。
陈明勇
2023/04/24
5730
Go Mutex:保护并发访问共享资源的利器
Go 并发编程之 Mutex
友情提示:此篇文章大约需要阅读 18分钟0秒,不足之处请多指教,感谢你的阅读。 订阅本站
Meng小羽
2020/11/23
6220
Go 并发编程之 Mutex
【Golang】并发
go 程(goroutine)是 go 并发的核心,它比线程要更小, 由 go Runtime 管理,运行 goroutine 只需要很少的栈空间,因此可以实现很大的并发量,在 go 中,开启一个 goroutine 只需要使用 go 关键字即可:
JuneBao
2022/10/26
4380
相关推荐
《Go语言程序设计》读书笔记(七)基于共享变量的并发
更多 >
领券
社区富文本编辑器全新改版!诚邀体验~
全新交互,全新视觉,新增快捷键、悬浮工具栏、高亮块等功能并同时优化现有功能,全面提升创作效率和体验
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
查看详情【社区公告】 技术创作特训营有奖征文