Go Mutex:保护并发访问共享资源的利器

  • Post author:
  • Post category:其他


原创文章,如需转载请联系

作者:陈明勇

公众号:Go技术干货



前言


Go

语言以

高并发

著称,其并发操作是重要特性之一。虽然并发可以提高程序性能和效率,但同时也可能带来

竞态条件



死锁

等问题。为了避免这些问题,

Go

提供了许多

并发原语

,例如

Mutex



RWMutex



WaitGroup



Channel

等,用于实现同步、协调和通信等操作。

本文将着重介绍

Go



Mutex

并发原语,它是一种锁类型,用于实现共享资源互斥访问。

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



Mutex



基本概念


Mutex



Go

语言中互斥锁的实现,它是一种同步机制,用于控制多个

goroutine

之间的并发访问。当多个

goroutine

尝试同时访问同一个共享资源时,可能会导致数据竞争和其他并发问题,因此需要使用互斥锁来协调它们之间的访问。

在这里插入图片描述

在上述图片中,我们可以将绿色部分看作是临界区。当

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

可能只能为

1



2

正确的做法是使用

defer

语句在函数返回前释放锁。

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



重复加锁

重复加锁操作被称为可重入操作。不同于其他一些编程语言的锁实现(例如

Java



ReentrantLock

),

Go



mutex

并不支持可重入操作,如果发生了重复加锁操作,就会导致死锁。例如:

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

的最大作用。



版权声明:本文为weixin_44604586原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。