周末,阅读 Mutex源码,收获极大

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 并发编程实战课-鸟窝带你攻克并发编程难题

推荐去看该专栏,非常棒!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值