在最近的一次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: goroutine之间有一定的时间间隔,伪并发,无数据预热;
- 场景2: goroutine之间时间间隔很小,无数据预热;
- 场景3:goroutine之间时间间隔很小,有数据预热;
场景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