「动手实践系列之自旋锁设计」如何用Go优雅的实现高性能自旋锁

时间:2022-03-16 00:52:18

一、简介

*介绍: 自旋锁计算机科学用于多线程同步的一种,线程反复检查锁变量是否可用。由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。

通用点讲是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才退出循环。

自旋锁的示意图: 「动手实践系列之自旋锁设计」如何用Go优雅的实现高性能自旋锁

二、自旋锁实现(Golang版本)

从自旋锁的定义和交互示意图可知,要想实现自旋锁初步需要关注四个地方:加锁、解锁、锁的状态值、循环尝试获取锁。

下面依据自旋锁的示意图来实现一版自旋锁:

加锁和解锁

对于Go语言来说,需要实现标准库提供的sync.Lock接口

sync.Locker

// A Locker represents an object that can be locked and unlocked.
type Locker interface {
	Lock()
	Unlock()
}

实现

// SpinLock 自旋锁对象定义
type SpinLock uint32

// Lock 加锁
func (sl *SpinLock) Lock() {

}

// UnLock 解锁
func (sl *SpinLock) UnLock() {

}

锁的状态&循环尝试获取锁

通过前面的分析可知,协程加锁实际是设置 锁的状态值,其他协程自旋根据状态值来判断是否可以获取到锁。

这里就引出了一个问题:并发场景下,如何保证状态值 的操作 安全

加锁!!!

思考: 状态值的修改应该采用什么样的锁去控制呢?

这里就需要简单说一下自旋锁的使用场景了,*有一句话是这样描述的:自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的;获取、释放自旋锁,实际上是读写自旋锁的存储内存或寄存器,因此这种读写操作必须是原子的。通常用test-and-set等原子操作来实现。不难发现,对于状态值的操作必须是原子性,那就明确了我们需要使用CAS (CompareAndSwap)来处理

CAS是什么

CAS是英文单词CompareAndSwap的缩写,中文意思是:比较并替换。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。

CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。

Go中CAS的实现

import "sync/atomic" 包下,如:func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)

相关问题已经明确了,接下来下代码了:


import "sync/atomic"

// SpinLock 自旋锁对象定义
type SpinLock uint32

// Lock 加锁
func (sl *SpinLock) Lock() {
	// 自旋尝试获取锁
  // 自旋 判断 锁的状态值 是否为0 ,为0的情况下置为1 这样协程就会获取到锁
	for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
	
	}
}

// UnLock 解锁
func (sl *SpinLock) UnLock() {
  // 将 锁的状态值  由 1 置为 0
	atomic.CompareAndSwapUint32((*uint32)(sl), 1, 0)
}

到此第一版自旋锁就大功完成;嘿嘿!!是不是在这里就沾沾自喜了?虽说自旋锁已经按照上面的介绍实现出来,但还有些优化点需要处理,比如:

1、当协程A 自旋锁获取成功后,协程B尝试获取锁,但在协程A持有锁的阶段,协程B一直失败,那我们如何降低协程B获取锁的失败率,且明知这段时间没法获取锁,是否可以控制获取锁自旋的频率呢?

2、对于Go来说有内置的协程调度器GMP,对于协程B来说,如果协程B为了获取锁一直自旋,那协程B的本地队列里的其他协程就无法执行。

优化

指数退避算法:

*指数退避是一种算法,它使用反馈以乘法方式降低某些过程的速率,以逐渐找到可接受的速率。

简述的话:以指数的方式进行等待重试直到达到次数的上限

runtime.Gosched()函数

runtime.Gosched()函数功能:使当前goroutine让出CPU时间片(“回避”),让其他的goroutine获得执行的机会。当前的goroutine会在未来的某个时间点继续运行。当一个goroutine发生阻塞,Go会自动地把与该goroutine处于同一系统线程的其他goroutines转移到另一个系统线程上去,以使这些goroutines不阻塞(从GMP模型角度来说,就是当与P绑定的M发生阻塞,P就与其解绑,然后与另一个空闲的M进行绑定 或者 去创建一个M进行绑定)。

实现

import (
	"runtime"
	"sync/atomic"
)

// SpinLock 自旋锁对象定义
type SpinLock uint32

const maxBackOff = 32

// Lock 加锁
func (sl *SpinLock) Lock() {
	backoff := 1
	// 自旋尝试获取锁
	for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
		for i := 0; i < backoff; i++ {
			runtime.Gosched()
		}
		if backoff < maxBackOff {
			backoff <<= 1
		}

	}
}

// UnLock 解锁
func (sl *SpinLock) UnLock() {
	atomic.CompareAndSwapUint32((*uint32)(sl), 1, 0)
}

总结

至此,本篇文章撰写完毕。之前在阅读ants源码时发现了自旋锁相关的实现,感觉这块自己有些模糊不明白作者为什么这样实现,所以结合自己的一些思考撰写该篇文章,主要列举了自旋锁实现时所需的知识点及一些设计的一些思路,希望对大家有用。如果有更好的设计可以在备注中留言,我们一起探讨下。

参考资料

指数退避算法

CAS*介绍

Goroutine 并发调度模型深度解析之手撸一个高性能 goroutine 池

文章持续更新,本文 GitHub **GoNotes**已收录,欢迎 Star 催更~~~