Go 语言保证线程安全,可以使用 channel 和 共享内存去保证。
Go 语言不仅仅提供基于 CSP 的通信模型,也支持基于共享内存的多线程数据访问,在Sync包提供了锁的基本原语。
-
sync.Mutex 互斥锁,Lock加锁,unlock解锁。不论读和写都是互斥的。
-
sync.RWMutex 读写分离锁,不限制并发读,只限制并发写和并发读写。
-
sync.WaitGroup 它的语意就是定义一个组,这个组里面会有假如100个线程,每个线程在结束时候都应该去调Done(),只有结束Done()减为0的时候才往下执行wait()
-
sync.Once 保证某段代码只执行一次
-
sync.Cond 让一组Goroutine 在满足特定条件时被唤醒
(1)互斥锁的机制
Mutex 的基本用法,在 Go 标准库中,package sync 提供了锁相关一系列同步语,Mutex 就是实现了Locker 接口的 lock 和 unlock方法。
Mutex 早期版本 2008:
// CAS操作,当时还没有抽象出atomic包
func cas(val *int32, old, new int32) bool func semacquire(*int32)
func semrelease(*int32) // 互斥锁的结构,包含两个字段
type Mutex struct {
key int32 // 锁是否被持有的标识
sema int32 // 信号量专用,用以阻塞/唤醒goroutine
}
// 保证成功在val上增加delta的值
func xadd(val *int32, delta int32) (new int32) {
for {
v := *val
if cas(val, v, v+delta) {
return v + delta
}
}
panic("unreached")
}
func (m *Mutex) Lock() {
if xadd(&m.key, 1) == 1 { //标识加1,如果等于1,成功获取到锁
return
}
semacquire(&m.sema) // 否则阻塞等待
}
func (m *Mutex) Lock() {
if xadd(&m.key, -1) == 0 { //标识加1,如果等于1,成功获取到锁
return
}
semacquire(&m.sema) // 否则阻塞等待
}
总结:最开始的 Mutex 版本是利用 CAS 原子操作来实现并发安全,通过对 key 标志量进行设置。
那么问题来了,Unlock 方法可以被其他任意的 Goroutine 调用释放,即使是没持有这个互斥锁的 Goroutine 也是可以进行操作。这样的话,当在临界区的 Goroutine 可能不知道这把锁已经被其他 Goroutine 释放了,它任然继续执行临界区的业务操作,会带来的问题就是资源竞争问题。
Mutex 版本 2011:
State 字段:
mutexWaiters //阻塞等待的waiter数量
mutexWoken //唤醒标记
mutexLocked //持有锁的标记
Go 开发者对 Mutex 进行一个大的调整
type Mutex struct {
state int32
sema uint32
}
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota
starvationThresholdNs = 1e6
)
源码分析得出:
首先通过 CAS 检测 state 字段中的标志,如果没有 Goroutine 持有锁,也没有等待持有锁的 Goroutine,那么 当前的 goroutine 就很幸运,就直接可以获取锁。
如果不够幸运,state 不是零值,那么就通过一个循环进行检查。之前版本的 Goroutine 没有机会获取锁,会进行休眠,锁释放唤醒后,再进行竞争获取锁。
Mutex 版本 2015:
如果新来的 goroutine 或者是被唤醒的 goroutine 首次获取不 到锁,它们就会通过自旋的方式,尝试检查锁是否被释放。在尝试一定的自旋次数后,再执行原来的逻辑。
解决饥饿:
通过不断优化,Mutex 应对高并发抢锁的场景也更加公平,但是新来的 Goroutine 也参与竞争,有可能每次都被新来的 Goroutine 抢到锁的机会,在极端情况下,等待中的 Goroutine 可能会一直获取不到锁,这就是会导致饥饿现象。
2016年 Go 1.9 中 Mutex 增加饥饿模式,让锁变得更加公平,不公平的等待时间限制 1 毫秒,并且修复了一个大 Bug:总是把唤醒的 goroutine 放在等待队列的尾部,会导致更加不公平的等待时间。
State 字段:
mutexWaiters //阻塞等待的waiter数量
mutexStariving //饥饿标记
mutexWoken //唤醒标记
mutexLocked //持有锁的标记
type Mutex struct {
state int32
sema uint32
}
type Locker interface {
Lock()
Unlock()
}
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving // 从state字段中分出一个饥饿标记
mutexWaiterShift = iota
starvationThresholdNs = 1e6 //将饥饿模式的最大等待时间阈值设置成了 1 毫秒,
)
Lock() 源码:
func (m *Mutex) Lock() {
// 幸运情况下就马上获取锁
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// 长路慢慢,需要尝试自旋竞争或饥饿状态下饥饿Goroutine竞争
m.lockSlow()
}
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false//此Goroutine的饥饿标记
awoke := false //唤醒标记
iter := 0 //自旋次数
old := m.state //当前锁状态
for {
// 锁是非饥饿状态,锁还没被释放,尝试自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin()
iter++
old = m.state // 再次获取锁的状态,之后会检查是否锁被释放了
continue
}
new := old
if old&mutexStarving == 0 {
new |= mutexLocked // 非饥饿状态,加锁
}
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift // waiter数量加1
}
if starving && old&mutexLocked != 0 {
new |= mutexStarving // // 设置饥饿状态
}
if awoke {
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
new &^= mutexWoken // 新状态清除唤醒标记
}
// 成功设置新状态
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 原来锁的状态已释放,并且不是饥饿状态,正常请求到了锁,返回
if old&(mutexLocked|mutexStarving) == 0 {
break // locked the mutex with CAS
}
// 处理饥饿状态
// 如果以前就在队列里面,加入到队列头
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
// 阻塞等待
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 唤醒之后检查锁是否应该处于饥饿状态
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
// 如果锁已经处于饥饿状态,直接抢到锁,返回
if old&mutexStarving != 0 {
if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
throw("sync: inconsistent mutex state")
}
//加锁并且将waiter数减1
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
delta -= mutexStarving // 最后一个waiter或者已经不饥饿了
}
atomic.AddInt32(&m.state, delta)
break
}
awoke = true
iter = 0
} else {
old = m.state
}
}
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
}
Mutex从开始到现在1.18版本的一个变化过程。总结:。饥饿模式的最大等待时间阈值设置成了 1 毫秒,一旦等待着等待时间超过这个阈值,Mutex 的处理就会有可能进入饥饿模式,优先让等待者获取到锁。
为了解决了等待 goroutine 队列的长尾问题,饥饿模式下,直接由 unlock 把锁交给等待队列中排在第一位的 goroutine (队 头),同时,饥饿模式下,新进来的 goroutine 不会参与抢锁也不会进入自旋状 态,会直接进入等待队列的尾部。这样很好的解决了老的 goroutine 一直抢不 到锁的场景。
饥饿模式的触发条件:当一个 goroutine 等待锁时间超过 1 毫秒时,或者当前 队列只剩下一个 goroutine 的时候,Mutex 切换到饥饿模式。
Unlock() 源码:
func (m *Mutex) Unlock() {
if race.Enabled {
_ = m.state
race.Release(unsafe.Pointer(m))
}
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
m.unlockSlow(new)
}
}
func (m *Mutex) unlockSlow(new int32) {
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
if new&mutexStarving == 0 {
old := new
for {
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
} else {
runtime_Semrelease(&m.sema, true, 1)
}
}
更多文章收录于GitHub:https://github.com/metashops/GoFamily
参考:Go 并发编程实战课-鸟窝带你攻克并发编程难题
推荐去看该专栏,非常棒!