go并发编程目录

一、前言

Go语言的内存模型规定了一个goroutine可以看到另外一个goroutine修改同一个变量的值的条件,这类似java内存模型中内存可见性问题(Java内存可见性问题可以参考拙作:Java并发编程之美一书)。

当多个goroutine并发同时存取同一个数据时候必须把并发的存取的操作顺序化,在go中可以实现操作顺序化的工具有高级的通道(channel)通信和同步原语比如sync包中的Mutex(互斥锁)、RWMutex(读写锁)或者和sync/atomic中的原子操作。

二、Happens Before原则

当程序里面只有一个goroutine时候,虽然编译器和CPU由于开启了优化功能可能调整读写操作的顺序,但是这个调整是不会影响程序的执行正确性:

a := 1//1
b := 2//2
c := a + b //3
...

如上代码由于编译器和cpu的优化,实际运行时候可能代码(2)先运行,然后代码(1)后执行,但是由于代码(3)依赖代码(1)和代码(2)创建的变量,所以代码(1)和(2)不会被放到代码(3)后运行,也就是说编译器和CPU在不改变程序正确性的前提下才会对指令进行重排序,所以上面代码在单一goroutine时候并不会存在问题,也就是在单一goroutine 中Happens Before所要表达的顺序就是程序执行的顺序。

但是在多个goroutine时候就可能存在问题,比如下面代码:

//变量b初始化为0var b int //goroutine Ago func() {a := 1     //1b := 2     //2c := a + b //3}()//goroutine Bgo func() {if 2 == b {//4fmt.Println(a)//5}}()
  • 如上代码变量b是一个全局变量,初始化为0值
  • 下面开启了两个goroutine,假设goroutine B有机会输出值时候,那么它可能输出的值是多少那?其实可能是0也可能是1,输出1大家可能会感到很直观,那么为何会输出0 了?
  • 这是因为编译器或者CPU可能会对goroutine A中的指令做重排序,可能先执行了代码(2),然后在执行了代码(1)。假设当goroutine A执行代码(2)后,调度器调度了goroutine B执行,则goroutine B这时候会输出0。

为了保证多goroutine下读取共享数据的正确性,go中引入happens before原则,即在go程序中定义了多个内存操作执行的一种偏序关系。如果操作e1先于e2发生,我们说e2 happens after e1,如果e1操作既不先于e2发生又不晚于e2发生,我们说e1操作与e2操作并发发生。

在单一goroutine 中Happens Before所要表达的顺序就是程序执行的顺序,happens before原则指出在单一goroutine 中当满足下面条件时候,对一个变量的写操作w1对读操作r1可见:

  • 读操作r1没有发生在写操作w1前
  • 在读操作r1之前,写操作w1之后没有其他的写操作w2对变量进行了修改

在一个goroutine里面,不存在并发,所以对变量的读操作r1总是对最近的一个写操作w1的内容可见,但是在多goroutine下则需要满足下面条件才能保证写操作w1对读操作r1可见:

  • 写操作w1先于读操作r1
  • 任何对变量的写操作w2要先于写操作w1或者晚于读操作r1

这两条条件相比第一组的两个条件更加严格,因为它要求没有任何写操作与w1或者读操作r1并发的运行,而是要求在w1操作前或读操作r1后发生。

在一个goroutine时候,不存在与w1或者r1并发的写操作,所以前面两种定义是等价的:一个读操作r1总是对最近的一个对写操作w1的内容可见。但是当有多个goroutines并发访问变量时候,就需要引入同步机制来建立happen-before条件来确保读操作r1对写操作w1写的内容可见。

需要注意的是在go内存模型中将多个goroutine中用到的全局变量初始化为它的类型零值在内被视为一次写操作,另外当读取一个类型大小比机器字长大的变量的值时候表现为是对多个机器字的多次读取,这个行为是未知的,go中使用sync/atomic包中的Load和Store操作可以解决这个问题。

解决多goroutine下共享数据可见性问题的方法是在访问共享数据时候施加一定的同步措施,比如sync包下的锁或者通道。

三、同步(Synchronization)

3.1初始化(Initialization)

程序的初始化是发生在一个goroutine内的,这个goroutine可以创建多个新的goroutine,创建的goroutine和当前的goroutine可以并发的运行。

如果在一个goroutine所在的源码包p里面通过import命令导入了包q,那么q包里面go文件的初始化方法的执行会happens before 于包p里面的初始化方法执行:

package mainimport ("fmt""main/hello"
)func init() {fmt.Println("--main thread init---")
}
func main() {fmt.Println("---main func start----")hello.SayHello()
}
  • 如上代码main包里面导入了main/hello包,后者里面含有一个hello.go的文件,内容如下:
package helloimport ("fmt"
)func init() {fmt.Println("--hello pkg init---")
}func SayHello() {fmt.Println("--hello jiaduo---")}
  • main包的main里面调用了包hello的SayHello 方法。

运行上面代码会输出:

--hello pkg init---
--main thread init---
---main func start----
--hello jiaduo---

可知hello包的init方法happen before main包的init执行,main包的init方法happen berfore main函数执行。

3.2 创建goroutine(Goroutine creation)

go语句启动一个新的goroutine的动作 happen before 该新goroutine的运行,例如下面程序:

package mainimport ("fmt""sync"
)var a string
var wg sync.WaitGroupfunc f() {fmt.Print(a)wg.Done()
}func hello() {a = "hello, world"go f()
}
func main() {wg.Add(1)hello()wg.Wait()}

如上代码调用hello方法后肯定会输出”hello,world”,可能等hello方法执行完毕后才输出(由于调度的原因)。

3.3 销毁goroutine(Goroutine destruction)

一个goroutine的销毁操作并不能确保 happen before 程序中的任何事件,比如下面例子:

var a stringfunc hello() {go func() { a = "hello" }()print(a)
}

如上代码 goroutine内对变量a的赋值并没有加任何同步措施,所以并能不保证hello函数所在的goroutine对变量a的赋值可见。如果要确保一个goroutine对变量的修改对其他goroutine可见,必须使用一定的同步机制,比如锁、通道来建立对同一个变量读写的偏序关系

3.4 通道通信(Channel communication)

在go中通道是用来解决多个goroutines之间进行同步的主要措施,在多个goroutines中,每个对通道进行写操作的goroutine都对应着一个从通道读操作的goroutine。

3.4.1 有缓冲通道

在有缓冲的通道时候向通道写入一个数据总是 happen before 这个数据被从通道中读取完成,如下例子:

package mainimport ("fmt"
)var c = make(chan int, 10)
var a stringfunc f() {a = "hello, world" //1c <- 0             //2
}func main() {go f()   //3<-c      //4fmt.Print(a) //5
}

如上代码运行后可以确保输出”hello, world”,这里对变量a的写操作(1) happen before 向通道写入数据的操作(2),而向通道写入数据的操作(2)happen before 从通道读取数据完成的操作(4),而步骤(4)happen before 步骤(5)的打印输出。

另外关闭通道的操作 happen before 从通道接受0值(关闭通道后会向通道发送一个0值),修改上面代码(2)如下:

package mainimport ("fmt"
)var c = make(chan int, 10)
var a stringfunc f() {a = "hello, world" //1close(c)            //2
}func main() {go f()   //3<-c      //4fmt.Print(a) //5
}

然后在运行也可以确保输出”hello, world”。

注:在有缓冲通道中通过向通道写入一个数据总是 happen before 这个数据被从通道中读取完成,这个happen before规则使多个goroutine中对共享变量的并发访问变成了可预见的串行化操作。

3.4.2 无缓冲通道

对应无缓冲的通道来说从通道接受(获取叫做读取)元素 happen before 向通道发送(写入)数据完成,看下下面代码:

package mainimport ("fmt"
)var c = make(chan int)
var a stringfunc f() {a = "hello, world" //1<-c                //2
}func main() {go f()       //3c <- 0       //4fmt.Print(a) //5
}

如上代码运行也可保证输出”hello, world”,注意改程序相比上一个片段,通道改为了无缓冲,并向通道发送数据与读取数据的步骤(2)(4)调换了位置。

在这里写入变量a的操作(1)happen before 从通道读取数据完毕的操作(2),而从通道读取数据的操作 happen before 向通道写入数据完毕的操作(4),而步骤(4) happen before 打印输出步骤(5)。

注:在无缓冲通道中从通道读取数据的操作 happen before 向通道写入数据完毕的操作,这个happen before规则使多个goroutine中对共享变量的并发访问变成了可预见的串行化操作。

如上代码如果换成有缓冲的通道,比如c = make(chan int, 1)则就不能保证一定会输出”hello, world”。

3.4.3 规则抽象

从容量为C的通道接受第K个元素 happen before 向通道第k+C次写入完成,比如从容量为1的通道接受第3个元素 happen before 向通道第3+1次写入完成。

这个规则对有缓冲通道和无缓冲通道的情况都适用,有缓冲的通道可以实现信号量计数的功能,比如通道的容量可以认为是最大信号量的个数,通道内当前元素个数可以认为是剩余的信号量个数,向通道写入(发送)一个元素可以认为是获取一个信号量,从通道读取(接受)一个元素可以认为是释放一个信号量,所以有缓冲的通道可以作为限制并发数的一个通用手段:

package mainimport ("fmt""time"
)var limit = make(chan int, 3)func sayHello(index int){fmt.Println(index )
}var work []func(int)
func main() {work := append(work,sayHello,sayHello,sayHello,sayHello,sayHello,sayHello)for i, w := range work {go func(w func(int),index int) {limit <- 1w(index)<-limit}(w,i)}time.Sleep(time.Second * 10)
}

如上代码main goroutine里面为work列表里面的每个方法的执行开启了一个单独的goroutine,这里有6个方法,正常情况下这7个goroutine可以并发运行,但是本程序使用缓存大小为3的通道来做并发控制,导致同时只有3个goroutine可以并发运行。

四、锁(locks)

sync包实现了两个锁类型,分别为 sync.Mutex(互斥锁)和 sync.RWMutex(读写锁)。

对应任何sync.Mutex or sync.RWMutex类型的遍历I来说调用n次 l.Unlock() 操作 happen before 调用m次l.Lock()操作返回,其中n<m,我们看下面程序:

package mainimport ("fmt""sync"
)var l sync.Mutex
var a stringfunc f() {a = "hello, world" //1l.Unlock()         //2
}func main() {l.Lock()     //3go f()       //4l.Lock()     //5fmt.Print(a) //6
}

运行上面代码可以确保输出”hello, world”,其中对变量a的赋值操作(1) happen before 步骤(2),第一次调用 l.Unlock()的操作(2) happen before 第二次调用l.Lock()的操作(5),操作(5) happen before 打印输出操作(6)

另外对任何一个sync.RWMutex类型的变量l来说,存在一个次数n,调用 l.RLock操作happens after 调用n次 l. Unlock(释放写锁)并且相应的 l.RUnlock happen before 调用n+1次 l.Lock(写锁)

package mainimport ("fmt""sync"
)var l sync.RWMutex
var a stringfunc unlock() {a = "unlock" //1l.Unlock()   //2
}func runlock() {a = "runlock" //3l.RUnlock()   //4
}func main() {l.Lock()    //5go unlock() //6l.RLock()      //7fmt.Println(a) //8go runlock()   //9l.Lock()     //10fmt.Print(a) //11l.Unlock()
}
  • 运行上面代码一定会输出如下:
unlock
runlock
  • 如上代码 (1)对a的赋值 happen before 代码(2),而对l.RLock() (代码7) 的调用happen after对l.Unlock()(代码2)的第1次调用,所以代码(8)输出unlock。
  • 而对代码(7)l.RLock() 的调用happen after对l.Unlock()(代码2) 的第1次调用,相应的有对l.RUnlock() (代码4)的调用happen before 第2次对l.Lock()(代码4)的调用,所以代码(11)输出runlock

也就是这里对任何一个sync.RWMutex类型的变量l来说,存在一个次数1,调用 l.RLock操作happens after 调用1次 l. Unlock(释放写锁)并且相应的 l.RUnlock happen before 调用2次 l.Lock(写锁)

五、一次执行(Once)

sync包提供了在多个goroutine存在的情况下进行安全初始化的一种机制,这个机制也就是提供的Once类型。多(goroutine)下多个goroutine可以同时执行once.Do(f)方法,其中f是一个函数,但是同时只有一个goroutine可以真正运行传递的f函数,其他的goroutine则会阻塞直到运行f的goroutine运行f完毕。

多goroutine下同时调用once.Do(f)时候,真正执行f()函数的goroutine, happen before 任何其他由于调用once.Do(f)而被阻塞的goroutine返回:

package mainimport ("fmt""sync""time"
)var a string
var once sync.Once
var wg sync.WaitGroupfunc setup() {time.Sleep(time.Second * 2) //1a = "hello, world"fmt.Println("setup over") //2
}func doprint() {once.Do(setup) //3fmt.Println(a) //4wg.Done()
}func twoprint() {go doprint()go doprint()
}func main() {wg.Add(2)twoprint()wg.Wait()
}

如上代码运行会输出:
setup over
hello, world
hello, world

  • 上面代码使用wg sync.WaitGroup等待两个goroutine运行完毕,由于 setup over只输出一次,所以setup方法只运行了一次
  • 由于输出了两次hello, world说明当一个goroutine在执行setup方法时候,另外一个在阻塞。

六、不正确的同步(Incorrect synchronization)

6.1 不正确的同步案例(一)

需要注意的是虽然一个goroutine对一个变量的读取操作r,可以观察到另外一个goroutine的写操作w对变量的修改,但是这不意味着happening after 读操作r的读操作可以看到 happen before写操作w的写操作对变量的修改(需要注意这里的先后指的是代码里面声明的操作的先后顺序,而不是实际执行时候的):

var a, b intfunc f() {a = 1//1b = 2//2
}func g() {print(b)//3print(a)//4
}func main() {go f()//5g()//6
}
  • 比如上面代码一个可能的输出为先打印2,然后打印0
  • 由于代码(1)(2)没有有任何同步措施,所以经过重排序后可能先执行代码(2),然后执行代码(1)。
  • 另外由于步骤(5)开启了一个新goroutine来执行f函数,所以f函数和g函数是并发运行,并且两个goroutine没做任何同步。
  • 假设f函数先执行,并且由于重排序限制性了步骤(2),然后g函数执行了步骤(3)则这时候会打印出2,然后执行步骤(4)则打印出0,然后执行步骤(1)给变量a赋值。

也就是说这里即使假设步骤(3)的读取操作r 对步骤(2)的写操作w的内容可见,但是还没不能保证步骤(3)的读取操作后面的读取操作步骤(4)可以看到 先于代码中声明的在步骤(2)前面的代码(1)对变量a赋值的内容。

6.2 不正确的同步案例(二)

使用双重检查机制来避免使用同步带来的开销,如下代码:

var a string
var done boolfunc setup() {a = "hello, world"done = true
}func doprint() {if !done {once.Do(setup)}print(a)
}func twoprint() {go doprint()go doprint()
}

如上代码并不能保证一定输出hello, world,而可能输出空字符串,这是因为在doPrint函数内即使可以能够看到setup中对done变量的写操作,也不能保证在doPrint里面看到对变量a的写操作。

6.3 不正确的同步案例(三)

另外一个常见的不正确的同步是等待某个变量的值满足一定条件:

var a string
var done boolfunc setup() {a = "hello, world"done = true
}func main() {go setup()for !done {}print(a)
}

该案例同理,并不能确保在main函数内即使可以看到对变量done的写操作,也可以看到对变量a的操作,所以main函数还是可能会输出空串。更糟糕的是由于两个goroutine没有对变量done做同步措施,main函数所在goroutine可能看不到对done的写操作,从而导致main函数所在goroutine一直运行在for循环出。

这种不正常的同步方式有更微妙的变体,例如这个程序:

type T struct {msg string
}var g *Tfunc setup() {t := new(T)t.msg = "hello, world"g = t
}func main() {go setup()for g == nil {}print(g.msg)
}

如上代码即使main函数内可以看到setup函数内对g的赋值,从而让main函数退出,但是也不能保证main函数可以看到对 g.msg的赋值,也就是可能输出空串

七、总结

通过上面所有的例子,不难看出解决多goroutine下共享数据可见性问题的方法是在访问共享数据时候施加一定的同步措施。本文翻译自https://golang.org/ref/mem ,并融入作者自己理解。

go并发编程之美(二)、go内存模型相关推荐

  1. Java并发编程 - 第三章 Java内存模型

    前言: Java 线程之间的通信对程序员完全透明,内存可见性问题很容易困扰 Java 程序员,本章将揭开 Java 内存模型神秘的面纱. 一.Java 内存模型的基础 1.1 并发编程模型的两个关键问 ...

  2. Java并发编程的艺术笔记-Java内存模型

    1.Java内存模型的基础 1.1 并发编程模型的两个关键问题 线程之间如何通信: 通信是指线程之间以何种机制来交换信息 通信机制有两种:共享内存和消息传递 线程之间如何同步: 同步:指程序中用于控制 ...

  3. 《Java并发编程之美》

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yAwEsYPZ-1661534116043)(img/\1625573175405.jpg)] [外链图片转存失败,源站 ...

  4. java并发编程之美-阅读记录1

    1.1什么是线程? 在理解线程之前先要明白什么是进程,因为线程是进程中的一个实体.(线程是不会独立存在的) 进程:是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,线程则是进程中的 ...

  5. 【Java并发编程】之二:线程中断

    [Java并发编程]之二:线程中断 使用interrupt()中断线程 ​ 当一个线程运行时,另一个线程可以调用对应的Thread对象的interrupt()方法来中断它,该方法只是在目标线程中设置一 ...

  6. 《Java并发编程之美》阅读笔记

    简介 最近在阅读<Java并发编程之美>这本书,为了督促自己啃完这本书,计划每读完一章写一篇阅读笔记,供以后参考. 笔记列表 第一部分 Java并发编程基础篇 第1章 并发编程线程基础 第 ...

  7. java并发编程之美-阅读记录2

    2.1什么是多线程并发编程 并发:是指在同一时间段内,多个任务同时在执行,并且执行没有结束(同一时间段又包括多个单位时间,也就是说一个cpu执行多个任务) 并行:是指在单位时间内多个任务在同时执行(也 ...

  8. Python并发编程之多进程(二)

    十.进程同步 进程之间数据不共享,但是共享同一套文件系统,所以访问同一个文件,或同一个打印终端,是没有问题的, 而共享带来的是竞争,竞争带来的结果就是错乱,如何控制,就是加锁处理 ---------- ...

  9. java并发编程之美-阅读记录11

    java并发编程实践 11.1ArrayBlockingQueue的使用 有关logback异步日志打印中的ArrayBlockingQueue的使用 1.异步日志打印模型概述 在高并发.高流量并且响 ...

  10. java并发编程实战(二)

    java并发编程中常常会用到两种容器来存放一些数据,这些数据需要保证能在多线程下正常访问.常见的容器分为两类:同步容器和并发容器.在java并发编程实战一书中的第五章也有讲解. 什么是同步容器以及优劣 ...

最新文章

  1. windows server 2012 application control policy
  2. c语言实现将两个文件复制到一个文件里_Python中复制文件的9种方法
  3. [TCP/IP] TCP流和UDP数据报之间的区别
  4. Android 3D开发,OpenGL ES 的使用(一)
  5. 在C++中子类继承和调用父类的构造函数方法
  6. louvain算法_单细胞聚类(四)图解Leiden算法对Louvain算法的优化
  7. .NET 6新特性试用 | 隐式using指令
  8. linker `cc` not found
  9. html52D转换3D,CSS3 Transform 2D和3D转换
  10. 输出整数各位数字 (15 分)
  11. Linux环境编程:fork系统调用及其陷阱 - 内存复用,文件不复用
  12. 计算器: 请输入两个数和一个符号,完成两个数的+ - * / % // **
  13. c1能力认证考试训练任务03-web基础与布局
  14. hexo图片展示-blog图床迁移至七牛云
  15. Tekton笔记(四)之Authentication及catalog skopeo
  16. git问题:Please make sure you have the correct access rights and the repository exists.
  17. 《俗人笔记》之《Java基础语法上》
  18. java spark yarn_Spark on yarn
  19. JAVA JDK的环境变量配置(配有图片,超级详细)
  20. 怎么得到网页中的SWF文件

热门文章

  1. 搜狗浏览器智慧版_4月浏览器份额榜单出炉:Edge登上第二、Chrome第一无人撼动...
  2. java 文件夹不存在则创建_java判断文件不存在就创建的方法
  3. Mysql插入中文时错误:ERROR 1366 (HY000): Incorrect string value: '\xE6\x9D\x8E\xE5\x8B\x87' for column
  4. docker privileged作用_docker容器性能监控cAdvisor+influxDB+grafana监控系统安装部署
  5. 关于jenkins打包部署
  6. (6)Redis的高可用方案
  7. java连接oracle jdbc连接
  8. 谈谈数字货币交易系统的发展
  9. ValueError: output parameter for reduction operation logical_and has too many dimensions ?
  10. 洛谷P1306 斐波那契公约数