🤨 一、什么是自旋锁

1、自旋锁提出的背景

在互斥地访问临界资源时,需要引入锁控制临界区,只有获取了锁的线程才能够对资源进行访问,同一时刻只能有一个线程获取到锁。那么没有获取到锁的线程应该怎么办?

自旋锁互斥锁

简而言之,需要获得自旋锁的线程循环等待,判断是否获得锁;而需要获得互斥锁的线程阻塞自己,等待其它线程解锁。

2、自旋锁的作用

​ 自旋锁适合锁资源在短时间内获取/释放的场景。当锁资源状态会在短时间内切换时,共享锁定的线程就避免了进入阻塞状态,从而降低了用户进程和内核切换的消耗。


🚀 二、自旋锁的Go实现

ants
antsworkersworkers
// 自旋锁的使用
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)小结

antsCAS自旋ants指数退避算法
2、实现自旋锁的原理
antsatomic.CompareAndSwapXXXCAS自旋
atomic.CompareAndSwapXXX
atomic.CompareAndSwapXXXatomictruefalsegoroutine
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、场景
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资源。

TicketLock

(3)是否决定使用自旋锁需要综合考虑实际的场景和生产环境。