Skip to content

Conversation

@davidfowl
Copy link
Member

  • Use a Lazy instead of the IOptionsCache (which is a concurrent dictionary)

Fixes #45260

@ghost
Copy link

ghost commented Mar 19, 2021

Tagging subscribers to this area: @maryamariyan
See info in area-owners.md if you want to be subscribed.

Issue Details
  • Use a Lazy instead of the IOptionsCache (which is a concurrent dictionary)

Fixes #45260

Author: davidfowl
Assignees: -
Labels:

area-Extensions-Options

Milestone: -

- Use a Lazy instead of the IOptionsCache (which is a concurrent dictionary)
@davidfowl davidfowl force-pushed the davidfowl/slim-options branch from 1b6851e to 592ede5 Compare March 19, 2021 04:17
}

public TOptions Value => _lazy.Value;
}
Copy link
Member

Choose a reason for hiding this comment

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

Looks simple enough.

Can you remind me what costs this is trying to address?

Copy link
Member

Choose a reason for hiding this comment

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

The current implementation is shared between "named" and "unnamed" Options. When using "named" options, we need to use a cache (ConcurrentDictionary):

/// <summary>
/// The default configured <typeparamref name="TOptions"/> instance, equivalent to Get(Options.DefaultName).
/// </summary>
public TOptions Value => Get(Options.DefaultName);
/// <summary>
/// Returns a configured <typeparamref name="TOptions"/> instance with the given <paramref name="name"/>.
/// </summary>
public virtual TOptions Get(string name)
{
name = name ?? Options.DefaultName;
if (!_cache.TryGetValue(name, out TOptions options))
{
// Store the options in our instance cache. Avoid closure on fast path by storing state into scoped locals.
IOptionsFactory<TOptions> localFactory = _factory;
string localName = name;
options = _cache.GetOrAdd(name, () => localFactory.Create(localName));
}
return options;

In the simple case where you never use a named option, we still create the dictionary, and check it to get the value.

Here, when you never use a named option, we can simply use a Lazy, saving the dictionary alloc and access.

Copy link
Member

Choose a reason for hiding this comment

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

saving the dictionary alloc

This is why I was asking though. If the goal is to make this cheaper when it's never used, and we're going to be creating a lot of these so that it matters, there are cheaper ways. Lazy still allocates, and in the current usage, there's not only the allocation for the Lazy itself, there's the internal LazyHelper that'll be allocated to serve as the lock object for the double-checked locking it employs by default, and there's the closure allocation to capture the factory, and there's the delegate allocation for the method on the closure. If it's never accessed, yeah, it's a tad cheaper, but not as much as you might think (and if it is accessed, you've basically doubled the allocation cost).

[Benchmark]
public object CreateLazy()
{
    return CreateLazyWithClosure(s => new ConcurrentDictionary<string, string>(1, 0));

    static Lazy<T> CreateLazyWithClosure<T>(Func<string, T> factory) =>
        new Lazy<T>(() => factory(""));
}

[Benchmark]
public object CreateDict() => new ConcurrentDictionary<string, string>(1, 0);

[Benchmark]
public object CreateLazyAndAccess() => ((Lazy<ConcurrentDictionary<string, string>>)CreateLazy()).Value;
Method Mean Allocated
CreateLazy 23.00 ns 160 B
CreateDict 36.82 ns 192 B
CreateLazyAndAccess 82.91 ns 352 B

Copy link
Member Author

Choose a reason for hiding this comment

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

Is it better to use LazyInitializer and keep the state in the implementation?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah I re-read your statement, I thought you were saying the ConcurrentDictionary was cheaper. You're just saying they are cheaper ways to avoid the lazy overhead. I thought about it a little but figured I'd let you comment and tell me what the optimal way was 😉

Copy link
Member

Choose a reason for hiding this comment

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

throw new ArgumentNullException(nameof(services));
}

services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptions<>), typeof(OptionsManager<>)));
Copy link
Member

Choose a reason for hiding this comment

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

I really hope people aren't doing this, but I gotta ask: do we know if people get IOptions<T>, and then cast/as it either to OptionsManager<T> or IOptionsSnapshot<T>?

I see OptionsManager<> is public, which made me think of this.

Copy link
Member

Choose a reason for hiding this comment

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

Something like this:

https://github.com/KevinDockx/HttpCacheHeaders/blob/7c7f0fe94b70e4273f0a8011c8daf78adb220c13/test/Marvin.Cache.Headers.Test/Extensions/ServiceExtensionFacts.cs#L75-L81

        private static void ValidateServiceOptions<T>(TestServer testServer, Func<OptionsManager<T>, bool> validOptions) where T : class, new()
        {
            var options = testServer.Host.Services.GetService(typeof(IOptions<T>));
            Assert.NotNull(options);
            var manager = (OptionsManager<T>)options;
            Assert.True(validOptions(manager));
        }

I know this is test code, but I can imagine other doing this unnatural thing.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is an acceptable breaking change IMO. It's unfortunate that OptionsManager is public but I don't think it's reasonable to tie our hands as a result. This is one of the benefits of using DI (swapping the implementation).

Good find though!

Copy link
Member Author

Choose a reason for hiding this comment

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

I did a query and found some packages using OptionsManager directly but none were casting the default implementation.

Copy link
Member

@eerhardt eerhardt left a comment

Choose a reason for hiding this comment

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

I think this looks good. I just want to make sure we are OK with the potential breaking change.

@davidfowl davidfowl merged commit 93cee26 into main Mar 19, 2021
@jkotas jkotas deleted the davidfowl/slim-options branch March 20, 2021 07:47
@davidfowl davidfowl added this to the 6.0.0 milestone Apr 2, 2021
@ghost ghost locked as resolved and limited conversation to collaborators May 3, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The IOptions<T> implementation can be optimized

4 participants