go 进阶 sync相关: 二. sync.Mutex 互斥锁

一. 使用示例

  1. 使用流程
  1. 创建sync.Mutex变量
  2. 调用 Lock()方法加锁
  3. 调用Unlock()方法释放锁
func TestMutex() {
	var mutex sync.Mutex
	sum := 0
	for i := 0; i < 10; i++ {
		go func(t int) {
			mutex.Lock()
			defer mutex.Unlock()
			sum += t
			fmt.Println(t)
		}(i)
	}
	time.Sleep(time.Second)
	fmt.Printf("Sum: %v\n", sum)
}

二. 源码分析

  1. Mutex 互斥锁是对共享资源进行访问控制的主要手段,对外暴露两个方法Lock()和Unlock()分别用于加锁和解锁
  2. 先看一下Mutex结构体,内部包含两个属性:
  1. Mutex.state表示互斥锁的状态,比如是否被锁定等。
  2. Mutex.sema表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程
type Mutex struct {
	//表示锁状态
	state int32
	//是用来控制锁状态的信号量
	sema  uint32
}

Mutex.state 详解

  1. 其中state是由32位组成的,其中前29用来记录等待当前互斥锁的 goroutine 个数,后3位表示真实的锁状态status

Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量
在这里插入图片描述

  1. 查看state后三位的锁状态,分为:
  1. Locked: 表示该Mutex是否已被锁定,0:没有锁定 1:已被锁定。
  2. Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。
  3. Starving:表示该Mutex是否处理饥饿状态, 0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms
  1. 协程之间抢锁实际上是抢给Locked赋值的权利,能给Locked域置1,就说明抢锁成功。抢不到的话就阻塞等待Mutex.sema信号量,一旦持有锁的协程解锁,等待的协程会依次被唤醒
  2. Woken和Starving主要用于控制协程间的抢锁过程

state=Starving 锁模式

  1. 在加锁时分为正常模式与饥饿模式,通过state中1bit位表示,也就是上方state中的Starving,0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms
  2. 正常模式下:
  1. 当一个 goroutine 占有锁时,后面的 goroutine 会以先进先出的顺序在等待队列里排队,
  2. 当锁被释放时,队列中最前面的 goroutine 会被唤醒,但是唤醒后的 goroutine 并不会立刻拥有锁,需要和新到达的 goroutine 竞争锁,
  3. 注意新的 goroutine 有一个已经在 CPU 上运行了的优势,并且新的goroutine 可能有多个,所以在竞争过程中,刚被唤醒的 goroutine 大概率会竞争失败,这个原因可能会导致一些在排队的 goroutine 很长时间得不到执行被 “饿死”,
  4. 为了让锁竞争更加公平,Go 1.9 添加了饥饿模式,
  1. 什么是饥饿模式: Go 1.9添加的,如果一个等待的 goroutine 超过 1 ms (starvationThresholdNs) 没有得到锁,这个锁就会被转换为饥饿模式。在饥饿模式下,锁竞争时,会直接交给第一个 goroutine,新来的 goroutine 将不会尝试去获得该锁,而是会直接放在队列尾部,注意正常状态下的性能是高于饥饿模式的,所以在大部分情况下,还是应该回到正常模式去的。当队列中最后一个 goroutine 被执行或者它的等待时间低于 1 ms 时,会将该锁的状态切换回正常

Lock() 加锁

  1. 在Lock方法中,先通过CAS判断m.state是不是等于 0,如果是说明时无锁状态,直接设置为mutexLocked表示加锁成功,如果不等于0,说明被锁定中需要执行lockSlow()进行自旋或阻塞等待
  2. 在lockSlow()内部重点执行了以下逻辑
  1. 判断是否可以自旋
  2. 自旋时执行runtime_doSpin()尝试自旋
  3. 当不能自旋时重新计算锁的状态
  4. 执行atomic.CompareAndSwapInt32(&m.state, old, new)更新锁状态
  5. 更新锁状态成功后如果不是获取锁成功,将当前goroutine放入等待队列等待,并且判断是否要转换状态进入饥饿模式
  6. 如果cas更新锁状态失败,锁被其他goroutine占用了,还原状态继续for循环
func (m *Mutex) Lock() {
	// 如果处于正常模式,且Mutex未上锁、没有等待获取锁的goroutine,则获取到锁并修改为已上锁状态,直接返回
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		return
	}
	m.lockSlow()
}
 
func (m *Mutex) lockSlow() {
	var waitStartTime int64
	starving := false
	awoke := false
	iter := 0
	old := m.state
	for {
		//1.判断是否可以自旋,如果可以执行if内
		if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
			//有阻塞的goroutine且为非woken状态,设置为已唤醒
			if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
				atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
				awoke = true
			}
            //2.尝试自旋
			runtime_doSpin()
			iter++ //更新迭代此处
			old = m.state //更新锁信息
			continue
		}

		//3.计算state锁状态
		new := old
		// 正常模式下,设置为已上锁状态,尝试CAS获取;而饥饿状态时不会设置,因为锁的所有权直接转给队列中的第一个goroutine
		if old&mutexStarving == 0 {
			new |= mutexLocked
		}
        // 饥饿状态或已上锁时,将当前goroutine添加到等待队列,等待者加一
		if old&(mutexLocked|mutexStarving) != 0 {
			new += 1 << mutexWaiterShift
		}
		// 如果当前goroutine处于饥饿状态且锁已被加锁,则将锁的状态转为饥饿
		if starving && old&mutexLocked != 0 {
			new |= mutexStarving
		}
        // // 如果当前goroutine为唤醒状态,则需重置woken位
		if awoke {
			// 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
		}
        //4.计算出锁的状态后尝试通过CAS更新锁状态
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
            //4.1获取锁成功: 若之前的状态既不是饥饿状态也不是被获取状态,则表明当前goroutine已获得锁
			if old&(mutexLocked|mutexStarving) == 0 {
				break
			}
			//4.2如果之前已经等待过了则需要放到队头
			queueLifo := waitStartTime != 0
			//如果 waitStartTime != 0 说明该 goroutine 在之前已经等待了
			if waitStartTime == 0 {
				waitStartTime = runtime_nanotime()
			}
            //4.3没有获得锁,阻塞,将当前goroutine放入等待队列
    		// 该方法使用一个 sleep 原语阻塞 goroutine
    		// 如果 queueLifo == true, 说明其之前已经等待过了,现在是被唤醒,这时会把它加入等待队列队首
    		// 反之说明是一个新来的 goroutine, 就把他加入队尾
    		// 该方法会不断调用尝试获取锁并休眠当前 Goroutine 等待信号量的释放,
    		//一旦当前 Goroutine 可以获取信号量,它就会立刻返回
			runtime_SemacquireMutex(&m.sema, queueLifo, 1)
            // 若等待时长超出阈值则转为饥饿状态
			starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
			old = m.state
            // 锁处于饥饿状态,锁的所有权直接移交给当前 goroutine
			if old&mutexStarving != 0 {
				// 如果锁的状态是被唤醒或被获取,或者等待队列为空,则抛出异常
				if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
					throw("sync: inconsistent mutex state")
				}
                // 当前的goroutine获得了锁,等待队列-1
				delta := int32(mutexLocked - 1<<mutexWaiterShift)
				if !starving || old>>mutexWaiterShift == 1 {
					// 若goroutine处于非饥饿状态或等待队列只有一个goroutine,则退出饥饿模式
					delta -= mutexStarving
				}
                // 原子性更新锁的状态,并退出执行业务逻辑
				atomic.AddInt32(&m.state, delta)
				break
			}
            // 若锁不是饥饿模式,则将当前goroutine状态设置为已唤醒,并重置iter
			awoke = true
			iter = 0
		} else {
            //锁被其他goroutine占用了,还原状态继续for循环
			old = m.state
		}
	}
}

1. 如何判断是否可以自旋

  1. 什么条件下会进入自旋: 加锁时,如果当前Locked位为1,说明该锁当前由其他协程持有,会执行lockSlow(),在该函数中,尝试加锁的协程并不是马上转入阻塞,而是会持续的探测Locked位是否变为0,这个过程即为自旋过程
//lockSlow()中是否进入自旋的判断
//正常模式下,并且锁是锁定状态,runtime_canSpin(iter)返回true时可以自旋进入if
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
	//自旋
}
  1. 什么是自旋: 自旋对应于CPU的”PAUSE”指令,CPU对该指令什么都不做,相当于CPU空转,对程序而言相当于sleep了一小段时间,时间非常短,当前实现是30个时钟周期。自旋过程中会持续探测Locked是否变为0,连续两次探测间隔就是执行这些PAUSE指令,它不同于sleep,不需要将协程转为睡眠状态
  2. 进入自旋后,什么条件下允许继续自旋: runtime_canSpin()什么时候返回true:
  1. 迭代次数小于4
  2. 并且多核CPU时
  3. 并且当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空时
// sync.Mutex 的主动自旋
//go:linkname sync_runtime_canSpin sync.runtime_canSpin
//go:nosplit
func sync_runtime_canSpin(i int) bool {
	// 若迭代次数超过active_spin(4),或 cpu核数为1,或 逻辑处理器>1,返回false
	if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
		return false
	}
    //若逻辑处理器的本地goroutine队里为空,返回false
	if p := getg().m.p.ptr(); !runqempty(p) {
		return false
	}
	return true
}
  1. 总结可以自旋的条件:
  1. 锁定状态
  2. 正常模式
  3. cpu核数大于1
  4. 迭代次数小于4
  5. p的数量要大于1,GOMAXPROCS()将处理器设置为1就不能启用自旋
  6. p中的可运行队列必须有1个为空,否则会延迟协程调度

2. runtime_doSpin()自旋更新

  1. 当运行自旋时,会执行if内的runtime_doSpin(), 该方法内会将循环次数设置为30次,自旋操作就是执行30次PAUSE指令,通过该指令占用CPU并消费CPU时间,进行忙等待;
const active_spin_cnt = 30
func sync_runtime_doSpin() {
	procyield(active_spin_cnt)
}
  1. 这就是整个自旋操作的逻辑,通过自旋优化阻塞唤醒的性能消耗

3. 计算锁状态

  1. 在获取锁时如果判断不可以自旋时,会计算锁的state状态,基于old状态声明到一个新状态,
  2. state计算过程
  1. 新状态处于非饥饿的条件下才可以加锁
  2. 如果old已经处于加锁或者饥饿状态,则等待者按照FIFO的顺序排队
  3. 如果当前锁处于饥饿模式,并且已被加锁,则将低3位的Starving状态位设置为1,表示饥饿

4. 执行atomic.CompareAndSwapInt32(&m.state, old, new) 更新锁状态

  1. 计算state锁状态完成后,执行CompareAndSwapInt32()通过cas更新锁状态,有更新成功与失败两种情况
  2. 如果更新失败则重试,锁被其他goroutine占用了,还原状态继续for循环
  3. 如果更新锁状态成功,会获取锁状态进行指定操作
  1. 获取到锁,当前加锁成功直接break
  2. 如果没有获取到锁执行runtime_SemacquireMutex()通过sleep 原语阻塞 goroutine放入等待队列进行等待
  3. 并且在阻塞时会通过waitStartTime是否等于0判断这个被阻塞的goroutine是不是第一次加入等待队列,如果第一次会加入到队列尾部,如果不是会加入到队列头部
  4. 并且会计算当前锁是否要加入饥饿模式,如果是锁的所有权直接移交给当前 goroutine

Unlock()解锁

  1. 使用AddInt32方法快速进行解锁,将m.state的低1位置为0,然后判断新的m.state值,如果值为0,则代表当前锁已经完全空闲了,结束解锁,不等于0说明当前锁没有被占用,会有等待的goroutine还未被唤醒,需要进行一系列唤醒操作,这部分逻辑就在unlockSlow方法内
func (m *Mutex) Unlock() {
    if race.Enabled {
        _ = m.state
        race.Release(unsafe.Pointer(m))
    }
    // 如果 m.state - mutexLocked == 0 说明没人等待该锁,同时该锁处于正常状态
    // 这时可以快速解锁,即锁状态会直接赋成 0
    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
            }
            
             // 唤醒新的等待者
            
            // 等待者减一,设置唤醒标志 woken
            new = (old - 1<<mutexWaiterShift) | mutexWoken
             // 设置 state, 唤醒一个阻塞着的 goroutine
            if atomic.CompareAndSwapInt32(&m.state, old, new) {
                runtime_Semrelease(&m.sema, false, 1)
                return
            }
             // 设置失败,重新获取状态设置
            old = m.state
        }
    } else {
        // 饥饿模式下,直接唤醒队首的 goroutine,这时 mutexLocked 位依然是 0
         // 但由于处在饥饿状态下,锁不会被其他新来的 goroutine 抢占
        runtime_Semrelease(&m.sema, true, 1)
    }
}
  1. 正常模式/饥饿模式都调用runtime_Semrelease(s *uint32, handoff bool, skipframes int)唤醒协程,只是这两种模式在第二个参数的传参上不同
  2. 为什么重复解锁要panic: Unlock过程分为将Locked置为0,然后判断Waiter值,如果值>0,则释放信号量,如果多次Unlock(),可能每次都释放一个信号量,会唤醒多个协程,多个协程唤醒后会继续在Lock()的逻辑里抢锁,增加Lock()实现的复杂度,引起不必要的协程切换
    参考博客1
    参考博客2
    参考博客3

三. 总结

  1. Mutex 互斥锁是对共享资源进行访问控制的主要手段,对外暴露两个方法Lock()和Unlock()分别用于加锁和解锁, 查看Mutex源码,内部包含
  1. Mutex.state表示互斥锁的状态,比如是否被锁定等。
  2. Mutex.sema表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程
  1. 其中state可以理解为有四部分构成:
  1. 前29位Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量
  2. Locked: 表示该Mutex是否已被锁定,0:没有锁定 1:已被锁定。
  3. Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。
  4. Starving:表示该Mutex是否处理饥饿状态, 0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms

Lock 加锁总结

  1. 在Lock方法中,先通过CAS判断m.state是不是等于 0,如果是说明时无锁状态,直接设置为mutexLocked表示加锁成功,如果不等于0,说明被锁定中需要执行lockSlow()进行自旋或阻塞等待,在lockSlow()内部重点执行了以下逻辑
  1. 首先判断是否可自旋, 如果可以进入自旋状态
  2. 如果不可以进入自旋,则会获取锁模式,根据模式的不同重新计算锁状态

1. 判断是否可自旋

  1. 什么是自旋: 加锁时,如果当前Locked位为1,说明该锁当前由其他协程持有,会执行lockSlow(),尝试加锁的协程并不是马上转入阻塞,而是会持续的探测Locked位是否变为0,这个过程即为自旋过程
  2. lockSlow()中通过一个if语句,判断当前锁模式,锁状态,如果正常模式下,并且锁定状态,调用runtime_canSpin(iter)返回true时则进入if开始自旋
  3. 什么情况选允许自旋: runtime_canSpin(iter)是判断可自旋条件,在该函数中
  1. 迭代次数小于4
  2. 并且多核CPU时
  3. 并且当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空时
  1. 总结可以自旋的条件:
  1. 锁定状态
  2. 正常模式
  3. cpu核数大于1
  4. 迭代次数小于4
  5. p的数量要大于1,GOMAXPROCS()将处理器设置为1就不能启用自旋
  6. p中的可运行队列必须有1个为空,否则会延迟协程调度
  1. 当允许自旋时,会调用runtime_doSpin()自旋更新

2. 进入自旋状态

  1. 当允许自旋时,会调用runtime_doSpin()自旋更新,该方法内会将循环次数设置为30次,自旋操作就是执行30次PAUSE指令,通过该指令占用CPU并消费CPU时间

3. 重新计算锁状态

  1. 当不能进入自旋状态时,会根据锁模式,重新计算锁状态
  1. 如果是正常模式,设置为加锁状态
  2. 如果是饥饿模式,设置等待锁的协程+1
  3. 如果是饥饿模式,并且锁还是被其它协程持有,还是设置锁为饥饿状态
  1. 总结就是当不能自旋时,会获取到锁的状态old,判断是否是饥饿状态当前锁如果是饥饿状态,
  1. 如果是正常模式,设置为获取锁状态,后续会通过cas尝试更新获取
  2. 如果old已经处于加锁和饥饿状态,则等待者按照FIFO的顺序排队,将获取锁的优先权交给阻塞队列中的第一个goroutine
  1. 当重新计算拿到了新的锁状态后,通过cas尝试更新

4. 更新锁状态到阻塞

  1. 当重新计算拿到了新的锁状态后,通过cas尝试更新,执行atomic.CompareAndSwapInt32(&m.state, old, new)
  2. 如果更新失败,继续在for循环中迭代
  3. 如果更新成功, 并且是获取到了锁跳出, 如果没有获取到锁:
  1. 通过waitStartTime 判断当前协程是否在前面已经等待过, waitStartTime != 0 说明该 goroutine 在之前已经等待了
  2. 调用runtime_SemacquireMutex()将当前goroutine放入等待队列, 在该函数中,如果当前协程已经等待过了,现在是被唤醒,会把它加入等待队列队首,如果是新的 goroutine, 就把他加入队尾
  3. runtime_SemacquireMutex()会休眠当前 Goroutine 等待信号量的释放,一旦当前 Goroutine 可以获取信号量,它就会立刻返回
  4. 在是否锁时会调用runtime_Semrelease()唤醒队列中的阻塞协程,当调用了该函数,会通知到Goroutine 拿到信号量,runtime_SemacquireMutex()则向下执行,继续向下执行根据锁模式的不同再次尝试获取锁

Unlock 解锁总结

  1. 在解锁时会调用Unlock, 如果 m.state - mutexLocked == 0 说明没人等待该锁,同时该锁处于正常状态,直接更新锁状态为0,否则调用unlockSlow(), 在unlockSlow()解锁时也分正常模式与饥饿模式,
  1. 正常模式下,等待着减1,执行runtime_Semrelease(&m.sema, false, 1)唤醒一个阻塞协程
  2. 饥饿模式下,会直接唤醒等待队列头部的协程,执行runtime_Semrelease(&m.sema, true, 1)

state=Starving 锁模式总结

  1. 在加锁时分为正常模式与饥饿模式,通过state中1bit位表示,也就是上方state中的Starving,0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms
  2. 正常模式下:
  1. 当一个 goroutine 占有锁时,后面的 goroutine 会以先进先出的顺序在等待队列里排队,
  2. 当锁被释放时,队列中最前面的 goroutine 会被唤醒,但是唤醒后的 goroutine 并不会立刻拥有锁,需要和新到达的 goroutine 竞争锁,
  3. 注意新的 goroutine 有一个已经在 CPU 上运行了的优势,并且新的goroutine 可能有多个,所以在竞争过程中,刚被唤醒的 goroutine 大概率会竞争失败,这个原因可能会导致一些在排队的 goroutine 很长时间得不到执行被 “饿死”,
  4. 为了让锁竞争更加公平,Go 1.9 添加了饥饿模式,
  1. 什么是饥饿模式: Go 1.9添加的,如果一个等待的 goroutine 超过 1 ms (starvationThresholdNs) 没有得到锁,这个锁就会被转换为饥饿模式。在饥饿模式下,锁竞争时,会直接交给第一个 goroutine,新来的 goroutine 将不会尝试去获得该锁,而是会直接放在队列尾部,注意正常状态下的性能是高于饥饿模式的,所以在大部分情况下,还是应该回到正常模式去的。当队列中最后一个 goroutine 被执行或者它的等待时间低于 1 ms 时,会将该锁的状态切换回正常
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值