一次关于读写锁的探索

在最近的一次goroutine数量过高(1w+)问题的排查中,发现大部分goroutine都阻塞在锁上,pprof如下。goroutine数量过多的问题是因为在执行异步任务时无脑的go func(),这个问题在本篇中就不讲了。阻塞在锁上是因为在异步任务链路中存在一次rpc请求,并且对该rpc请求的结果使用了lru的本地缓存,很多goroutine都阻塞在lru的锁上。

在典型的双链表实现的lru中,Get和Set都需要加互斥锁,因为在Get操作中涉及到了链表顺序的调整。本篇中,我尝试实现一种使用读写锁的Lru缓存,并且分析互斥锁和读写锁在不同情况下的表现。

首先看下现存代码的逻辑,demo如下。

在方法的入口,在循环中串行地执行任务,每个任务中会创建一个goroutine执行异步操作GetLruData。该操作是完全异步的,不需要等待结果。在GetLruData的异步操作中,需要做rpc请求获取数据,并且给请求结果加了lru的本地缓存。

在GetLruData方法中,直接通过互斥锁LruLock将整个方法锁住,把LRU操作的并发安全和缓存击穿的问题一起给解决了。当然是有效的,但是效率太低(在遇到不同参数的请求时),所以都阻塞在锁上。

func ConcurrentRequest() {
   for i := 0; i < 10000; i ++ {
      time.Sleep(1*time.Millisecond)
      wg.Add(1)
      go GetLruData("key")
   }
   wg.Wait()
}

func GetLruData(key string) {
   defer wg.Done()
   LruLock.Lock()
   defer LruLock.Ulock()
   
   if _, ok := LruCache.Get(key); ok {
      return
   }
   
   // 模拟rpc请求耗时两秒,然后设置lru值
   time.Sleep(2*time.Second)

   _ = LruCache.Add("key", 100)
}

我尝试从三种场景下进行分析效果:

场景1

互斥锁

先把逻辑改一下改成,将Lru和缓存击穿的锁分开。这里的lru使用了开源的实现,是典型的双链表结构,Get和Set操作中自带互斥锁的获取。然后goroutine的创建之间有1ms的间隔,rpc请求的平均时间为2s。

package LruLock

import (
   lru "github.com/hashicorp/golang-lru"
   "sync"
   "time"
)

// 首先使用经典的双向链表+map实现的lru测试:
// 在这种情况下Get\Set操作都需要加互斥锁,因为Get操作中不仅仅是简单地读,同时涉及到链表的调整;
// 使用waitGroup是为了测试实际的执行时间
var (
   wg sync.WaitGroup
   LruCache *lru.Cache
   DL sync.Mutex
)

func init() {
   LruCache, _ = lru.New(5)
}

func ConcurrentRequest() {
   fmt.PrintLn(time.Now())
   for i := 0; i < 10000; i ++ {
       // 模拟前置操作的消耗时间
      time.Sleep(1*time.Millisecond)
      wg.Add(1)
      go GetLruData("key")
   }
   wg.Wait()
}

func GetLruData(key string) {
   defer wg.Done()
   if _, ok := LruCache.Get(key); ok {
      return
   }

   // 锁防击穿
   DL.Lock()
   defer DL.Unlock()
   if _, ok := LruCache.Get(key); ok {
      return
   }

   // 模拟rpc请求耗时两秒,然后设置lru值
   time.Sleep(2*time.Second)

   _ = LruCache.Add("key", 100)
   fmt.PrintLn(time.Now())
}

benchmark的测试结果如下,耗时14.127s。

读写锁

下面这个是我尝试实现的使用读写锁的Lru缓存。(仅仅是简陋的展示思路)使用一个silce来保存数据,Get()操作时获取读锁,可以并发进行,读取时更新对应item的lastTime。

在大并发下可能存在并发安全的问题,但是我认为可能是可以容忍的,时间不会偏差太大。(关于这点,稍后验证来更新结论,不管如何,我们先以此探究读写锁)。goroutine的创建之间有1ms的间隔,rpc请求的平均时间为2s。

package LruRWLock

import (
   "sync"
   "time"
)

// 使用另一种实现可以使用读写锁Lru进行测试
type LruItem struct {
   Key string
   Value int
   Last int64
}

var LruCache []LruItem

// 使用waitGroup是为了测试实际的执行时间
var (
   wg sync.WaitGroup
   DL sync.Mutex
   LruLock sync.RWMutex
)

func ConcurrentRequest() {
   fmt.Println(time.Now())
   for i := 0; i < 10000; i ++ {
      time.Sleep(1*time.Millisecond)
      wg.Add(1)
      go GetLruData("key")
   }
   wg.Wait()
}

func GetLruData(key string) {
   defer wg.Done()

   LruLock.RLock()
   // lru并发读,这里只更新读取的item的最近读取时间。
   // 真正大并发下时间可能有一点偏差,但是这些偏差我认为可以容忍的。稍后验证。
   for _, item := range LruCache {
      if item.Key == key {
         item.Last = time.Now().UnixNano()
         return
      }
   }
   LruLock.RUnlock()

   // 模拟分布式锁防击穿,简陋之处请见谅
   DL.Lock()
   defer DL.Unlock()

   LruLock.RLock()
   for _, item := range LruCache {
      if item.Key == key {
         item.Last = time.Now().UnixNano()
         return
      }
   }
   LruLock.RUnlock()

   // 模拟rpc请求耗时两秒,然后设置lru值
   time.Sleep(2*time.Second)

   LruLock.Lock()
   defer LruLock.Unlock()
   
   // 在写操作时如果Lru超过size,则按照last进行排序,取最近的几个值
   LruCache = append(LruCache, LruItem{"key", 5, time.Now().UnixNano()})
   if len(LruCache) > 5 {
   sort.Slice(LruCache, func(i, j int) bool {
      if LruCache[i].Last < LruCache[j].Last {
         return true
      }
      return false
   })

   temp := make([]LruItem, 5, 5)
   
   for i := 0; i < 5; i++ {
      temp[i] = LruCache[i+len(LruCache)-5]
   }
   LruCache = temp
   }
   fmt.Println(time.Now())
}

可以看到执行时间略微减少,不到200ms。实际多次测试的结果显示有的时候不如互斥锁的情况。

分析及结论

在该场景下,表现来看使用读写锁的差别不大,为什么呢?在相应的场景下,我们的goroutine隔1ms创建一个,也就是说读请求是1ms来一次,这个间隔远大于我们做一次lru读操作的耗时的。也就是说,虽然使用了读锁,但是读操作还是串行的。所以差别不大。

读写锁的优势在于并发读,当读操作的耗时越大时,其优势才越明显。这个是核心的基础。

场景2

在场景2下,我们去掉了创建goroutine之间的间隔,将伪并发变为真并发。但是没有数据预热,仍然需要2s的I/O来建立缓存数据。

demo如下,GetLruData方法同上。代码不重复贴了,占位置。

package LruLock

func ConcurrentRequest() {
   fmt.PrintLn(time.Now())
   for i := 0; i < 10000; i ++ {
       // 加强并发
      // time.Sleep(1*time.Millisecond)
      wg.Add(1)
      go GetLruData("key")
   }
   wg.Wait()
}

直接贴结果。

互斥锁

读写锁

分析及结论

可以看到在场景2下互斥锁的性能反而比读写锁要好。这种情况读写锁的性能和互斥锁差不多。因为这种情况下没有数据预热,在建立初始缓存的2s时间里,所有的goroutine都经过了开头的lru读操作而阻塞在防击穿的互斥锁上。所以读写锁还是互斥锁意义不大。

场景3

在场景3,在第一次请求后我们sleep 3s,预热数据,建立初始数据。

package LruLock

func ConcurrentRequest() {
   fmt.PrintLn(time.Now())
   for i := 0; i < 10000; i ++ {
       // 加强并发
      // time.Sleep(1*time.Millisecond)
      wg.Add(1)
      go GetLruData("key")
      if i == 0{
          time.sleep(3*time.Second)
      }
   }
   wg.Wait()
}

互斥锁

读写锁

分析及结论

可以看到结果依然相差不大。。。。我们可以把场景三抽象为对一个数据结构的读取,一个是并发读,一个是串行读,只不过是读取操作是太快了,叠加10000次的影响也是微乎其微的。测试的单次读取是10纳秒级别的。

总结

**可以看到在读操作耗时很小的情况下,使用读写锁确实是没什么优化的。**所以这就是lru(O(1)的时间复杂度)不用读写锁的原因吗,我还以为自己有什么惊人的发现。。。。但是如果读操作存在一定耗时的情况下,就会产生一些影响。在上述的三种场景下,又有不同的影响,读者可以自己思考然后验证一下效果。

最后:关于并发读lru的安全性问题

关于上面所说的读写锁的并发安全性问题,进行了一些验证。从下面的结果来看似乎还可以。但是本地运行很难模拟线上的复杂环境,是有可能出现偏差很大的情况的,但是以go来说感觉可能性会小一些?。

var RwL sync.RWMutex

type Item struct {
   Key string
   Last int64
}

func main() {
   aItem := Item{"key", 0}
   for i := 0;i < 10000; i ++ {
      go func(i int) {
         RwL.RLock()
         defer RwL.RUnlock()
         if aItem.Key == "key" {
            aItem.Last += 1
         }
      }(i)
   }
   time.Sleep(3*time.Second)
   fmt.Println(aItem.Last)
}

运行结果为:9717
func main() {
	aItem := Item{"key", 0}
	for i := 0;i < 10000; i ++ {
		go func(i int) {
			RwL.RLock()
			defer RwL.RUnlock()
			if aItem.Key == "key" {
				j := i
				aItem.Last = int64(j)
			}
		}(i)
	}
	time.Sleep(3*time.Second)
	fmt.Println(aItem.Last)
}

运行结果: 9883
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值