写在前面的话
数据竞态:当存在两个或以上的goroutine,对一个数据,同时进行读写操作时(非原子操作),我们称之为该数据存在竟态。在竟态条件下,读者可能读到非一致性的数据:比如一个结构体数据,goroutine1 正在读v1版的数据;此时goroutine2 进行数据更新到v2;导致goroutine1读取到的数据部分是v1版的数据,部分是v2版的数据。
当多个goroutine操作存在竟态的数据时,必须将并发操作进行顺序化,避免出现数据不一致。在golang中可以实现操作顺序化的方法有:基于通道(channel)通信,使用同步原语(如sync包中的Mutex、RWMutex等),使用原子操作(sync/atomic)等。
另一方面,Golang内存模型,定义了一些限制条件,当程序遵循这些限制条件时,就可以实现数据免竟态。这些限制条件被称之为“Hanpens Before”规则。我们将在下一章重点说明。
警告:
理解和掌握golang 的 Hanpens Before规则,有助于我们了解哪些情况下,数据的可见性是有保障,而哪些情况下是不确定的的,从而避免跌入数据不可见的迷障。另一方面,我们在设计和实现程序时,不要过度依赖golang的Hanpens Before规则,而使程序变得晦涩难懂。
Hanpens Before (先行发生)
理解Hanpens Before
为了提高程序执行时的效率,编译器和处理器常常会对指令做重排序。重排序分三种类型:
(1)编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
(2)指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
(3)内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
在一个goroutine中,读和写必须按照程序指定的顺序执行。也就是说,只有当指令重排没有改变既定的代码的逻辑顺序时,编译器和处理器才可以重新排序在单个goroutine中执行的读写操作。反过来讲,只要程序的既定逻辑没有被改变,那么编译器和处理器就可以按需要对指令进行重新排序。由于这种重新排序,一个goroutine观察到的执行顺序可能与另一个goroutine感知到的执行顺序不同。例如:
有全局变量a 和b,在goroutine1 中有如下代码:
// in goroutine1
a = 10 // action 1
b = 1 // action 2
c = a + b // action 3
fmt.Println(c) // action 4
同时,在goroutine2 中,读取a和b的内容:
// in goroutine2
for {
if b == 1 {
fmt.Println(a)
break
}
}
按程序表达,goroutine2中,当 b == 1 达成时,a应该已经被赋值了,程序期望输出 a的值是10。 但实际运行时,却发现输出a 的值有时是10,有时是0。 这是因为,在goroutine1 中,a和b的赋值操作,并没有逻辑依赖关系,所以编译器和处理器可以对该两条指令进行重新排序,导致实际b=1 可能先于a =10 被执行;此时goroutine2中,看到b==1 已经达成,但此时a却依旧保持初始值0(这里有两种情况会导致看见a==0,其一,goroutine1 尚未执行a=10;其二,goroutine1执行了a=10,但由于CPU cache 缓存,goroutine2 并未取到更新后的a的值)。
这种场景下,我们说:
“action 1 Hanpens Before action 2 条件不必要”。
当 Hanpens Before 条件不满足,且没有额外引入同步机制确保操作顺序时,程序就可能出现未知状态。例如一个经典范式:
var(
inited = false
config *Config
)
func doInit() {
c := loadConfig()
if c == nil {
panic("load config error")
}
config =c
inited = true
}
func main() {
go doInit()
for !inited {
// wait init done
}
doWithConfig(config)
}
上面的例子,是一个经典的异步初始化的模型范例,甚至在一些生产代码也可以见到同样的应用。但是,上面的程序,有可能出现在main流程中,等待初始化成功后,执行后面操作时,却得到config是一个空指针而引发程序崩溃。
继续回到本节开篇的例子,在goroutine1 中对 a,b,c 进行赋值,现在有goroutine3 ,代码如下:
// in goroutine 3
for {
if c == 11 {
fmt.Println(a)
break
}
}
运行程序发现,goroutine3 打印a 的值是明确的,都是10。这是因为,在goroutine1 中, c= a+b 要求在对c 赋值之前,a 和b 必须要完成赋值。即:
action 1 和action 2 Hanpens Before action 3 条件必要
总结
Hanpens Before 描述了两个行为的明确的先后关系: actionA 先于 actionB 发生。这种关系,通常是有一些基础策略,逻辑依赖,实现原理所决定的。比如,golang中,程序启动的流程为:
(1)从main 包开始执行,先引入所依赖的包;所述依赖包还有依赖,则递归引入依赖;
(2)引入依赖包后,执行该依赖包的init函数(如果有);
(3)从main 包的main函数开始执行。
通过以上流程,不难得出:
(1)加载程序所有依赖包 Hanpens Before main()函数开始执行;
(2)依赖包的init函数执行完毕 Hanpens Before main() 函数开始执行;
(3)在加载过程中,软件包完成其依赖包的加载 Hanpens Before 该软件包init()函数开始执行。
另一方面,当两个行为或过程满足 Hanpens Before 的必要条件时,说明他们的执行顺序是明确有序的,它们就不会出现数据静态,无需额外的同步机制。
下面我们将详细说明,golang中那些过程是满足Hanpens Before 条件的。
golang 中符合Hanpens Before的过程组合
Go的初始化
程序初始化运行在单个goroutine(主协程)中,并且该goroutine能够创立其余并发运行的goroutine。
- 如果包p导入了包q,则q包init函数执行完结 先于 p包init函数的执行。
- main函数的执行发生在所有init函数执行完成之后。
goroutine的创建和退出
- goroutine的创建先于goroutine的执行。
- (注意)goroutine的退出不与程序中的任何过程形成Hanpens Before必要条件;即goroutine的退出是无法预测的。如果用一个goroutine察看另一个goroutine的退出,请应用锁(waitgroup)或者Channel来保障绝对有序。
关于goroutine的创建 先于 goroutine 的执行,感觉上像句废话;个人理解是:创建goroutine 的语句(尤其是复合语句)的执行,一定先于goroutine开始执行之前。举例:
func delay( sec int) int {
time.Sleep(time.Second * sec)
return sec
}
go fmt.Println(delay(1))
上述代码执行时,创建goroutine的过程会因为调用delay 函数而阻塞 main goroutine 一秒钟。
channel的发送和接收
- 对channel 的send 动作开始 先于 对channel的接收完成
见下面的示例代码:
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world"
c <- 0
}
func main() {
go f()
<-c
print(a)
}
上面的程序 打印的输出一定是“heallo,world”。因为主协程是在读取到管道返回值后,才读取a的值;当管道能够读取到值时,写管道的动作一定先前发生了;f协程中,对a赋值先于写channel(这点是程序的检测点机制,不展开);因而可以推论出:对a的赋值 先于 对a的读取。因此该程序的读写流程是严格同步的。
- 对channel的关闭动作 先于 读端因为channel已关闭而读到一个零值
见下面的示例代码:
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world"
close(c)
}
func main() {
go f()
<-c
print(a)
}
上面的程序 打印的输出一定是“heallo,world”。因为读取端获取到返回值时,f协程的close动作一定先发生了。
- 在一个无缓存的channel上,接收完成 先于 发送完成
var c = make(chan int)
var a string
func f() {
a = "hello, world"
<-c
}
func main() {
go f()
c <- 0
print(a)
}
- 容量为C的channel,第k个接收完成 先于 第k +C个发送完成前
本质上,是带缓冲的channel当buffer满后,发生端会阻塞等待一个buffer空闲后、完成写入后返回。应用该原理,一个限制并发数的调度队列可以简单实现为:
var limit = make(chan int, 3)
func scheduleWorker(w interface{}) {
limit <- 1
w()
<-limit
}
func main() {
for _, w := range work {
go scheduleWorker(w)
}
select{}
}
上面代码, scheduleWorker中,worker 被调度后,先尝试往channel中写入一个标记;如果此时buffer 未满则可以顺利写入(类似于获取令牌),程序继续执行真正的worker任务;相反,如果channel的buffer已满(已有三个任务正在执行),那么当前任务会阻塞在channel的写入上,直到其他任务执行完毕后,从channel中读取一个数据(类似于补充令牌),触发写端继续写入。
锁
- 对于任何sync.Mutex或sync.RWMutex变量l以及n < m,第n次l.Unlock()的调用先于第m次l.Lock()的调用返回。
见下面示例代码:
var l sync.Mutex
var a string
func f() {
a = "hello, world"
l.Unlock()
}
func main() {
l.Lock() // 第一次锁
go f() // 执行一次解锁
l.Lock() // 第二次锁
print(a)
}
上面程序输出a 的值“hello, world”。主协程中,第一次获取锁成功,当第二次再尝试获取锁时,程序被阻塞,直到锁状态被Unlock解除。这样,a的写入和读取是严格有序的。
- 对于读写锁l(sync.RWMutex),当有写锁存在时,任意的读加锁(l.RLock)返回 在 所有写锁解锁(l.Unlock)完成之后
- 对于读写锁l(sync.RWMutex), 当有读锁存在时,任意的写加锁(l.Lock)返回 在 所有的读锁解锁(l.RUnlock)完成之后
总结读写锁的持锁规则:
(1)当存在读加锁(l.Rlock)时,新的读加锁无需等待之前的读锁解锁,即可并行持锁(相较与互斥锁的主要改进);
(2)当存在读加锁(l.Rlock)时,写锁(l.Lock)必须等待所有的读锁解锁(l.RUnlock)完成后,方能持锁;
(3)当存在写加锁(l.Lock)时,读锁(l.Rlock)必须等待所有的写锁解锁(l.Unlock)完成后,方能持锁;
(4)当存在写加锁(l.Lock)时,写锁(l.Lock)必须等待所有的写锁解锁(l.Unlock)完成后,方能持锁。
Once
- 使用once进行初始化,once.do(f)的返回,一定在 f函数执行完成之后。
once本质上是一个封装的单例模式,内部采用了互斥锁,确保多个goroutine 并发调用once.do进行初始化,其中只有一个协程可以获取到执行f的权限;其余协程都等待f执行完成后方可以返回。
原子操作
- sync/atomic中提供的API 可以在并发协程中调用而保持严格的可见性。
Finalizers
- 通过runtime包对实例设置 finalizers函数,SetFinalizer(x, f) 完成 先于 f 函数的执
这个与创建协程的逻辑一致。