Skip to content
32 changes: 28 additions & 4 deletions src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Threading;

namespace Microsoft.Extensions.Options
{
Expand All @@ -16,11 +17,22 @@ public class OptionsCache<[DynamicallyAccessedMembers(Options.DynamicallyAccesse
where TOptions : class
{
private readonly ConcurrentDictionary<string, Lazy<TOptions>> _cache = new ConcurrentDictionary<string, Lazy<TOptions>>(concurrencyLevel: 1, capacity: 31, StringComparer.Ordinal); // 31 == default capacity
private int _generation;

/// <summary>
/// Gets a monotonically-increasing counter that is incremented on every mutation (Clear, TryAdd, TryRemove).
/// Consumers can use this to detect whether the cache contents may have changed since they last read an entry.
/// </summary>
internal int Generation => Volatile.Read(ref _generation);

/// <summary>
/// Clears all options instances from the cache.
/// </summary>
public void Clear() => _cache.Clear();
public void Clear()
{
_cache.Clear();
Interlocked.Increment(ref _generation);
}

/// <summary>
/// Gets a named options instance, or adds a new instance created with <paramref name="createOptions"/>.
Expand Down Expand Up @@ -97,19 +109,31 @@ public virtual bool TryAdd(string? name, TOptions options)
{
ArgumentNullException.ThrowIfNull(options);

return _cache.TryAdd(name ?? Options.DefaultName, new Lazy<TOptions>(
bool added = _cache.TryAdd(name ?? Options.DefaultName, new Lazy<TOptions>(
#if !(NET || NETSTANDARD2_1)
() =>
#endif
options));
if (added)
{
Interlocked.Increment(ref _generation);
}
return added;
}

/// <summary>
/// Tries to remove an options instance.
/// </summary>
/// <param name="name">The name of the options instance.</param>
/// <returns><see langword="true"/> if anything was removed; otherwise, <see langword="false"/>.</returns>
public virtual bool TryRemove(string? name) =>
_cache.TryRemove(name ?? Options.DefaultName, out _);
public virtual bool TryRemove(string? name)
{
bool removed = _cache.TryRemove(name ?? Options.DefaultName, out _);
if (removed)
{
Interlocked.Increment(ref _generation);
}
return removed;
}
}
}
48 changes: 44 additions & 4 deletions src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Threading;
using Microsoft.Extensions.Primitives;

namespace Microsoft.Extensions.Options
Expand All @@ -18,9 +20,12 @@ public class OptionsMonitor<[DynamicallyAccessedMembers(Options.DynamicallyAcces
where TOptions : class
{
private readonly IOptionsMonitorCache<TOptions> _cache;
private readonly OptionsCache<TOptions>? _fastCache;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: is it worth adding this field? Repeating a cast is probably cheaper than adding this field.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was copilot's idea and by benchmarks it seems having a slight edge (judged by the flow where we get with default options name) but I'll add a direct benchmark to be sure.

private readonly IOptionsFactory<TOptions> _factory;
private readonly List<IDisposable> _registrations = new List<IDisposable>();
internal event Action<TOptions, string>? _onChange;
private TOptions? _currentValue;
private int _currentValueGeneration;

/// <summary>
/// Initializes a new instance of <see cref="OptionsMonitor{TOptions}"/> with the specified factory, sources, and cache.
Expand All @@ -32,6 +37,7 @@ public OptionsMonitor(IOptionsFactory<TOptions> factory, IEnumerable<IOptionsCha
{
_factory = factory;
_cache = cache;
_fastCache = cache as OptionsCache<TOptions>;

void RegisterSource(IOptionsChangeTokenSource<TOptions> source)
{
Expand Down Expand Up @@ -76,7 +82,42 @@ private void InvokeChanged(string? name)
/// <exception cref="MissingMethodException">The <typeparamref name="TOptions"/> does not have a public parameterless constructor or <typeparamref name="TOptions"/> is <see langword="abstract"/>.</exception>
public TOptions CurrentValue
{
get => Get(Options.DefaultName);
get
{
OptionsCache<TOptions>? fastCache = _fastCache;
if (fastCache is null)
{
// User-supplied IOptionsMonitorCache: no generation tracking, always go through Get.
return Get(Options.DefaultName);
}

int gen = fastCache.Generation;
// Read generation before value. RefreshCurrentValue writes _currentValue before
// _currentValueGeneration (release), so an acquire-load of _currentValueGeneration
// here guarantees the subsequent read of _currentValue sees the matching published
// value and not a stale one.
int cachedGen = Volatile.Read(ref _currentValueGeneration);
TOptions? value = Volatile.Read(ref _currentValue);
if (value is not null && cachedGen == gen)
{
return value;
}

return RefreshCurrentValue(gen);
}
}

[MethodImpl(MethodImplOptions.NoInlining)]
private TOptions RefreshCurrentValue(int gen)
{
TOptions value = Get(Options.DefaultName);
// Write value before generation: the generation acts as a release signal that
// _currentValue is ready. A reader that acquire-loads _currentValueGeneration and
// sees gen is then guaranteed (via the release/acquire pairing) to observe the
// _currentValue written here.
Volatile.Write(ref _currentValue, value);
Comment thread
rosebyte marked this conversation as resolved.
Volatile.Write(ref _currentValueGeneration, gen);
Comment on lines +118 to +119
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT, this code is correct. But isn't Volatile.Write/Volatile.Read on _currentValue unnecessary? I think it should be sufficient to use Volatile only on _currentValueGeneration.

(But safety is of course more important than small performance improvement, so if we're not sure, an extra Volatile is much better than a missing Volatile.)

return value;
}

/// <summary>
Expand All @@ -88,7 +129,7 @@ public TOptions CurrentValue
/// <exception cref="MissingMethodException">The <typeparamref name="TOptions"/> does not have a public parameterless constructor or <typeparamref name="TOptions"/> is <see langword="abstract"/>.</exception>
public virtual TOptions Get(string? name)
{
if (_cache is not OptionsCache<TOptions> optionsCache)
if (_fastCache is null)
{
// copying captured variables to locals avoids allocating a closure if we don't enter the if
string localName = name ?? Options.DefaultName;
Expand All @@ -97,8 +138,7 @@ public virtual TOptions Get(string? name)
}

// non-allocating fast path
return optionsCache.GetOrAdd(name, static (name, factory) => factory.Create(name), _factory);

return _fastCache.GetOrAdd(name, static (name, factory) => factory.Create(name), _factory);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -537,5 +537,89 @@ public WaitHandleConfigureOptions(WaitHandle waitHandle)
void IConfigureNamedOptions<FakeOptions>.Configure(string? name, FakeOptions options) => _waitHandle.WaitOne();
void IConfigureOptions<FakeOptions>.Configure(FakeOptions options) => _waitHandle.WaitOne();
}

[Fact]
public void CurrentValue_ReflectsLatestAfterCacheClear()
{
var services = new ServiceCollection().AddOptions().AddSingleton<IConfigureOptions<FakeOptions>>(new CountIncrement(this));
var sp = services.BuildServiceProvider();

var monitor = sp.GetRequiredService<IOptionsMonitor<FakeOptions>>();
var cache = sp.GetRequiredService<IOptionsMonitorCache<FakeOptions>>();

Assert.Equal("1", monitor.CurrentValue.Message);

cache.Clear();

Assert.Equal("2", monitor.CurrentValue.Message);
}

[Fact]
public void CurrentValue_ReflectsLatestAfterTryRemoveDefault()
{
var services = new ServiceCollection().AddOptions().AddSingleton<IConfigureOptions<FakeOptions>>(new CountIncrement(this));
var sp = services.BuildServiceProvider();

var monitor = sp.GetRequiredService<IOptionsMonitor<FakeOptions>>();
var cache = sp.GetRequiredService<IOptionsMonitorCache<FakeOptions>>();

Assert.Equal("1", monitor.CurrentValue.Message);

cache.TryRemove(Options.DefaultName);

Assert.Equal("2", monitor.CurrentValue.Message);
}

[Fact]
public void CurrentValue_FallsBackToGetForCustomIOptionsMonitorCache()
{
var customCache = new CustomOptionsMonitorCache<FakeOptions>();
var services = new ServiceCollection()
.AddOptions()
.AddSingleton<IConfigureOptions<FakeOptions>>(new CountIncrement(this))
.AddSingleton<IOptionsMonitorCache<FakeOptions>>(customCache);
var sp = services.BuildServiceProvider();

var monitor = sp.GetRequiredService<IOptionsMonitor<FakeOptions>>();

Assert.Equal("1", monitor.CurrentValue.Message);

customCache.Clear();

Assert.Equal("2", monitor.CurrentValue.Message);
}

private sealed class CustomOptionsMonitorCache<TOptions> : IOptionsMonitorCache<TOptions>
where TOptions : class
{
private readonly Dictionary<string, TOptions> _cache = new Dictionary<string, TOptions>(StringComparer.Ordinal);

public TOptions GetOrAdd(string? name, Func<TOptions> createOptions)
{
name ??= Options.DefaultName;
if (!_cache.TryGetValue(name, out TOptions? value))
{
value = createOptions();
_cache[name] = value;
}
return value;
}

public bool TryAdd(string? name, TOptions options)
{
name ??= Options.DefaultName;
if (_cache.ContainsKey(name))
return false;
_cache[name] = options;
return true;
}

public bool TryRemove(string? name)
{
return _cache.Remove(name ?? Options.DefaultName);
}

public void Clear() => _cache.Clear();
}
}
}
Loading