AI智能
改变未来

Golang的内存模型

「深度学习福利」大神带你进阶工程师,立即查看>>>

本文来自小天同学的投稿,233作为一个不会Golang的Java渣渣都看懂一些了。看完只能感慨是时候开始学Go了!强烈推荐给正在学习Golang或者对Golang感兴趣的小伙伴,建议收藏~

前言

本文主要是对The Go Memory Model一文的翻译,也想借此机会加深对golang内存模型的理解。

介绍

go内存模型规定了某个goroutine的读操作保证能观测到来自其他goroutine对这个变量的写操作的一组条件。

建议

那些数据在修改的同时如果被其他goroutine访问到,必须串行化(serialize)这种访问才能保证数据的安全性。

为了串行化访问,通过channel或者其他同步原语比如syncsync/atomic包来保护数据。

如果你必须阅读这篇文档剩下的内容以理解你的程序的行为,你将会变得太聪明。

永远不要聪明。

Happens Before

在单个goroutine里,读和写必须表现得好像它们在按照程序指定的顺序执行。

也就是,编译器和处理器可能重排序(reorder)单个goroutine内执行的读和写,当然重排序不会改变语言规范所定义的在这个goroutine内的行为。因为重排序的存在,一个goroutine观测到的执行顺序可能和其他goroutine观测到的不同。

举个栗子,如果一个goroutine执行a = 1; b = 2;另外一个goroutine可能观测到对变量b的更新先于a

为了规定读和写的依赖,我们定义了happens before,一个部分有序的执行

如果事件e1 happens before 事件e2,那我们说e2 happens after e1。另外,如果e1没有happens before e2同时e2没有happens before e1,那我们说e1和e2并发地发生。

在单goroutine环境,happens-before顺序就是程序所表达的顺序。

在下面两个条件满足时,对变量v的读r允许观测到对v的写w:

1. r没有happen before w

2. 没有其他对v的写w\’ happens after w并happen before r

要保证对变量v的读观测到对v的特定写w,w是唯一允许被r观测到的写。即是,r被保证观测到w需要同时满足下列两个条件:

1. w happens before r

2. 任何其他对变量v的写要么happens before w要么happens after r

这对条件比第一对更强,它需要没有其他写和w或者r并发地发生。

笔记:第一对条件是允许观测,第二对条件是保证观测,约束强度不一样。

在单goroutine环境,没有并发,所以这两个定义是等价的:一个读r观测到最近的对这个变量的写w。

当多个goroutine同时访问一个共享变量v,它们必须使用同步事件来建立happens-before条件以保证读观测到想要的写。

对变量v的零值初始化的行为像内存模型的写。

对大于一个机器字word的变量的读和写的行为像多机器字尺寸(multiple machine-word-sized)操作一样未定义顺序。

笔记:比如32位系统的一个字word是4byte,对int64类型的变量v的写和读,可能发生goroutine1写了4个byte,goroutine2读了v的值,goroutine1继续写v剩下的4个byte这种顺序

同步

初始化

程序开始时运行在单个goroutine,但是这个goroutine可能创建其他goroutine并发运行。

  • 如果一个包p导入包q,q的init函数的完成happens before任何p的init开始前。

  • main.main函数的开始happens after所有init函数已经结束。

goroutine创建

  • 开始一个新goroutine的go语句happens before这个goroutine执行开始*

比如下面这个栗子:


varastring

funcf(){

print(a)

}

funchello(){

Step1:a=\"hello,world\"

Step2:gof()

}

调用hello一定会打印\”hello, world\”,后者发生在a赋值的未来的某个时间点(可能在hello函数返回后)。

goroutine销毁

goroutine的退出不保证happens before程序中的任何事件。比如下面这个程序:


varastring

funchello(){

gofunc(){a=\"hello\"}()

print(a)

}

这个赋值没有跟随任何同步事件,所以不保证被任何其他goroutine观测到。实际上,激进的编译器可能删除这整个go语句(go statemennt,即go func() { a = \"hello\" }()这行)。

如果一个goroutine的作用必须被其他goroutine观测到,使用一个同步机制比如一个锁或者channel通信来建立相对关系。

channel通信

channel通信是goroutine间主要的同步方法。任何在一个特定channel的send匹配一个在这个channel上的相应receive,通常这个receive在另一个goroutine。

  • 一个channel上的send happens before这个channel相应receive的完成

varc=make(chanint,10)

varastring

funcf(){

a=\"hello,world\"

c<-0

}

funcmain(){

gof()

<-c

print(a)

}

这个程序保证打印\”hello, world\”。往a的写happens before c的send,happens before c的相应的receive的完成,happens before print

笔记:这里没解释为什么往a的写happens before c的send,个人理解channel的send前添加了一个内存屏障,保证了其他线程观察到的a的写happens before c的send

  • channel的关闭过程happens before 这个channel上的receive返回零值

笔记:这里很好理解,channel的close操作可以看作send。

  • 在一个unbuffered channel上的receive happens before channel的send完成

笔记:这条有点反直觉


varc=make(chanint)

varastring

funcf(){

a=\"hello,world\"

<-c

}


funcmain(){

gof()

c<-0

print(a)

}

这个程序(和上一个类似,除了send和receive语句交换了然后用了一个unbuffered channel)同样保证打印\”hello, world\”。往a的写happens before c的receive,happens before相应的c上的send的完成,happens before print函数。

如果这个channel是buffered的(比如: c = make(chan int, 1)),那这个程序不会保证打印\”hello, world\”。(它可能打印空字符串,宕掉,或者做其他事情)

  • 在一个容量C的channel上的第k个receive happens before那个channel上第k+C个send的完成

这个规则推广了前一个buffered channel规则。它允许通过buffered channel建模一个计数信号量(semaphore):channel里的item个数对应活跃用户数目,channel的容量对应最大可同时使用量,send item申请(acquire)信号量,receive item释放(release)信号量。这是一个实现有限并发的常见模式。

这个程序为工作列表里的每一个条目开启一个goroutine,但是这些goroutine用了一个有限的channel来确保最多有3个工作在同时运行。


varlimit=make(chanint,3)

funcmain(){

for_,w:=rangework{

gofunc(wfunc()){

limit<-1

w()

<-limit

}(w)

}

select{}

}

sync包实现了两种锁数据类型,sync.Mutexsync.RWMutex

对任何sync.Mutex或者sync.RWMutex变量l,假设n < m,调用第n个l.Unlock() happens before第m个l.Lock()返回。

笔记:这个很好理解,锁释放之后才能获取。


varlsync.Mutex

varastring

funcf(){

a=\"hello,world\"

l.Unlock()

}

funcmain(){

l.Lock()

gof()

l.Lock()

print(a)

}

这个程序保证打印\”hello, world\”。第一次调用l.Unlock()(函数f里)happens before第二次调用l.Lock()(函数main里)返回,happens before print

笔记:这里存在和channel同样的问题,没有解释为什么第二次调用l.Lock() happens before print,原因猜测和channel一样l.Lock()会添加一个内存屏障保证happens before关系)

对于任何调用l.RLock在一个sync.RWMutex变量l,有一个这样的n使得l.RLock的返回happens after第n个调用l.Unlock,而且与这个l.RLock配对的l.RUnlock happens before 第 n+1 个l.Lock

笔记:定义比较晦涩,其实描述的规则很简单,读锁在写锁释放后或写锁不存在的条件下才能获取,写锁要在所有存在的读锁释放后才能获取)

Once

sync包提供一种安全的机制应对多个goroutine的并发初始化,即Once类型。多个线程可以执行[once.Do(f](once.Do(f))(这里f是一个函数),但是只有一个goroutine可以执行f(),而其他goroutine的调用会阻塞直到f()返回。

  • 来自[once.Do(f](once.Do(f))的单次对f()调用happens before任何[once.Do(f](once.Do(f))调用的返回

varastring

varoncesync.Once

funcsetup(){

a=\"hello,world\"

}

funcdoprint(){

[once.Do(setup](once.Do(setup))

print(a)

}

functwoprint(){

godoprint()

godoprint()

}

这个程序调用twoprint将会只调用setup一次。setup函数将会在任何print调用前完成。程序的结果是\”hello, world\”将会被打印两次。

不正确的同步

注意一个读r可能观测到一个和r并发写w产生的值。即使这种情况发生了,也不能说明happens after r的读将会观测到happens before w的写。


vara,bint

funcf(){

a=1

b=2

}

funcg(){

print(b)

print(a)

}

funcmain(){

gof()

g()

}

这个程序里可能发生g打印2然后打印0 。

这个事实让一些常见模式无效。

Double Check是一种避免同步开销的方式。举个栗子,下面这个twoprint程序的行为可能不正确:


varastring

vardonebool

funcsetup(){

a=\"hello,world\"

done=true

}

funcdoprint(){

if!done{

[once.Do(setup](once.Do(setup))

}

print(a)

}

functwoprint(){

godoprint()

godoprint()

}

不能保证观测到done的写入意味着可以观测到a的写入。这个版本可能错误地打印出一个空字符串而不是\”hello, world\”。

笔记:once只能保证once和f()的happens before关系,但是不能保证once的f()和其他非once的happens before关系)

另外一个不正确的模式是在一个值上的忙等待(busy waiting)


varastring

vardonebool

funcsetup(){

a=\"hello,world\"

done=true

}

funcmain(){

gosetup()

for!done{

}

print(a)

}

和前一个一样,在main函数里,不能保证观测到done的写入意味着可以观测到a的写入,所以这个程序可能也打印一个空字符串。更糟糕的是,main函数观测到对done的写入也是不能保证的,因为在两个线程之间没有同步。main函数的循环不能保证结束。

对这种情形有一种微妙的变式


typeTstruct{

msgstring

}

varg*T

funcsetup(){

t:=new(T)

t.msg=\"hello,world\"

g=t

}

funcmain(){

gosetup()

forg==nil{

}

print(g.msg)

}

即使main观测到 g != nil然后退出循环,也不能保证它会观测到g.msg的初始化的值。

对于所有这些例子,解决方案都是相同的:使用显式的同步。

后记

go内存模型规范里其实缺失了很重要的一项,对原子操作的规定,在Java里原子操作是可以保证memory order,golang的当前编译器实现也是保证了memory order。不过如果想编写跨编译器,跨编译器版本的兼容代码,安全的建议是在一般go程序中不要依赖原子操作来保证memory order。

引申阅读:

[1].https://go101.org/article/memory-model.html

[2].https://github.com/golang/go/issues/5045

在看多了,优秀的小天同学可能还会给我们出下篇~

本文分享自微信公众号 – 码农知识点(gh_261b51b112fe)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

赞(0) 打赏
未经允许不得转载:爱站程序员基地 » Golang的内存模型