异步定时器在调度后台服务中的应用

17

我正在使用 .Net-Core 编写一个托管服务,它基于定时器在后台运行任务。

目前我必须像这样同步运行代码:

public override Task StartAsync(CancellationToken cancellationToken)
{
    this._logger.LogInformation("Timed Background Service is starting.");

    this._timer = new Timer(ExecuteTask, null, TimeSpan.Zero,
        TimeSpan.FromSeconds(30));

    return Task.CompletedTask;
}

private void ExecuteTask(object state)
{
    this._logger.LogInformation("Timed Background Service is working.");
    using (var scope = _serviceProvider.CreateScope())
    {
        var coinbaseService = scope.ServiceProvider.GetRequiredService<CoinbaseService>();
        coinbaseService.FinalizeMeeting();
    }
}

我希望能够在定时器上异步运行代码,但我不想使用fire and forget方式异步运行代码,因为这可能会导致我的代码出现竞争条件。

例如(订阅timer.Elapsed事件)

有没有一种方法可以利用异步代码来进行定时调度而不执行fire and forget呢?


1
为什么你不能只用 private async Task ExecuteTask(object state) 呢? - zaitsman
@zaitsman ExecuteTask 是从计时器触发的,我不想放任我的任务,但是在调试后我认为它仍然会发生。 - johnny 5
3个回答

31

对于那些寻求完整的示例以防止任务并发运行的人。基于 @Gabriel Luci 的答案和评论。

请随意评论,以便我可以进行更正。

    /// <summary>
    /// Based on Microsoft.Extensions.Hosting.BackgroundService  https://github.com/aspnet/Extensions/blob/master/src/Hosting/Abstractions/src/BackgroundService.cs
    /// Additional info: - https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-2.2&tabs=visual-studio#timed-background-tasks
    ///                  - https://dev59.com/6FQJ5IYBdhLWcg3wdFd4
    /// </summary>

    public abstract class TimedHostedService : IHostedService, IDisposable
    {
        private readonly ILogger _logger;
        private Timer _timer;
        private Task _executingTask;
        private readonly CancellationTokenSource _stoppingCts = new CancellationTokenSource();

        public TimedHostedService(ILogger<TimedHostedService> logger)
        {
            _logger = logger;
        }

        public Task StartAsync(CancellationToken cancellationToken)
        {
            _logger.LogInformation("Timed Background Service is starting.");

            _timer = new Timer(ExecuteTask, null, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(-1));

            return Task.CompletedTask;
        }

        private void ExecuteTask(object state)
        {
            _timer?.Change(Timeout.Infinite, 0);
            _executingTask = ExecuteTaskAsync(_stoppingCts.Token);
        }

        private async Task ExecuteTaskAsync(CancellationToken stoppingToken)
        {
            await RunJobAsync(stoppingToken);
            _timer.Change(TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(-1));
        }

        /// <summary>
        /// This method is called when the <see cref="IHostedService"/> starts. The implementation should return a task 
        /// </summary>
        /// <param name="stoppingToken">Triggered when <see cref="IHostedService.StopAsync(CancellationToken)"/> is called.</param>
        /// <returns>A <see cref="Task"/> that represents the long running operations.</returns>
        protected abstract Task RunJobAsync(CancellationToken stoppingToken);

        public virtual async Task StopAsync(CancellationToken cancellationToken)
        {
            _logger.LogInformation("Timed Background Service is stopping.");
            _timer?.Change(Timeout.Infinite, 0);

            // Stop called without start
            if (_executingTask == null)
            {
                return;
            }

            try
            {
                // Signal cancellation to the executing method
                _stoppingCts.Cancel();
            }
            finally
            {
                // Wait until the task completes or the stop token triggers
                await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite, cancellationToken));
            }

        }

        public void Dispose()
        {
            _stoppingCts.Cancel();
            _timer?.Dispose();
        }
    }

15
< p > async 的整个目的是不阻塞主线程。但这已经是一个后台线程,所以除非它是 ASP.NET Core 应用程序,否则并不重要。那只有在这种情况下才会有影响,因为有限的线程池被耗尽意味着无法再提供更多请求。

如果你真的想让它运行 async,只需将其设置为 async

private async void ExecuteTask(object state)
{
    //await stuff here
}

是的,我知道你说你不想“点火和忘记”,但是事件就是那样的:它们被点燃然后就被忘记了。所以你的ExecuteTask方法将被调用,没有人会在意(或检查)它是否(1)仍在运行或(2)是否失败了。无论你是异步运行还是同步运行都是如此。

您可以通过将ExecuteTask方法内的所有内容都包装在try/catch块中,并确保将其记录在某个地方以便了解发生了什么来减轻故障。

另一个问题是知道它是否仍在运行(即使您不运行async也是一个问题)。也有一种方法可以缓解这个问题:

private Task doWorkTask;

private void ExecuteTask(object state)
{
    doWorkTask = DoWork();
}

private async Task DoWork()
{
    //await stuff here
}
在此情况下,您的计时器仅启动任务。但区别在于,您保留了对Task的引用。这将使您可以在代码中的任何其他位置检查Task的状态。例如,如果您想要验证它是否完成,可以查看doWorkTask.IsCompleteddoWorkTask.Status
此外,当您的应用程序关闭时,您可以使用:
await doWorkTask;

在关闭应用程序之前,要确保任务已经完成。否则,线程将被强制关闭,可能会导致状态不一致。请注意,如果在 DoWork() 中出现未处理的异常,使用 await doWorkTask 将会抛出一个异常。

在开始下一个任务之前,验证上一个任务是否已经完成也是一个好主意。


异步操作可以释放线程,我想要释放我的线程。此外,我的服务代码是异步编写的,我不想同步运行。 - johnny 5
它只有在释放ASP.NET Core中的线程时才有帮助。否则,它并不重要。我更新了我的答案,讨论了如何做到这一点以及如何处理一些问题,实际上,无论是异步还是非异步都会出现这些问题。 - Gabriel Luci
谢谢,我会将此标记为已解决,因为事实证明,无论我使用fire and forget或不使用它,我的任务都会并发运行:/,但是这是否与较旧版本的.net有关呢?特别是在共享托管上,因为可以通过IIS限制每个进程的线程数?异步使用硬件中断,因此在网络和I/O操作期间会释放线程。 - johnny 5
1
你是对的:如果你在一个Web应用程序中运行它,那么是的,你应该以async方式运行它(无论它是否是fire-and-forget)。 - Gabriel Luci
3
为了防止任务同时运行(我猜你的意思是定时器的下一个迭代在前一个没有完成的情况下开始运行?),你可以手动重启定时器,而不是让它自己重置。这可以通过声明new Timer(ExecuteTask, null, TimeSpan.Zero, -1);来实现,当任务完成后,调用_timer.Change(TimeSpan.FromSeconds(30), -1)告诉它开始倒计时。 - Gabriel Luci
显示剩余2条评论

8

以下是基于之前回答而进行改进的版本。 改进之处:

  1. 捕获可能在任务执行期间出现的异常,并且不会阻止下一个任务的执行。
  2. 对于每个任务执行,都会创建一个范围,因此您可以在RunJobAsync中访问任何作用域服务。
  3. 您可以在继承类中指定间隔和初始任务执行时间。

访问范围服务示例

    protected override async Task RunJobAsync(IServiceProvider serviceProvider, CancellationToken stoppingToken)
    {
            DbContext context = serviceProvider.GetRequiredService<DbContext>();
    }

源代码:

public abstract class TimedHostedService : IHostedService, IDisposable
{
    private readonly ILogger _logger;
    private Timer _timer;
    private Task _executingTask;
    private readonly CancellationTokenSource _stoppingCts = new CancellationTokenSource();

    IServiceProvider _services;
    public TimedHostedService(IServiceProvider services)
    {
        _services = services;
        _logger = _services.GetRequiredService<ILogger<TimedHostedService>>();
        
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _timer = new Timer(ExecuteTask, null,FirstRunAfter, TimeSpan.FromMilliseconds(-1));

        return Task.CompletedTask;
    }

    private void ExecuteTask(object state)
    {
        _timer?.Change(Timeout.Infinite, 0);
        _executingTask = ExecuteTaskAsync(_stoppingCts.Token);
    }

    private async Task ExecuteTaskAsync(CancellationToken stoppingToken)
    {
        try
        {
            using (var scope = _services.CreateScope())
            {
                await RunJobAsync(scope.ServiceProvider, stoppingToken);
            }
        }
        catch (Exception exception)
        {
            _logger.LogError("BackgroundTask Failed", exception);
        }
        _timer.Change(Interval, TimeSpan.FromMilliseconds(-1));
    }

    /// <summary>
    /// This method is called when the <see cref="IHostedService"/> starts. The implementation should return a task 
    /// </summary>
    /// <param name="serviceProvider"></param>
    /// <param name="stoppingToken">Triggered when <see cref="IHostedService.StopAsync(CancellationToken)"/> is called.</param>
    /// <returns>A <see cref="Task"/> that represents the long running operations.</returns>
    protected abstract Task RunJobAsync(IServiceProvider serviceProvider, CancellationToken stoppingToken);
    protected abstract TimeSpan Interval { get; }
    
    protected abstract TimeSpan FirstRunAfter { get; }
    
    public virtual async Task StopAsync(CancellationToken cancellationToken)
    {
        _timer?.Change(Timeout.Infinite, 0);

        // Stop called without start
        if (_executingTask == null)
        {
            return;
        }

        try
        {
            // Signal cancellation to the executing method
            _stoppingCts.Cancel();
        }
        finally
        {
            // Wait until the task completes or the stop token triggers
            await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite, cancellationToken));
        }

    }

    public void Dispose()
    {
        _stoppingCts.Cancel();
        _timer?.Dispose();
    }
}

1
改进建议:使用 Timeout.InfiniteTimeSpan 代替 TimeSpan.FromMilliseconds(-1) - Theodor Zoulias

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接