-
Notifications
You must be signed in to change notification settings - Fork 4.8k
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
[API Proposal]: Asynchronous DI support #65656
Comments
Tagging subscribers to this area: @dotnet/area-extensions-dependencyinjection Issue DetailsBackground and motivationThere are cases where it is necessary to inject a dependency as a result of an asynchronous operation. This usually comes up when the dependency requires input from some IO operation (like retrieving a secret from a remote store). Today, developers tend to put blocking calls in factories: var services = new ServiceCollection();
services.AddSingleton<IRemoteConnectionFactory, RedisConnectionFactory>();
services.AddSingleton<IRemoteConnection>(sp =>
{
var factory = sp.GetRequiredService<IRemoteConnectionFactory>();
// NOOOOOO 😢
return factory.ConnectAsync().Result;
});
ServiceProvider sp = services.BuildServiceProvider();
IRemoteConnection connection = await sp.GetRequiredServiceAsync<IRemoteConnection>();
public interface IRemoteConnection
{
Task PublishAsync(string channel, string message);
Task DisposeAsync();
}
public interface IRemoteConnectionFactory
{
Task<IRemoteConnection> ConnectAsync();
} The only other viable solution is to move that async operation to method calls, which results in deferring all IO until methods are called (where things can truly be async). This is a non-trivial refactoring that might be impossible depending on the circumstance. The idea here is to provide asynchronous construction support, so that these scenarios can work. There are a set of features that would be required outside of this API proposal to make this work but those will be left out for now (specifically around how constructors would work). API Proposalnamespace Microsoft.Extensions.DependencyInjection
{
public interface IAsyncServiceProvider : IServiceProvider
{
ValueTask<object> GetServiceAsync(Type serviceType);
}
// Added the IAsyncServiceProvider implementation
public sealed class ServiceProvider : IAsyncDisposable, IDisposable, IServiceProvider, IAsyncServiceProvider
{
}
// This AsyncImplementationFactory gets added to the existing service descriptor
public class ServiceDescriptor
{
public ServiceDescriptor(Func<IAsyncServiceProvider, ValueTask<object>> asyncImplementationFactory)
{
AsyncImplementationFactory = asyncImplementationFactory;
}
public Func<IAsyncServiceProvider, ValueTask<object>>? AsyncImplementationFactory { get; }
public static AsyncServiceDescriptor Describe(Type serviceType, Func<IAsyncServiceProvider, ValueTask<object>> asyncFactory, ServiceLifetime serviceLifetime) => null;
}
// These are extension methods that take an async factory
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddSingleton<TService>(this IServiceCollection services, Func<IAsyncServiceProvider, ValueTask<TService>> asyncFactory);
public static IServiceCollection AddSingleton(this IServiceCollection services, Type serviceType, Func<IAsyncServiceProvider, ValueTask<object>> asyncFactory);
public static IServiceCollection AddScoped<TService>(this IServiceCollection services, Func<IAsyncServiceProvider, ValueTask<TService>> asyncFactory);
public static IServiceCollection AddScoped(this IServiceCollection services, Type serviceType, Func<IAsyncServiceProvider, ValueTask<object>> asyncFactory);
public static IServiceCollection AddTransient<TService>(this IServiceCollection services, Func<IAsyncServiceProvider, ValueTask<TService>> asyncFactory);
public static IServiceCollection AddTransient(this IServiceCollection services, Type serviceType, Func<IAsyncServiceProvider, ValueTask<object>> asyncFactory);
}
public static class AsyncServiceProviderExtensions
{
public static async ValueTask<T> GetRequiredServiceAsync<T>(this IAsyncServiceProvider serviceProvider);
}
} API Usagevar services = new ServiceCollection();
services.AddSingleton<IRemoteConnection>(sp =>
{
var factory = sp.GetRequiredService<IRemoteConnectionFactory>();
return factory.ConnectAsync();
});
ServiceProvider sp = services.BuildServiceProvider();
IRemoteConnection connection = await sp.GetRequiredServiceAsync<IRemoteConnection>();
public interface IRemoteConnection
{
Task PublishAsync(string channel, string message);
Task DisposeAsync();
}
public interface IRemoteConnectionFactory
{
Task<IRemoteConnection> ConnectAsync();
} Alternative DesignsNo response Risks
|
FWIW, it feels weird to put |
Related discussion, itself linking to this overarching issue. |
Ok, so there's a few things to cover off here. Some of them may be specific to Autofac, some of them not. We dipped our toe in the async-di water last year to add support for I'll lead with what you describe as "implementation complexity", just because we should discuss the amount of change needed by us if you want to do async DI properly. Arguably, complexity isn't a superb reason to just not do something, but in this case... Async viralityFundamentally, the entire Autofac resolve pipeline will need to be updated to use We can't only change the resolve path for the async registrations; the viral nature of async prevents it, unless we do sync-over-async when resolving those services. The ability to await a service resolve would have to be everywhere to actually gain the async benefits.
We also really don't want to maintain a sync and async resolve pipeline, that would be a huge amount of extra code maintenance, because, like I said, everything would have to change in our resolve pipeline. So that all implies our pipeline becomes async by default, and we add a new public class LifetimeScope
{
public T Resolve<T>()
{
var task = ResolveAsync<T>();
if (result.IsCompleted)
{
return task.Result;
}
// ??
throw new Exception("?");
}
public ValueTask<T> ResolveAsync()
{
// do the resolve
}
} It's important to note that we cannot know before beginning the resolve whether or not the resolve operation will complete synchronously, so we basically have to "try" and somehow bail after the fact. This all starts to get pretty complicated. LazyUsers of Autofac often inject Specifically, it has a property used to access the underlying service. That won't work for async, so I imagine we would need a new Beyond technical problems, this puts an onus on the component to know which type of lazy it should use; this introduces coupling between the service and the dependency, which somewhat defies the point. Func DependenciesAutofac let's you inject invokable factories as Thread SafetyOne of the things we've generally asserted in Autofac is that a Resolve happens on a single thread. Because it's possible for users to create new lifetime scopes and generally do very weird stuff in resolve paths, there are locks in place that would need to be replaced, because the resolve would now be able to proceed on a different thread than the one it started on. IntegrationsMost of our existing active integrations would need updating to understand/support async, which is quite an undertaking in and of itself. There's almost certainly more challenges here than I've documented; these are just a few. I'm pretty confident that this would be a huge piece of work to implement for Autofac. Beyond the actual difficulty though... Libraries would have to changeThese changes could get more complex/confusing for users if an existing library component depends on a service that a user can override. For a random example, let's say a library Now, Either the library implementer has to change to support async DI injection, Autofac would have to change to have a compatibility "fallback" of doing sync-over-async, or there will be a lot of users having to apply workarounds like the below to get compatibility with their libraries. // 🤮
builder.Register(ctx => new Lazy<MyService>(() => ctx.ResolveAsync<MyService>().Result)); Performance OverheadsI don't have the numbers on this, but I imagine the runtime team will; what are the overheads of a User Breaking ChangesIn 2020, Autofac v6 changed the way that Autofac can be extended by switching to a pipeline approach, and letting users add middleware. Middleware created since then may likely have to change to support users, even if they don't use async factory registration, which I can imagine might annoy our users somewhat. As a parting note, I will add that Autofac is about object construction. That's pretty much it. People have tried to do some pretty crazy things with Autofac beyond that task, up to and including doing their entire app startup using build callbacks and the I generally think that injecting a factory type, and then calling async methods on that factory when you need it, to get the appropriate instance, isn't the end of the world, and I'm not sure the trade-offs would be worth it. |
I agree with @alistairjevans that the "native" async support will be a very complex task. If we concentrate only on the async resolution (because it is unclear to me how an async injection suppose to work), Here is the working example from the DryIoc using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace DryIoc.Microsoft.DependencyInjection.Specification.Tests
{
public static class AsyncExt
{
public static IServiceCollection AddSingleton<TService>(this IServiceCollection services, Func<IServiceProvider, Task<TService>> asyncFactory)
{
var factoryID = Factory.GetNextID();
Task<TService> CreateServiceAsync(IServiceProvider sp)
{
var dryIoc = sp.GetRequiredService<IResolverContext>();
var result = dryIoc.SingletonScope.GetOrAddViaFactoryDelegate(factoryID, r => asyncFactory(r), dryIoc);
return (Task<TService>)result;
}
return services.AddSingleton<Func<IServiceProvider, Task<TService>>>(CreateServiceAsync);
}
public static Task<TService> GetRequiredServiceAsync<TService>(this IServiceProvider sp) =>
sp.GetRequiredService<Func<IServiceProvider, Task<TService>>>().Invoke(sp);
}
public class AsyncResolutionTestsPOC
{
[Test]
public async Task GetRequiredServiceAsync()
{
var services = new ServiceCollection();
services.AddSingleton<IRemoteConnectionFactory, TestConnectionFactory>();
services.AddSingleton<IRemoteConnection>(sp =>
{
var factory = sp.GetRequiredService<IRemoteConnectionFactory>();
return factory.ConnectAsync();
});
var providerFactory = new DryIocServiceProviderFactory();
var provider = providerFactory.CreateServiceProvider(providerFactory.CreateBuilder(services));
var connection1 = await provider.GetRequiredServiceAsync<IRemoteConnection>();
Assert.IsNotNull(connection1);
var connection2 = await provider.GetRequiredServiceAsync<IRemoteConnection>();
Assert.AreSame(connection2, connection1);
await connection2.PublishAsync("hello", "sailor");
}
public interface IRemoteConnection
{
Task PublishAsync(string channel, string message);
Task DisposeAsync();
}
public interface IRemoteConnectionFactory
{
Task<IRemoteConnection> ConnectAsync();
}
class TestConnectionFactory : IRemoteConnectionFactory
{
public Task<IRemoteConnection> ConnectAsync() => Task.FromResult<IRemoteConnection>(new TestRemoteConnection());
}
class TestRemoteConnection : IRemoteConnection
{
public Task DisposeAsync() => Task.CompletedTask;
public async Task PublishAsync(string channel, string message)
{
await Task.Delay(TimeSpan.FromMilliseconds(17));
Console.WriteLine(channel + "->" + message);
}
}
}
} |
I went through implementation of async in one of my prototypes for Unity. Ended up with two independent pipelines with rather complex synchronization issues. For example, all thread based lifetimes stopped working in async. After spending couple of months on this endeavor I decided to scratch support for async completely. |
@alistairjevans Thanks for those thoughts. Those resonate and I understand how complex it would be to support this (as it forks the entire code base). @dadhi I love this. I've listed it under alternative designs. There are some complexities around:
I was thinking that we could look at an constructor surrogate. A static factory method on the type (Create or CreateAsync). You could even envision a static abstract interface method here ( |
I've updated the API proposal. We need to agree on some semantics for how this extension would work. I like the idea that we would map async resolution to |
In the demonstrated approach DryIoc will cache the task for the
Not sure. At least |
I could be missing something, but it seemed like the convenience of trying to wrap the async factory in the DI container was to abstract away the factory itself so you could "simply inject" the thing that required the async/await. In the new proposal, it just moves the factory itself out a level, plus it introduces the need for any async resolution to use service location. How is that better than just resolving the factory and using it directly? |
A bit nervous to be perhaps stating the obvious here but why not something like this:-
Here Regardless of the above, some additional thoughts:- Async calls are typically going to be more prone to transient or other exceptions, especially if they involve any network bound stuff.
I suspect the answers to the above would be that the containers wouldn't help here, and so that stuff would have to be "handled" by the developer in their underlying factory method implementations.. In other words, developer authored factory methods or classes would still be desired in perhaps most cases, if not initially, then eventually. General observation: Placing async code in DI location takes control away from the application and hands it to the DI container. This is not necessarily wise without wrapping it with sufficient resiliency or other features and ensuring you have visibility and insight into failures and that the impact of a failure upon the application can be understood. By injecting a factory and calling an async method tylically within another async method the control flow is easy to follow and the impact easy to assess. By promoting the async factory method somewhere up into the container for DI to handle, I wonder whether that will actually make developers lives any easier when they do have to reason about failures or impacts. P.s I am not saying it will or it won't just a consideration. |
I would expect that a failed async initialisation (e.g. database is down) would have the exception handled precisely the same way as throwing an exception in a constructor is now (e.g. an invalid requested state in the new object). I'm not sure why it might want to be semantically distinct. It's still a failure to create the object you wanted. If you require special handling for a transient error, you can do that in the async constructor or a try/catch, as usual. |
On the subject of consuming services that might be created asynchronously... I think that you want to be able to request both If your class doesn't do any async initialisation, you don't need to care. If you do, you can request a Task for everything (regardless of how it was created) and only await it when required. That means when or if a dependency changes to async construction, your code doesn't need to change to take advantage of it. For example, if you don't have any async initialisation in the second class, both classes have the same behaviour. public class SyncConstructor(TService service) {}
public class AsyncConstructor{
private AsyncConstructor(TService service) {}
[AsyncConstructor] // or however we declare an async factory.
public static async Task<AsyncConstructor> Factory(Task<TService service>) {
// async stuff here...
return new AsyncConstructor(await service);
}
} Of course, it only makes sense to wait for potentially async objects, but you could, for example, start requesting l10n from a slow resource, and it's suddenly an async service. |
Background and motivation
There are cases where it is necessary to inject a dependency as a result of an asynchronous operation. This usually comes up when the dependency requires input from some IO operation (like retrieving a secret from a remote store). Today, developers tend to put blocking calls in factories:
The only other viable solution is to move that async operation to method calls, which results in deferring all IO until methods are called (where things can truly be async). This is a non-trivial refactoring that might be impossible depending on the circumstance. The idea here is to provide asynchronous construction support, so that these scenarios can work.
API Proposal
Async Resolution Support
Async Injection Support
These APIs would use the convention that async resolution is tied to
ValueTask/Task<TServiceType>
and would resolve the service and await the result as part of construction (see the example for more details).NOTE: The generic version could be done using static abstract interface methods and would be more trim friendly.
API Usage
Risks
3rd party DI containers would need to support thisImplementation complexity (but we can handle this 😄). It's a bit easier if we do it as an extension.The text was updated successfully, but these errors were encountered: