1介绍
互斥锁是并发控制的一个基本手段,是为了避免竞争而建立的一种并发控制机制。简单来说就是控制并发
比如下面能得到我们想要的结果吗?
func main() {
sum :=0
var w sync.WaitGroup
for i:=0;i<1000;i++{
w.Add(1)
go func() {
defer w.Done()
sum+=1
}()
}
w.Wait()
fmt.Println(sum)
}
肯定不能,不等于1000,而且sum执行值每次都不一样,因为在并发时,并不能保证sum 就一定是被人加+1的值,如果都去+1然后赋值就有可能出现覆盖情况。所以这时候我们就需要能控制这种并发带来的问题。今天我们的主角就是mutex了。
2源码解析
互斥锁进行了几代优化:
-
简单的互斥量cas 操作,flag 为1 代表加锁。
问题:
请求锁的goroutine会排队等待获取互斥锁。虽然这貌似很公平,但是从性能上来看,却不是最优的。因为如果我们能够把锁交给正在占用CPU时间片的goroutine的话,那就不需要做上下文的切换,在高并发的情况下,可能会有更好的性能。
-
加入唤醒标志
如果想要获取锁的goroutine没有机会获取到锁,就会进行休眠,但是在锁释放唤醒之后,它并不能像先前一样直接获取到锁,还是要和正在请求锁的goroutine进行竞争。这会给后来请求锁的goroutine一个机会,也让CPU中正在执行的goroutine有更多的机会获取到锁,在一定程度上提高了程序的性能。
相对于初版的设计,这次的改动主要就是,新来的goroutine也有机会先获取到锁,甚至一个goroutine可能连续获取到锁,打破了先来先得的逻辑。但是,代码复杂度也显而易见。
虽然这一版的Mutex已经给新来请求锁的goroutine一些机会,让它参与竞争,没有空闲的锁或者竞争失败才加入到等待队列中。
-
自旋
在2015年2月的改动中,如果新来的goroutine或者是被唤醒的goroutine首次获取不到锁,它们就会通过自旋(spin,通过循环不断尝试,spin的逻辑是在runtime实现的)的方式,尝试检查锁是否被释放。在尝试一定的自旋次数后,再执行原来的逻辑。
对于临界区代码执行非常短的场景来说,这是一个非常好的优化。因为临界区的代码耗时很短,锁很快就能释放,而抢夺锁的goroutine不用通过休眠唤醒方式等待调度,直接spin几次,可能就获得了锁。
问题:
新来的goroutine也参与竞争,有可能每次都会被新来的goroutine抢到获取锁的机会,在极端情况下,等中的goroutine可能会一直获取不到锁,这就是饥饿问题。
-
饥饿模式和正常模式
正常模式下,waiter都是进入先入先出队列,被唤醒的waiter并不会直接持有锁,而是要和新来的goroutine进行竞争。新来的goroutine有先天的优势,它们正在CPU中运行,可能它们的数量还不少,所以,在高并发情况下,被唤醒的waiter可能比较悲剧地获取不到锁,这时,它会被插入到队列的前面。如果waiter获取不到锁的时间超过阈值1毫秒,那么,这个Mutex就进入到了饥饿模式。
在饥饿模式下,Mutex的拥有者将直接把锁交给队列最前面的waiter。新来的goroutine不会尝试获取锁,即使看起来锁没有被持有,它也不会去抢,也不会spin,它会乖乖地加入到等待队列的尾部。
如果拥有Mutex的waiter发现下面两种情况的其中之一,它就会把这个Mutex转换成正常模式:
-
此waiter已经是队列中的最后一个waiter了,没有其它的等待锁的goroutine了;
-
此waiter的等待时间小于1毫秒。
正常模式拥有更好的性能,因为即使有等待抢锁的waiter,goroutine也可以连续多次获取到锁。
饥饿模式是对公平性和性能的一种平衡,它避免了某些goroutine长时间的等待锁。在饥饿模式下,优先对待的是那些一直在等待的waiter。
2.1 创建锁
锁的接口
// A Locker represents an object that can be locked and unlocked.
type Locker interface { //锁接口
Lock()
Unlock()
}
锁的结构
// 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. type Mutex struct {//互斥锁实例 state int32 //状态 sema uint32 //信号量 }
锁的常量
const (
mutexLocked = 1 << iota //0x0000 0001
mutexWoken //0x0000 0010
mutexStarving //0x0000 0100
mutexWaiterShift = iota 3 //等待者数量的标志,最多可以阻塞2^29个goroutine。
starvationThresholdNs = 1e6
)
-
mutexLocked代表锁的状态,如果为1代表已加锁
-
mutexWoken代表是否唤醒,如果为 1代表已唤醒
-
mutexStarving代表是否处于饥饿模式,如果为1 代表是
-
mutexWaiterShift 值是3,有人会问这里为什么不左移,而是直接是3,这是因为3这个会有1<<mutexWaiterShift代表1个waiter数量,也有右移代表获取mutex上面的waiter数量。
-
starvationThresholdNs代表判断饥饿模式的时间,如果等待时间超过这个时间就判断为饥饿
2.2 加锁
图来自于:go中sync.Mutex源码解读
Lock
// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}
-
先直接用cas原语加锁,如果m.state等于0,那么直接将m.state 置为mutexLocked,代表加锁,如果成功,m.state为0x0000 0001,代表此时上锁成功。如果没成功就走lockSlow。
lockSlow
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
for {
//自旋逻辑
// Don't spin in starvation mode, ownership is handed off to waiters
// so we won't be able to acquire the mutex anyway.
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// Active spinning makes sense.
// Try to set mutexWoken flag to inform Unlock
// to not wake other blocked goroutines.
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 //如果来到这里就停止自旋了,如果第一次来到这里,说明只执行了自旋,并且有进行其他操作,也没为饥饿
// Don't try to acquire starving mutex, new arriving goroutines must queue.
//old&mutexStarving == 0==(?&0000 0100)==0,说明原来不是饥饿模式
//这里补充一下,锁如果是饥饿模式,其他来的goroutine是不会执行下面代码的,所以是抢不到锁的
if old&mutexStarving == 0 {
new |= mutexLocked //意思是将m.state的最后一位置为1 ,new 现在是上锁了
}
//该句意思代表如果之前的状态是
//mutexLocked|mutexStarving==0x0000 0001|0x0000 0100=0x0000 0101
//(?&0x0000 0101)!=0说明,原来是加锁的或者原来是饥饿的
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift //此时将waiter数量+1
}
// The current goroutine switches mutex to starvation mode.
// But if the mutex is currently unlocked, don't do the switch.
// Unlock expects that starving mutex has waiters, which will not
// be true in this case.
if starving && old&mutexLocked != 0 {
//如果 starving是true并且old 是上锁的,那么将new 设置为饥饿状态
// starving是true 这里说明唤醒后抢锁失败又循环到这里了,并且被判断为饥饿
new |= mutexStarving
}
if awoke { //如果awoke==true,说明goroutine已经被唤醒了,然后将锁的唤醒标志重置
// The goroutine has been woken from sleep,
// so we need to reset the flag in either case.
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
// &^ 是将new 的唤醒位清空
// mutexWoken=&0x0000 0010,当任何数与这个进行&^操作时,都会被置为0,如果new Woken为1,那么就会被清空为0,如果new Woken为0,那么与左侧保持一致,为0。
new &^= mutexWoken
}
//尝试将m.state 设置为new,如果成功走下面逻辑
if atomic.CompareAndSwapInt32(&m.state, old, new) {
//上面执行成功不代表上锁成功了,有可能是waiter数量改变,或者其他标志位设置成功
//这里说明old 不是饥饿状态并且也没有加锁了,如果old 不是饥饿状态,并且没有加锁,那么到这里说明加锁成立,退出这个循环返回就行了
if old&(mutexLocked|mutexStarving) == 0 {
break // locked the mutex with CAS
}
// If we were already waiting before, queue at the front of the queue.
//第一次循环来的时候waitStartTime为0,所以queueLifo=true,说明之前已经等待过了,那么就会被放在等待队列头部等待被唤醒,如果queueLifo=false代表是第一次来,放在队尾
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
//这里会阻塞,直到unlock 被唤醒,
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
//判断是不是饥饿模式,如果是饥饿模式,并且等待时间超过了设定的值(单位纳米)
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state //重新获取m.state 赋值给old
if old&mutexStarving != 0 { //这里说明现在m.state 已经是饥饿状态了,此时goroutine被唤醒
// If this goroutine was woken and mutex is in starvation mode,
// ownership was handed off to us but mutex is in somewhat
// inconsistent state: mutexLocked is not set and we are still
// accounted as waiter. Fix that.
if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
throw("sync: inconsistent mutex state")
}
//delta 就是就一个值而已表示加锁然后减去一个等待者数量,因为AddInt32只能传一个参数,所以这里可以先算后面的值
delta := int32(mutexLocked - 1<<mutexWaiterShift)
//如果starving为false,代表不饥饿了,或者等待者数量为一个了,等待者数量只有一个,没必须饥饿了
if !starving || old>>mutexWaiterShift == 1 {
// Exit starvation mode.
// Critical to do it here and consider wait time.
// Starvation mode is so inefficient, that two goroutines
// can go lock-step infinitely once they switch mutex
// to starvation mode.
// 退出饥饿模式
// 在这里这么做至关重要,还要考虑等待时间。
// 饥饿模式是非常低效率的,一旦两个goroutine将互斥锁切换为饥饿模式,它们便可以无限锁锁。
delta -= mutexStarving //去掉饥饿状态
}
//饥饿状态下直接将锁给这个被唤醒的goroutine,所以这一步直接原子赋值操作了
atomic.AddInt32(&m.state, delta)
break
}
//如果m.state 不是饥饿模式,那么将唤醒设置为true,自旋次数设置为0,重新开始抢锁
awoke = true
iter = 0
} else { // 如果CAS不成功,也就是说没能成功获得锁,锁被别的goroutine获得了或者锁一直没被释放
// 那么就更新状态,重新开始循环尝试拿锁
old = m.state
}
}
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
}
自旋
自旋逻辑,这是go对之前版本的mutex做的一个优化。
先解释几个位运算含义:
mutexLocked|mutexStarving==( 0x0000 0001 | 0x0000 0100 = 0x 0000 0101) 含义代表处于饥饿模式并且已加锁
old&(mutexLocked|mutexStarving) == mutexLocked =(?&0x0000 0101)=0x0000 0001) 含义代表old 必须不是饥饿模式,并且已加锁,如果是饥饿模式,就不可能出现第三位为1。
old&mutexWoken == 0==(?& 0x0000 0010)=0 ) 含义代表old Woken 这一位上必须为0,说明代表原来状态不是唤醒的
old>>mutexWaiterShift != 0 代表丢掉后面三位获取m.state前面的位数,也就是waiter等待者的数量,含义就是等待者数量不为0
old|mutexWoken 代表将old 设置为唤醒,因为(任何数|0x0000 0010)状态都是唤醒的
// Don't spin in starvation mode, ownership is handed off to waiters
// so we won't be able to acquire the mutex anyway.
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// Active spinning makes sense.
// Try to set mutexWoken flag to inform Unlock
// to not wake other blocked goroutines.
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin()
iter++
old = m.state
continue
}
-
上面的逻辑是首先不是饥饿模式并且是上锁状态并且可以自旋就走自旋逻辑。
如果awoke依然为false&&原来是未唤醒状态&&锁上面等待者数量大于0&&将m.state 的状态设置为已唤醒成功,则将awoke设置为true。
然后开始自旋,将自旋次数++,并且将新状态覆盖掉老状态,自旋次数超过设置的就会退出自旋
runtime_canSpin
// Active spinning for sync.Mutex.
//go:linkname sync_runtime_canSpin sync.runtime_canSpin
//go:nosplit
func sync_runtime_canSpin(i int) bool {
// sync.Mutex is cooperative, so we are conservative with spinning.
// Spin only few times and only if running on a multicore machine and
// GOMAXPROCS>1 and there is at least one other running P and local runq is empty.
// As opposed to runtime mutex we don't do passive spinning here,
// because there can be work on global runq or on other Ps.
if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
return false
}
if p := getg().m.p.ptr(); !runqempty(p) {
return false
}
return true
}
-
自旋条件1:i >= active_spin代表自旋次数要小于4,不能一直自旋
-
自旋条件2: 单核cpu 不能自旋
-
自旋条件3: gomaxprocs>=1,并且至少有一个在正运行的平,runq 不为空
-
自旋条件4: 单核cpu 不能自旋
runtime_doSpin
//go:linkname sync_runtime_doSpin sync.runtime_doSpin
//go:nosplit
func sync_runtime_doSpin() {
procyield(active_spin_cnt)
}
procyield 就是汇编写的,来看procyield 汇编代码
Go\src\runtime\asm_amd64.s +567
TEXT runtime·procyield(SB),NOSPLIT,$0-0
MOVL cycles+0(FP), AX
again:
PAUSE
SUBL $1, AX
JNZ again
RET
procyield内部实现什么也不做,只是反复调用 PAUSE 指令 30 次。
总结
总结整个流程:
-
上来先试着加锁,如果加锁成功,说明没人等待也没人抢,直接上锁返回
-
如果没成功就走慢速加锁。慢速加锁。首先判断有没有上锁和是不是饥饿状态
-
没有上锁或者是饥饿状态
-
上锁或者是饥饿状态并且满足自旋内部条件开始自旋
自旋过程中将锁置为唤醒,并且标记awoke = true
-
-
没有上锁或者是饥饿状态,将m.state 拷贝一份赋给new, 如果锁不是饥饿状态,给new上锁,所以这里能解释为什么饥饿状态下,别的goroutine 是抢不到锁的。
-
过去的状态如果为上锁或者饥饿,将new的waiter数量+1,因为锁没有释放轮不到自己,如果在饥饿模式下,会直接被队头抢走,也轮不到自己。所有要+1
-
awoke=true,表示已经被唤醒了,所以要将new 的唤醒标志awoke重新置为0
-
cas 原语将新值覆盖掉旧值,前提是旧值没有发生改变,如果发生变化,则重新循环开始抢锁,因为这个goroutine已经被唤醒了,但是没有抢到锁,所有重新开始抢有可能会继续自旋的
-
cas 原语操作成功,那么走下面逻辑,old 的状态如果不是饥饿也没上锁,那么new肯定是加来锁的,能进来这里,说明把锁状态变为1也成功了,注意waiter数没有+1,因为old 在这两种条件下是不会操作waiter数的。此时已经抢锁成功,直接返回。别人把锁释放了,某个自旋的goroutine运气好,一把就操作成功了。
if old&(mutexLocked|mutexStarving) == 0 { break // locked the mutex with CAS }
-
没成功就走下面阻塞逻辑,如果第一次来,不好意思排队尾,如果已经来过这里了,把你排队头吧。然后陷入阻塞等待被唤醒。
queueLifo := waitStartTime != 0 if waitStartTime == 0 { waitStartTime = runtime_nanotime() }
-
被唤醒后,如果锁不是饥饿状态,不好意思,你自己去重新开始抢锁吧。将awoke = true,自旋次数iter = 0
-
如果是饥饿状态,加锁将等待者数量-1,然后更新锁的值,其中还要判断下过去是不是饥饿状态,如果过去是饥饿状态,但是现在不饥饿了,要去掉饥饿标志。假设不去掉会怎么样?锁一直饥饿,所有goroutine都会抢不到锁,最后陷入阻塞,等待别人解锁后才能加锁成功。
2.3解锁
图来自于:go中sync.Mutex源码解读
unlock
// Unlock unlocks m.
// It is a run-time error if m is not locked on entry to Unlock.
//
// A locked Mutex is not associated with a particular goroutine.
// It is allowed for one goroutine to lock a Mutex and then
// arrange for another goroutine to unlock it.
func (m *Mutex) Unlock() {
if race.Enabled {
_ = m.state
race.Release(unsafe.Pointer(m))
}
// Fast path: drop lock bit.
new := atomic.AddInt32(&m.state, -mutexLocked) //将锁的加锁位直接减为0,获取出来新的状态值
if new != 0 { //如果值不为0,说明上面有人等待,则走慢解锁
// Outlined slow path to allow inlining the fast path.
// To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
m.unlockSlow(new)
}
}
unlockSlow
func (m *Mutex) unlockSlow(new int32) {
//判断是否多次解锁,如果是多次解锁,则抛出异常
//new+mutexLocked代表将锁置为1,如果两个状态& 不为0,则说明重复解锁
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
if new&mutexStarving == 0 {
old := new
for {
// If there are no waiters or a goroutine has already
// been woken or grabbed the lock, no need to wake anyone.
// In starvation mode ownership is directly handed off from unlocking
// goroutine to the next waiter. We are not part of this chain,
// since we did not observe mutexStarving when we unlocked the mutex above.
// So get off the way.
//如果等待者数量等于0,或者锁的状态已经变为加锁,唤醒,或者饥饿直接就返回了
//因为在唤醒状态,goroutine会自己去抢锁,饥饿会直接把锁交给等待者队头
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
//下面是选择一个goroutine去唤醒,先将waiter 数量-1 ,然后将锁的状态设置成被唤醒
// Grab the right to wake someone.
new = (old - 1<<mutexWaiterShift) | mutexWoken
//下面代表更新锁的状态,如果成功就唤醒一个goroutine,注意是唤醒队尾的
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
} else {
// Starving mode: handoff mutex ownership to the next waiter, and yield
// our time slice so that the next waiter can start to run immediately.
// Note: mutexLocked is not set, the waiter will set it after wakeup.
// But mutex is still considered locked if mutexStarving is set,
// so new coming goroutines won't acquire it.
//饥饿模式,锁的使用权直接交给下一个等待者
runtime_Semrelease(&m.sema, true, 1)
}
}
慢解锁先判断是不是饥饿状态
if new&mutexStarving == 0 {
old := new
for {
// If there are no waiters or a goroutine has already
// been woken or grabbed the lock, no need to wake anyone.
// In starvation mode ownership is directly handed off from unlocking
// goroutine to the next waiter. We are not part of this chain,
// since we did not observe mutexStarving when we unlocked the mutex above.
// So get off the way.
//如果锁上面没有人等待,并且锁也状态是加锁,或者唤醒或者饥饿模式,直接返回就行了
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
// Grab the right to wake someone.
//将waiter数量-1 并且将锁设置成已唤醒
new = (old - 1<<mutexWaiterShift) | mutexWoken
//尝试更新锁的状态,如果更新成功则唤醒一个阻塞的goroutine去获取锁
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1)
return
}
//cas 操作没有成功,说明状态被改过了,重新执行cas 操作
old = m.state
}
}
如果是饥饿状态直接释放信号量,唤醒等待的waiter
else {
// Starving mode: handoff mutex ownership to the next waiter, and yield
// our time slice so that the next waiter can start to run immediately.
// Note: mutexLocked is not set, the waiter will set it after wakeup.
// But mutex is still considered locked if mutexStarving is set,
// so new coming goroutines won't acquire it.
// 饥饿模式下, 直接将锁的拥有权传给等待队列中的第一个.
// 注意此时state的mutexLocked还没有加锁,唤醒的goroutine会设置它。
// 在此期间,如果有新的goroutine来请求锁, 因为mutex处于饥饿状态, mutex还是被认为处于锁状态,
// 新来的goroutine不会把锁抢过去.
runtime_Semrelease(&m.sema, true, 1)
}
总结
总结整个流程:
-
先将锁最后一位减去1置为0,再判断锁上面是否为0,不为0说明有人抢锁,走慢解锁
atomic.AddInt32(&m.state, -mutexLocked)
-
慢解锁首先判断是不是饥饿状态,如果是饥饿状态,唤醒队头去抢锁就行。
-
不是饥饿状态先在for循环里面更新锁的状态,当锁上面没有等待者了,锁被标记唤醒了,已经有人抢到锁了,或者锁变成饥饿模式了,直接返回就行了。
锁不是这些状态,这时候需要手动将锁设置为唤醒,等待者数量-1,然后更新锁的状态,如果锁更新成功了才去唤醒,要不然继续循环。
3常用错误使用分析
3.1 锁重入
当一个线程获取锁时,如果没有其它线程拥有这个锁,那么,这个线程就成功获取到这个锁。之 后,如果其它线程再请求这个锁,就会处于阻塞等待的状态。但是,如果拥有这把锁的线程再请 求这把锁的话,不会阻塞,而是成功返回,所以叫可重入锁(有时候也叫做递归锁)。只要你拥 有这把锁,你可以可着劲儿地调用,比如通过递归实现一些算法,调用者不会阻塞或者死锁。
Mutex的实现中没有记录哪个goroutine拥有这把锁。理论上,任何goroutine 都可以随意地Unlock这把锁
下面就是锁重入的例子
package main
import (
"sync"
)
type Person struct {
lock *sync.Mutex
}
func (p *Person)xxLock() {
p.lock.Lock()
p.xxLock1()
defer p.lock.Unlock()
//......
}
func (p *Person)xxLock1() {
p.lock.Lock()
defer p.lock.Unlock()
//......
}
func main() {
p:=Person{lock: &sync.Mutex{}}
p.xxLock()
}
获取goroutine id 方式
从堆栈获得
平时 打印panic 堆栈时能看到goroutine id,所以我们可以直接从这获取,但是并不稳定,哪天版本更新,获取方式可能就变了
func GoID() int {
var buf [64]byte
n := runtime.Stack(buf[:], false)
idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0]
id, err := strconv.Atoi(idField)
if err != nil {
panic(fmt.Sprintf("cannot get goroutine id: %v", err))
}
return id
}
第三方库
好消息是现在已经有很多成熟的方法了,可以支持多个Go版本的goroutine id,给你推荐一个常 用的库:petermattis/goid。
方案一:通过hacker的方式获取到goroutine id,记录下获取锁的goroutine id,它可以实现 Locker接口。
// RecursiveMutex 包装一个Mutex,实现可重入
type RecursiveMutex struct {
sync.Mutex
owner int64 // 当前持有锁的goroutine id
recursion int32 // 这个goroutine 重入的次数
}
func (m *RecursiveMutex) Lock() {
gid := goid.Get()
// 如果当前持有锁的goroutine就是这次调用的goroutine,说明是重入
if atomic.LoadInt64(&m.owner) == gid {
m.recursion++
return
}
m.Mutex.Lock()
// 获得锁的goroutine第一次调用,记录下它的goroutine id,调用次数加1
atomic.StoreInt64(&m.owner, gid)
m.recursion = 1
}
func (m *RecursiveMutex) Unlock() {
gid := goid.Get()
// 非持有锁的goroutine尝试释放锁,错误的使用
if atomic.LoadInt64(&m.owner) != gid {
panic(fmt.Sprintf("wrong the owner(%d): %d!", m.owner, gid))
}
// 调用次数减1
m.recursion--
if m.recursion != 0 { // 如果这个goroutine还没有完全释放,则直接返回
return
}
// 此goroutine最后一次调用,需要释放锁
atomic.StoreInt64(&m.owner, -1)
m.Mutex.Unlock()
}
方案二:调用Lock/Unlock方法时,由goroutine提供一个token,用来标识它自己,而不是我 们通过hacker的方式获取到goroutine id,但是,这样一来,就不满足Locker接口了。 可重入锁(递归锁)解决了代码重入或者递归调用带来的死锁问题,同时它也带来了另一个好 处,就是我们可以要求,只有持有锁的goroutine才能unlock这个锁。这也很容易实现,因为在上 面这两个方案中,都已经记录了是哪一个goroutine持有这个锁。
方案一是用goroutine id做goroutine的标识,我们也可以让goroutine自己来提供标识。不管怎么 说,Go开发者不期望你利用goroutine id做一些不确定的东西,所以,他们没有暴露获取 goroutine id的方法。
下面的代码是第二种方案。调用者自己提供一个token,获取锁的时候把这个token传入,释放锁 的时候也需要把这个token传入。通过用户传入的token替换方案一中goroutine id,其它逻辑和 方案一一致
// Token方式的递归锁
type TokenRecursiveMutex struct {
sync.Mutex
token int64
recursion int32
}
// 请求锁,需要传入token
func (m *TokenRecursiveMutex) Lock(token int64) {
if atomic.LoadInt64(&m.token) == token { //如果传入的token和持有锁的token一致,说明是递归调用
m.recursion++
return
}
m.Mutex.Lock() // 传入的token不一致,说明不是递归调用
// 抢到锁之后记录这个token
atomic.StoreInt64(&m.token, token)
m.recursion = 1
}
// 释放锁
func (m *TokenRecursiveMutex) Unlock(token int64) {
if atomic.LoadInt64(&m.token) != token { // 释放其它token持有的锁
panic(fmt.Sprintf("wrong the owner(%d): %d!", m.token, token))
}
m.recursion-- // 当前持有这个锁的token释放锁
if m.recursion != 0 { // 还没有回退到最初的递归调用
return
}
atomic.StoreInt64(&m.token, 0) // 没有递归调用了,释放锁
m.Mutex.Unlock()
}
3.2 锁拷贝
拷贝情况1: 直接拷贝
import (
"sync"
)
var lock sync.Mutex
type Person struct {
lock sync.Mutex
}
func main() {
p:=Person{lock: lock}
}
拷贝情况2:嵌入拷贝,这种情况是很常见的,比如一个结构体里面带有sync.map对象,但是将这个结构体赋值给别的结构体却是对象
package main
import (
"sync"
)
type Person struct {
lock *sync.Mutex
}
type Test struct {
person Person
}
func main() {
p:=Person{lock: &sync.Mutex{}}
t:=Test{person: p}
}
3.3 死锁
死锁并不是锁的一种,而是一种错误使用锁导致的现象,死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。系统发生死锁现象不仅浪费大量的系统资源,甚至导致整个系统崩溃,带来灾难性后果。所以,对于死锁问题在理论上和技术上都必须予以高度重视。
可以参考下面的死锁说明
chan 造成的死锁,可以参考下面的情况
互斥锁造成的死锁
接下来就看一个死锁的例子
package main
import (
"sync"
"time"
)
func main() {
var mutex1 sync.Mutex
var mutex2 sync.Mutex
wg :=sync.WaitGroup{}
wg.Add(2)
//goroutine 1
go func() {
defer wg.Done()
mutex1.Lock()
defer mutex1.Unlock()
time.Sleep(1 * time.Second)
mutex2.Lock()//需要mutex2锁,但是2锁没释放,等待1锁释放,但是2锁我还没有,怎么释放1锁?
mutex2.Unlock()
}()
//goroutine 2
go func() {
defer wg.Done()
mutex2.Lock()
defer mutex2.Unlock()
time.Sleep(1 * time.Second)
mutex1.Lock()//需要mutex1锁,
defer mutex1.Unlock()
}()
wg.Wait()
}
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [semacquire]:
3.4 调用lock 没有调用unlock
Unlock方法可以被任意的goroutine调用释放锁,即使是没持有这个互斥锁的goroutine,也可以进行这个操作。这是因为,Mutex本身并没有包含持有这把锁的goroutine的信息,所以,Unlock也不会对此进行检查。Mutex的这个设计一直保持至今。
这就带来了一个有趣而危险的功能。为什么这么说呢?
你看,其它goroutine可以强制释放锁,这是一个非常危险的操作,因为在临界区的goroutine可能不知道锁已经被释放了,还会继续执行临界区的业务操作,这可能会带来意想不到的结果,因为这个goroutine还以为自己持有锁呢,有可能导致data race问题。
所以,我们在使用Mutex的时候,必须要保证goroutine尽可能不去释放自己未持有的锁,一定要遵循“谁申请,谁释放”的原则。在真实的实践中,我们使用互斥锁的时候,很少在一个方法中单独申请锁,而在另外一个方法中单独释放锁,一般都会在同一个方法中获取锁和释放锁。
-
日常使用中,如果加完锁,判断错误,然后返回,结果就忘记unlock
package main
import (
"errors"
"sync"
)
var lock sync.Mutex
func main() {
Test1()
}
func Test1() {
lock.Lock()
err:=errors.New("有错")
if err!=nil{
return
}
lock.Unlock()
}
-
代码重构,重构别人的代码,由于不熟悉,忘记掉unlock
3.5panic 导致的死锁
package main
import (
"fmt"
"sync"
)
type Person struct {
lock *sync.Mutex
}
func (p *Person)xxLock() {
var err error
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("getStatsReader panic %v", r)
}
}()
p.lock.Lock()
//......
}
func (p *Person)xxLock1() {
panic("xxxoo")//由于业务逻辑函数导致的panic
//......
p.lock.Unlock()
}
func main() {
p:=Person{lock: &sync.Mutex{}}
p.xxLock()
}
现在复现一下这个问题,函数调用子函数,子函数触发了panic,当前函数进行了recover,此时锁有释放吗?
解决方法一种就是在recover 判断里面进行unlock,如下
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("getStatsReader panic %v", r)
}
if err!=nil{
p.lock.Unlock()
}
}()
大型项目有时候由于代码复杂,重构比较困难,直接就这样处理了,其实更好的方式就是在一个函数里面同时写lock和unlock,其实这也避免不了中间panic情况,如果解锁是在整个操作完,defer p.lock.Unlock()是一种比较好的方法。如果希望锁的范围更小,在代码中间解锁,有可能有recover和panic的情况出现,最好在recover里面处理下。
4常用锁解析
4.1 自旋锁
自旋锁是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断地判断是否能够被成功获取,直到获取到锁才会退出循环。
获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting。
它是为实现保护共享资源而提出的一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能由一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,“自旋”一词就是因此而得名。
type spinLock uint32
func (sl *spinLock) Lock() {
for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
runtime.Gosched()
}
}
func (sl *spinLock) Unlock() {
atomic.StoreUint32((*uint32)(sl), 0)
}
func NewSpinLock() sync.Locker {
var lock spinLock
return &lock
}
-
自旋锁的核心在于 runtime.Gosched,切走cpu 执行权限,等轮到它执行的再进行一次如果没加锁成功再一次切走执行权限
-
加锁操作就是cas 操作简单的互斥量,1 代表加锁成功
4.2 阻塞锁
实现在抢锁时候一直等待完成,跟互斥锁很像,但粒度更大,而且带超时机制,如果超时则报错,只需要在排他锁上稍微加一个超时机制就行了
package main
import (
"errors"
"fmt"
"log"
"sync"
"time"
)
type Mutex struct {
ch chan struct{}
}
func NewMutex() *Mutex {
mu:= &Mutex{
ch: make(chan struct{},1),
}
mu.ch<- struct{}{}
return mu
}
func (m* Mutex)Lock() {
<-m.ch
}
func (m* Mutex)LockTimeout(duration int)error {
ticker:=time.NewTimer(time.Duration(duration)*time.Second)
defer ticker.Stop()
select {
case <-m.ch:
return nil
case <-ticker.C:
log.Println("锁超时")
return errors.New("锁超时")
}
}
func(m* Mutex) TryLock() bool {
select {
case <-m.ch:
return true
default:
}
return false
}
func (m* Mutex)Unlock() {
m.ch<- struct{}{}
}
func main() {
m:=NewMutex()
sum :=0
var w sync.WaitGroup
for i:=0;i<1000;i++{
w.Add(1)
go func() {
defer w.Done()
err:=m.LockTimeout(1)
if err!=nil{
return
}
time.Sleep(10*time.Second)
sum+=1
m.Unlock()
}()
}
w.Wait()
fmt.Println(sum)//1,因为其他抢不到锁,超时退出了
}
这个锁其实还有更高级的实现,后面有空会补上例子,包括可重入,按传入参数key 进行加锁。
4.3 排他锁
在实际开发中,如果要更新配置数据,我们通常需要加锁,这样可以避免同时有多个goroutine 并发修改数据。有的时候,我们也会使用TryLock。这样一来,当某个goroutine想要更改配置数 据时,如果发现已经有goroutine在更改了,其他的goroutine调用TryLock,返回了false,这个 goroutine就会放弃更改。
Go官方issue 6123有一个讨论,标准库的Mutex不会添加TryLock方法。
通过channel实现排他锁
package main
import (
"errors"
"fmt"
"log"
"sync"
"time"
)
type Mutex struct {
ch chan struct{}
}
func NewMutex() *Mutex {
mu:= &Mutex{
ch: make(chan struct{},1), //重点是这,有1个缓存
}
mu.ch<- struct{}{}//然后填充缓存
return mu
}
func (m* Mutex)Lock() {
<-m.ch //将缓存释放掉,其他人来就会阻塞
}
func(m* Mutex) TryLock() bool {
select {
case <-m.ch:
return true
default:
}
return false //没抢到自己返回
}
func (m* Mutex)Unlock() {
m.ch<- struct{}{}
}
func main() {
m:=NewMutex()
sum :=0
var w sync.WaitGroup
for i:=0;i<1000;i++{
w.Add(1)
go func() {
defer w.Done()
ok:=m.TryLock()
if !ok{
return
}
sum+=1
m.Unlock()
}()
}
w.Wait()
fmt.Println(sum)
}
5位运算详解
符号 | 描述 | 运算规则 |
---|---|---|
& | 与 | 两个位都为1时,结果才为1 |
| | 或 | 两个位都为0时,结果才为0 |
^ | 异或 | 两个位相同为0,相异为1 |
~ | 取反 | 0变1,1变0 |
<< | 左移 | 各二进位全部左移若干位,高位丢弃,低位补0 |
>> | 右移 | 各二进位全部右移若干位,对无符号数,高位补0,有符号数,各编译器处理方法不一样,有的补符号位(算术右移),有的补0(逻辑右移) |
&
总结:两位同时为1,结果才为1,否则结果为0。
例如:3&5
即 0000 0011& 0000 0101 = 0000 0001,因此 3&5 的值得1。
注意:负数按补码形式参加按位与运算。
|
运算规则:
0|0=0 0|1=1 1|0=1 1|1=1
总结:参加运算的两个对象只要有一个为1,其值为1。
例如:3|5
即 0000 0011| 0000 0101 = 0000 0111,因此,3|5
的值得7。
注意:负数按补码形式参加按位或运算。
^
运算规则:
0^0=0 0^1=1 1^0=1 1^1=0
总结:参加运算的两个对象,如果两个相应位相同为0,相异为1。
异或的几条性质:
1、交换律
2、结合律 (a^b)^c == a^(b^c)
3、对于任何数x,都有 x^x=0,x^0=x
4、自反性: a^b^b=a^0=a;
&^
^1=0 ^0=1
总结:对一个二进制数按位取反,即将0变1,1变0。
主要功能:
将运算符左边数据相异的位保留,相同位清零。
fmt.Println(0&^0)//0
fmt.Println(0&^1)//0
fmt.Println(1&^0)//1
fmt.Println(1&^1)//0
-
如果右侧是0,则左侧数保持不变
-
如果右侧是1,则左侧数一定清零
-
功能同a&(^b)相同
-
如果左侧是变量,也等同于:
var a int a &^= b
6semaphore(信号量)
官方描述
// Semaphore implementation exposed to Go.
// Intended use is provide a sleep and wakeup
// primitive that can be used in the contended case
// of other synchronization primitives.
// Thus it targets the same goal as Linux's futex,
// but it has much simpler semantics.
//
// That is, don't think of these as semaphores.
// Think of them as a way to implement sleep and wakeup
// such that every sleep is paired with a single wakeup,
// even if, due to races, the wakeup happens before the sleep.
//
// See Mullender and Cox, ``Semaphores in Plan 9,''
// https://swtch.com/semaphore.pdf
// 具体的用法是提供 sleep 和 wakeup 原语
// 以使其能够在其它同步原语中的竞争情况下使用
// 因此这里的 semaphore 和 Linux 中的 futex 目标是一致的
// 只不过语义上更简单一些
//
// 也就是说,不要认为这些是信号量
// 把这里的东西看作 sleep 和 wakeup 实现的一种方式
// 每一个 sleep 都会和一个 wakeup 配对
// 即使在发生 race 时,wakeup 在 sleep 之前时也是如此
在go/src/sync/runtime.go
中,定义了这几个方法
// Semacquire等待*s > 0,然后原子递减它。
// 它是一个简单的睡眠原语,用于同步
// library and不应该直接使用。
func runtime_Semacquire(s *uint32)
// SemacquireMutex类似于Semacquire,用来阻塞互斥的对象
// 如果lifo为true,waiter将会被插入到队列的头部
// skipframes是跟踪过程中要省略的帧数,从这里开始计算
// runtime_SemacquireMutex's caller.
func runtime_SemacquireMutex(s *uint32, lifo bool, skipframes int)
// Semrelease会自动增加*s并通知一个被Semacquire阻塞的等待的goroutine
// 它是一个简单的唤醒原语,用于同步
// library and不应该直接使用。
// 如果handoff为true, 传递信号到队列头部的waiter
// skipframes是跟踪过程中要省略的帧数,从这里开始计算
// runtime_Semrelease's caller.
func runtime_Semrelease(s *uint32, handoff bool, skipframes int)
具体的实现是在go/src/runtime/sema.go
中
//go:linkname sync_runtime_Semacquire sync.runtime_Semacquire
func sync_runtime_Semacquire(addr *uint32) {
semacquire1(addr, false, semaBlockProfile, 0)
}
//go:linkname sync_runtime_Semrelease sync.runtime_Semrelease
func sync_runtime_Semrelease(addr *uint32, handoff bool, skipframes int) {
semrelease1(addr, handoff, skipframes)
}
//go:linkname sync_runtime_SemacquireMutex sync.runtime_SemacquireMutex
func sync_runtime_SemacquireMutex(addr *uint32, lifo bool, skipframes int) {
semacquire1(addr, lifo, semaBlockProfile|semaMutexProfile, skipframes)
}
go futex
上面提到了和futex
作用一样,关于futex
futex(快速用户区互斥的简称)是一个在Linux上实现锁定和构建高级抽象锁如信号量和POSIX互斥的基本工具
Futex 由一块能够被多个进程共享的内存空间(一个对齐后的整型变量)组成;这个整型变量的值能够通过汇编语言调用CPU提供的原子操作指令来增加或减少,并且一个进程可以等待直到那个值变成正数。Futex 的操作几乎全部在用户空间完成;只有当操作结果不一致从而需要仲裁时,才需要进入操作系统内核空间执行。这种机制允许使用 futex 的锁定原语有非常高的执行效率:由于绝大多数的操作并不需要在多个进程之间进行仲裁,所以绝大多数操作都可以在应用程序空间执行,而不需要使用(相对高代价的)内核系统调用。
go中的semaphore
作用和futex
目标一样,提供sleep
和wakeup
原语,使其能够在其它同步原语中的竞争情况下使用。当一个goroutine
需要休眠时,将其进行集中存放,当需要wakeup
时,再将其取出,重新放入调度器中。
例如在读写锁的实现中,读锁和写锁之前的相互阻塞唤醒,就是通过sleep
和wakeup
实现,当有读锁存在的时候,新加入的写锁通过semaphore
阻塞自己,当前面的读锁完成,在通过semaphore
唤醒被阻塞的写锁。
有兴趣可去go源码看看实现:Go\src\runtime\futex,后面有精力再去剖析这个
来自大佬的总结:
这一对 semacquire 和 semrelease 理解上可能不太直观。 首先,我们必须意识到这两个函数一定是在两个不同的 M(线程)上得到执行,否则不会出现并发,我们不妨设为 M1 和 M2。 当 M1 上的 G1 执行到 semacquire1 时,如果快速路径成功,则说明 G1 抢到锁,能够继续执行。但一旦失败且在慢速路径下 依然抢不到锁,则会进入 goparkunlock,将当前的 G1 放到等待队列中,进而让 M1 切换并执行其他 G。 当 M2 上的 G2 开始调用 semrelease1 时,只是单纯的将等待队列的 G1 重新放到调度队列中,而当 G1 重新被调度时(假设运气好又在 M1 上被调度),代码仍然会从 goparkunlock 之后开始执行,并再次尝试竞争信号量,如果成功,则会归还 sudog。
参考
【同步原语】6.8 同步原语 | Go 语言原本