ASP.NET 7中动态创建带作用域依赖的长期运行任务(无编译时托管服务)
我正在使用ASP.NET,需要从任意作用域服务、单例服务或托管服务内部创建并启动长期运行任务。任务的数量和创建时机在编译时无法确定,而是在处理HTTP请求或BackgroundService循环过程中,当需要执行大量CPU密集型工作(比如模拟计算)时,才在线程池创建多个长期运行任务。此外,这些长期运行任务需要使用生命周期与其自身一致的作用域依赖项。
public interface ILongRunningTaskStarter { public void FireAndForget<TWorkDependencies>(Func<TWorkDependencies, CancellationToken, Task> longRunningWork) where TWorkDependencies : notnull; } public class LongRunningTaskStarter : ILongRunningTaskStarter { protected readonly ILogger<LongRunningTaskStarter> _logger; private readonly IServiceProvider _serviceProvider; private readonly CancellationTokenSource _stoppingCts = new CancellationTokenSource(); public LongRunningTaskStarter(ILogger<LongRunningTaskStarter> logger, IServiceProvider serviceProvider) { _logger = logger; _serviceProvider = serviceProvider; } private Func<CancellationToken, Task> WrapInScope<TWorkDependencies>(Func<TWorkDependencies, CancellationToken, Task> longRunningWork) where TWorkDependencies : notnull { return async (stoppingToken) => { try { _logger.LogDebug("Long running task on Thread {ThreadId} has started.", Thread.CurrentThread.ManagedThreadId); using var scope = _serviceProvider.CreateScope(); var scopedDependencies = scope.ServiceProvider.GetRequiredService<TWorkDependencies>(); await longRunningWork(scopedDependencies, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) { _logger.LogDebug("Long running task on Thread {ThreadId} has stopped.", Thread.CurrentThread.ManagedThreadId); throw; } catch (Exception ex) { _logger.LogError(ex, "Uncaught exception from long running task on Thread {ThreadId}.", Thread.CurrentThread.ManagedThreadId); } }; } public void FireAndForget<TWorkDependencies>(Func<TWorkDependencies, CancellationToken, Task> longRunningWork) where TWorkDependencies : notnull { var stoppingToken = _stoppingCts.Token; var scopedWork = WrapInScope(longRunningWork); Task.Run(() => scopedWork(stoppingToken), stoppingToken).ConfigureAwait(false); } }
public class SimulationExampleComponent : ISimulationExampleComponent { private ILongRunningTaskStarter _taskStarter; public SimulationExampleComponent(ILongRunningTaskStarter taskStarter) { _taskStarter = taskStarter; } public async Task ExecuteAvailableSimulationJobs() { var simulationInputList = await RequestSimulationJobsFromServer(); foreach (var simulationInput in simulationInputList) { var longRunningWork = (ISimulationDependencies dep, CancellationToken token) => Simulate(dep, token, simulationInput); _taskStarter.FireAndForget(longRunningWork); } } private static Task<double[]> RequestSimulationJobsFromServer() { var dummyInput = new Random().NextDoubleSequence().Take(100).ToArray(); return Task.FromResult(dummyInput); } private static async Task Simulate(ISimulationDependencies dep, CancellationToken stoppingToken, double simulationInput) { // 实际的长期运行工作: // 1. 在发送状态事件的同时执行模拟所需操作。 // 2. 将输出写入数据存储。 // 3. 通知相关方模拟已完成。 } }
services.AddScoped<ISimulationDependencies, SimulationDependencies>(); services.AddSingleton<ILongRunningTaskStarter, LongRunningTaskStarter>(); services.AddScoped<ISimulationExampleComponent, SimulationExampleComponent>();
1. 代码是否存在死锁风险,尤其是在scope.ServiceProvider.GetRequiredService<TWorkDependencies>()调用时?
当前代码不存在明显死锁风险。GetRequiredService是同步调用,但这里是在Task.Run启动的线程池线程中执行,没有阻塞ASP.NET请求上下文的同步上下文(因为用了ConfigureAwait(false)且在非请求线程中)。只要TWorkDependencies的构造函数中没有同步等待异步操作且阻塞当前线程的逻辑,就不会触发死锁。
2. 是否存在依赖项被提前释放的风险?await longRunningWork是否能确保作用域不会被提前释放?
是的,await longRunningWork能保证作用域不会提前释放。因为scope是用using var声明的,其释放会延迟到await完成之后的代码块末尾。只要longRunningWork内部没有提前释放依赖项的手动操作,作用域内的依赖项会在任务完成后才被释放,不存在提前释放的问题。
3. .ConfigureAwait(false)调用对访问作用域有影响吗?作用域是否与线程上下文正交且独立?
ConfigureAwait(false)对作用域访问没有影响。作用域是基于IServiceScope实例的,与线程上下文完全正交。只要持有有效的IServiceScope实例,无论在哪个线程上访问其ServiceProvider获取依赖项,都是安全的。ConfigureAwait(false)只是避免捕获原上下文,不会改变作用域的有效性。
4. ASP.NET DI框架是否会出现嵌套作用域的问题?比如在作用域A中创建任务,任务再创建作用域B?
嵌套作用域是ASP.NET DI支持的正常场景,不会有问题。作用域B是从根服务提供者创建的(因为LongRunningTaskStarter是单例,注入的IServiceProvider是根容器),并非嵌套在作用域A中。每个任务的作用域都是独立的根作用域的子作用域,彼此隔离,不会继承或干扰原请求的作用域A。
5. 是否有更好的实现方法?
你的方案本质上是服务定位器模式,虽然符合常见做法,但可以做一些优化:
- 支持任务追踪:当前是Fire-and-Forget,无法追踪任务状态。可以修改
FireAndForget返回Task,让调用方可选追踪任务(但要注意调用方不能阻塞等待)。 - 集成主机停止逻辑:当前的
_stoppingCts没有关联主机的停止信号,应该让LongRunningTaskStarter实现IHostedService,在StopAsync中调用_stoppingCts.Cancel(),确保主机停止时能正确终止所有任务。 - 适配异步释放:如果
TWorkDependencies实现了IAsyncDisposable,当前的using var scope会自动调用DisposeAsync,确保异步资源被正确释放。 - 弱化服务定位器味:可以通过泛型工厂注册的方式,将依赖项的解析提前到DI容器层面,但对于动态创建的任务,服务定位器模式本身是合理的折中方案。
内容的提问来源于stack exchange,提问作者Joerg

