Mutex
Q7nl1s admin

在本教程中,我们将学习关于互斥的知识。我们还将学习如何使用互斥器和通道来解决竞争条件。

关键部分

在学习mutex之前,了解并发编程中的关键部分的概念是很重要的。当一个程序并发运行时,修改共享资源的代码部分不应该被多个Goroutine同时访问。这个修改共享资源的代码部分被称为关键部分(critical section)。例如,我们假设有一段代码是将一个变量x增加1的。

1
x = x + 1  

只要上述代码是由一个Goroutine访问的,就不应该有任何问题。

让我们看看为什么当有多个Goroutine同时运行时,这段代码会失败。为了简单起见,我们假设有两个Goroutine同时运行上面这行代码。

在内部,上述这行代码将在以下步骤中被系统执行(还有更多的技术细节涉及到寄存器,加法如何工作等等,但为了本教程,让我们假设这就是三个步骤)。

  1. 获取x的当前值
  2. 计算x+1
  3. 将步骤2中的计算值分配给x

当这三个步骤只由一个Goroutine执行时,一切都很好。

让我们来讨论一下当2个Goroutine同时运行这段代码时会发生什么。下面的图片描述了当两个Goroutine同时访问这行代码x = x + 1时可能发生的一种情况。

mutex_0

我们假设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执行后,x1

现在让我们来看看可能发生的另一种情况。

mutex_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上定义了两种方法,即LockUnlock。在调用LockUnlock之间的任何代码将只由一个Goroutine执行,从而避免了竞争条件。

1
2
3
mutex.Lock()  
x = x + 1
mutex.Unlock()

在上面的代码中,x = x + 1将在任何时间点上只被一个Goroutine执行,从而避免了竞争条件。

如果一个Goroutine已经持有锁,而一个新的Goroutine正试图获得锁,那么新的Goroutine将被阻止,直到mutex被解锁。

带有竞争条件的程序

在这一节中,我们将写一个有竞争条件的程序,在接下来的章节中,我们将修复这个竞争条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main  
import (
"fmt"
"sync"
)
var x = 0
func increment(wg *sync.WaitGroup) {
x = x + 1
wg.Done()
}
func main() {
var w sync.WaitGroup
for i := 0; i < 1000; i++ {
w.Add(1)
go increment(&w)
}
w.Wait()
fmt.Println("final value of x", x)
}

在上面的程序中,第7行的increment函数将x的值增加了1,然后调用WaitGroup的Done()来通知其完成。

我们从第15行生成了1000个increment Goroutines。每一个Goroutines都是同时运行的,当多个Goroutines试图同时访问x的值使其加1时,发生了竞争条件。

请在本地运行这个程序,因为playground是确定性的,竞争条件不会在playground上发生。在你的本地机器上多次运行这个程序,你可以看到,由于竞争条件,每次的输出都是不同的。我遇到的一些输出是final value of x 929final value of x 961final value of x 998等等。

使用突变器解决竞争条件的问题

在上面的程序中,我们生成了1000个Goroutines。如果每个Goroutines都将x的值增加1,那么x的最终期望值应该是1000。在这一节中,我们将使用一个突变器来解决上面程序中的竞争条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main  
import (
"fmt"
"sync"
)
var x = 0
func increment(wg *sync.WaitGroup, m *sync.Mutex) {
m.Lock()
x = x + 1
m.Unlock()
wg.Done()
}
func main() {
var w sync.WaitGroup
var m sync.Mutex
for i := 0; i < 1000; i++ {
w.Add(1)
go increment(&w, &m)
}
w.Wait()
fmt.Println("final value of x", x)
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main  
import (
"fmt"
"sync"
)
var x = 0
func increment(wg *sync.WaitGroup, ch chan bool) {
ch <- true
x = x + 1
<- ch
wg.Done()
}
func main() {
var w sync.WaitGroup
ch := make(chan bool, 1)
for i := 0; i < 1000; i++ {
w.Add(1)
go increment(&w, ch)
}
w.Wait()
fmt.Println("final value of x", x)
}

在上面的程序中,我们创建了一个容量为1的缓冲通道(buffered cahnnel),它被传递给第18行的increment Goroutine。这个缓冲通道被用来确保只有一个Goroutine访问增加x的关键部分的代码。这是通过在x被递增之前向缓冲通道传递true来实现的。由于缓冲通道的容量为1,所有其他试图写到这个通道的Goroutine都被阻止,直到在第9行x增加1后从第10行的这个通道中读出数值。实际上,这只允许一个Goroutine访问关键部分。

这个程序还打印了

1
final value of x 1000  

互斥与通道

我们已经用mutexeschannels解决了竞争条件的问题。那么,我们如何决定什么时候使用呢?答案就在于你所要解决的问题。如果你想解决的问题更适合使用互斥器,那么就继续使用互斥器。如果需要的话,请不要犹豫,使用mutex。如果问题似乎更适合通道,那就使用它吧:)。

大多数Go新手试图用通道来解决每一个并发问题,因为它是语言的一个很酷的特性。这是不对的。这门语言让我们可以选择使用Mutex或者Channel,选择其中一种并没有错。

一般来说,当Goroutine需要相互通信时,使用通道;当只有一个Goroutine需要访问代码的关键部分时,使用Mutex。

在我们上面解决的问题中,我更倾向于使用mutex,因为这个问题不需要Goroutine之间的任何通信。因此,mutex将是一个非常棒的选择。

我的建议是,为问题选择工具,而不要试图让问题适应工具 :)

至此,本教程结束。祝你有个愉快的一天。

 Comments
Comment plugin failed to load
Loading comment plugin
Powered by Hexo & Theme Keep
Unique Visitor Page View