-
Notifications
You must be signed in to change notification settings - Fork 5.3k
Slimmer IOptions<T> #49852
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Slimmer IOptions<T> #49852
Conversation
|
Tagging subscribers to this area: @maryamariyan Issue Details
Fixes #45260
|
- Use a Lazy instead of the IOptionsCache (which is a concurrent dictionary)
1b6851e to
592ede5
Compare
| } | ||
|
|
||
| public TOptions Value => _lazy.Value; | ||
| } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 😉
There was a problem hiding this comment.
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<>))); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Something like this:
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.
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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.
eerhardt
left a comment
There was a problem hiding this 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.
Fixes #45260