goroutine执行顺序
上一篇说到,goroutine是程序执行过程中的一个独立的单元,当程序中只有一个goroutine时,程序逻辑按照代码的顺序来执行,但是当程序中同时运行多个goroutine时, 逻辑的执行顺序是不确定的,比如下面的例子:
func main() {
for i := 0; i < 10; i++ {
go display(i)
}
time.Sleep(time.Second)
}
func display(n int) {
fmt.Printf("Number %d First\n", n)
fmt.Printf("Number %d Second\n", n)
}
/*output:
Number 9 First
Number 6 First
Number 5 First
Number 5 Second
Number 4 First
Number 4 Second
Number 0 First
Number 0 Second
Number 9 Second
Number 8 First
Number 8 Second
Number 6 Second
Number 3 First
Number 3 Second
Number 7 First
Number 7 Second
Number 2 First
Number 2 Second
Number 1 First
Number 1 Second
*/
上面的代码中,我们开启了10个goroutine,但是实际运行结果则是不同的goroutine穿插执行。如果这些goroutine之间没有关联,那么穿插执行并不会导致什么问题,但是如果它们之间有关联,比如需要访问同一块内存, 则程序的执行结果可能会依赖各个goroutine的执行顺序,这种情况称之为竞态。
竞态
竞态可能会导致很严重的bug,更为严重的是,这些bug通常是偶现且难以排查的。以下是一个简化的卖票系统的例子:
var tickes int
func main() {
tickes = 1000
for idx := 0; idx < 10; idx++ {
go sell()
}
time.Sleep(time.Second)
fmt.Printf("current tickets:%d\n", tickes)
}
func sell() {
for {
if tickes > 0 {
runtime.Gosched() //让出执行时间
tickes--
} else {
break
}
}
}
//current tickets:-5
在这个例子中,我们启动了10个goroutine来卖票,每个goroutine判断当前是否还有票,如果有,则卖出一张。但是每次卖票之前,使用runtime.Gosched()故意
让出CPU的使用权,此时Golang会选择一个其他goroutine执行,这就导致:某个goroutine判断tickets > 0的时候是成功的,但是当它执行tickets--的时候,
tickets已经不是原来的数目了。
这里的runtime.Gosched()不是必须的,即使goroutine没有主动让出CPU,Golang在调度的时候也可能会在这个位置将CPU交给其他goroutine,这里只是为了让错误更容易出现。
输出结果也未必一定是-5,而是要看当时具体的调度情况。
回头看sell这个方法,当我们直接调用这个方法时,程序不会有任何问题,但是当我们使用go sell()创建goroutine执行这个方法时,程序则可能出现错误的情况。
这种情况下我们称这个方法“不是线程安全的”,反之如果我们通过goroutine,尤其是多个gouroutine执行这个方法,结果仍然不可能出错,那就称为“线程安全的”。
互斥锁
要解决竞态问题,一个最直接的想法就是不让goroutine在容易出错的位置交出CPU,这种方式叫做加锁,其中最暴力的一种叫做互斥锁,下面是声明一个互斥锁的方法:
var mu sync.Mutex
互斥锁有Lock方法和Unlock方法,两个方法调用之间的代码称为“临界区”,当调用Lock方法时,当前的goroutine就获得了锁,临界区中的代码只能被获得了锁
的goroutine执行,这就保证了即使发生调度,比如我们使用代码runtime.Gosched()主动让出CPU,Golang也只能再次把CPU分配给我们获得锁的这个goroutine。上代码:
var tickes int
var mu sync.Mutex
func main() {
tickes = 1000
for idx := 0; idx < 10; idx++ {
go sell(idx)
}
time.Sleep(time.Second)
fmt.Printf("current tickets:%d\n", tickes)
}
func sell(n int) {
for {
mu.Lock()//加锁,临界区开始
if tickes > 0 {
runtime.Gosched() //让出执行时间
tickes--
} else {
break
}
mu.Unlock()//解锁,临界区结束
}
}
//current tickets:0
这里我们用Lock和Unlock方法包裹单次卖票的逻辑,这样即使判断完有没有票后主动交出CPU,程序也不会出错。此时我们的sell方法就是线程安全的。
原则上来讲,如果一个方法我们声明为导出的,那么它应当被设计成线程安全的,因为我们不知道用户会以怎样的方式使用它。
当然这样的枷锁方式虽然可以解决竞态的问题,但是也会导致执行效率下降,极端的情况,如果整个方法都在临界区内,那么这个方法相当于只能同步执行,因为即使创建多个goroutine 去执行,同一时间也只能有一个在执行。
BGM
{#{% music id=“450048033” auto=“1” %}#}