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

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

前言

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

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

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

Mutex

基本概念

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

mutex.png

在上述图片中,我们可以将绿色部分看作是临界区。当 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 的最大作用。

目录
相关文章
|
4月前
|
人工智能 安全 算法
Go入门实战:并发模式的使用
本文详细探讨了Go语言的并发模式,包括Goroutine、Channel、Mutex和WaitGroup等核心概念。通过具体代码实例与详细解释,介绍了这些模式的原理及应用。同时分析了未来发展趋势与挑战,如更高效的并发控制、更好的并发安全及性能优化。Go语言凭借其优秀的并发性能,在现代编程中备受青睐。
149 33
|
3月前
|
存储 Go 开发者
Go 语言中如何处理并发错误
在 Go 语言中,并发编程中的错误处理尤为复杂。本文介绍了几种常见的并发错误处理方法,包括 panic 的作用范围、使用 channel 收集错误与结果,以及使用 errgroup 包统一管理错误和取消任务,帮助开发者编写更健壮的并发程序。
79 4
Go 语言中如何处理并发错误
|
2月前
|
监控 Java 编译器
限流、控并发、减GC!一文搞懂Go项目资源优化的正确姿势
本章介绍Go语言项目在构建与部署阶段的性能调优和资源控制策略,涵盖编译优化、程序性能提升、并发与系统资源管理、容器化部署及自动化测试等内容,助力开发者打造高效稳定的生产级应用。
|
1月前
|
数据采集 Go API
Go语言实战案例:多协程并发下载网页内容
本文是《Go语言100个实战案例 · 网络与并发篇》第6篇,讲解如何使用 Goroutine 和 Channel 实现多协程并发抓取网页内容,提升网络请求效率。通过实战掌握高并发编程技巧,构建爬虫、内容聚合器等工具,涵盖 WaitGroup、超时控制、错误处理等核心知识点。
|
1月前
|
数据采集 消息中间件 编解码
Go语言实战案例:使用 Goroutine 并发打印
本文通过简单案例讲解 Go 语言核心并发模型 Goroutine,涵盖协程启动、输出控制、主程序退出机制,并结合 sync.WaitGroup 实现并发任务同步,帮助理解 Go 并发设计思想与实际应用。
|
4月前
|
Go 开发者
Go语言内存共享与扩容机制 -《Go语言实战指南》
本文深入探讨了Go语言中切片的内存共享机制与自动扩容策略。切片作为动态数组的抽象,其底层结构包含指针、长度和容量。多个切片可能共享同一底层数组,修改一个切片可能影响其他切片。当切片容量不足时,`append`会触发扩容,新容量按指数增长以优化性能。为避免共享导致的副作用,可通过`copy`创建独立副本或在函数中使用只读方式处理。最后总结了最佳实践,帮助开发者高效使用切片,写出更优代码。
117 10
|
4月前
|
人工智能 安全 Go
Go语言中 Mutex 的实现原理
本文深入解析了 Go 中 `sync.Mutex` 的实现原理及其工作机制。`Mutex`(互斥锁)是并发编程中的基础同步机制,用于保护共享资源不被多个 Goroutine 同时访问。Go 的 `sync.Mutex` 通过轻量级的结构体实现,包含 `state` 和 `sema` 两个字段,分别用于表示锁状态和管理 Goroutine 的阻塞与唤醒。 文章详细分析了锁的获取(`Lock()`)和释放(`Unlock()`)过程,包括快速路径和慢速路径的实现逻辑。在慢速路径中,介绍了自旋锁优化、饥饿模式以及信号量机制的应用,确保高效且公平地管理锁竞争。
|
5月前
|
数据采集 监控 Go
用 Go 实现一个轻量级并发任务调度器(支持限速)
本文介绍了如何用 Go 实现一个轻量级的并发任务调度器,解决日常开发中批量任务处理的需求。调度器支持最大并发数控制、速率限制、失败重试及结果收集等功能。通过示例代码展示了其使用方法,并分析了核心组件设计,包括任务(Task)和调度器(Scheduler)。该工具适用于网络爬虫、批量请求等场景。文章最后总结了 Go 并发模型的优势,并提出了扩展功能的方向,如失败回调、超时控制等,欢迎读者交流改进。
181 25
|
7月前
|
存储 缓存 安全
Go 语言中的 Sync.Map 详解:并发安全的 Map 实现
`sync.Map` 是 Go 语言中用于并发安全操作的 Map 实现,适用于读多写少的场景。它通过两个底层 Map(`read` 和 `dirty`)实现读写分离,提供高效的读性能。主要方法包括 `Store`、`Load`、`Delete` 等。在大量写入时性能可能下降,需谨慎选择使用场景。
|
10月前
|
存储 负载均衡 监控
如何利用Go语言的高效性、并发支持、简洁性和跨平台性等优势,通过合理设计架构、实现负载均衡、构建容错机制、建立监控体系、优化数据存储及实施服务治理等步骤,打造稳定可靠的服务架构。
在数字化时代,构建高可靠性服务架构至关重要。本文探讨了如何利用Go语言的高效性、并发支持、简洁性和跨平台性等优势,通过合理设计架构、实现负载均衡、构建容错机制、建立监控体系、优化数据存储及实施服务治理等步骤,打造稳定可靠的服务架构。
211 1