likes
comments
collection
share

Go并发编程 | 锁

作者站长头像
站长
· 阅读数 38

引言

在Go语言中,锁的机制主要体现在sync包里的MutexRWMutexsync.Mutex 是Go语言中最基础的互斥锁,sync.RWMutex则是读写锁,通过这两种同步原语,可以将临界区保护起来从而解决竞争条件问题。

锁的使用

Mutex核心是两个方法

  • Lock
  • Unlock

RWMutex相比多出了读锁的两个方法

  • RLock
  • RUnlock

在使用时,一般是将MutexRWMutex和需要保护的资源封装在一个结构体内,通过定义在这个结构体上的方法来操作,避免其他协作者在不安全的情况下进入临界区。

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 直接获取到锁的机会,减少了上下文切换的次数。

为了实现这种方式,Mutexkey字段被替换为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
评论
请登录