Skip to content

Commit 39b4bbf

Browse files
committed
Improve FreeLRU expiration cleanup
1 parent a69d567 commit 39b4bbf

5 files changed

Lines changed: 270 additions & 33 deletions

File tree

contrab/freelru/concurrentlru_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package freelru_test
22

33
import (
44
"fmt"
5+
"sync"
6+
"sync/atomic"
57
"testing"
68
"time"
79

@@ -57,6 +59,36 @@ func TestConcurrentLRUConstructorDoesNotEscape(t *testing.T) {
5759
}
5860
}
5961

62+
func TestConcurrentLRUConstructorRunsOnce(t *testing.T) {
63+
for _, sharded := range []bool{false, true} {
64+
t.Run(fmt.Sprintf("sharded=%v", sharded), func(t *testing.T) {
65+
cache, err := freelru.NewConcurrent[int, int](16, maphash.NewHasher[int]().Hash32, sharded)
66+
require.NoError(t, err)
67+
68+
var constructorCalls atomic.Int32
69+
var waitGroup sync.WaitGroup
70+
start := make(chan struct{})
71+
for range 32 {
72+
waitGroup.Add(1)
73+
go func() {
74+
defer waitGroup.Done()
75+
<-start
76+
value, _, ok := cache.GetAndRefreshOrAdd(1, func() (int, bool) {
77+
constructorCalls.Add(1)
78+
time.Sleep(time.Millisecond)
79+
return 2, true
80+
})
81+
require.Equal(t, 2, value)
82+
require.True(t, ok)
83+
}()
84+
}
85+
close(start)
86+
waitGroup.Wait()
87+
require.Equal(t, int32(1), constructorCalls.Load())
88+
})
89+
}
90+
}
91+
6092
func BenchmarkConcurrentLRUGetAndRefreshOrAdd(b *testing.B) {
6193
hasher := maphash.NewHasher[int]()
6294
synced, err := freelru.NewSynced[int, int](1024, hasher.Hash32)
@@ -81,6 +113,51 @@ func BenchmarkConcurrentLRUGetAndRefreshOrAdd(b *testing.B) {
81113
})
82114
}
83115

116+
func BenchmarkConcurrentLRUGetAndRefreshOrAddMiss(b *testing.B) {
117+
hasher := maphash.NewHasher[int]()
118+
for _, sharded := range []bool{false, true} {
119+
b.Run(fmt.Sprintf("sharded=%v", sharded), func(b *testing.B) {
120+
cache, err := freelru.NewConcurrent[int, int](4096, hasher.Hash32, sharded)
121+
require.NoError(b, err)
122+
cache.SetLifetime(time.Hour)
123+
for key := 0; key < 4096; key++ {
124+
cache.Add(key, key)
125+
}
126+
key := 4096
127+
b.ReportAllocs()
128+
b.ResetTimer()
129+
for b.Loop() {
130+
_, _, ok := cache.GetAndRefreshOrAdd(key, func() (int, bool) {
131+
return key, true
132+
})
133+
if !ok {
134+
b.Fatal("constructor result was rejected")
135+
}
136+
key++
137+
}
138+
})
139+
}
140+
}
141+
142+
func BenchmarkConcurrentLRUGetAndRefreshOrAddRefresh(b *testing.B) {
143+
hasher := maphash.NewHasher[int]()
144+
for _, sharded := range []bool{false, true} {
145+
b.Run(fmt.Sprintf("sharded=%v", sharded), func(b *testing.B) {
146+
cache, err := freelru.NewConcurrent[int, int](4096, hasher.Hash32, sharded)
147+
require.NoError(b, err)
148+
cache.SetLifetime(time.Hour)
149+
cache.Add(1, 2)
150+
state := [24]uint64{}
151+
b.ReportAllocs()
152+
for b.Loop() {
153+
_, _, _ = cache.GetAndRefreshOrAdd(1, func() (int, bool) {
154+
return int(state[0]), true
155+
})
156+
}
157+
})
158+
}
159+
}
160+
84161
func benchmarkSyncedLRUGetAndRefreshOrAdd(b *testing.B, cache *freelru.SyncedLRU[int, int]) {
85162
cache.Add(1, 2)
86163
state := [24]uint64{}

contrab/freelru/lru.go

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,10 @@ type LRU[K comparable, V comparable] struct {
7272
hash HashKeyCallback[K]
7373
healthCheck HealthCheckCallback[K, V]
7474
lifetime time.Duration
75-
metrics Metrics
75+
// nextExpire is a conservative lower bound. Refreshing or removing the
76+
// earliest entry may leave it stale, which only causes one extra scan.
77+
nextExpire int64
78+
metrics Metrics
7679

7780
// used for element clearing after removal or expiration
7881
emptyKey K
@@ -241,6 +244,9 @@ func (lru *LRU[K, V]) evict(pos uint32) {
241244
lru.unlinkElement(pos)
242245
lru.unlinkBucket(pos)
243246
lru.len--
247+
if lru.len == 0 {
248+
lru.nextExpire = 0
249+
}
244250

245251
if lru.onEvict != nil {
246252
// Save k/v for the eviction function.
@@ -279,11 +285,12 @@ func (lru *LRU[K, V]) move(to, from uint32) {
279285

280286
// insert stores the k/v at pos.
281287
// It updates the head to point to this position.
282-
func (lru *LRU[K, V]) insert(pos uint32, key K, value V, lifetime time.Duration) {
288+
func (lru *LRU[K, V]) insert(pos uint32, key K, value V, lifetime time.Duration, currentTime int64) {
283289
lru.elements[pos].key = key
284290
lru.elements[pos].value = value
285-
lru.elements[pos].expire = expire(lifetime)
291+
lru.elements[pos].expire = expireAt(currentTime, lifetime)
286292
lru.elements[pos].lifetime = lifetime
293+
lru.noteExpire(lru.elements[pos].expire)
287294

288295
if lru.len == 0 {
289296
lru.elements[pos].prev = pos
@@ -304,7 +311,37 @@ func expire(lifetime time.Duration) int64 {
304311
if lifetime == 0 {
305312
return 0
306313
}
307-
return now() + lifetime.Milliseconds()
314+
return expireAt(now(), lifetime)
315+
}
316+
317+
func expireAt(currentTime int64, lifetime time.Duration) int64 {
318+
if lifetime == 0 {
319+
return 0
320+
}
321+
return currentTime + lifetime.Milliseconds()
322+
}
323+
324+
func (lru *LRU[K, V]) noteExpire(expireTime int64) {
325+
if expireTime != 0 && (lru.nextExpire == 0 || expireTime < lru.nextExpire) {
326+
lru.nextExpire = expireTime
327+
}
328+
}
329+
330+
func (lru *LRU[K, V]) refreshLifetimeAt(pos uint32, currentTime int64) {
331+
oldExpire := lru.elements[pos].expire
332+
newExpire := expireAt(currentTime, lru.elements[pos].lifetime)
333+
lru.elements[pos].expire = newExpire
334+
if oldExpire == 0 || newExpire < oldExpire {
335+
lru.noteExpire(newExpire)
336+
}
337+
}
338+
339+
func (lru *LRU[K, V]) refreshLifetime(pos uint32) {
340+
if lru.elements[pos].lifetime == 0 {
341+
lru.elements[pos].expire = 0
342+
return
343+
}
344+
lru.refreshLifetimeAt(pos, now())
308345
}
309346

310347
// clearKeyAndValue clears stale data to avoid memory leaks
@@ -374,6 +411,16 @@ func (lru *LRU[K, V]) AddWithLifetime(key K, value V, lifetime time.Duration) (e
374411

375412
func (lru *LRU[K, V]) addWithLifetime(hash uint32, key K, value V,
376413
lifetime time.Duration,
414+
) (evicted bool) {
415+
var currentTime int64
416+
if lifetime != 0 {
417+
currentTime = now()
418+
}
419+
return lru.addWithLifetimeAt(hash, key, value, lifetime, currentTime)
420+
}
421+
422+
func (lru *LRU[K, V]) addWithLifetimeAt(hash uint32, key K, value V,
423+
lifetime time.Duration, currentTime int64,
377424
) (evicted bool) {
378425
bucketPos, startPos := lru.hashToPos(hash)
379426
if startPos == emptyBucket {
@@ -394,7 +441,7 @@ func (lru *LRU[K, V]) addWithLifetime(hash uint32, key K, value V,
394441

395442
lru.elements[pos].nextBucket = pos
396443
lru.elements[pos].prevBucket = pos
397-
lru.insert(pos, key, value, lifetime)
444+
lru.insert(pos, key, value, lifetime, currentTime)
398445
return evicted
399446
}
400447

@@ -404,8 +451,9 @@ func (lru *LRU[K, V]) addWithLifetime(hash uint32, key K, value V,
404451
if lru.elements[pos].key == key {
405452
// Key exists, replace the value and update element to be the head element.
406453
lru.elements[pos].value = value
407-
lru.elements[pos].expire = expire(lifetime)
454+
lru.elements[pos].expire = expireAt(currentTime, lifetime)
408455
lru.elements[pos].lifetime = lifetime
456+
lru.noteExpire(lru.elements[pos].expire)
409457

410458
if pos != lru.head {
411459
lru.unlinkElement(pos)
@@ -445,7 +493,7 @@ func (lru *LRU[K, V]) addWithLifetime(hash uint32, key K, value V,
445493
lru.elements[pos].prevBucket = lru.elements[startPos].prevBucket
446494
lru.elements[lru.elements[startPos].prevBucket].nextBucket = pos
447495
lru.elements[startPos].prevBucket = pos
448-
lru.insert(pos, key, value, lifetime)
496+
lru.insert(pos, key, value, lifetime, currentTime)
449497

450498
if lru.elements[pos].prevBucket != pos {
451499
// The bucket now contains more than 1 element.
@@ -537,7 +585,7 @@ func (lru *LRU[K, V]) getAndRefresh(hash uint32, key K) (value V, ok bool) {
537585
lru.setHead(pos)
538586
}
539587
lru.metrics.Hits++
540-
lru.elements[pos].expire = expire(lru.elements[pos].lifetime)
588+
lru.refreshLifetime(pos)
541589
return lru.elements[pos].value, ok
542590
}
543591

@@ -546,30 +594,29 @@ func (lru *LRU[K, V]) getAndRefresh(hash uint32, key K) (value V, ok bool) {
546594
}
547595

548596
func (lru *LRU[K, V]) GetAndRefreshOrAdd(key K, constructor func() (V, bool)) (V, bool, bool) {
549-
value, updated, ok := lru.getAndRefreshOrAdd(lru.hash(key), key, constructor)
550-
if !updated && ok {
551-
lru.PurgeExpired()
552-
}
597+
value, updated, ok, _ := lru.getAndRefreshOrAdd(lru.hash(key), key, constructor)
553598
return value, updated, ok
554599
}
555600

556-
func (lru *LRU[K, V]) getAndRefreshOrAdd(hash uint32, key K, constructor func() (V, bool)) (value V, updated bool, ok bool) {
601+
func (lru *LRU[K, V]) getAndRefreshOrAdd(hash uint32, key K, constructor func() (V, bool)) (value V, updated bool, ok bool, currentTime int64) {
557602
if pos, ok := lru.findKeyNoExpire(hash, key); ok {
558603
if pos != lru.head {
559604
lru.unlinkElement(pos)
560605
lru.setHead(pos)
561606
}
562607
lru.metrics.Hits++
563-
lru.elements[pos].expire = expire(lru.elements[pos].lifetime)
564-
return lru.elements[pos].value, true, true
608+
lru.refreshLifetime(pos)
609+
return lru.elements[pos].value, true, true, 0
565610
}
566611
lru.metrics.Misses++
567612
value, ok = constructor()
568613
if !ok {
569614
return
570615
}
571-
lru.addWithLifetime(hash, key, value, lru.lifetime)
572-
return value, false, true
616+
currentTime = now()
617+
lru.purgeExpiredAt(currentTime)
618+
lru.addWithLifetimeAt(hash, key, value, lru.lifetime, currentTime)
619+
return value, false, true, currentTime
573620
}
574621

575622
// Peek looks up a key's value from the cache, without changing its recent-ness.
@@ -616,6 +663,7 @@ func (lru *LRU[K, V]) updateLifetime(hash uint32, key K, value V, lifetime time.
616663

617664
lru.elements[pos].lifetime = lifetime
618665
lru.elements[pos].expire = expire(lifetime)
666+
lru.noteExpire(lru.elements[pos].expire)
619667

620668
if pos != lru.head {
621669
lru.unlinkElement(pos)
@@ -707,26 +755,33 @@ func (lru *LRU[K, V]) Purge() {
707755
_, _, _ = lru.RemoveOldest()
708756
}
709757

758+
lru.nextExpire = 0
710759
lru.metrics = Metrics{}
711760
}
712761

713762
// PurgeExpired purges all expired items from the LRU.
714763
// The evict function is called for each expired item.
715764
func (lru *LRU[K, V]) PurgeExpired() {
716-
n := now()
717-
loop:
718-
l := lru.len
719-
if l == 0 {
765+
lru.purgeExpiredAt(now())
766+
}
767+
768+
func (lru *LRU[K, V]) purgeExpiredAt(currentTime int64) {
769+
if lru.nextExpire == 0 || lru.nextExpire > currentTime {
720770
return
721771
}
722-
pos := lru.elements[lru.head].next
723-
for i := uint32(0); i < l; i++ {
724-
if lru.elements[pos].expire != 0 && lru.elements[pos].expire <= n {
772+
nextExpire := int64(0)
773+
for pos := uint32(0); pos < lru.len; {
774+
expireTime := lru.elements[pos].expire
775+
if expireTime != 0 && expireTime <= currentTime {
725776
lru.removeAt(pos)
726-
goto loop
777+
continue
727778
}
728-
pos = lru.elements[pos].next
779+
if expireTime != 0 && (nextExpire == 0 || expireTime < nextExpire) {
780+
nextExpire = expireTime
781+
}
782+
pos++
729783
}
784+
lru.nextExpire = nextExpire
730785
}
731786

732787
// Metrics returns the metrics of the cache.

0 commit comments

Comments
 (0)