在本教程中,我们将了解Mutex(互斥锁)。我们还将学习如何使用Mutex信道解决竞争问题。

临界区#

在学习Mutex之前,了解并发编程中关键概念非常重要。当程序并发运行时,多个协程不应该同时访问修改共享资源的代码。修改共享资源的这段代码称为临界区。例如,假设我们有一段代码,将一个变量 x 自增 1

x = x + 1  

如果只有一个协程访问上述代码,那么没有任何问题。

让我们来看看为什么在多个协程并发运行时代码会出问题。为简单起见,我们假设有2个协程会并发运行上面的代码。

实际上,x=x+1在系统底层是有多个步骤来完成的。有更多技术细节涉及到寄存器,以及加法的工作原理等。在这里我们简单归纳为三个步骤:

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

当这三个步骤仅由一个协程执行时,一切都很顺利。

让我们讨论一下,当2个协程并发运行此代码时会发生什么。下图描绘了两个协程同时访问代码x = x + 1时可能发生的情况。
 
我们假设x的初始值为0,协程1先获取初始值x,然后计算x+1。在将计算结果赋值给x之前,系统调度协程2执行。现在协程2获得x的初始值仍然时0,计算x+1.然后系统调度协程1执行将计算结果1赋值给x,因此x变为1,之后协程2执行,同样将计算结果1赋值给x。因此在两个协程执行结束后x最终的值是1.

现在我们考虑另外一种可能发生的情况。
 
在上面场景中,协程1先执行而且一次性执行完三个步骤,因此x的值变成1.之后协程2开始执行,这个时候协程2获取的x的初始值变成1,当协程2执行结束,x的值变成2.

从上述两个例子,我们可以看到最终x的值是1或者2取决于上下文如何切换。程序结果无法预测,结果取决于协程的调度顺序,像这种情况,我们称之为竞争条件

上述场景,只有一个协程在同一时刻访问临界区资源,竞争条件是不会发生的。我们可以使用Mutex来解决竞争条件。

Mutex#

Mutex通常被用来提供一个锁定机制去确保在任何时刻只有一个协程访问临界区资源代码,避免发生竞争条件的情况。Mutex属于 sync包。Mutex定义了两种方法:LockUnlock。任何在LockUnlock之间的代码只能被一个协程执行,从而避免竞争条件发生。例如下面代码x=x+1,同一时刻只会被一个协程调用。

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

在上面代码中,x=x+1在任意时刻只能被一个协程访问执行。从而防止竞争条件发生。

如果已经有一个协程持有互斥锁,这个时候另外一个协程尝试去请求该互斥锁,该协程会一直阻塞直到原协程释放互斥锁。

竞争条件的程序#

在本节中,我们将编写一个具有竞争条件的程序,在接下来的部分中我们将修复竞争条件。之后的课程将会解决这个竞争问题。

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,然后重新赋值给x,最后并调用 WaitGroupDone(),通知该函数已结束。

在上述程序的第 15 行,我们创建了 1000increment 协程。这些协程并发运行,并且在第 8 行试图增加 x 的值,当多个并发的协程同时访问 x 的值,就会发生竞争条件。

请在你的本地机器上多运行几次,由于竞态条件,每一次输出可能都不同。我遇到的几次输出是 final value of x 941final value of x 928final value of x 922 等。

使用Mutex解决竞争条件#

在上面的程序中,我们产生了1000个协程。如果每个协程都将x的值递增1,则x的最终期望值应为1000.在本节中,我们将使用Mutex修复上述程序中的竞争条件。

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,其值为零值。在上述程序里,我们修改了 increment 函数,将增加 x 的代码(x = x + 1)放置在 m.Lock()m.Unlock()之间。现在这段代码不存在竞态条件了,因为任何时刻都只允许一个协程执行这段代码。

现在如果运行该程序,它将输出

final value of x 1000

在第18 行,传递 Mutex 的地址很重要。如果传递的是 Mutex 的值,而非地址,那么每个协程都会得到 Mutex 的一份拷贝,竞态条件还是会发生。

使用信道解决竞争条件#

我们也可以使用信道解决竞争条件。让我们看看这是如何完成的。

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 的缓冲信道,并在第 18 行将它传入 increment 协程。该缓冲信道用于保证只有一个协程访问增加 x 的临界区。原理是:方法是在 x 增加之前(第 8 行),传入 true 给缓冲信道。由于缓冲信道的容量为 1,其他协程试图往通道写数据时被阻塞,当 x 增加后,信道的值被读取(第 10 行)。实际上这就保证了只允许一个协程访问临界区。

该程序也输出:

final value of x 1000

Mutex vs 信道#

我们可以通过Mutex信道 解决竞争条件的问题,那么我们如何决定使用哪一种方案?答案在于你要解决的问题。如果你尝试解决的问题更适合Mutex,那么就使用Mutex。如果问题更适合使用信道解决,那么就使用信道,而不要使用Mutex

大多数新手喜欢使用信道来解决所有的并发问题,因为这种方法看起来更酷一些,这是错误的,语言给了我们使用Mutex信道的选择,所以无论选择哪个都是没错的。

通常来讲,使用协程需要互相通信时那就选择信道,如果只是需要访问临界区资源代码的话就推荐使用Mutex

我的建议是选择解决问题的工具,而不要试图让问题去适应工具。