文章目录
本文是
ants
源码解析系列的引申阅读,阐释了什么是自旋锁、自旋锁的作用、Go实现以及测试对比。1、参考文章
看完你就明白的锁系列之自旋锁 - 程序员cxuan - 博客园 (cnblogs.com)
2、变更说明
(1)2022年08月28日发布第一版
🤨 一、什么是自旋锁
1、自旋锁提出的背景
在互斥地访问临界资源时,需要引入锁控制临界区,只有获取了锁的线程才能够对资源进行访问,同一时刻只能有一个线程获取到锁。那么没有获取到锁的线程应该怎么办?
通常有两种处理方式:一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁叫做**自旋锁
**,它不用将线程阻塞起来(NON-BLOCKING);还有一种处理方式就是把自己阻塞起来,等待重新调度请求,这种叫做互斥锁
。
简而言之,需要获得自旋锁的线程循环等待,判断是否获得锁;而需要获得互斥锁的线程阻塞自己,等待其它线程解锁。
2、自旋锁的作用
自旋锁适合锁资源在短时间内获取/释放的场景。当锁资源状态会在短时间内切换时,共享锁定的线程就避免了进入阻塞状态,从而降低了用户进程和内核切换的消耗。
🚀 二、自旋锁的Go实现
1、参考ants
源码
(1)在ants
源码中,使用自旋锁控制访问池的workers
队列,获取其过期的workers
。
// 自旋锁的使用
p.lock.Lock()
// 获取worker队列中过期的worker
expiredWorkers := p.workers.retrieveExpiry(p.options.ExpiryDuration)
p.lock.Unlock()
(2)自旋锁的定义
type spinLock uint32
const maxBackoff = 16
func (sl *spinLock) Lock() {
backoff := 1
for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
// 指数退避算法
for i := 0; i < backoff; i++ {
runtime.Gosched()
// runtime.Gosched(),用于让出CPU时间片,让出当前goroutine的执行权限,
// 调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。
}
if backoff < maxBackoff {
backoff <<= 1
}
}
}
func (sl *spinLock) Unlock() {
atomic.StoreUint32((*uint32)(sl), 0)
}
// NewSpinLock instantiates a spin-lock.
func NewSpinLock() sync.Locker {
return new(spinLock)
}
(3)小结
- 在
ants
源码中可以看出,自旋锁通过CAS自旋
实现,用于控制小区域的、短时间使用的临界区。 ants
在定义自旋锁时引入了指数退避算法
,丰富了自旋锁,这就稍微有点偏超纲了。接下来让我们聚焦于自旋锁的实现。
2、实现自旋锁的原理
(1)在ants
中通过atomic.CompareAndSwapXXX
方法实现CAS自旋
。
(2)atomic.CompareAndSwapXXX
方法(知识点补充,掌握的读者可以跳过)
-
atomic.CompareAndSwapXXX
是atomic
原子操作包下的一个函数,接收3个参数,1个是目标操作数的地址,第2个是旧值,第3个是新值。返回值是一个布尔类型。 -
顾名思义,比较并交换。比较目标操作数与旧值,当目标操作数的值等于旧值时,就会被赋予新值,并且返回
true
;否则返回false
。 -
该方法确保了各
goroutine
互斥地操作目标操作数的同时,保证了目标操作数在两种状态间正确地切换。 -
通过下面两段代码就知道这个函数的作用了。
func main() {
var v int32
var old int32 = 999
var new_ int32 = 666
v = old
is := atomic.CompareAndSwapInt32(&v, old, new_)
if is {
fmt.Println("交换成功!v=", v)
} else {
fmt.Println("交换失败!v=", v)
}
}
// [out] 交换成功!v= 666
func main() {
var v int32
var old int32 = 999
var new_ int32 = 666
v = old
is := atomic.CompareAndSwapInt32(&v, 111, new_)
if is {
fmt.Println("交换成功!v=", v)
} else {
fmt.Println("交换失败!v=", v)
}
}
// 交换失败!v= 999
3、具体实现
type SpinLock uint32
func (s *SpinLock) Lock() {
// 当s=0时(Unlock),尝试设置为1(Lock)
for !atomic.CompareAndSwapUint32((*uint32)(s), 0, 1) {
runtime.Gosched() // 让出当前G的执行权
}
}
func (s *SpinLock) Unlock() {
atomic.StoreUint32((*uint32)(s), 0)
}
func NewSpinLock() sync.Locker {
var l SpinLock
return &l
}
🚦 三、对比测试
下面通过多个G竞争自旋锁和互斥锁,对比程序的性能。
1、场景
- N个
goroutine
互斥地访问临界资源。
func TLock(l sync.Locker) {
var wg sync.WaitGroup
N := 5 // N个G竞争锁
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
l.Lock()
time.Sleep(time.Nanosecond * 100) // 模拟数据的读取操作
l.Unlock()
// 对读取到的数据的操作
}()
}
wg.Wait()
}
2、测试函数
func BenchmarkSpinLock(b *testing.B) {
for i := 0; i < b.N; i++ {
l := NewSpinLock()
TLock(l)
}
b.ReportAllocs()
}
func BenchmarkMutex(b *testing.B) {
for i := 0; i < b.N; i++ {
var l sync.Mutex
TLock(&l)
}
b.ReportAllocs()
}
3、结果
>> go test -bench . -benchtime=60s
goos: windows
goarch: amd64
pkg: demo
cpu: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz
BenchmarkSpinLock-8 1418 46357656 ns/op 625 B/op 12 allocs/op
BenchmarkMutex-8 978 74129670 ns/op 609 B/op 12 allocs/op
PASS
ok demo 151.029s
(1)可以看到,在本地笔记本的规格上,对于小区域、短时间占用的临界区,使用自旋锁的效率大约是是使用互斥锁的1.6倍。
- 注意,在不同架构或者不同CPU主频的机器上测试结果可能存在差异,CPU主频越高的机器上,自旋锁与互斥锁的差异越小(我的笔记本为 1.60 GHZ,算是低的了,因此差异较为明显)。
⏰四、小结
1、自旋锁的优点
(1)自旋锁主要是为了降低了用户进程和内核切换的消耗,适用于等待获得锁时间较短的场景。
2、自旋锁的缺点
(1)当锁轮转时间较长,单个线程占用锁的时间较长时,自旋锁循环等待的时间较长,会消耗大量的CPU资源。
(2)自旋锁无法确保公平性,具体的解决方法有TicketLock
。
(3)是否决定使用自旋锁需要综合考虑实际的场景和生产环境。