Go自旋锁源码剖析

11-23 4,894 views

        上一篇说到Mutex,Golang互斥锁的实现包含了自旋的功能。那么今天来说说自旋锁,在golang中自旋锁并没有作为单独的锁出现,而是用它实现其它类型的锁。在剖析源码之前,先来了解一下什么是自旋锁?

自旋锁概念

        它和互斥锁比较类似,都是为了实现保护共享资源而提出一种锁机制。它们都是为了解决对某项资源的互斥所用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就是说在任何时刻最多只能有一个执行单位获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单位保持,调用者就一直循环(转圈)在那里看是否该自旋锁的保持者已经释放了锁,在此期间它还在执行CPU指令,它还处于活跃状态,它不用上下文切换,所以速度和互斥锁相比速度要快的多。由此可以得出,自旋锁是一种比较低级的保护数据结构或者代码片段的原始方式。

自旋锁的问题

        既然自旋锁不会释放cpu,所以一直在空耗CPU资源,如果不加限制,由于申请者一直在循环等待,因此自旋锁在锁定的时候,如果不成功,不会休眠,会持续的尝试,单cpu的时候自旋锁会让其它process动不了。因此,一般自旋锁实现会有一个参数限定最多持续尝试的次数或占用cpu时间片超时时间。超出后,自旋锁放弃当前time slice。等下一次重新获取。

      还有一个问题就是死锁的问题。试图递归地获得自旋锁必然会引起死锁:递归程序的持有实例在第二个实例循环,以试图获得相同自旋锁时,不会释放此自旋锁。在递归程序中使用自旋锁应该遵循以下策略:递归程序绝不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获取相同的自旋锁。此外如果一个进程将资源锁定,那么,即使其它申请这个资源的进程不停地疯狂”自旋”,也无法获得资源,从而进入死循环。

自旋转锁的使用场景

        自旋锁比较适用于锁使用者保持锁时间比较短的情况。正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的,自旋锁的效率远高于互斥锁。信号量和读写信号量适用于保持时间较长的情况,它们会导致调用者睡眠,因此只能在进程上下文使用,而自旋锁适用于保持时间非常短的情况,它可以在任何上下文使用。

        在了解了自旋锁的概念、优缺点和使用场景之后,接下来说说Go语言中自旋的具体源码。

const (
	active_spin     = 4	// 自旋的次数, 该值和Mutex中的iter有关系
	active_spin_cnt = 30	// 每次自旋的计数器
	passive_spin    = 1
)


// runtime/proc.go
// go:linkname sync_runtime_canSpin sync.runtime_canSpin
// 用户检查是否可以进入自旋态度
func sync_runtime_canSpin(i int) bool {
	// 1. 已经执行了多次自旋
	// 2. 是否是单核CPU
	// 3. 没有其它正在运行的P
        if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
                return false
        }

        // 4. 当前P和G队列为空
        if p := getg().m.p.ptr(); !runqempty(p) {
                return false
        }
        return true
}

         条件1避免长时间自旋浪费CPU的情况;条件2、3 用来保证除了当前在运行的Goroutine之外,还有其他的Goroutine在运行;条件4是避免自旋等待的条件是由当前P的其它G来触发,这样会导致在自旋变的没有意义,因为永远无法触发。

// go:linkname sync_runtime_doSpin sync.runtime_doSpin
// 进入自旋状态
func sync_runtime_doSpin() {
  	// active_spin_cnt: 自旋计数器,默认30。
        procyield(active_spin_cnt)
}


TEXT runtime·procyield(SB),NOSPLIT,$0-0
        MOVL    cycles+0(FP), AX
again:
        PAUSE
        SUBL    $1, AX		// active_spin_cnt 计数器递减,直到计数器为0,退出自旋。
        JNZ     again		// 不等于0时跳转到agin
        RET

        其实自选锁很简单,首先检查是否可以获取自选锁,然后进入自旋状态。自选锁的实现很简单,判断active_apin_cnt计数器是否递减到零,如果没有到零,则跳转至agin label,一直在这里转圈,直到active_spin_cnt等于0。procyield函数最主要的指令就是PAUSE,PAUSE 指令提升了自旋等待循环(spin-wait loops)的性能。PAUSE 指令提醒处理器: 这段代码序列时循环等待。利用该提示可避免大多数情况下的内存顺序违规(memory order violation),将大幅提升性能。另一功能是降低Intel P4 在执行循环等待时的耗电量。处理器在循环等待时执行得非常快,这将导致消耗大量电力,而在循环中插入PAUSE指令会大幅降低电力消耗。