Golang基础篇-竞态
文章目录
goroutine执行顺序
上一篇说到,goroutine是程序执行过程中的一个独立的单元,当程序中只有一个goroutine时,程序逻辑按照代码的顺序来执行,但是当程序中同时运行多个goroutine时, 逻辑的执行顺序是不确定的,比如下面的例子:
|
|
上面的代码中,我们开启了10个goroutine,但是实际运行结果则是不同的goroutine穿插执行。如果这些goroutine之间没有关联,那么穿插执行并不会导致什么问题,但是如果它们之间有关联,比如需要访问同一块内存, 则程序的执行结果可能会依赖各个goroutine的执行顺序,这种情况称之为竞态。
竞态
竞态可能会导致很严重的bug,更为严重的是,这些bug通常是偶现且难以排查的。以下是一个简化的卖票系统的例子:
|
|
在这个例子中,我们启动了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,这种方式叫做加锁,其中最暴力的一种叫做互斥锁,下面是声明一个互斥锁的方法:
|
|
互斥锁有Lock
方法和Unlock
方法,两个方法调用之间的代码称为“临界区”,当调用Lock
方法时,当前的goroutine就获得了锁,临界区中的代码只能被获得了锁
的goroutine执行,这就保证了即使发生调度,比如我们使用代码runtime.Gosched()
主动让出CPU,Golang也只能再次把CPU分配给我们获得锁的这个goroutine。上代码:
|
|
这里我们用Lock
和Unlock
方法包裹单次卖票的逻辑,这样即使判断完有没有票后主动交出CPU,程序也不会出错。此时我们的sell
方法就是线程安全的。
原则上来讲,如果一个方法我们声明为导出的,那么它应当被设计成线程安全的,因为我们不知道用户会以怎样的方式使用它。
当然这样的枷锁方式虽然可以解决竞态的问题,但是也会导致执行效率下降,极端的情况,如果整个方法都在临界区内,那么这个方法相当于只能同步执行,因为即使创建多个goroutine 去执行,同一时间也只能有一个在执行。