详解Golang中的Mutex并发原语

 更新时间:2023年03月28日 08:21:45   作者:陈明勇  
Mutex 是 Go 语言中互斥锁的实现,它是一种同步机制,用于控制多个 goroutine 之间的并发访问。本文将着重介绍 Go 的 Mutex 并发原语,希望对大家有所帮助

前言

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

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

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

Mutex

基本概念

MutexGo 语言中互斥锁的实现,它是一种同步机制,用于控制多个 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 可能只能为 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并发原语内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • 向Rust学习Go考虑简单字符串插值特性示例解析

    向Rust学习Go考虑简单字符串插值特性示例解析

    这篇文章主要为大家介绍了向Rust学习Go考虑简单字符串插值特性示例解析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-02-02
  • golang的强制类型转换实现

    golang的强制类型转换实现

    这篇文章主要介绍了golang的强制类型转换实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2021-02-02
  • 详解MongoDB Go Driver如何记录日志

    详解MongoDB Go Driver如何记录日志

    这篇文章主要为大家介绍了MongoDB Go Driver如何记录日志详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-08-08
  • 使用go进行云存储上传实现实例

    使用go进行云存储上传实现实例

    这篇文章主要为大家介绍了使用go进行云存储上传实例,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪<BR>
    2024-01-01
  • Golang map实现原理深入分析

    Golang map实现原理深入分析

    map是一种无序的基于key-value的数据结构,Go语言中的map是引用类型,必须初始化才能使用,下面这篇文章主要给大家介绍了关于golang中map使用的几点注意事项,需要的朋友可以参考下
    2023-01-01
  • Go语言学习网络编程与Http教程示例

    Go语言学习网络编程与Http教程示例

    这篇文章主要为大家介绍了Go语言学习网络编程与Http教程示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-03-03
  • 通过Go channel批量读取数据的示例详解

    通过Go channel批量读取数据的示例详解

    批量处理的主要逻辑是:从 channel 中接收数据,积累到一定数量或者达到时间限制后,将数据批量处理(例如发送到 Kafka 或者写入网络),下面我将展示一个从 Go channel 中批量读取数据,并批量发送到 Kafka 和批量写入网络数据的示例,需要的朋友可以参考下
    2024-10-10
  • 详解如何修改Go结构体的私有字段

    详解如何修改Go结构体的私有字段

    在 Go 语言中,结构体字段的访问权限是由字段名的首字母决定的:首字母大写表示公共字段(public),首字母小写表示私有字段(private),本文给大家介绍了如何修改Go结构体的私有字段,需要的朋友可以参考下
    2025-01-01
  • GO实现跳跃表的示例详解

    GO实现跳跃表的示例详解

    跳表全称叫做跳跃表,简称跳表,是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。本文将利用GO语言编写一个跳表,需要的可以参考一下
    2022-12-12
  • 简单聊聊Golang中defer预计算参数

    简单聊聊Golang中defer预计算参数

    在golang当中defer代码块会在函数调用链表中增加一个函数调用,下面这篇文章主要给大家介绍了关于Golang中defer预计算参数的相关资料,文中通过实例代码介绍的非常详细,需要的朋友可以参考下
    2022-03-03

最新评论