"timer + Task.Run" 对比 "while loop + Task.Delay" 在 asp.net 核心托管服务中

"timer + Task.Run" vs "while loop + Task.Delay" in asp.net core hosted service

我有一个要求,后台服务应该 运行 Process 方法每天在 0:00 a.m.

因此,我的一位团队成员编写了以下代码:

public class MyBackgroundService : IHostedService, IDisposable
{
    private readonly ILogger _logger;
    private Timer _timer;

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

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

    public Task StartAsync(CancellationToken cancellationToken)
    {
        TimeSpan interval = TimeSpan.FromHours(24);
        TimeSpan firstCall = DateTime.Today.AddDays(1).AddTicks(-1).Subtract(DateTime.Now);

        Action action = () =>
        {
            Task.Delay(firstCall).Wait();

            Process();

            _timer = new Timer(
                ob => Process(),
                null,
                TimeSpan.Zero,
                interval
            );
        };

        Task.Run(action);
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _timer?.Change(Timeout.Infinite, 0);

        return Task.CompletedTask;
    }

    private Task Process()
    {
        try
        {
            // perform some database operations
        }
        catch (Exception e)
        {
            _logger.LogError(e, e.Message);
        }
        return Task.CompletedTask;
    }
}

此代码按预期工作。但我不喜欢它同步等待直到第一次调用 Process,所以线程被阻塞并且不执行任何有用的工作(如果我错了请纠正我)。

我可以像这样让一个动作异步并在其中等待:

public Task StartAsync(CancellationToken cancellationToken)
{
    // code omitted for brevity

    Action action = async () =>
    {
        await Task.Delay(firstCall);

        await Process();
        
        // code omitted for brevity
}

但我不确定在这里使用 Task.Run 是不是一件好事,因为 Process 方法应该执行一些 I/O 操作(查询数据库并插入一些数据),并且因为它是不建议在 ASP.NET 环境中使用 Task.Run

我重构了StartAsync如下:

public async Task StartAsync(CancellationToken cancellationToken)
{
    TimeSpan interval = TimeSpan.FromHours(24);
    TimeSpan firstDelay = DateTime.Today.AddDays(1).AddTicks(-1).Subtract(DateTime.Now);

    await Task.Delay(firstDelay);

    while (!cancellationToken.IsCancellationRequested)
    {
        await Process();

        await Task.Delay(interval, cancellationToken);
    }
}

这让我根本无法在 MyBackgroundService 中使用计时器。

我应该使用“timer + Task.Run”的第一种方法还是使用“while loop + Task.Delay”的第二种方法?

while 循环方法更简单、更安全。使用 Timer class 有两个隐藏的问题:

  1. 后续事件可能会以重叠方式调用附加的事件处理程序。
  2. 处理程序内部抛出的异常被吞没,此行为可能会在 .NET Framework 的未来版本中发生变化。(来自 docs

您当前的 while 循环实现可以通过多种方式改进:

  1. TimeSpan 计算期间多次读取 DateTime.Now 可能会产生意外结果,因为 DateTime.Now 返回的 DateTime 每次都可能不同。最好将 DateTime.Now 存储在变量中,并在计算中使用存储的值。
  2. 如果您还使用相同的标记作为 Task.Delay 的参数,则在 while 循环中检查条件 cancellationToken.IsCancellationRequested 可能会导致不一致的取消行为。完全跳过此检查更简单且一致。这样取消令牌将始终产生 OperationCanceledException 作为结果。
  3. 理想情况下,Process 的持续时间不应影响下一个操作的调度。一种方法是在开始 Process 之前创建 Task.Delay 任务,并在 Process 完成后创建 await 它。或者您可以根据当前时间重新计算下一次延迟。这还有一个好处,即在 system-wise 时间更改的情况下,调度将自动调整。

这是我的建议:

public async Task StartAsync(CancellationToken cancellationToken)
{
    TimeSpan scheduledTime = TimeSpan.FromHours(0); // midnight
    TimeSpan minimumIntervalBetweenStarts = TimeSpan.FromHours(12);

    while (true)
    {
        var scheduledDelay = scheduledTime - DateTime.Now.TimeOfDay;

        while (scheduledDelay < TimeSpan.Zero)
            scheduledDelay += TimeSpan.FromDays(1);

        await Task.Delay(scheduledDelay, cancellationToken);

        var delayBetweenStarts =
            Task.Delay(minimumIntervalBetweenStarts, cancellationToken);

        await ProcessAsync();

        await delayBetweenStarts;
    }
}

minimumIntervalBetweenStarts 的原因是为了防止非常剧烈的 system-wise 时间变化。