таймер + Task.Run vs while loop + Task.Delay в основной службе asp.net

У меня есть требование, чтобы фоновая служба запускала Process метод каждый день в 0:00 утра.

Итак, один из членов моей команды написал следующий код:

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 должен выполнять некоторые операции ввода-вывода (запрашивать базу данных и вставлять некоторые данные), и потому что не рекомендуется использовать Task.Run в среде ASP.NET.

Я провел рефакторинг 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.

Должен ли я использовать первый подход с таймером + Task.Run или второй с циклом while + Task.Delay?


person Dmitry Stepanov    schedule 24.10.2020    source источник
comment
Я не буду комментировать, а вместо этого укажу на незначительный ... недостаток? проблема? Если Process займет 5 минут, ваше расписание будет искажаться на 5 минут при каждом выполнении. То есть, пока ваша служба не будет перезапущена.   -  person Lasse V. Karlsen    schedule 24.10.2020
comment
Зачем вообще нужна первая задержка? Таймер уже поддерживает параметр, указывающий задержку перед первым запуском. Затем вы можете удалить все, кроме создания таймера.   -  person Evk    schedule 24.10.2020
comment
Или вы могли бы просто использовать библиотеку, предназначенную именно для этого? codeburst.io/   -  person stuartd    schedule 24.10.2020
comment
@ LasseV.Karlsen, это реальный существенный недостаток. Спасибо, что заметили это.   -  person Dmitry Stepanov    schedule 25.10.2020


Ответы (1)


Подход while петли проще и безопаснее. При использовании класса Timer есть два скрытых подводных камня:

  1. Последующие события могут потенциально вызывать присоединенный обработчик событий в манере дублирования.
  2. Исключения, возникающие внутри обработчика, поглощаются, и это поведение может быть изменено в будущих выпусках .NET Framework. (из docs)

Однако вашу текущую реализацию цикла while можно улучшить различными способами:

  1. Чтение DateTime.Now несколько раз во время вычисления TimeSpan может привести к неожиданным результатам, потому что DateTime, возвращаемый DateTime.Now, может каждый раз отличаться. Предпочтительно хранить DateTime.Now в переменной и использовать сохраненное значение в вычислениях.
  2. Проверка условия cancellationToken.IsCancellationRequested в цикле while может привести к несогласованному поведению отмены, если вы также используете тот же токен в качестве аргумента Task.Delay. Полностью пропустить эту проверку проще и последовательнее. Таким образом, отмена токена всегда будет приводить к OperationCanceledException.
  3. В идеале продолжительность Process не должна влиять на планирование следующей операции. Один из способов сделать это - создать Task.Delay задачу перед запуском Process и await после завершения Process. Или вы можете просто пересчитать следующую задержку на основе текущего времени. Это также имеет то преимущество, что расписание будет корректироваться автоматически в случае системного изменения времени.

Вот мое предложение:

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 - защита от очень резких системных изменений времени.

person Theodor Zoulias    schedule 24.10.2020
comment
Спасибо за все ваши предложения. Вы сказали, что я прочитал DateTime.Now несколько раз. Но я использую DateTime.Now только один раз, когда рассчитываю firstDelay. Я что-то упускаю? - person Dmitry Stepanov; 25.10.2020
comment
@DmitryStepanov с удовольствием! Да, DateTime.Today также подразумевает чтение свойства DateTime.Now (исходный код < / а>). - person Theodor Zoulias; 25.10.2020