高效协程池ants 自旋锁spinlock中指数退避算法应用

254 阅读2分钟

最近在学习一些优秀的go开源项目,浏览高性能协程池库ants的源码时,发现其自旋锁spinlock的实现颇有意思

在追求高性能的场景下,重量级锁带来的性能损耗通常来说是不可接受的,其常见的一种解决方案就是采用无锁化技术:自旋锁

注: 本文不会介绍CAS的实现思想和使用方式,建议同学提前了解~

话不多说,show code

如下代码就是ants中自旋锁spinlock的实现

其中spinLock的Lock方法就是尝试自旋加锁,可以看到其底层的CAS操作是基于go原生原子包atomic中提供的CAS方法,atomic.CompareAndSwapUint32

type spinLock uint32

const maxBackoff = 16

func (sl *spinLock) Lock() {
    backoff := 1
    for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
       // backoff是呈指数级增长的
       for i := 0; i < backoff; i++ {
          // 让出当前CPU时间片
          runtime.Gosched()
       }
       if backoff < maxBackoff {
          // 位运算,左移一位:1 -> 2 -> 4 -> 8 -> ···
          backoff <<= 1
       }
    }
}

func (sl *spinLock) Unlock() {
    atomic.StoreUint32((*uint32)(sl), 0)
}

func NewSpinLock() sync.Locker {
    return new(spinLock)
}

现在,我们来计算最多可能的自旋次数,以及 runtime.Gosched() 被调用的总次数:

  • 第一次失败:backoff = 1,调用 runtime.Gosched() 1 次。
  • 第二次失败:backoff = 2,调用 runtime.Gosched() 2 次。
  • 第三次失败:backoff = 4,调用 runtime.Gosched() 4 次。
  • 第四次失败:backoff = 8,调用 runtime.Gosched() 8 次。
  • 第五次失败:backoff = 16(达到 maxBackoff),调用 runtime.Gosched() 16 次

由此可知,单个groutine最多只会尝试5次获取自旋锁;且每次获取失败,退避值backoff是呈指数级增长的,实际的退避操作是循环backoff次调用runtime.Gosched()让出时间片,减少该groutine获取锁的频率

指数退避很好的解决了在锁竞争激烈的情况下对CPU的压力,防止大量的groutine持续高频地获取锁

指数退避在很多技术实现思想里都有出现,例如

  • TCP协议中的拥塞避免
  • 消息队列中消息处理失败的重试策略
  • 数据库连接池连接失败的重试策略

还有诸多优秀的实现案例,感兴趣的同学们可以继续深入扩展了解