slava项目(5):实现复杂操作的锁LockMap

文章讨论了一个名为slava的GitHub开源项目,该项目使用Go构建高性能云数据库。在处理并发安全时,作者提出了一个问题,即单个key的并发安全与复杂操作(如MSETNX和Incr)的安全性。为了解决这个问题,作者设计了一个基于哈希槽的锁管理系统,通过哈希函数映射key到有限数量的锁,减少了内存泄漏风险并优化了并发性能。同时,为了避免死锁,文章还介绍了按特定顺序加锁和解锁的策略。
摘要由CSDN通过智能技术生成

slava是作者参与的一个github开源项目,项目的主要的工作是用Go语言构建一个高性能、K-V云数据库。 slava项目的连接

在slava(3)构建内存数据库的时候,设置了一个可以适用于高并发场景的concourrentMap,其可以保证对单个key进行操作时候的并发安全性,但是无法保证一些复杂操作的安全型,例如:MSETNX,需要判断所有的键是否都不存在数据库中,如果都不存在则将设置多个键值,如果有一个存在则不设置,所以我们在执行这个命令前,需要一次性对所有操作的key进行上锁。Incr的操作,要先对读取key的值,然后做加法,最后写入,要实现并发安全,所以需要在执行incr之前就锁住key所在的map。

如果这里我们采用的加锁方式是,对每一个key都设置一个锁,在操作命令前对所有操作的key加锁的话,那么就必须在解锁后释放锁,不然在高并发场景中会出现大量的内存泄漏。但是,由于解锁和释放锁并不是原子操作的,可能会存在锁误删的现象

例如:我们给建立一个哈希表,表中存放每个键值的锁map[string]*sync.RWMutex。锁误删现象如下表所示。

表1 锁误删现象
时间协程A协程B
t1m["a"].Unlock()
t2m["a"] = &sync.RWMutex{}
t3delete(m, "a")
t4m["a"].Lock()

如上表所示,协程A在t1时刻释放了锁,这时候(t2)协程B在这个时候初始化了键值key = “a”的锁,但是没来得及上锁,这时候(t3)协程A为了释放内存,防止内存泄漏,则删除了key = “a”的锁,然后再t4时刻协程B上锁报错,因为锁已经被协程A删除,这就是协程A误删了协程B的锁。

解决上面的锁误删的情况的办法是,不释放锁,但是不释放锁会引发内存泄漏问题。所以给每个key初始化锁的方式并不适合我们的项目场景。

既然没办法给每个key一个锁,那么作者考虑对给定的哈希槽进行加锁。原因是,我们可以设置一定数量的哈希槽,在进行复杂操作之前先对哈希槽进行加锁,哈希槽的数量远比我们的key数量要少,这时候给每个哈希槽初始化锁,即使锁不释放,不delete也不会造成太多的内存泄漏。在开始的时候哈希槽的数量设置为1024个,但是随着k-v的数量越来越多防止哈希碰撞的概率越来越大,后面会对哈希槽进行扩容,以此来减少加锁的粒度,有利于高并发。

根据这样的思想设置一个Locks的结构体,结构体中是哈希槽的数组。

// Lock结构体为key提供读写锁
type Locks struct {
	table []*sync.RWMutex
}

// 创建一个lock map
func Make(tableSize int) *Locks {
	table := make([]*sync.RWMutex, tableSize)
	for i := 0; i < len(table); i++ {
		table[i] = &sync.RWMutex{}
	}
	return &Locks{table: table}
}

与之前的类似,设置哈希函数

// 设置哈希函数
const ( // 用于哈希函数计算
	prime32 = uint32(16777619)
)

func fnv32(key string) uint32 {
	hash := uint32(2166136261)
	for i := 0; i < len(key); i++ {
		hash *= prime32
		hash ^= uint32(key[i])
	}
	return hash
}

// 找到hash值对应table的索引
func (locks *Locks) spread(hashCode uint32) uint32 {
	if locks == nil {
		panic("dict is nil")
	}
	tableSize := uint32(len(locks.table))
	return (tableSize - 1) & hashCode
}

实现加锁和解锁的功能

// 获取某个key的写锁

func (locks *Locks) Lock(key string) {
	index := locks.spread(fnv32(key))
	mu := locks.table[index]
	mu.Lock()
}

// 获取key的读锁

func (locks *Locks) RLock(key string) {
	index := locks.spread(fnv32(key))
	mu := locks.table[index]
	mu.RLock()
}

// 释放key的写锁

func (locks *Locks) UnLock(key string) {
	index := locks.spread(fnv32(key))
	mu := locks.table[index]
	mu.Unlock()
}

// 释放key对应的读锁

func (locks *Locks) RUnLock(key string) {
	index := locks.spread(fnv32(key))
	mu := locks.table[index]
	mu.RUnlock()
}

在锁定多个key时需要注意,若协程A持有键a的锁试图获得键b的锁,此时协程B持有键b的锁试图获得键a的锁则会形成死锁。解决方法是所有协程都按照相同顺序加锁,若两个协程都想获得键a和键b的锁,那么必须先获取键a的锁后获取键b的锁,这样就可以避免循环等待。

解开多个写锁,解开写锁的时候,按照从大到小的顺序解开。先从大的的锁开始解开,上锁从小的锁开始上锁,这样可以防止死锁。例如,A协程含有所有的资源锁a,b,c,此时B协程想要上锁,如果A协程先从小的锁开始释放的话。这时候A协程释放了a资源,这时候B协程获取了a资源,还要等待b,c 资源,然而这是A协程要处理某些情况又需要a资源。这时候A协程就会等待B协程释放a资源,而B协程等待A协程释放b,c资源,这样就形成相互等待对方的资源释放,而形成死锁。

// 锁定多个key,为了防止多个协程之间循环等待,让所有饿协程都按照一定的顺序获取keys的锁
// 例如,协程A想获得a和b的锁,此时协程A已经拥有a的锁,想获得b的锁,但是协程B也想获得a和b的锁
// 此时协程B已经获取了b的锁,等待a的锁,这种情况下就会出现相互等待对方资源释放而造成死锁的现象。
// 解决的办法,就是按照一定的的顺序进行加锁

// 获取多个keys所在的槽,并且根据reverse字段选择进行排序,如果reverse为true则按照从大到小排序,如果为false则按照从小到大排序

func (locks *Locks) toLockindices(keys []string, reverse bool) []uint32 {
	// 用来存储keys所对应的所有索引
	indexMap := make(map[uint32]struct{})
	for _, key := range keys {
		index := locks.spread(fnv32(key))
		indexMap[index] = struct{}{}
	}
	// 预设容量,这一提升性能,避免底层频繁的扩容
	indices := make([]uint32, 0, len(indexMap))
	for index := range indexMap {
		indices = append(indices, index)
	}
	sort.Slice(indices, func(i, j int) bool {
		if !reverse {
			return indices[i] < indices[j]
		}
		return indices[i] > indices[j]
	})
	return indices
}

// 对多个key进行上锁,按照从小到达的规则进行上锁,主要是为了防止协程相互等待对方的资源而导致死锁
// 这里对多个key值进行上写锁

func (locks *Locks) Locks(keys ...string) {
	indices := locks.toLockindices(keys, false)
	for _, index := range indices {
		mu := locks.table[index]
		mu.Lock()
	}
}

// 对多个key上读锁,防止协程相互等待对方的资源而导致死锁

func (locks *Locks) RLocks(keys ...string) {
	indices := locks.toLockindices(keys, false)
	for _, index := range indices {
		mu := locks.table[index]
		mu.RLock()
	}
}

// 解开多个写锁,解开写锁的时候,按照从大到小的顺序解开
// 先从大的的锁开始解开,上锁从小的锁开始上锁,这样可以防止死锁
// 例如,A协程含有所有的资源锁a,b,c,此时B协程想要上锁,如果A协程先从小的锁开始释放的话
// 这时候A协程释放了a资源,这时候B协程获取了a资源,还要等待b,c 资源,然而这是A协程要处理某些情况又需要a资源
// 这时候A协程就会等待B协程释放a资源,而B协程等待A协程释放b,c资源,这样就形成相互等待对方的资源释放,而形成死锁。

func (locks *Locks) UnLocks(keys ...string) {
	indices := locks.toLockindices(keys, true)
	for _, index := range indices {
		mu := locks.table[index]
		mu.Unlock()
	}
}

// 释放多个key的读锁

func (locks *Locks) RUnLocks(keys ...string) {
	indices := locks.toLockindices(keys, true)
	for _, index := range indices {
		mu := locks.table[index]
		mu.RUnlock()
	}
}

在对多个key进行操作上锁的时候,要判断这些key式只读还是可以写操作的key,如果只读的key则加读锁就可以了,这样可以提升读性能。

// 对写和读的操作进行上锁,在写和读的命令中允许存在重复的key

func (locks *Locks) RWLocks(writeKeys []string, readKeys []string) {
	keys := append(writeKeys, readKeys...)
	indices := locks.toLockindices(keys, false)

	// 记录writeKeys中的key值的索引
	writeKeysSet := make(map[uint32]struct{}, len(writeKeys))
	for _, key := range writeKeys {
		index := locks.spread(fnv32(key))
		writeKeysSet[index] = struct{}{}
	}
	for _, index := range indices {
		_, ok := writeKeysSet[index]
		mu := locks.table[index]
		if ok {
			mu.Lock()
		} else {
			mu.RLock()
		}
	}
}

// 释放读写操作的锁,写和读的key可能会存在重复

func (locks *Locks) RWUnLocks(writeKeys []string, readKeys []string) {
	keys := append(writeKeys, readKeys...)
	indices := locks.toLockindices(keys, true)
	writeKeysSet := make(map[uint32]struct{}, len(writeKeys))
	for _, key := range writeKeys {
		index := locks.spread(fnv32(key))
		writeKeysSet[index] = struct{}{}
	}
	for _, index := range indices {
		_, ok := writeKeysSet[index]
		mu := locks.table[index]
		if ok {
			mu.Unlock()
		} else {
			mu.RUnlock()
		}
	}
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值