Go并发入门之Mutex

158 阅读6分钟

Go语言作为一种支持高效并发的编程语言,在处理并发任务时表现出色。在本文中,我们将重点介绍Go语言中的一种重要的同步机制——Mutex(互斥锁),以帮助您入门并理解如何正确地在并发代码中使用它。

0. 互斥锁及其实现机制

在并发编程中,互斥锁(Mutex)是一种重要的同步机制,用于保护共享资源,以避免多个线程或进程同时访问和修改造成的数据竞争和不一致性。互斥锁确保在同一时间只有一个线程可以进入临界区(访问共享资源),从而确保数据的正确性和一致性。

互斥锁有两个基本操作:锁定(Lock)和解锁(Unlock)。当一个线程想要访问共享资源时,它首先会尝试获取互斥锁。如果互斥锁当前没有被其他线程占用,该线程就会获得锁定,进入临界区执行操作;如果互斥锁已被其他线程占用,该线程就会被阻塞,直到锁被释放。

互斥锁就很好地解决了资源竞争问题,有人也把互斥锁叫做排它锁。那在 Go 标准库中,它提供了 Mutex 来实现互斥锁这个功能。

同步原语的适用场景:

  • 共享资源。当多个线程需要访问临界区(共享资源)时,互斥锁可以确保在同一时间只有一个线程进入临界区。

  • 任务编排。需要 goroutine 按照一定的规律执行,而 goroutine 之间有相互等待或者依赖的顺序关系,我们常常使用 WaitGroup 或者 Channel 来实现。

  • 消息传递。信息交流以及不同的 goroutine 之间的线程安全的数据交流,常常使用 Channel 来实现。

1. Mutex 的基本使用方法

在 Go 的标准库中,package sync 提供了锁相关的一系列同步原语,这个 package 还定义了一个 Locker 的接口,Mutex 就实现了这个接口。

Locker 的接口定义了锁同步原语的方法集:

type Locker interface {
    Lock()
    Unlock()
}

互斥锁 Mutex 只提供了两个方法 Lock 和 Unlock:进入临界区之前调用 Lock方法,退出临界区的时候调用 Unlock 方法:

func(m *Mutex)Lock()
func(m *Mutex)Unlock()

一旦一个 goroutine 通过调用 Lock 方法获取了锁的所有权,其他试图获取锁的 goroutine 将会在 Lock 方法调用处被阻塞,直到该锁被释放并且它自身获取到了锁的所有权。

并发访问场景中不使用锁的例子:

在这个例子中,我们创建了 10 个 goroutine,同时不断地对一个变量(count)进行加 1操作,每个 goroutine 负责执行 10 万次的加 1 操作,我们期望的最后计数的结果是 10 * 100000 = 1000000 (一百万)。

package main
import (
    "fmt"
    "sync"
)

func main() {
    var count = 0
    // 使用WaitGroup等待10个goroutine完成
    var wg sync.WaitGroup
    wg.Add(10)
    for i := 0; i < 10; i++ {
       go func() {
          defer wg.Done()
          // 对变量count执行10次加1
          for j := 0; j < 100000; j++ {
             count++
          }
       }()
    }
    // 等待10个goroutine完成
    wg.Wait()
    fmt.Println(count)
}

但是,每次运行,你都可能得到不同的结果,基本上不会得到理想中的一百万的结果。

image.png 这是为什么呢?

其实,这是因为,count++ 不是一个原子操作,它至少包含几个步骤,比如读取变量count 的当前值,对这个值加 1,把结果再保存到 count 中。因为不是原子操作,就可能有并发的问题。

// count++操作的汇编代码
MOVQ "".count(SB), AX
LEAQ 1(AX), CX
MOVQ CX, "".count(SB)

接下来,根据上面count的例子,我们来看看 Mutex 的基本用法。

1.临界区前后获取锁,释放锁

我们知道,这里的共享资源是 count 变量,临界区是 count++,只要在临界区前面获取锁,在离开临界区的时候释放锁,就能完美地解决 data race 的问题了。

package main

import (
    "fmt"
    "sync"
)

func main() {
    // 互斥锁保护计数器
    var mu sync.Mutex
    // 计数器的值
    var count = 0
    // 辅助变量,用来确认所有的goroutine都完成
    var wg sync.WaitGroup
    wg.Add(10)
    // 启动10个gourontine
    for i := 0; i < 10; i++ {
       go func() {
          defer wg.Done()
          // 累加10万次
          for j := 0; j < 100000; j++ {
             mu.Lock()
             count++
             mu.Unlock()
          }
       }()
    }
    wg.Wait()
    fmt.Println(count)
}

如果你再运行一下程序,就会发现,data race 警告没有了,系统干脆地输出了1000000:

image.png

这里有一点需要注意:Mutex 的零值是还没有 goroutine 等待的未加锁的状态,所以你不需要额外的初始化,直接声明变量(如 var mu sync.Mutex)即可。

2.嵌入struct

那 Mutex 还有哪些用法呢?很多情况下,Mutex 会嵌入到其它 struct 中使用,比如下面的方式:

type Counter struct {
    mu sync.Mutex
    Count uint64
}

3.嵌入字段

有时候,我们还可以采用嵌入字段的方式。通过嵌入字段,你可以在这个 struct 上直接调用 Lock/Unlock 方法。

package main

import (
    "fmt"
    "sync"
)

type Counter struct {
    sync.Mutex
    Count uint64
}

func main() {
    var counter Counter
    var wg sync.WaitGroup
    wg.Add(10)
    for i := 0; i < 10; i++ {
       go func() {
          defer wg.Done()
          for j := 0; j < 100000; j++ {
             counter.Lock()
             counter.Count++
             counter.Unlock()
          }
       }()
    }
    wg.Wait()
    fmt.Println(counter.Count)
}

如果嵌入的 struct 有多个字段,我们一般会把 Mutex 放在要控制的字段上面,然后使用空格把字段分隔开来。

4.锁相关封装成方法

甚至,你还可以把获取锁、释放锁、计数加一的逻辑封装成一个方法,对外不需要暴露锁等逻辑:

package main

import (
    "fmt"
    "sync"
)

func main() {
    // 封装好的计数器
    var counter Counter
    var wg sync.WaitGroup
    wg.Add(10)
    // 启动10个goroutine
    for i := 0; i < 10; i++ {
       go func() {
          defer wg.Done()
          // 执行10万次累加
          for j := 0; j < 100000; j++ {
             counter.Incr() // 受到锁保护的方法
          }
       }()
    }
    wg.Wait()
    fmt.Println(counter.Count())
}

// 线程安全的计数器类型
type Counter struct {
    CounterType int
    Name        string
    
    mu          sync.Mutex
    count       uint64
}

// 加1的方法,内部使用互斥锁保护
func (c *Counter) Incr() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

// 得到计数器的值,也需要锁保护
func (c *Counter) Count() uint64 {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

2. 四种易错场景

image.png

  • Lock/Unlock 不是成对出现
  • Copy已经使用的Mutex
  • 重入
  • 死锁

总结

本文介绍了互斥锁及其实现机制、标准库中 Mutex 的基本使用方法,以及Mutex的四种易错场景。需要强调的是,手误和重入导致的死锁,是最常见的使用 Mutex 的 Bug。请大家开发的时候一定要多多注意。