The Go Memory Model(翻译版)

本文深入探讨Go语言中的并发概念,包括goroutine、channel、锁机制以及sync.Once的使用。通过实例解析了如何保证goroutine间的正确同步,防止数据竞争,确保程序的正确执行。强调了channel在多goroutine通信中的重要性,并展示了死锁和初始化变量的安全方法。
摘要由CSDN通过智能技术生成

前言

在学习mit 6.824的时候有一节课讲到go线程和内存管理,此节课的要求是先把golang官方的go memory model读完,我在看的时候索性就随手翻译一下,原文的链接在此

介绍

Go 内存模型指定了在何种条件下可以保证在一个 goroutine 中读取变量时观察到在不同 goroutine 中写入相同变量所产生的值。

建议

当我们程序设定一个变量要被多个goroutine访问,更改,此时必须要将多个goroutine访问序列化

将多个goroutine的访问序列化,可以通过channel和其他同步方式实现,比如sync或者sync/atomic

如果我们想要通过阅读剩下的文档以了解上述程序中的机制,那你太聪明了

不要太聪明!
(wtf???)

开始之前

假设我们有一个goroutine,他的读写等操作就像程序实现预定好的步骤执行,在单个goroutine中读写,编译器和处理器可能会对读,写的顺序重新定序,在重新定序的时候并不会改变我们程序为此单个goroutine定义的行为,因为对于重新定序而言,一个goroutine看到的顺序可能和其他的goroutine看到的不一样,比如一个goroutine执行a = 1 ;b = 2其他的goroutine可能看到b的赋值先于a的赋值

对于读,写的特殊要求,我们定义了happen before,他是go程序执行内存操作的一部分顺序,假设我们有一个event e1,e1发生于e2之前,我们也可以说e2发生于e1之后,假设e1不发生于e2之前或者之后,我们可以称为e1e2并行

对于单个goroutine来说,这个happen before的顺序取决于程序如何去表达她


如果下面2项都成立,则允许对变量v进行读操作的事件r看着对变量v进行写操作的事件w发生(不会改变程序的结果)
1.事件r并没有发生在事件w之前
2.没有其他的写事件发生于事件w之后,且事件r之前


为了保证对一个变量v进行读操作r可以看着对变量v进行特定的写操作w发生,我们要保证写操作w是唯一的写操作,r是被允许观察v的变化,也就是说w要满足下面2个要求,r可以被保证去观察到w操作

1.w发生在r之前
2.对于变量v的其他写事件,要么发生在w之前或者r之后

上述第②个条件是第①个条件的加强版,①和②需要没有其他的写操作同时在w和r发生的时候同时发生

对于单个goroutine的情况是没有并发操作发生的,所以他们的结果都一样,一个读操作r看到变量v被最近的一次写操作w给改变其值,对于多个goroutine的情况下,多个goroutine去访问变量v,他们必须要使用同步化的事件

同步

初始化

我们的go程序最开始只有一个goroutine(就像主线程),但是这个"主"goroutine可以创建多个其他的goroutine,这些多个goroutine可以并发运行
假设我们有一个package p,p import了另一个package q,q的初始化函数发生在p之前,main函数发生于所有init函数之后

创建goroutine

golang用go去创建一个新的goroutine,他发生于主goroutine开始执行之后

var a string //共享变量a

func f(){
	fmt.Println(a)
}
func hello(){
	a = "hello,world"
	go f() //创建一个goroutine执行函数f
}

如果我们调用hello函数将会打印helloworld

goroutine退出

goroutine的退出并不保证发生在任何程序语句之前,例如

var s string

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

上面的程序中假如我们还有一个其他的goroutine,其他的goroutine并不能保证a = “hello” (因为另一个goroutine不能保证运行于上述goroutine之后),一些激进的golang编译器甚至会删除第四行的go语句

如果我们一定要goroutine后于其他goroutine执行,我们需要用一些同步机制,比如锁,channel通讯,等等

go channel 通讯

go channel是golang中保证多goroutine同步的常用方法,一个goroutine向特定的channel发送数据,其他对应的goroutine通过这个特定的channel拿数据

goroutine向channel发送数据的操作应该前于另一个goroutine向channel拿数据的操作

var c = make(chan int,10) //创建一个channel,channel传送int类型数据,channel容量为10
var a string

func f(){
	a = "hello,world"
	c <- 0 //goroutine向channel c发送数据0
}

func main(){
	go f() //开一个goroutine运行f函数,这个函数定义a的值,并且向channel发送数据0
	<-c /*“主”goroutine从channel c接受数据,"主"goroutine会在此处block等待
	数据从channel c中传递过来*/
	print(a) /*会直接答应hello world,因为在上一个语句从channel中获取数据
	的操作阻塞了主goroutine,从channel得到数据意味着上一个goroutine已经运
	行完毕,因为上一个goroutine向channel传递参数操作位于给a赋值之后*/
}

具体的解释已经写在代码中,假如我们将句子中的<-c改成close(c)也会达到同样的效果

译者注:
channel可以实现锁机制,当我们有一个无缓冲channel的时候,channel的发送者和接收者都是同步的,通俗讲他们必须要准备好,如果发送者向channel发送了数据,接收者没有接收,发送者就会block在这里,如果接收者先接收,那么会一直阻塞到发送者发送数据
 
对于有缓冲channel,接收者和发送者就不必同步,因为channel中有缓存,但是有缓冲channel还是有可能阻塞,比如发送者向一个满的channel发送数据,再或者接收者向一个空的channel接收数据,这些情况都会导致阻塞
 
对于close一个有缓冲channel来说,channel缓冲中有数据,被close后我们可以继续从channel中拿数据
 

下面的程序不能保证答应出a的值为hello world,甚至还可能crash,或者打印空值

var c = make(chan int,1)
var a string

func f(){
	a = "hello world"
	<-c
} 

func main(){
	go f()
	c <- 0
	print(a)
}

为什么不能保证?他和我们上一个程序差不多啊,因为goroutine和主goroutine的发送接收顺序不一样,我们这样想,因为是buffered channel,我们执行go f()开了一个goroutine去执行对a初始化的操作,并且执行从channel c中获取数据的操作,假设这个goroutine先于主goroutine运行,那么这个goroutine将会阻塞,直到主goroutine将0传入channel为止,这个情况才有可能在主goroutine执行最后print(a)之前将a赋值,再假设主goroutine先于另一个goroutine运行,主goroutine执行c<-0这个操作不会阻塞,因为是buffered channel,到最后打印print(a)的时候另一个goroutine也有可能还没运行,所以这个情况a就打印出0或者直接crash
如果我们把上面的buffered channel改成unbuffered channel(var c = make(chan int,1)改为var c = make(chan int))就不会出现打印0值和crash的情况

var limit = make(chan int,3)
 func main(){
	for _,w := range work{  //work是一个工作列表,列表中的成员是函数指针
		go func(w func()){ //匿名函数的参数是一个函数指针w
			limit <- 1
			w()
			<-limit
		}(w) //调用匿名函数,并且为匿名函数传入参数w,w是函数指针
	}
	/*上述代码有些抽象,大意是创建一个goroutine,goroutine运行一个匿名函
	数匿名函数参数是一个函数指针,匿名函数体是向buffered channel limit传
	入参数-1(不会阻塞),运行函数w,最后从channel limit拿出数据(不阻塞)*/
	select{}
	/*我们一般看到的select里面都有case后加一个channel通讯操作(接收或者发
	送),但是这里啥也没有代表什么呢空select{}会永远阻塞(sleeping)但是他不
	会占用cpu,空select{}和for{}差不多,都是永远阻塞,但是for{}会占用100%
	的cpu,但是golang看到这个goroutine会永远阻塞不可能被唤醒,所
	以就主动报错退出*/
}

上述的代码非常的巧妙他会为每一个work创建一个goroutine去执行,并且保证同时只有3个goroutine同时运行,因为channel的buffer为3,也就是最多只能有3个goroutine向channel发送消息,多了就阻塞,非常高明的代码

锁机制

锁机制由golang的sync包提供,并且提供了2种锁sync.Mutex(互斥锁),sync.RWMutex(读写锁)

译者注:
对于互斥锁和读写锁我简单介绍一下,首先互斥锁一般设置在结构中代表对结构加锁,首先我们线程对于一个共享变量最先的操作是加锁,然后操作,最后解锁,如果我们使用了互斥锁,一但锁住一个变量其他的任何线程试图对此变量进行加锁都会被阻塞,直到我最开始对共享变量加锁的线程解锁,其他被阻塞的线程才会从阻塞状态变成可运行状态,然后访问,互斥锁极其安全,但是带来的问题是对资源的利用率降低,因为我们读操作并不会更改共享变量的数值,在互斥锁的情况下,不管你读,还是写之前都要对变量进行加锁,只要这个变量早已经被其他变量锁住,你再加锁一定会被阻塞,
在此背景下,读写锁诞生了,读写锁的锁分为2个状态,分别是读锁和写锁,读写锁的共享变量访问方式也是:加锁->读/写->解锁,不过我们可以对变量加读锁和写锁,当我们对变量加了读锁其他的线程都可以为此变量加读锁,但是如果某个线程对这个已经加了读锁的变量强加写锁,此变量会被阻塞(就算随后最先加读锁的线程解锁后,这个准备加写锁的线程自动变成加读锁),如果一个线程对一个共享变量加了写锁,当一个线程对共享变量加写锁,这个时候其他任何线程无论对此共享变量加读锁或者写锁都会被阻塞(一但对共享变量加的是写锁,其余模式就和互斥锁一样)
死锁:
死锁讲的是有一个线程A,线程B,共享资源a,共享资源b,最开始A对a加互斥锁(或者写锁),B对b加互斥锁(或者写锁),并且2个线程都没有要解锁的意思,此时A突然对b加互斥锁(或者读写锁),此时A被阻塞(因为b没有被解锁),非常不巧的是B又突然对a进行加锁(互斥锁或者读写锁),这个时候B也被阻塞(因为a没有被解锁),这个时候A和B都被阻塞,并且永远不能被解锁(除非操作系统发起中断信号)

下列的程序保证打印hello world

import "sync"

var l sync.Mutex //设置互斥量
var a string

func f(){
	a = "hello world"
	l.Unlock()
}
func main(){
	l.lock()
	go f()
	l.Lock()
	print(a)
}

为什么保证打印hello world?因为首先我们对互斥量进行加锁然后我们创建一个goroutine,此goroutine将a赋值为hello world,并且最后有一个解锁共享变量l的操作,再看我们main函数第三段是对共享变量再进行加锁,要知道我们一个变量如果被锁住,在没有解锁的情况再加锁线程会被阻塞,而解锁的操作就在goroutine中,所以我们运行到main函数的第三段的时候如果l加锁成功,说明最开始的锁已经被解开,另一句话说就是goroutine的内容已经运行完毕,如果没有解锁那么主goroutine会被锁在main的第三段直到子goroutine运行完毕

once

在sync这个包中为我们提供了在多goroutine场景下一种安全的初始化变量的机制,那就是用once这个类型,在多线程的环境下多线程同时执行once.Do(f)顺带执行函数f(),除了抢占到once.Do(f)的线程,其他的则一直被block直到函数f()结束返回(就算是多线程下once.Do(f)中的函数f()只被执行一次)
下面是示例代码

import "sync"

var a string
var once sync.Once //定义once类型

func setup(){
	a = "hello world"
}

func doprint(){
	once.Do(setup) //"注册"setup函数,
	print(a)
}

func twoprint(){
	go doprint()
	go setup()
}

在我们调用twoprint的时候将会调用set一次(只会调用一次),在我们调用twoprint的时候setup函数将会在doprint之前完成,最终的结果是print打印2次,setup只被执行了一次

golang语法保证init()函数被单线程调用一次(init是线程安全的除非多线程)
sync.Once是我们想控制某一些代码执行,比如lazy init,我们在最开始创建一个资源,在需要他的时候再初始化

once和我们的多个goroutine同步有啥关系呢?因为如果我们使用once.Do去初始化一个变量,那么无论有多少个goroutine通过once.Do去初始化这个变量,这个变量只会被初始化一遍,并且在初始化的时候,其他goroutine被阻塞(参与抢占once.Do的goroutine)

错误的同步

假设对于一个变量v有一个读操作r,还有一个写操作w同时发生,并不意味着读r在写w之后发生
有下列代码

var a,b int

func f(){
	a = 1
	b = 2
}

func g(){
	print(b)
	print(a)
}

func main(){
	go f()
	g()
}

上述代码g可能答应2或者0

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值