同步原语与锁¶
当提到并发编程、多线程编程时,我们往往都离不开『锁』这一概念,Go 语言作为一个原生支持用户态进程 Goroutine 的语言,也一定会为开发者提供这一功能,锁的主要作用就是保证多个线程或者 Goroutine 在访问同一片内存时不会出现混乱的问题,锁其实是一种并发编程中的同步原语(Synchronization Primitives)。
在这一节中我们就会介绍 Go 语言中常见的同步原语 Mutex
、RWMutex
、WaitGroup
、Once
和 Cond
以及扩展原语 ErrGroup
、Semaphore
和 SingleFlight
的实现原理,同时也会涉及互斥锁、信号量等并发编程中的常见概念。
1. 基本原语¶
Go 语言在 sync 包中提供了用于同步的一些基本原语,包括常见的互斥锁 Mutex
与读写互斥锁 RWMutex
以及 Once
、WaitGroup
。
这些基本原语的主要作用是提供较为基础的同步功能,我们应该使用 Channel 和通信来实现更加高级的同步机制,我们在这一节中并不会介绍标准库中全部的原语,而是会介绍其中比较常见的 Mutex
、RWMutex
、Once
、WaitGroup
和 Cond
,我们并不会涉及剩下两个用于存取数据的结构体 Map
和 Pool
1.1. Mutex¶
Go 语言中的互斥锁在 sync
包中,它由两个字段 state
和 sema
组成,state
表示当前互斥锁的状态,而 sema
真正用于控制锁状态的信号量,这两个加起来只占 8 个字节空间的结构体就表示了 Go 语言中的互斥锁。
type Mutex struct {
state int32
sema uint32
}
状态¶
互斥锁的状态是用 int32
来表示的,但是锁的state
并不是互斥的,它的最低三位分别表示 mutexLocked
、mutexWoken
和 mutexStarving
,剩下的位置都用来表示当前有多少个 Goroutine 等待互斥锁被释放:
|-----------------|---------------| -----------| -----------|
| waitGoroutines | mutexStarving | mutexWoken | mutexLocked|
互斥锁在被创建出来时,所有的状态位的默认值都是 0
,当互斥锁被锁定时 mutexLocked
就会被置成 1
、当互斥锁被在正常模式下被唤醒时 mutexWoken
就会被被置成 1
、mutexStarving
用于表示当前的互斥锁进入了状态,最后的几位是在当前互斥锁上等待的 Goroutine 个数。
饥饿模式¶
Mutex
在可能会进入饥饿模式,饥饿模式主要功能就是保证互斥锁的获取的『公平性』(Fairness)。
互斥锁可以同时处于两种不同的模式,也就是正常模式和饥饿模式,在正常模式下,所有锁的等待者都会按照先进先出的顺序获取锁,但是如果一个刚刚被唤起的 Goroutine 遇到了新的 Goroutine 进程也调用了 Lock
方法时,大概率会获取不到锁,为了减少这种情况的出现,防止 Goroutine 被『饿死』,一旦 Goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式。
在饥饿模式中,互斥锁会被直接交给等待队列最前面的 Goroutine,新的 Goroutine 在这时不能获取锁、也不会进入自旋的状态,它们只会在队列的末尾等待,如果一个 Goroutine 获得了互斥锁并且它是队列中最末尾的协程或者它等待的时间少于 1ms,那么当前的互斥锁就会被切换回正常模式。
相比于饥饿模式,正常模式下的互斥锁能够提供更好地性能,饥饿模式的主要作用就是避免一些 Goroutine 由于陷入等待无法获取锁而造成较高的尾延时,这也是对 Mutex
的一个优化。
加锁¶
互斥锁 Mutex
的加锁是靠 Lock
方法完成的,最新的 Go 语言源代码中已经将 Lock
方法进行了简化,方法的主干只保留了最常见、简单并且快速的情况;当锁的状态是 0
时直接将 mutexLocked
位置成 1
:
func (m *Mutex) Lock() {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
m.lockSlow()
}