157. Go实现指数后退算法的自旋锁【单机+Redis版(带自动续期)】

工作中经常会用到锁来处理一些并发安全问题,拿到锁的协程可以执行某些操作,获取不到锁的则需要进行等待。

一、自旋锁与普通锁对比

自旋锁(Spin Lock)和普通锁(如互斥锁等)相比,有以下一些好处:

一、性能方面

  • 低开销的忙等待
    在多处理器系统中,当一个线程试图获取自旋锁而发现锁已经被占用时,它会进行忙等待(自旋),即不断地检查锁是否被释放。这种忙等待的开销在锁被占用时间很短的情况下,比普通锁的阻塞 - 唤醒机制开销要小。
    例如,在一个缓存一致性较好的多核系统中,如果一个线程短暂地占用一个自旋锁(如几纳秒到几十纳秒),另一个等待该锁的线程进行自旋等待,不需要进行上下文切换(而普通锁在阻塞时需要进行上下文切换,涉及保存和恢复线程的寄存器状态、内存映射等,这是一个相对耗时的操作)。

  • 避免线程切换的开销
    普通锁在获取失败时,可能会使线程进入阻塞状态,这就涉及到线程的切换。线程切换需要操作系统内核的介入,包括将当前线程的状态保存到内核空间、从内核空间恢复下一个要运行线程的状态等操作,这些操作会消耗一定的时间。

自旋锁通过自旋等待,避免了这种线程切换的开销,从而在锁竞争不激烈且锁占用时间短的场景下,能够提供更高的性能,这也是选择自旋锁而不是普通锁的场景。

二、适用场景方面

  • 短临界区保护
    • 对于临界区代码执行时间很短的情况,自旋锁非常适用。因为在这种情况下,自旋等待的时间相对较短,不会造成过多的CPU资源浪费。
    • 例如,在一个多线程的计数器更新场景中,每次更新操作可能只涉及简单的加法或减法运算,使用自旋锁可以高效地保护计数器的并发访问,而不会因为频繁的线程切换(使用普通锁可能会出现的情况)而降低性能。

二、Go语言实现自旋锁

import (
	"runtime"
	"sync"
	"sync/atomic"
)

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()
		}
		if backoff < maxBackoff {
			backoff <<= 1
		}
	}
}

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

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

三、指数后退算法基本原理

指数后退算法(Exponential Backoff Algorithm)是一种在计算机网络和分布式系统中常用的算法。

  1. 重试机制
    • 当一个操作(如网络请求、资源获取等)失败时,不是立即再次尝试,而是等待一段时间后再重试。
    • 这个等待时间不是固定的,而是按照指数级增长的方式确定。例如,第一次失败后等待1秒,第二次失败后等待2秒,第三次失败后等待4秒,以此类推。
  2. 目的
    • 主要目的是为了避免在短时间内对一个可能暂时不可用的资源进行过多的重试,从而减轻系统的负担,同时也提高了重试成功的概率。在网络环境中,这种算法可以应对网络拥塞、服务器暂时过载等情况。
  3. 计算等待时间
    • 通常等待时间 ( T = T 0 (T = T_0 (T=T0* 2 n ) 2^n) 2n),其中 ( T 0 ) (T_0) (T0) 是初始的等待时间(如1秒),n 是失败的次数。
  4. 随机化
    • 为了避免多个客户端同时进行重试而产生新的冲突(例如在网络中的多个设备同时按照相同的指数后退时间表进行重试可能会再次造成拥塞),通常会在计算出的等待时间基础上加上一个小的随机时间。例如,如果计算出等待时间为4秒,可能会再加上一个0 - 1秒之间的随机时间,实际等待时间可能是4.3秒。
  5. 终止条件
    • 一般会设定一个最大重试次数或者最大等待时间。当达到这个条件时,如果操作仍然失败,就不再重试,而是可能向上层报告错误或者采取其他的处理措施。

四、runtime.Gosched()功能

Go语言中,runtime.Gosched()是一个非常有用的函数。

  1. 让出CPU时间片
    • 当一个Go协程调用runtime.Gosched()时,它会暂停当前协程的执行,将CPU时间片让给其他可运行的协程。这有助于实现多协程之间的公平调度。
    • 例如,假设有两个协程协程A协程B协程A执行到runtime.Gosched()时,它会暂停自己的执行,此时Go运行时系统就有机会调度协程B来运行。
  2. 非阻塞操作
    • 调用runtime.Gosched()并不会阻塞当前协程等待某个特定的事件或条件。它只是简单地将当前协程放入等待运行的队列中,然后让Go运行时系统选择下一个要执行的协程。
  3. 与其他并发原语的区别
    • time.Sleep()不同,time.Sleep()是让协程睡眠一段时间,在这段时间内协程不会占用CPU资源。而runtime.Gosched()是主动让出CPU时间片,协程仍然处于可运行状态,随时可能被重新调度执行。
    • channel操作(如<-chan等待接收数据)相比,channel操作是基于特定的通信和同步机制,而runtime.Gosched()是一种更通用的调度控制手段。

五、Redis分布式版(带自动续期)

有了上面的基础介绍,不难写出Redis分布式版的代码,如下:

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
    "time"
)

var ctx = context.Background()
var rdb *redis.Client

func init() {
    rdb = redis.NewClient(&redis.Options{
        Addr:     "127.0.0.1:6379",
        Password: "", // 如果有密码则设置
        DB:       0,
    })
}

// DistributedSpinLock 分布式自旋锁结构体
type DistributedSpinLock struct {
    key      string
    value    string
    ttl      time.Duration
    renewer  *time.Ticker
}

// NewDistributedSpinLock 创建分布式自旋锁实例
func NewDistributedSpinLock(key string, value string, ttl time.Duration) *DistributedSpinLock {
    return &DistributedSpinLock{
        key:      key,
        value:    value,
        ttl:      ttl,
    }
}

// Lock 尝试获取锁
func (dsl *DistributedSpinLock) Lock() bool {
	backoff := 1
    for {
        setResult, err := rdb.SetNX(ctx, dsl.key, dsl.value, dsl.ttl).Result()
        if err!= nil {
            fmt.Printf("Error setting key in Redis: %v\n", err)
            return false
        }
        // 没有获取到锁时,退避算法等待重试
        if !setResult {
        	// 指数后退算法
			for i := 0; i < backoff; i++ {
				runtime.Gosched()
			}
			if backoff < maxBackoff {
				backoff <<= 1
			}
		}
        
        // 获取到锁,启动自动续期
        dsl.renewer = time.NewTicker(dsl.ttl / 2)
        go dsl.autoRenew()
        return true
        }
}

// Unlock 释放锁
func (dsl *DistributedSpinLock) Unlock() {
    dsl.renewer.Stop()
    rdb.Del(ctx, dsl.key)
}

// autoRenew 自动续期方法
func (dsl *DistributedSpinLock) autoRenew() {
	defer func() {
			if err := recover(); err != nil {
				fmt.Println(err)
			}
		}()
    for range dsl.renewer.C {
        _, err := rdb.Expire(ctx, dsl.key, dsl.ttl).Result()
        if err!= nil {
            fmt.Printf("Error renewing lock in Redis: %v\n", err)
            // 可以根据实际情况决定是否要继续尝试续期或者直接退出
        }
    }
}
  1. DistributedSpinLock结构体包含了锁的相关属性,如key(用于在Redis中标识锁)、value(可以是持有锁的标识,如客户端ID等)、ttl(锁的生存时间)、一个renewer字段,它是一个*time.Ticker类型,用于定期触发自动续期操作。
  2. Lock方法通过不断尝试在Redis中使用SETNX命令设置键值对(如果键不存在则设置成功,表示获取到锁),如果失败则按照退避算法进行等待后再次尝试,当成功获取锁(Lock方法中SetNX成功)时,会创建一个Ticker,并启动一个goroutine执行autoRenew方法。autoRenew方法会在每次Ticker触发时尝试对Redis中的锁键进行续期操作(通过Expire命令)。。
  3. Unlock方法中,除了删除Redis中的锁键,还会停止自动续期的Ticker,从而释放锁。

请注意:

  • 在实际应用中,需要根据具体的业务需求调整锁的ttl等参数。
  • 这里的错误处理可以进一步优化,例如在获取锁失败时,可以根据业务需求进行重试策略的调整或者返回更详细的错误信息给调用者。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值