在本教程中,我们将学习关于互斥的知识。我们还将学习如何使用互斥器和通道来解决竞争条件。
关键部分
在学习mutex之前,了解并发编程中的关键部分的概念是很重要的。当一个程序并发运行时,修改共享资源的代码部分不应该被多个Goroutine同时访问。这个修改共享资源的代码部分被称为关键部分(critical section)。例如,我们假设有一段代码是将一个变量x增加1的。
1 | x = x + 1 |
只要上述代码是由一个Goroutine访问的,就不应该有任何问题。
让我们看看为什么当有多个Goroutine同时运行时,这段代码会失败。为了简单起见,我们假设有两个Goroutine同时运行上面这行代码。
在内部,上述这行代码将在以下步骤中被系统执行(还有更多的技术细节涉及到寄存器,加法如何工作等等,但为了本教程,让我们假设这就是三个步骤)。
- 获取x的当前值
- 计算x+1
- 将步骤2中的计算值分配给x
当这三个步骤只由一个Goroutine执行时,一切都很好。
让我们来讨论一下当2个Goroutine同时运行这段代码时会发生什么。下面的图片描述了当两个Goroutine同时访问这行代码x = x + 1
时可能发生的一种情况。
我们假设x的初始值为0。Goroutine 1得到了x的初始值,计算了x + 1
,在它可以将计算值分配给x之前,系统上下文切换到Goroutine 2
。现在Goroutine 2
得到x
的初始值,仍然是0
,计算x+1
。在这之后,系统上下文再次切换到Goroutine 1
。现在Goroutine 1
将其计算值1分配给x,因此x变成了1。然后,Goroutine 2再次开始执行,然后把它的计算值,也就是1
分配给x
,因此在两个Goroutine执行后,x
是1
。
现在让我们来看看可能发生的另一种情况。
在上述情况下,Goroutine 1开始执行并完成了所有的三个步骤,因此x的值成为1。然后,Goroutine 2
开始执行。现在x
的值是1
,当Goroutine 2
执行完毕,x
的值是2
。
所以从这两个案例中,你可以看到x的最终值是1或2,这取决于上下文切换的方式。这种程序的输出取决于Goroutine的执行顺序的不理想情况被称为**竞争条件**(race condition)。
在上述情况下,如果在任何时候只允许一个Goroutine访问代码的关键部分,那么竞争条件是可以避免的。这可以通过使用Mutex来实现。
Mutex
Mutex被用来提供一个锁定机制,以确保在任何时候只有一个Goroutine在运行代码的关键部分,以防止竞争条件的发生。
Mutex在sync包中可用。在Mutex上定义了两种方法,即Lock和Unlock。在调用Lock
和Unlock
之间的任何代码将只由一个Goroutine执行,从而避免了竞争条件。
1 | mutex.Lock() |
在上面的代码中,x = x + 1
将在任何时间点上只被一个Goroutine执行,从而避免了竞争条件。
如果一个Goroutine已经持有锁,而一个新的Goroutine正试图获得锁,那么新的Goroutine将被阻止,直到mutex被解锁。
带有竞争条件的程序
在这一节中,我们将写一个有竞争条件的程序,在接下来的章节中,我们将修复这个竞争条件。
1 | package main |
在上面的程序中,第7行的increment
函数将x
的值增加了1
,然后调用WaitGroup的Done()
来通知其完成。
我们从第15行生成了1000个increment
Goroutines。每一个Goroutines都是同时运行的,当多个Goroutines试图同时访问x
的值使其加1
时,发生了竞争条件。
请在本地运行这个程序,因为playground是确定性的,竞争条件不会在playground上发生。在你的本地机器上多次运行这个程序,你可以看到,由于竞争条件,每次的输出都是不同的。我遇到的一些输出是final value of x 929
,final value of x 961
,final value of x 998
等等。
使用突变器解决竞争条件的问题
在上面的程序中,我们生成了1000个Goroutines。如果每个Goroutines都将x的值增加1,那么x的最终期望值应该是1000。在这一节中,我们将使用一个突变器来解决上面程序中的竞争条件。
1 | package main |
Mutex是一个结构类型,我们在第15行创建一个零值的Mutex
类型的变量m
。在上面的程序中的第15行,我们改变了increment
函数,使增加x = x + 1
的代码在m.Lock()
和m.Unlock()
之间。现在这段代码不存在任何竞争条件,因为在任何时候只有一个Goroutine被允许执行这段代码。
现在,如果这个程序被运行,它将输出:
1 | final value of x 1000 |
在第18行中传递mutex的地址是很重要的。如果mutex是通过值而不是通过地址传递的,每个Goroutine将有自己的mutex副本,竞争条件仍然会发生。
使用通道解决竞争条件
我们也可以用通道来解决这个竞争条件。让我们看看这是如何做到的。
1 | package main |
在上面的程序中,我们创建了一个容量为1
的缓冲通道(buffered cahnnel),它被传递给第18行的increment
Goroutine。这个缓冲通道被用来确保只有一个Goroutine访问增加x的关键部分的代码。这是通过在x
被递增之前向缓冲通道传递true
来实现的。由于缓冲通道的容量为1
,所有其他试图写到这个通道的Goroutine都被阻止,直到在第9行x增加1后从第10行的这个通道中读出数值。实际上,这只允许一个Goroutine访问关键部分。
这个程序还打印了
1 | final value of x 1000 |
互斥与通道
我们已经用mutexes
和channels
解决了竞争条件的问题。那么,我们如何决定什么时候使用呢?答案就在于你所要解决的问题。如果你想解决的问题更适合使用互斥器,那么就继续使用互斥器。如果需要的话,请不要犹豫,使用mutex。如果问题似乎更适合通道,那就使用它吧:)。
大多数Go新手试图用通道来解决每一个并发问题,因为它是语言的一个很酷的特性。这是不对的。这门语言让我们可以选择使用Mutex或者Channel,选择其中一种并没有错。
一般来说,当Goroutine需要相互通信时,使用通道;当只有一个Goroutine需要访问代码的关键部分时,使用Mutex。
在我们上面解决的问题中,我更倾向于使用mutex,因为这个问题不需要Goroutine之间的任何通信。因此,mutex将是一个非常棒的选择。
我的建议是,为问题选择工具,而不要试图让问题适应工具 :)
至此,本教程结束。祝你有个愉快的一天。