原创文章,如需转载请联系
作者:陈明勇
公众号: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
的最大作用。