Skip to content

Commit 959e2a4

Browse files
Clean up and fix thread safety issues in MemoryCache
- Mark as sealed and change protected members to private - Change "_dict" from "IDictionary<TKey, TValue>" to "ConcurrentDictionary<TKey, TValue>" and rename to "_map". - Rename "CacheEntry" to "Entry" - Change "Entry" to protect its data - Change "Entry.LastAccess" to get and set its data using Volatile.Read/Write to ensure that other threads don't get stale values due to instruction reordering. - Change "Compact" method to "CompactIfNeeded" and make it acquire the lock. - Use double-checked locking for compaction - Use the "SelectAndOrderByAsArray" to avoid the extra allocation of LINQ "OrderBy" followed by "ToArray" - Update compaction to be thread-safe by... 1. Capture the value of LastAccess when acquiring an ordered array of all items. This is used when removing items to ensure that items touched by other threads during compaction aren't thrown away. 2. Switch from IDictionary<TKey, TValue>.Remove(...) to ConcurrentDictionary<TKey, TValue>.TryRemove(...) for removing items. 3. If we remove an item that was touched during compaction, call TryAdd to restore it. 4. Count the number of items we remove to ensure that we remove *at least* the expected number of items. - Add comments throughout.
1 parent 52f0850 commit 959e2a4

File tree

3 files changed

+117
-48
lines changed

3 files changed

+117
-48
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Threading;
6+
7+
namespace Microsoft.CodeAnalysis.Razor.Utilities;
8+
9+
internal sealed partial class MemoryCache<TKey, TValue> where TKey : notnull
10+
where TValue : class
11+
{
12+
private sealed class Entry(TValue value)
13+
{
14+
private long _lastAccessTicks = DateTime.UtcNow.Ticks;
15+
16+
public DateTime LastAccess => new(Volatile.Read(ref _lastAccessTicks));
17+
public TValue Value => value;
18+
19+
public void UpdateLastAccess()
20+
{
21+
Volatile.Write(ref _lastAccessTicks, DateTime.UtcNow.Ticks);
22+
}
23+
}
24+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public event Action Compacted
2121

2222
public readonly bool TryGetLastAccess(TKey key, out DateTime result)
2323
{
24-
if (instance._dict.TryGetValue(key, out var value))
24+
if (instance._map.TryGetValue(key, out var value))
2525
{
2626
result = value.LastAccess;
2727
return true;

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/MemoryCache`2.cs

Lines changed: 92 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,81 +5,126 @@
55
using System.Collections.Concurrent;
66
using System.Collections.Generic;
77
using System.Diagnostics.CodeAnalysis;
8-
using System.Linq;
98

109
namespace Microsoft.CodeAnalysis.Razor.Utilities;
1110

12-
// We've created our own MemoryCache here, ideally we would use the one in Microsoft.Extensions.Caching.Memory,
13-
// but until we update O# that causes an Assembly load problem.
14-
internal partial class MemoryCache<TKey, TValue>
11+
/// <summary>
12+
/// A thread-safe, size-limited cache with approximate LRU (Least Recently Used)
13+
/// eviction policy. When the cache reaches its size limit, it removes approximately
14+
/// half of the least recently used entries.
15+
/// </summary>
16+
/// <typeparam name="TKey">The type of keys in the cache.</typeparam>
17+
/// <typeparam name="TValue">The type of values in the cache.</typeparam>
18+
/// <param name="sizeLimit">The maximum number of entries the cache can hold before compaction is triggered.</param>
19+
/// <param name="concurrencyLevel">The estimated number of threads that will update the cache concurrently.</param>
20+
internal sealed partial class MemoryCache<TKey, TValue>(int sizeLimit = 50, int concurrencyLevel = 2)
1521
where TKey : notnull
1622
where TValue : class
1723
{
18-
private const int DefaultSizeLimit = 50;
19-
private const int DefaultConcurrencyLevel = 2;
20-
21-
protected IDictionary<TKey, CacheEntry> _dict;
22-
23-
private readonly object _compactLock;
24-
private readonly int _sizeLimit;
25-
24+
private readonly ConcurrentDictionary<TKey, Entry> _map = new(concurrencyLevel, capacity: sizeLimit);
25+
26+
/// <summary>
27+
/// Lock used to synchronize cache compaction operations. This prevents multiple threads
28+
/// from attempting to compact the cache simultaneously while allowing concurrent reads.
29+
/// </summary>
30+
private readonly object _compactLock = new();
31+
private readonly int _sizeLimit = sizeLimit;
32+
33+
/// <summary>
34+
/// Optional callback invoked after cache compaction completes. Only used by tests.
35+
/// </summary>
2636
private Action? _compactedHandler;
2737

28-
public MemoryCache(int sizeLimit = DefaultSizeLimit, int concurrencyLevel = DefaultConcurrencyLevel)
29-
{
30-
_sizeLimit = sizeLimit;
31-
_dict = new ConcurrentDictionary<TKey, CacheEntry>(concurrencyLevel, capacity: _sizeLimit);
32-
_compactLock = new object();
33-
}
34-
35-
public bool TryGetValue(TKey key, [NotNullWhen(returnValue: true)] out TValue? result)
38+
/// <summary>
39+
/// Attempts to retrieve a value from the cache and updates its last access time if found.
40+
/// </summary>
41+
public bool TryGetValue(TKey key, [NotNullWhen(true)] out TValue? result)
3642
{
37-
if (_dict.TryGetValue(key, out var value))
43+
if (_map.TryGetValue(key, out var entry))
3844
{
39-
value.LastAccess = DateTime.UtcNow;
40-
result = value.Value;
45+
entry.UpdateLastAccess();
46+
result = entry.Value;
4147
return true;
4248
}
4349

4450
result = default;
4551
return false;
4652
}
4753

54+
/// <summary>
55+
/// Adds or updates a value in the cache. If the cache is at capacity, triggers compaction
56+
/// before adding the new entry.
57+
/// </summary>
4858
public void Set(TKey key, TValue value)
4959
{
60+
CompactIfNeeded();
61+
62+
_map[key] = new Entry(value);
63+
}
64+
65+
/// <summary>
66+
/// Removes approximately half of the least recently used entries when the cache reaches capacity.
67+
/// </summary>
68+
private void CompactIfNeeded()
69+
{
70+
// Fast path: check size without locking
71+
if (_map.Count < _sizeLimit)
72+
{
73+
return;
74+
}
75+
5076
lock (_compactLock)
5177
{
52-
if (_dict.Count >= _sizeLimit)
78+
// Double-check after acquiring lock in case another thread already compacted
79+
if (_map.Count < _sizeLimit)
5380
{
54-
Compact();
81+
return;
5582
}
56-
}
5783

58-
_dict[key] = new CacheEntry
59-
{
60-
LastAccess = DateTime.UtcNow,
61-
Value = value,
62-
};
63-
}
84+
// Create a snapshot with last access times to implement approximate LRU eviction.
85+
// This captures each entry's access time to determine which entries were least recently used.
86+
var orderedItems = _map.ToArray().SelectAndOrderByAsArray(
87+
selector: static x => (x.Key, x.Value.LastAccess),
88+
keySelector: static x => x.LastAccess);
6489

65-
public void Clear() => _dict.Clear();
90+
var toRemove = Math.Max(_sizeLimit / 2, 1);
6691

67-
protected virtual void Compact()
68-
{
69-
var kvps = _dict.ToArray().OrderBy(x => x.Value.LastAccess).ToArray();
92+
// Remove up to half of the oldest entries using an atomic remove-then-check pattern.
93+
// This ensures we don't remove entries that were accessed after our snapshot was taken.
94+
foreach (var (itemKey, itemLastAccess) in orderedItems)
95+
{
96+
// Atomic remove-then-check pattern eliminates race conditions
97+
// Note: If TryRemove fails, another thread already removed this entry.
98+
if (_map.TryRemove(itemKey, out var removedEntry))
99+
{
100+
if (removedEntry.LastAccess == itemLastAccess)
101+
{
102+
// Entry was still old when removed - successful eviction
103+
toRemove--;
104+
105+
// Stop early if we've removed enough entries
106+
if (toRemove == 0)
107+
{
108+
break;
109+
}
110+
}
111+
else
112+
{
113+
// Entry was accessed after snapshot - try to restore it
114+
// If TryAdd fails, another thread already added a new entry with this key,
115+
// which is acceptable - we preserve the hot entry's data either way
116+
_map.TryAdd(itemKey, removedEntry);
117+
}
118+
}
119+
}
70120

71-
for (var i = 0; i < _sizeLimit / 2; i++)
72-
{
73-
_dict.Remove(kvps[i].Key);
121+
_compactedHandler?.Invoke();
74122
}
75-
76-
_compactedHandler?.Invoke();
77123
}
78124

79-
protected class CacheEntry
80-
{
81-
public required TValue Value { get; init; }
82-
83-
public required DateTime LastAccess { get; set; }
84-
}
125+
/// <summary>
126+
/// Removes all entries from the cache.
127+
/// </summary>
128+
public void Clear()
129+
=> _map.Clear();
85130
}

0 commit comments

Comments
 (0)