互斥锁(Mutex)
Last updated
Was this helpful?
Last updated
Was this helpful?
本节我们主要介绍下互斥锁
,然后看下如果使用互斥锁
和管道
来解决竞态问题。
在讨论互斥锁之前,我们首先需要弄明白临界区
的概念。临界区属于并发编程的范畴,指的是不同goroutine
中访问同一个临界资源的那段代码,所谓临界资源是指被所有goroutine
共享的资源。这就好比多个人同时去争抢一个苹果,肯定会有冲突问题。我们通过一个经典例子来理解下临界区的概念。下面的这行代码很简单,将一个变量递增1。
如果这行代码自始自终仅在一个协程内执行,那么不会有任何的冲突问题。
但是假设有多个并发的协程都会执行该行代码,那么程序逻辑就有可能出现问题。为了简单起见,我们仅考虑2个并发的协程都在执行该行代码。
前面的这行代码在计算机内部其实是拆成了如下几个步骤执行的(真实情况会更加复杂,这里为了说明问题简化为3步)。
获取x
变量当前值
计算x + 1
将计算结果赋值给变量x
如果这3步仅在一个协程内执行,那么不会有什么逻辑问题,但是一旦涉及到并发编程的情况就会更加复杂。现在我们看下如果有2个并发的协程在同时执行前面的这3步会发生什么情况。
我们假设变量x
的初始值为0。
系统先调度协程1,获取x
的值,并计算出x + 1
。
系统再调度协程2,同样的获取x
的值,并计算x + 1
,但是需要注意,此刻获取到的x
仍然是0。
系统再切换到协程1,将计算结果1赋值给变量x
,现在x
变为1。
系统再次切换到协程2,将协程2内部的计算结果1赋值给变量x
,这时x
仍然是1。
最终虽然每个协程都调用了x = x + 1
,但是变量x
的值为1。
这中调度只是其中一种可能的方式,下面我们再看另外一种可能的调度顺序。
我们假设变量x
的初始值为0。
系统先调度协程1,获取x
的值,并计算出x + 1
,将计算结果1赋值给变量x
,此时变量x
值为1。
系统再调度协程2,获取x
的值,这时获取到的是1,并计算x + 1
,计算结果为2,接着将计算结果2赋值给变量x
,此时变量x
值为2。
最终变量x
的值为2。
这两种不同的执行顺序都有可能出现,因此程序输出结果也就不稳定,会引起bug。我们一般称这种情况为竞态
。
存在竞态的根本原因是临界区的代码被并发的协程同时访问。因此如果我们能够控制临界区的代码每次仅能被一个协程执行,就能避免竞态的发生。借助本节将要介绍的互斥锁
便可以解决竞态问题。
互斥锁用来保证临界区的代码每次仅能被一个协程执行,以防止竞态问题发生。
Golang中互斥锁Mutex
位于sync
包中,Mutex
上定义了两个方法,Lock
和Unlock
。所有位于Lock
和Unlock
之间的代码每次仅能被一个协程执行,即有效避免了竞态问题。
这里x = x + 1
每次仅能被一个协程执行,有效避免了竞态问题。
如果某个协程已经先获取到了锁,另外的协程尝试获取该锁时会阻塞,知道锁被释放。
这里我们先来看一个存在竞态问题的例子,然后通过互斥锁来解决该竞态问题。
这里通过函数increment
来创建协程,我们一共创建了1000个并发协程,在每个协程内部执行x = x + 1
,显然这里的x
是个共享资源,存在竞态问题。该程序执行时的输出是不稳定的,比如我本机的输出为:
由于竞态问题的存在,因此执行结果不是1000。
前面的程序中我们创建了1000个协程,在每个协程内将变量x
递增1,因此我们预期的是输出1000。下面用互斥锁来保证逻辑的正确:
Mutex
是个结构体类型,这里我们声明了一个互斥锁类型变量m
。然后在临界区代码x = x + 1
前后加了m.Lock()
以及m.Unlock()
两行代码,即加上了锁,这样便避免了多个协程访问共享资源的竞态问题。该程序输出如下:
这里需要注意我们是怎么使用互斥锁的。我们创建了一个互斥锁变量,然后将指针传给了所有协程。这样才能让所有协程去争抢一把锁,否则如果是值传递,所有协程都有一份独立的拷贝出来的锁,竞态问题依然会发生。
除了互斥锁之外,我们还可以通过管道来解决前面的竞态问题。
这里我们创建了一个带缓存的管道,容量为1。我们在临界区代码x = x + 1
之前增加了一行代码ch <- true
,该行代码写了1个数据到管道中,由于管道容量为1,因此其他协程如果也想写数据到该管道中,就会阻塞,直到管道中的这1个数据被读走。而只有临界区的代码执行完,我们才通过<-ch
读走了管道中的数据,因此也就避免了竞态问题。该程序输出为:
前面互斥锁和管道两种方案都可以解决竞态问题,那么怎么判断应该用哪个方案呢?这还得看具体的问题场景是什么样的。如果需要通过管道来传递一些有意义的数据,那就最好使用管道的方案,如果仅仅是为了解决竞态问题,比如前面的例子,那就最好使用互斥锁。既然Golang提供了这几种不同的工具,我们就要对症下药,选择最合适的解决方案。