goroutine执行顺序

上一篇说到,goroutine是程序执行过程中的一个独立的单元,当程序中只有一个goroutine时,程序逻辑按照代码的顺序来执行,但是当程序中同时运行多个goroutine时, 逻辑的执行顺序是不确定的,比如下面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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通常是偶现且难以排查的。以下是一个简化的卖票系统的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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,这种方式叫做加锁,其中最暴力的一种叫做互斥锁,下面是声明一个互斥锁的方法:

1
var mu sync.Mutex

互斥锁有Lock方法和Unlock方法,两个方法调用之间的代码称为“临界区”,当调用Lock方法时,当前的goroutine就获得了锁,临界区中的代码只能被获得了锁 的goroutine执行,这就保证了即使发生调度,比如我们使用代码runtime.Gosched()主动让出CPU,Golang也只能再次把CPU分配给我们获得锁的这个goroutine。上代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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

这里我们用LockUnlock方法包裹单次卖票的逻辑,这样即使判断完有没有票后主动交出CPU,程序也不会出错。此时我们的sell方法就是线程安全的。 原则上来讲,如果一个方法我们声明为导出的,那么它应当被设计成线程安全的,因为我们不知道用户会以怎样的方式使用它。

当然这样的枷锁方式虽然可以解决竞态的问题,但是也会导致执行效率下降,极端的情况,如果整个方法都在临界区内,那么这个方法相当于只能同步执行,因为即使创建多个goroutine 去执行,同一时间也只能有一个在执行。

BGM