Skip to content

Commit a182b36

Browse files
committed
use MailboxProcessor for lock-free eviction handling
1 parent aa76a84 commit a182b36

File tree

3 files changed

+65
-71
lines changed

3 files changed

+65
-71
lines changed

src/Compiler/Utilities/Caches.fs

Lines changed: 57 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,28 @@ type CacheOptions =
2424
HeadroomPercentage = 50
2525
}
2626

27+
// It is important that this is not a struct, because LinkedListNode holds a reference to it,
28+
// and it holds the reference to that Node, in a circular way.
2729
[<Sealed; NoComparison; NoEquality>]
2830
[<DebuggerDisplay("{ToString()}")>]
2931
type CachedEntity<'Key, 'Value> =
3032
val mutable Key: 'Key
3133
val mutable Value: 'Value
3234
val mutable AccessCount: int64
33-
val mutable Node: LinkedListNode<CachedEntity<'Key, 'Value>>
35+
val mutable Node: LinkedListNode<CachedEntity<'Key, 'Value>> voption
3436

3537
new(key, value) =
3638
{
3739
Key = key
3840
Value = value
3941
AccessCount = 0L
40-
Node = Unchecked.defaultof<_>
42+
Node = ValueNone
4143
}
4244

45+
// This is one time initialization, outside of the constructor because of circular reference.
46+
// The contract is that each CachedEntity that the EntityPool produces, has Node assigned.
4347
member this.WithNode() =
44-
this.Node <- LinkedListNode(this)
48+
this.Node <- ValueSome(LinkedListNode this)
4549
this
4650

4751
member this.ReUse(key, value) =
@@ -134,6 +138,8 @@ type CacheMetrics(cacheId) =
134138
observedCaches[cacheId].Dispose()
135139
observedCaches.TryRemove(cacheId) |> ignore
136140

141+
// Creates and after reclaiming holds entities for reuse.
142+
// More than totalCapacity can be created, but it will hold for reuse at most totalCapacity.
137143
type EntityPool<'Key, 'Value>(totalCapacity, cacheId) =
138144
let pool = ConcurrentBag<CachedEntity<'Key, 'Value>>()
139145
let mutable created = 0
@@ -148,6 +154,8 @@ type EntityPool<'Key, 'Value>(totalCapacity, cacheId) =
148154
if Interlocked.Increment &created > totalCapacity then
149155
overCapacity.Add 1L
150156

157+
// Associate a LinkedListNode with freshly created entity.
158+
// This is a one time initialization.
151159
CachedEntity(key, value).WithNode()
152160

153161
member _.Reclaim(entity: CachedEntity<'Key, 'Value>) =
@@ -169,10 +177,14 @@ module Cache =
169177
| NonNull _ when capacity > 1024 -> 1024
170178
| _ -> capacity
171179

180+
[<Struct>]
181+
type EvictionQueueMessage<'Key, 'Value> =
182+
| Add of CachedEntity<'Key, 'Value>
183+
| Update of CachedEntity<'Key, 'Value>
184+
172185
[<Sealed; NoComparison; NoEquality>]
173186
[<DebuggerDisplay("{GetStats()}")>]
174-
type Cache<'Key, 'Value when 'Key: not null and 'Key: equality>
175-
internal (totalCapacity, headroom, cts: CancellationTokenSource, ?name, ?observeMetrics) =
187+
type Cache<'Key, 'Value when 'Key: not null and 'Key: equality> internal (totalCapacity, headroom, ?name, ?observeMetrics) =
176188

177189
let instanceId = defaultArg name (Guid.NewGuid().ToString())
178190

@@ -197,75 +209,61 @@ type Cache<'Key, 'Value when 'Key: not null and 'Key: equality>
197209

198210
let evictionQueue = LinkedList<CachedEntity<'Key, 'Value>>()
199211

200-
let addToEvictionQueue (entity: CachedEntity<'Key, 'Value>) =
201-
lock evictionQueue <| fun () -> evictionQueue.AddLast(entity.Node)
202-
203-
// Only LRU currrently. We can add other strategies when needed.
204-
let updateEvictionQueue (entity: CachedEntity<'Key, 'Value>) =
205-
lock evictionQueue
206-
<| fun () ->
207-
208-
let node = entity.Node
209-
210-
// Sync between store and the eviction queue is not atomic. It might be already evicted or not yet added.
211-
if node.List = evictionQueue then
212-
// Just move this node to the end of the list.
213-
evictionQueue.Remove(node)
214-
evictionQueue.AddLast(node)
215-
216-
let tryEvictOne () =
217-
match evictionQueue.First with
218-
| null -> evictionFails.Add 1L
219-
| first ->
220-
match store.TryRemove(first.Value.Key) with
221-
| true, removed ->
222-
lock evictionQueue <| fun () -> evictionQueue.Remove(first)
223-
pool.Reclaim(removed)
224-
evictions.Add 1L
225-
| _ -> evictionFails.Add 1L
226-
227212
// Non-evictable capacity.
228213
let capacity = totalCapacity - headroom
229214

230-
let backgroundEvictionComplete = Event<_>()
215+
let evicted = Event<_>()
231216

232-
let evictItems () =
233-
while store.Count > capacity - headroom && evictionQueue.Count > 0 do
234-
tryEvictOne ()
217+
let evictionProcessor =
218+
new MailboxProcessor<EvictionQueueMessage<_, _>>(fun mb ->
219+
let rec processNext () =
220+
async {
221+
match! mb.Receive() with
222+
| EvictionQueueMessage.Add entity ->
235223

236-
backgroundEvictionComplete.Trigger()
224+
assert entity.Node.IsSome
237225

238-
let rec backgroundEviction () =
239-
async {
240-
let utilization = (float store.Count / float totalCapacity)
241-
// So, based on utilization this will scale the delay between 0 and 1 seconds.
242-
// Worst case scenario would be when 1 second delay happens,
243-
// if the cache will grow rapidly (or in bursts), it will go beyond the maximum capacity.
244-
// In this case underlying dictionary will resize, AND we will have to evict items, which will likely be slow.
245-
// In this case, cache stats should be used to adjust MaximumCapacity and PercentageToEvict.
246-
let delay = 1000.0 - (1000.0 * utilization)
226+
evictionQueue.AddLast(entity.Node.Value)
247227

248-
if delay > 0.0 then
249-
do! Async.Sleep(int delay)
228+
// Evict one immediately if necessary.
229+
if evictionQueue.Count > capacity then
230+
let first = nonNull evictionQueue.First
250231

251-
if store.Count > capacity then
252-
evictItems ()
232+
match store.TryRemove(first.Value.Key) with
233+
| true, removed ->
234+
evictionQueue.Remove(first)
235+
pool.Reclaim(removed)
236+
evictions.Add 1L
237+
evicted.Trigger()
238+
| _ -> evictionFails.Add 1L
253239

254-
return! backgroundEviction ()
255-
}
240+
| EvictionQueueMessage.Update entity ->
241+
entity.AccessCount <- entity.AccessCount + 1L
242+
243+
assert entity.Node.IsSome
256244

257-
do Async.Start(backgroundEviction (), cancellationToken = cts.Token)
245+
let node = entity.Node.Value
246+
assert (node.List = evictionQueue)
247+
// Just move this node to the end of the list.
248+
evictionQueue.Remove(node)
249+
evictionQueue.AddLast(node)
258250

259-
member val BackgroundEvictionComplete = backgroundEvictionComplete.Publish
251+
do! processNext ()
252+
}
253+
254+
processNext ())
255+
256+
do evictionProcessor.Start()
257+
258+
member val Evicted = evicted.Publish
260259

261260
member val Name = instanceId
262261

263262
member _.TryGetValue(key: 'Key, value: outref<'Value>) =
264263
match store.TryGetValue(key) with
265264
| true, cachedEntity ->
266265
hits.Add 1L
267-
Interlocked.Increment(&cachedEntity.AccessCount) |> ignore
268-
updateEvictionQueue cachedEntity
266+
evictionProcessor.Post(EvictionQueueMessage.Update cachedEntity)
269267
value <- cachedEntity.Value
270268
true
271269
| _ ->
@@ -277,16 +275,16 @@ type Cache<'Key, 'Value when 'Key: not null and 'Key: equality>
277275
let cachedEntity = pool.Acquire(key, value)
278276

279277
if store.TryAdd(key, cachedEntity) then
280-
addToEvictionQueue cachedEntity
278+
evictionProcessor.Post(EvictionQueueMessage.Add cachedEntity)
281279
true
282280
else
283281
pool.Reclaim(cachedEntity)
284282
false
285283

286284
interface IDisposable with
287285
member this.Dispose() =
286+
evictionProcessor.Dispose()
288287
store.Clear()
289-
cts.Cancel()
290288

291289
if observeMetrics then
292290
CacheMetrics.RemoveInstrumentation instanceId
@@ -307,9 +305,7 @@ type Cache<'Key, 'Value when 'Key: not null and 'Key: equality>
307305
let headroom =
308306
int (float options.TotalCapacity * float options.HeadroomPercentage / 100.0)
309307

310-
let cts = new CancellationTokenSource()
311-
312308
let cache =
313-
new Cache<_, _>(totalCapacity, headroom, cts, ?name = name, ?observeMetrics = observeMetrics)
309+
new Cache<_, _>(totalCapacity, headroom, ?name = name, ?observeMetrics = observeMetrics)
314310

315311
cache

src/Compiler/Utilities/Caches.fsi

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ module internal Cache =
2121

2222
[<Sealed; NoComparison; NoEquality>]
2323
type internal Cache<'Key, 'Value when 'Key: not null and 'Key: equality> =
24-
new:
25-
totalCapacity: int * headroom: int * cts: CancellationTokenSource * ?name: string * ?observeMetrics: bool ->
26-
Cache<'Key, 'Value>
24+
new: totalCapacity: int * headroom: int * ?name: string * ?observeMetrics: bool -> Cache<'Key, 'Value>
2725

2826
member TryGetValue: key: 'Key * value: outref<'Value> -> bool
2927
member TryAdd: key: 'Key * value: 'Value -> bool
@@ -33,7 +31,7 @@ type internal Cache<'Key, 'Value when 'Key: not null and 'Key: equality> =
3331
interface IDisposable
3432

3533
/// For testing only
36-
member BackgroundEvictionComplete: IEvent<unit>
34+
member Evicted: IEvent<unit>
3735

3836
static member Create<'Key, 'Value> :
3937
options: CacheOptions * ?name: string * ?observeMetrics: bool -> Cache<'Key, 'Value>

tests/FSharp.Compiler.ComponentTests/CompilerService/Caches.fs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,14 @@ let ``Eviction of least recently used`` () =
5050
let mutable value = 0
5151
cache.TryGetValue("key1", &value) |> ignore
5252

53-
let evictionComplete = new ManualResetEvent(false)
54-
cache.BackgroundEvictionComplete.Add(fun _ -> evictionComplete.Set() |> ignore)
53+
let evicted = new ManualResetEvent(false)
54+
cache.Evicted.Add(fun _ -> evicted.Set() |> ignore)
5555

5656
// Add a third item, which should schedule key2 for eviction
5757
cache.TryAdd("key3", 3) |> ignore
5858

5959
// Wait for eviction to complete using the event
60-
evictionComplete.WaitOne() |> ignore
60+
evicted.WaitOne() |> ignore
6161

6262
Assert.False(cache.TryGetValue("key2", &value), "key2 should have been evicted")
6363
Assert.True(cache.TryGetValue("key1", &value), "key1 should still be in cache")
@@ -76,14 +76,14 @@ let ``Metrics can be retrieved`` () =
7676
let mutable value = 0
7777
cache.TryGetValue("key1", &value) |> ignore
7878

79-
let evictionComplete = new ManualResetEvent(false)
80-
cache.BackgroundEvictionComplete.Add(fun _ -> evictionComplete.Set() |> ignore)
79+
let evicted = new ManualResetEvent(false)
80+
cache.Evicted.Add(fun _ -> evicted.Set() |> ignore)
8181

8282
// Add a third item, which should schedule key2 for eviction
8383
cache.TryAdd("key3", 3) |> ignore
8484

8585
// Wait for eviction to complete using the event
86-
evictionComplete.WaitOne() |> ignore
86+
evicted.WaitOne() |> ignore
8787

8888
let metrics = CacheMetrics.GetStats "test_metrics"
8989

0 commit comments

Comments
 (0)