Go 同步与互斥 | 青训营

120 阅读5分钟

同步与互斥

互斥量

在编程中,引入了对象互斥锁的概念,来保证共享数据操作的完整性。每个对象都对应于一个可称为互斥锁的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。Go 语言的 sync 包提供了两种锁类型:sync.Mutexsync.RWMutex

sync.Mutex

// A Mutex is a mutual exclusion lock.
// The zero value for a Mutex is an unlocked mutex.
//
// A Mutex must not be copied after first use.
//
// In the terminology of the Go memory model,
// the n'th call to Unlock “synchronizes before” the m'th call to Lock
// for any n < m.
// A successful call to TryLock is equivalent to a call to Lock.
// A failed call to TryLock does not establish any “synchronizes before”
// relation at all.
type Mutex struct {
    state int32
    sema  uint32
}

加锁

func (m *Mutex) Lock()
func (m *Mutex) TryLock() bool

解锁

func (m *Mutex) Unlock()

sync.Mutex 提供了两种不同的模式,即正常模式和饥饿模式

  1. 正常模式(Normal Mode): 正常模式是 sync.Mutex 的默认模式,也是大多数情况下所使用的模式。在正常模式下,协程在等待锁时,会进入一个队列并等待锁的释放。一旦锁被释放,等待的协程中的一个会被唤醒并获得锁。这种模式可以保证公平性,即所有协程都有机会获得锁,避免了某些协程长时间等待锁的问题。
  2. 饥饿模式(Starvation Mode): 饥饿模式是一种为了减少竞争和提高性能的模式。在饥饿模式下,锁的所有权会优先授予已经等待较长时间的协程,而不是先入先出原则。这可以避免某些协程因为频繁等待锁而引起的性能问题。饥饿模式的引入是为了优化某些特定场景下的性能,但可能会导致某些协程长时间等待锁而引起公平性问题

sync.RWMutex

type RWMutex struct {
    w           Mutex        // held if there are pending writers
    writerSem   uint32       // semaphore for writers to wait for completing readers
    readerSem   uint32       // semaphore for readers to wait for completing writers
    readerCount atomic.Int32 // number of pending readers
    readerWait  atomic.Int32 // number of departing readers
}

加锁

func (rw *RWMutex) Lock()
func (rw *RWMutex) RLock()
func (rw *RWMutex) TryLock() bool
func (rw *RWMutex) TryRLock() bool

解锁

func (rw *RWMutex) RUnlock()
func (rw *RWMutex) Unlock()

条件变量

条件变量是基于互斥锁的一种同步工具。在 Go 语言中,我们需要用 sync.NewCond 函数来初始化一个 sync.Cond 类型的条件变量

初始化条件变量

var lock sync.Mutex
cond := sync.NewCond(&lock)

阻塞当前 goroutine

条件变量的 Wait 方法需要在它基于的互斥锁保护下执行,否则就会引发不可恢复的 panic

func (c *Cond) Wait()

唤醒 goroutine

func (c *Cond) Signal()
func (c *Cond) Broadcast()

为什么先要锁定条件变量基于的互斥锁,才能调用它的 Wait 方法?

条件变量的 Wait 方法在阻塞当前的 goroutine 之前,会解锁它基于的互斥锁,所以在调用该 Wait 方法之前,我们必须先锁定那个互斥锁,否则在调用这个 Wait 方法时,就会引发一个不可恢复的 panic

为什么要用 for 语句来包裹调用其 Wait 方法的表达式?

这主要是为了防止虚假唤醒而导致出错。如果一个 goroutine 因收到通知而被唤醒,但却发现共享资源的状态,依然不符合它的要求,那么就应该再次调用条件变量的 Wait 方法,并继续等待下次通知的到来

原子操作

原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何上下文切换

Go 语言的 sync/atomic 包中的函数可以做的原子操作有:加法(Add)、比较并交换(Compare And Swap,CAS)、加载(Load)、存储(Store)和交换(Swap)。这些函数针对的数据类型并不多。但是,对这些类型中的每一个,sync/atomic 包都会有一套函数给予支持。这些数据类型有:int32、int64、uint32、uint64、uintptr,以及 unsafe 包中的 Pointer。不过,针对 unsafe.Pointer 类型,该包并未提供进行原子加法操作的函数

func AddInt32(addr *int32, delta int32) (new int32)
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func LoadInt32(addr *int32) (val int32)
func StoreInt32(addr *int32, val int32)
func SwapInt32(addr *int32, new int32) (old int32)

atomic.Value

  • 参数值不能为 nil
  • 参数值的类型必须与首个被存储值的类型相同

sync.Once

sync.Once 是 Go 标准库提供的使函数只执行一次的实现,常应用于单例模式,例如初始化配置、保持数据库连接等。作用与 init 函数类似,但有区别

  • init 函数是当所在的 package 首次被加载时执行,若迟迟未被使用,则既浪费了内存,又延长了程序加载时间
  • sync.Once 可以在代码的任意位置初始化和调用,因此可以延迟到使用时再执行,并发场景下是线程安全的
// Once is an object that will perform exactly one action.
//
// A Once must not be copied after first use.
//
// In the terminology of the Go memory model,
// the return from f “synchronizes before”
// the return from any call of once.Do(f).
type Once struct {
    // done indicates whether the action has been performed.
    // It is first in the struct because it is used in the hot path.
    // The hot path is inlined at every call site.
    // Placing done first allows more compact instructions on some architectures (amd64/386),
    // and fewer instructions (to calculate offset) on other architectures.
    done uint32
    m    Mutex
}
func (o *Once) Do(f func())

sync.WaitGroup

WaitGroup 用于等待特定数量的 goroutines 执行完成

type WaitGroup struct {
    noCopy noCopy
    state atomic.Uint64 // high 32 bits are counter, low 32 bits are waiter count.
    sema  uint32
}