工作中经常会用到锁来处理一些并发安全问题,拿到锁的协程可以执行某些操作,获取不到锁的则需要进行等待。
一、自旋锁与普通锁对比
自旋锁(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
秒,第二次失败后等待2
秒,第三次失败后等待4
秒,以此类推。
目的
- 主要目的是为了避免在短时间内对一个可能暂时不可用的资源进行过多的重试,从而减轻系统的负担,同时也提高了重试成功的概率。在网络环境中,这种算法可以应对网络拥塞、服务器暂时过载等情况。
计算等待时间
- 通常等待时间
(
T
=
T
0
(T = T_0
(T=T0*
2
n
)
2^n)
2n),其中
(
T
0
)
(T_0)
(T0) 是初始的等待时间(如
1
秒),n
是失败的次数。
- 通常等待时间
(
T
=
T
0
(T = T_0
(T=T0*
2
n
)
2^n)
2n),其中
(
T
0
)
(T_0)
(T0) 是初始的等待时间(如
随机化
- 为了避免多个客户端同时进行重试而产生新的冲突(例如在网络中的多个设备同时按照相同的指数后退时间表进行重试可能会再次造成拥塞),通常会在计算出的等待时间基础上加上一个小的随机时间。例如,如果计算出等待时间为
4
秒,可能会再加上一个0 - 1
秒之间的随机时间,实际等待时间可能是4.3
秒。
- 为了避免多个客户端同时进行重试而产生新的冲突(例如在网络中的多个设备同时按照相同的指数后退时间表进行重试可能会再次造成拥塞),通常会在计算出的等待时间基础上加上一个小的随机时间。例如,如果计算出等待时间为
终止条件
- 一般会设定一个最大重试次数或者最大等待时间。当达到这个条件时,如果操作仍然失败,就不再重试,而是可能向上层报告错误或者采取其他的处理措施。
四、runtime.Gosched()功能
在Go
语言中,runtime.Gosched()
是一个非常有用的函数。
让出CPU时间片
- 当一个
Go
协程调用runtime.Gosched()
时,它会暂停当前协程的执行,将CPU
时间片让给其他可运行的协程。这有助于实现多协程之间的公平调度。 - 例如,假设有两个协程
协程A
和协程B
,协程A
执行到runtime.Gosched()
时,它会暂停自己的执行,此时Go
运行时系统就有机会调度协程B
来运行。
- 当一个
非阻塞操作
- 调用
runtime.Gosched()
并不会阻塞当前协程等待某个特定的事件或条件。它只是简单地将当前协程放入等待运行的队列中,然后让Go
运行时系统选择下一个要执行的协程。
- 调用
与其他并发原语的区别
- 与
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)
// 可以根据实际情况决定是否要继续尝试续期或者直接退出
}
}
}
DistributedSpinLock
结构体包含了锁的相关属性,如key
(用于在Redis
中标识锁)、value
(可以是持有锁的标识,如客户端ID
等)、ttl
(锁的生存时间)、一个renewer
字段,它是一个*time.Ticker
类型,用于定期触发自动续期操作。Lock
方法通过不断尝试在Redis
中使用SETNX
命令设置键值对(如果键不存在则设置成功,表示获取到锁),如果失败则按照退避算法进行等待后再次尝试,当成功获取锁(Lock
方法中SetNX
成功)时,会创建一个Ticker
,并启动一个goroutine
执行autoRenew
方法。autoRenew
方法会在每次Ticker
触发时尝试对Redis
中的锁键进行续期操作(通过Expire
命令)。。- 在
Unlock
方法中,除了删除Redis
中的锁键,还会停止自动续期的Ticker
,从而释放锁。
请注意:
- 在实际应用中,需要根据具体的业务需求调整锁的
ttl
等参数。 - 这里的错误处理可以进一步优化,例如在获取锁失败时,可以根据业务需求进行重试策略的调整或者返回更详细的错误信息给调用者。