Go并发编程 | 锁
引言
在Go语言中,锁的机制主要体现在sync
包里的Mutex
和RWMutex
。sync.Mutex
是Go语言中最基础的互斥锁,sync.RWMutex
则是读写锁,通过这两种同步原语,可以将临界区保护起来从而解决竞争条件问题。
锁的使用
Mutex
核心是两个方法
Lock
Unlock
RWMutex
相比多出了读锁的两个方法
RLock
RUnlock
在使用时,一般是将Mutex
或RWMutex
和需要保护的资源封装在一个结构体内,通过定义在这个结构体上的方法来操作,避免其他协作者在不安全的情况下进入临界区。
type safeResource struct {
resource interface{}
mu sync.Mutex
}
func (s *safeResource) DoSomethingToResource() {
s.mu.Lock()
defer s.mu.Unlock()
}
其中,使用defer来解锁是一种惯用法,defer保证即使发生panic,也可以成功解锁从而避免死锁。
Go的map不是线程安全的,当同时对map进行并发读写的时候,就有可能导致panic,可以用锁来实现一个简单的并发安全map。
type SafeMap[K comparable, V any] struct {
data map[K]V
mu sync.RWMutex
}
func (s *SafeMap[K, V]) Put(key K, val V) {
s.mu.Lock()
defer s.mu.Unlock()
s.data[key] = val
}
func (s *SafeMap[K, V]) Gey(key K) (any, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
res, ok := s.data[key]
return res, ok
}
func (s *SafeMap[K, V]) LoadOrStore(key K, newVal V) (val V, loaded bool) {
s.mu.RLock()
res, ok := s.data[key]
s.mu.RUnlock()
if ok {
return res, true
}
s.mu.Lock()
defer s.mu.Unlock()
res, ok = s.data[key]
if ok {
return res, true
}
s.data[key] = newVal
return newVal, false
}
其中LoadOrStore
方法利用了典型的double-check,这种模式能够确保即使在多线程环境中,初始化操作也只会被执行一次,最常利用于单例模式中。
Mutex的原理与演化
初始版本
锁本质是一种状态,加锁解锁只是锁的某个变量的变化。Go最初的Mutex结构体包含两个字段
- key :一个flag,用来 表示这个锁是否已被某个goroutine持有,如果为0表示锁未被持有,1表示锁被持有没有等待者,n表示被持有且有n-1个等待者
- sema:一个信号量,用于控制goroutine休眠与唤醒
此外还有两个与信号量(semaphore)相关的函数semacquire()
和semrelease()
。semacquire()
用于把调用者goroutine压入一个队列,并把此goroutine设为阻塞状态,用于处理不能获取到锁的goroutine。semrelease()
用于从队列中取出一个goroutine并唤醒,被唤醒��goroutine会直接获取到锁。
在调用Lock()
获取锁时,会重复执行CAS操作对key执行加一操作直至成功,如果将key置为1,代表锁没有被其他goroutine持有,则会直接获取到锁,否则会调用semacquire()
将自己休眠。
在调用Unlock()
释放锁时,会将key减1,然后调用semrelease()
唤醒某个goroutine。
多给新的 goroutine 一些机会
在这一版本的实现中,每次调用Lock()
获取锁时,会首先尝试获取锁,这称之为快速路径。当一个休眠的 goroutine 被唤醒时,因为正在运行的 goroutine 的Lock()
也会尝试获取锁,因此被唤醒的 goroutine 不会直接获取锁,而是要与正在运行的 goroutine 竞争,这种方式给了新的 goroutine 直接获取到锁的机会,减少了上下文切换的次数。
为了实现这种方式,Mutex
的key
字段被替换为state
字段。state
是一个 int32 类型的复合型字段,第一位mutexLocked
表示这个锁是否被持有,第二位mutexWoken
表示是否有唤醒的 goroutine,剩余的位数表示等待此锁的 goroutine 数量。在上锁和解锁的过程中,会通过二进制位操作来修改各部分的值。
多给竞争者一些机会
为了避免过多的上下文切换,给获取锁增加了一条快速路径,使得正在执行的 goroutine 有机会直接获取到锁。但是只执行一次尝试的概率过低,因此在后续的改动中,第一次请求锁的 goroutine 和 被唤醒的 goroutine 都会在首次获取不到锁后自旋一段时间,通过循环不断尝试,在尝试一定的自旋次数后,再执行原来的逻辑。
这也是大多数锁的实现方式,自旋作为快路径,等待队列作为慢路径。通过次数或时间退出来控制自旋,慢路径与语言特性有关。
解决饥饿问题
在极端的情况下,goroutine 可能一直获取不到锁,这与错误的编程方式无关而在于锁的设计。因此为了让获取锁变得更公平,不公平的等待时间被限制在1ms,并在state中增加了mutexStarving
作为饥饿标志。
基于饥饿标志,锁有两种模式:正常模式和饥饿模式。正常模式关注效率,被唤醒的 goroutine会与正在利用 CPU 的 goroutine 竞争。饥饿模式关注公平,锁会直接从释放锁的协程传递给队列中等待时间最长的协程,而不是让出来给任何协程竞争。
锁的注意事项
误写
误写是使用锁时常见的错误,有可能忘记使用defer
关键字解锁,还有可能是因为Unlock()
的调用在另一个分支中,根本忘记了解锁。
死锁
死锁可能由于设计不周导致,如哲学家问题,也有可能是漏写或者锁的调用顺序相反。
如有两个操作都需要获取两把锁来完成,但是两个操作获取锁的顺序不同,当两个操作都获取到自己需要的第一把锁时,另一把锁被对方持有,则会导致死锁。
锁重入
Go 的 Mutex 和 Java 的 ReentrantLock 不同,Go 的 Mutex 是不可重入的,也就是不能被递归调用,如果在调用链中已经获取到锁的 goroutine 再次调用Lock()
,则会阻塞在Lock()
处。
引用
晁岳攀.深入理解Go并发编程.2023 Kather Cox-Buday.Go语言并发之道.2018
转载自:https://juejin.cn/post/7370235634295275530