互斥锁(Mutex)

本节我们主要介绍下互斥锁,然后看下如果使用互斥锁管道来解决竞态问题。

临界区 & 竞态问题

在讨论互斥锁之前,我们首先需要弄明白临界区的概念。临界区属于并发编程的范畴,指的是不同goroutine中访问同一个临界资源的那段代码,所谓临界资源是指被所有goroutine共享的资源。这就好比多个人同时去争抢一个苹果,肯定会有冲突问题。我们通过一个经典例子来理解下临界区的概念。下面的这行代码很简单,将一个变量递增1。

x = x + 1

如果这行代码自始自终仅在一个协程内执行,那么不会有任何的冲突问题。

但是假设有多个并发的协程都会执行该行代码,那么程序逻辑就有可能出现问题。为了简单起见,我们仅考虑2个并发的协程都在执行该行代码。

前面的这行代码在计算机内部其实是拆成了如下几个步骤执行的(真实情况会更加复杂,这里为了说明问题简化为3步)。

  1. 获取x变量当前值

  2. 计算x + 1

  3. 将计算结果赋值给变量x

如果这3步仅在一个协程内执行,那么不会有什么逻辑问题,但是一旦涉及到并发编程的情况就会更加复杂。现在我们看下如果有2个并发的协程在同时执行前面的这3步会发生什么情况。

  1. 我们假设变量x的初始值为0。

  2. 系统先调度协程1,获取x的值,并计算出x + 1

  3. 系统再调度协程2,同样的获取x的值,并计算x + 1,但是需要注意,此刻获取到的x仍然是0。

  4. 系统再切换到协程1,将计算结果1赋值给变量x,现在x变为1。

  5. 系统再次切换到协程2,将协程2内部的计算结果1赋值给变量x,这时x仍然是1。

  6. 最终虽然每个协程都调用了x = x + 1,但是变量x的值为1。

这中调度只是其中一种可能的方式,下面我们再看另外一种可能的调度顺序。

  1. 我们假设变量x的初始值为0。

  2. 系统先调度协程1,获取x的值,并计算出x + 1,将计算结果1赋值给变量x,此时变量x值为1。

  3. 系统再调度协程2,获取x的值,这时获取到的是1,并计算x + 1,计算结果为2,接着将计算结果2赋值给变量x,此时变量x值为2。

  4. 最终变量x的值为2。

这两种不同的执行顺序都有可能出现,因此程序输出结果也就不稳定,会引起bug。我们一般称这种情况为竞态

存在竞态的根本原因是临界区的代码被并发的协程同时访问。因此如果我们能够控制临界区的代码每次仅能被一个协程执行,就能避免竞态的发生。借助本节将要介绍的互斥锁便可以解决竞态问题。

互斥锁

互斥锁用来保证临界区的代码每次仅能被一个协程执行,以防止竞态问题发生。

Golang中互斥锁Mutex位于sync包中,Mutex上定义了两个方法,LockUnlock。所有位于LockUnlock之间的代码每次仅能被一个协程执行,即有效避免了竞态问题。

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)
}

这里通过函数increment来创建协程,我们一共创建了1000个并发协程,在每个协程内部执行x = x + 1,显然这里的x是个共享资源,存在竞态问题。该程序执行时的输出是不稳定的,比如我本机的输出为:

final value of x 919

由于竞态问题的存在,因此执行结果不是1000。

使用互斥锁解决竞态问题

前面的程序中我们创建了1000个协程,在每个协程内将变量x递增1,因此我们预期的是输出1000。下面用互斥锁来保证逻辑的正确:

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是个结构体类型,这里我们声明了一个互斥锁类型变量m。然后在临界区代码x = x + 1前后加了m.Lock()以及m.Unlock()两行代码,即加上了锁,这样便避免了多个协程访问共享资源的竞态问题。该程序输出如下:

final value of x 1000

这里需要注意我们是怎么使用互斥锁的。我们创建了一个互斥锁变量,然后将指针传给了所有协程。这样才能让所有协程去争抢一把锁,否则如果是值传递,所有协程都有一份独立的拷贝出来的锁,竞态问题依然会发生。

使用管道解决竞态问题

除了互斥锁之外,我们还可以通过管道来解决前面的竞态问题。

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。我们在临界区代码x = x + 1之前增加了一行代码ch <- true,该行代码写了1个数据到管道中,由于管道容量为1,因此其他协程如果也想写数据到该管道中,就会阻塞,直到管道中的这1个数据被读走。而只有临界区的代码执行完,我们才通过<-ch读走了管道中的数据,因此也就避免了竞态问题。该程序输出为:

final value of x 1000

互斥锁 vs 管道

前面互斥锁和管道两种方案都可以解决竞态问题,那么怎么判断应该用哪个方案呢?这还得看具体的问题场景是什么样的。如果需要通过管道来传递一些有意义的数据,那就最好使用管道的方案,如果仅仅是为了解决竞态问题,比如前面的例子,那就最好使用互斥锁。既然Golang提供了这几种不同的工具,我们就要对症下药,选择最合适的解决方案。

Last updated