详解Golang中的Mutex并发原语

前言

Go 语言以 高并发 著称,其并发操作是重要特性之一。虽然并发可以提高程序性能和效率,但同时也可能带来 竞态条件死锁 等问题。为了避免这些问题,Go 提供了许多 并发原语,例如 MutexRWMutexWaitGroupChannel 等,用于实现同步、协调和通信等操作。

本文将着重介绍 GoMutex 并发原语,它是一种锁类型,用于实现共享资源互斥访问。

说明:本文使用的代码基于的 Go 版本:1.20.1

Mutex

基本概念

MutexGo 语言中互斥锁的实现,它是一种同步机制,用于控制多个 goroutine 之间的并发访问。当多个 goroutine 尝试同时访问同一个共享资源时,可能会导致数据竞争和其他并发问题,因此需要使用互斥锁来协调它们之间的访问。

详解Golang中的Mutex并发原语_第1张图片

在上述图片中,我们可以将绿色部分看作是临界区。当 g1 协程通过 mutex 对临界区进行加锁后,临界区将会被锁定。此时如果 g2 想要访问临界区,就会失败并进入阻塞状态,直到锁被释放,g2 才能拿到临界区的访问权。

结构体介绍

type Mutex struct {
    state int32
    sema  uint32
}

字段:

state

state 是一个 int32 类型的变量,它存储着 Mutex 的各种状态信息(未加锁、被加锁、唤醒状态、饥饿状态),不同状态通过位运算进行计算。

sema

sema 是一个信号量,用于实现 Mutex 的等待和唤醒机制。

方法:

Lock()

Lock() 方法用于获取 Mutex 的锁,如果 Mutex 已经被其他的 goroutine 锁定,则 Lock() 方法会一直阻塞,直到该 goroutine 获取到锁为止。

UnLock()

Unlock() 方法用于释放 Mutex 的锁,将 Mutex 的状态设置为未锁定的状态。

TryLock()

Go 1.18 版本以后,sync.Mutex 新增一个 TryLock() 方法,该方法为非阻塞式的加锁操作,如果加锁成功,返回 true,否则返回 false

虽然 TryLock() 的用法确实存在,但由于其使用场景相对较少,因此在使用时应该格外谨慎。TryLock() 方法注释如下所示:

// Note that while correct uses of TryLock do exist, they are rare,
// and use of TryLock is often a sign of a deeper problem
// in a particular use of mutexes.

代码示例

我们先来看一个有并发安全问题的例子

package main

import (
   "fmt"
   "sync"
)

var cnt int

func main() {
   var wg sync.WaitGroup
   for i := 0; i < 10; i++ {
      wg.Add(1)
      go func() {
         defer wg.Done()
         for j := 0; j < 10000; j++ {
            cnt++
         }
      }()
   }
   wg.Wait()
   fmt.Println(cnt)
}

在这个例子中,预期的 cnt 结果为 10 * 10000 = 100000。但是由于多个 goroutine 并发访问了共享变量 cnt,并且没有进行任何同步操作,可能导致读写冲突(race condition),从而影响 cnt 的值和输出结果的正确性。这种情况下,不能确定最终输出的 cnt 值是多少,每次执行程序得到的结果可能不同。

在这种情况下,可以使用互斥锁(sync.Mutex)来保护共享变量的访问,保证只有一个 goroutine 能够同时访问 cnt,从而避免竞态条件的问题。修改后的代码如下:

package main

import (
   "fmt"
   "sync"
)

var cnt int
var mu sync.Mutex

func main() {
   var wg sync.WaitGroup
   for i := 0; i < 10; i++ {
      wg.Add(1)
      go func() {
         defer wg.Done()
         for j := 0; j < 10000; j++ {
            mu.Lock()
            cnt++
            mu.Unlock()
         }
      }()
   }
   wg.Wait()
   fmt.Println(cnt)
}

在这个修改后的版本中,使用互斥锁来保护共享变量 cnt 的访问,可以避免出现竞态条件的问题。具体而言,在 cnt++ 操作前,先执行 Lock() 方法,以确保当前 goroutine 获取到了互斥锁并且独占了共享变量的访问权。在 cnt++ 操作完成后,再执行 Unlock() 方法来释放互斥锁,从而允许其他 goroutine 获取互斥锁并访问共享变量。这样,只有一个 goroutine 能够同时访问 cnt,从而确保了最终输出结果的正确性。

易错场景

忘记解锁

如果使用 Lock() 方法之后,没有调用 Unlock() 解锁,会导致其他 goroutine 被永久阻塞。例如:

package main

import (
   "fmt"
   "sync"
   "time"
)

var mu sync.Mutex
var cnt int

func main() {
   go increase(1)
   go increase(2)

   time.Sleep(time.Second)
   fmt.Println(cnt)
}

func increase(delta int) {
   mu.Lock()
   cnt += delta
}

在上述代码中,通常情况下,cnt 的结果应该为 3。然而没有解锁操作,其中一个 goroutine 被阻塞,导致没有达到预期效果,最终输出的 cnt 可能只能为 12

正确的做法是使用 defer 语句在函数返回前释放锁。

func increase(delta int) {
   mu.Lock()
   defer mu.Unlock() // 通过 defer 语句在函数返回前释放锁
   cnt += delta
}

重复加锁

重复加锁操作被称为可重入操作。不同于其他一些编程语言的锁实现(例如 JavaReentrantLock),Gomutex 并不支持可重入操作,如果发生了重复加锁操作,就会导致死锁。例如:

package main

import (
   "fmt"
   "sync"
   "time"
)

var mu sync.Mutex
var cnt int

func main() {
   go increase(1)
   go increase(2)

   time.Sleep(time.Second)
   fmt.Println(cnt)
}

func increase(delta int) {
   mu.Lock()
   mu.Lock()
   cnt += delta
   mu.Unlock()
}

在这个例子中,如果在 increase 函数中重复加锁,将会导致 mu 锁被第二次锁住,而其他 goroutine 将被永久阻塞,从而导致程序死锁。正确的做法是只对需要加锁的代码段进行加锁,避免重复加锁。

基于 Mutex 实现一个简单的线程安全的缓存

import "sync"

type Cache struct {
   data map[string]any
   mu   sync.Mutex
}

func (c *Cache) Get(key string) (any, bool) {
   c.mu.Lock()
   defer c.mu.Unlock()
   value, ok := c.data[key]
   return value, ok
}

func (c *Cache) Set(key string, value any) {
   c.mu.Lock()
   defer c.mu.Unlock()
   c.data[key] = value
}

上述代码实现了一个简单的线程安全的缓存。使用 Mutex 可以保证同一时刻只有一个 goroutine 进行读写操作,避免多个 goroutine 并发读写同一数据时产生数据不一致性的问题。

对于缓存场景,读操作比写操作更频繁,因此使用 RWMutex 代替 Mutex 会更好,因为 RWMutex 允许多个 goroutine 同时进行读操作,只有在写操作时才会进行互斥锁定,从而减少了锁的竞争,提高了程序的并发性能。后续文章会对 RWMutex 进行介绍。

小结

本文主要介绍了 Go 语言中互斥锁 Mutex 的概念、对应的字段和方法、基本使用和易错场景,最后基于 Mutex 实现一个简单的线程安全的缓存。

Mutex 是保证共享资源数据一致性的重要手段,但使用不当会导致性能下降或死锁等问题。因此,在使用 Mutex 时需要仔细考虑代码的设计和并发场景,发挥 Mutex 的最大作用。

到此这篇关于详解Golang中的Mutex并发原语的文章就介绍到这了,更多相关Golang Mutex并发原语内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

你可能感兴趣的:(详解Golang中的Mutex并发原语)