Implémentation d’un Timer avec TPL

Dans cet article, on cherche à proposer une implémentation d’un Timer en utilisant TPL (i.e. Task Parallel Library). Il existe une classe qui permet d’effectuer un traitement de façon périodique: System.Threading.Timer. Cette classe n’est pas très moderne puisqu’elle existe depuis les premières versions du Framework. Elle permet d’effectuer correctement un traitement périodique toutefois elle souffre de quelques défauts:

  • Cette classe est difficilement testable: les traitements asynchrones peuvent être difficiles à tester. C’est encore plus vrai avec System.Threading.Timer puisqu’on ne maitrise pas les itérations, elles sont déclenchées par l’écoulement du temps. Si on veut tester une périodicité de 30 min, il faut attendre 30 min que l’itération s’exécute.
  • Elle ne permet pas de tirer partie des avantages de TPL: gestion des exceptions, gestion de l’annulation de l’exécution, continuation, options de création des Task etc…

Il existe d’autres implémentations d’une tâche périodique toutefois elles présentent les mêmes inconvénients que System.Threading.Timer:

  • System.Timers.Timer
  • System.Windows.Forms.Timer utilisable dans le cadre de Windows Forms.
  • System.Windows.Threading.DispatcherTimer en WPF.

Le code correspondant à cet article se trouve dans le repository GitHub msoft/PeriodicTask.

Exemple avec System.Threading.Timer

Comme indiqué plus haut, on souhaite utiliser la flexibilité de TPL pour apporter une implémentation d’une tâche périodique.

Avant de commencer, voici un exemple rapide de l’implémentation d’une tâche périodique avec System.Threading.Timer (cf. classe SimpleTimerUsage). Cet exemple sert de base pour l’implémentation avec TPL. Le code “métier” exécuté de façon périodique est dans ExecuteJob():

public class SimpleTimerUsage : IDisposable 
{ 
    private readonly ILogger logger; 
    private Timer timer; 
     
    public SimpleTimerUsage(ILogger logger, int periodicity) 
    { 
        this.logger = logger; 
        this.timer = new Timer(ExecuteJob, null, 0, periodicity); 
    } 

    #region IDisposable member 
     
    public void Dispose() 
    { 
        if (this.timer != null) 
        { 
            this.timer.Dispose(); 
            this.timer = null; 
        } 
         
        GC.SuppressFinalize(this); 
    } 

    #endregion 

    private void ExecuteJob(object stateInfo) 
    { 
        this.logger.Info("Executing job..."); 
        Thread.Sleep(100); 
        this.logger.Info("Job executed"); 
    }
} 

Cette classe permet de lancer un timer à l’instanciation pour exécuter le méthode ExecuteJob(). La classe est disposable pour permettre de stopper l’exécution du timer.

Pour lancer l’exécution, il suffit d’instancier la classe:

var logger = NLog.LogManager.GetCurrentClassLogger(); 

var timerUsage = new SimpleTimerUsage(logger, 3000); 

Console.ReadLine(); 

Pour exécuter le code correspondant à cet exemple, il faut compiler le projet PeriodicTaskCore avec .NET Core après avoir cloné le repository GitHub:

user@debian:~% git clone https://github.com/msoft/PeriodicTask.git
user@debian:~% cd PeriodicTask
user@debian:~/PeriodicTask/% dotnet build

Pour exécuter le projet:

user@debian:~/PeriodicTask/% cd PeriodicTaskCore
user@debian:~/PeriodicTask/PeriodicTaskCore/% dotnet run

Pour plus de détails sur les commandes de la CLI .NET Core: “Commandes courantes de la CLI .NET Core”.

Sans surprise, la fonction ExecuteJob() est exécutée de façon périodique:

2018-11-17 05:09:31.7410|INFO|PeriodicTaskCore.Program|Executing job...
2018-11-17 05:09:31.9083|INFO|PeriodicTaskCore.Program|Job executed
2018-11-17 05:09:34.9134|INFO|PeriodicTaskCore.Program|Executing job...
2018-11-17 05:09:35.0160|INFO|PeriodicTaskCore.Program|Job executed
2018-11-17 05:09:38.0172|INFO|PeriodicTaskCore.Program|Executing job...
2018-11-17 05:09:38.1187|INFO|PeriodicTaskCore.Program|Job executed
2018-11-17 05:09:41.1214|INFO|PeriodicTaskCore.Program|Executing job...
2018-11-17 05:09:41.2270|INFO|PeriodicTaskCore.Program|Job executed
2018-11-17 05:09:44.2295|INFO|PeriodicTaskCore.Program|Executing job...
2018-11-17 05:09:44.3335|INFO|PeriodicTaskCore.Program|Job executed

Implémentation d’une tâche périodique avec TPL

Pour implémenter une tâche périodique avec TPL, on peut créer une Task avec une boucle infinie:

private void ExecuteJobPeriodically() 
{ 
    while (true) 
    { 
        Task.Delay(this.periodicity.Value).Wait();
        this.ExecuteJobOnce(); 
    } 
}

private void ExecuteJobOnce() 
{ 
    this.logger.Info("Executing job..."); 
    Thread.Sleep(100); 
    this.logger.Info("Job executed"); 
}
 

Ainsi la boucle infinie exécute ExecuteJobOnce() comme précédemment. A chaque itération on attends le temps correspondant à la périodicité avec Task.Delay() puis on exécute le traitement en lançant:

ExecuteJobOnce().

Pour lancer l’exécution, il suffit de créer la Task et de la lancer de cette façon:

public void LaunchJob() 
{ 
    this.timer = Task.Run(() =>  
    { 
        this.ExecuteJobOnce(); 
        this.ExecuteJobPeriodically(); 
    }, TaskCreationOptions.LongRunning); 
} 

On lance l’exécution du traitement à l’instanciation de la Task puis de façon périodique par la suite.

Gestion de l’annulation

En introduisant la possiblité d’annuler l’exécution en utilisant un CancellationToken, l’implémentation devient:

private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); 

public void LaunchJob() 
{ 
    var cancellationToken = this.cancellationTokenSource.Token; 
    if (!this.IsTimerRunning()) 
    { 
        this.timer = Task.Run(() =>  
        { 
            this.ExecuteJobOnce(cancellationToken); 
            this.ExecuteJobPeriodically(cancellationToken); 
        }, cancellationToken, TaskCreationOptions.LongRunning); 
    } 
} 

private void ExecuteJobPeriodically(CancellationToken cancellationToken) 
{ 
    while (true) 
    { 
        Task.Delay(this.periodicity.Value, cancellationToken).Wait(cancellationToken); 
        cancellationToken.ThrowIfCancellationRequested(); 
        this.ExecuteJobOnce(cancellationToken); 
    } 
} 

private void ExecuteJobOnce(CancellationToken cancellationToken) 
{ 
    if (cancellationToken.IsCancellationRequested) return; 

    this.ExecutePeriodicTask(cancellationToken); 
} 

Pour annuler l’exécution, il suffit d’exécuter:

cancellationTokenSource.Cancel(); 

Ajout d’un traitement en cas d’annulation et de d’exception

Si l’exécution de la Task provoque une erreur et si elle est annulée, on souhaite effectuer un traitement comme par exemple logguer un message. Ce type de traitement peut être effectué en utilisation des continuations:

protected void CreateAndLaunchTimer() 
{ 
    var cancellationToken = this.cancellationTokenSource.Token; 
    if (!this.IsTimerRunning()) 
    { 
        this.timer = new Task(() =>  
        { 
            this.ExecuteJobOnce(cancellationToken); 
            this.ExecuteJobPeriodically(cancellationToken); 
        }, cancellationToken, TaskCreationOptions.LongRunning); 

        // Exécuté quand la Task provoque une exception 
        this.timer.ContinueWith(t =>  
        { 
            this.OnPeriodicTaskFaulted(t.Exception); 
        }, TaskContinuationOptions.OnlyOnFaulted);

        // Exécuté quand la task est annulé  
        this.timer.ContinueWith(t => 
        { 
            this.OnPeriodicTaskCanceled(); 
        }, TaskContinuationOptions.OnlyOnCanceled);

        // Exécuté quand la task s'arrête 
        this.timer.ContinueWith(t => 
        { 
            this.OnPeriodiTaskCompleted(); 
        }, TaskContinuationOptions.OnlyOnRanToCompletion);

        this.timer.Start(); 
    } 
} 

protected virtual void OnPeriodicTaskFaulted(AggregateException exception) 
{ 
    this.Logger.Error("Periodic task raised an exception: {0}", exception); 
} 

protected virtual void OnPeriodicTaskCanceled() 
{ 
    this.Logger.Info("Periodic task has been canceled."); 
} 

protected virtual void OnPeriodiTaskCompleted() 
{ 
    this.Logger.Info("Periodic task ended"); 
} 

On peut arrêter l’exécution en exécutant la méthode suivante, on ne fait que vérifier que la tâche n’est pas déjà stoppée puis on la stoppe:

public void StopPeriodicTask() 
{ 
    if (this.IsTimerRunning() && !this.cancellationTokenSource.Token.IsCancellationRequested) 
    { 
        this.OnPeriodicTaskStopping(); 
        this.cancellationTokenSource.Cancel(); 
        bool completed = false; 

        try 
        { 
            completed = this.timer.Wait(TimeSpan.FromSeconds(5)); 
        } 
        catch (AggregateException occuredException) 
        { 
            occuredException.Handle(ex =>  
            { 
                if (ex is TaskCanceledException || ex is OperationCanceledException) 
                { 
                    completed = true; 
                    return true; 
                } 
                return false; 
            }); 
        } 
    } 
} 

Avec:

private bool IsTimerRunning() 
{ 
    return !(this.timer == null || this.timer.IsFaulted || this.timer.IsCompleted); 
} 

Protéger le lancement de la tâche périodique

On peut ensuite ajouter un lock pour empêcher le lancement de la tâche plusieurs fois si la méthode LaunchJob() est exécutée par des threads différents. On modifie ainsi la méthode LaunchJob():

public void LaunchJob() 
{ 
    var cancellationToken = this.cancellationTokenSource.Token; 
    lock(this.timerCreationLock) 
    { 
        if (!this.IsTimerRunning()) 
        { 
            // ... 
        } 
    } 
} 

Avec:

private readonly object timerCreationLock = new object(); 

Si cette méthode est exécutée plusieurs fois ou par des threads différents, la Task sera créée et lancée de façon unique.

Encapsuler l’implémentation

Pour rendre l’implémentation plus réutilisable et permettre sa réalisation, on peut encapsuler le code dans une classe abstraite. Le code complet de cette classe est dans: PeriodicTask.cs.

Ainsi on peut utiliser la classe PeriodicTask en dérivant de cette classe et en implémentant le code “métier”, par exemple:

public class PeriodicTaskUsage : PeriodicTask 
{ 
    public PeriodicTaskUsage(ILogger logger, TimeSpan? periodicity) : 
        base(logger, periodicity) 
    { 
        // Lancement de la tâche périodique 
        this.LaunchJob();  
    } 
     
    protected override void ExecutePeriodicTask(CancellationToken cancellationToken) 
    { 
        this.Logger.Info("Executing job..."); 
        Thread.Sleep(100); 
        this.Logger.Info("Job executed"); 
    } 
} 

Pour lancer l’exécution, il suffit d’instancier la classe PeriodicTaskUsage:

var logger = NLog.LogManager.GetCurrentClassLogger(); 

var periodicTaskUsage = new PeriodicTaskUsage(logger,  
TimeSpan.FromMilliseconds(3000)); 

Console.ReadLine(); 

Le résultat de l’exécution est le même que précédemment.

Tester l’exécution du code “métier”

Comme indiqué en introduction, la classe System.Threading.Timer est difficilement testable. On souhaite pouvoir tester le code “métier” ainsi que la plus grande quantité de code dans la classe PeriodicTask. Le projet de test est PeriodicTaskTests.

Le code “métier” dans l’exemple se trouve dans la méthode:

protected override void ExecutePeriodicTask(CancellationToken cancellationToken) 
{ 
    this.Logger.Info("Executing job..."); 
    Thread.Sleep(100); 
    this.Logger.Info("Job executed"); 
} 

Pour tester ce code, on utilise le test suivant:

[TestMethod] 
public void When_executing_PeriodicTask_Then_DomainCode_Shall_Be_Executed() 
{ 
    var loggerMock = new Mock<ILogger>(); 
    
    var periodicTaskUsage = new PeriodicTaskForTest(loggerMock.Object); 
    
    // Exécution de la 1ere itération 
    Assert.IsTrue(periodicTaskUsage.ExecuteIteration().Wait(5000));
    
    // On peut effectuer des vérifications 
    loggerMock.Verify(l => l.Info("Executing job..."), Times.Once); 
    loggerMock.Verify(l => l.Info("Job executed"), Times.Once); 
    
    // Exécution d'une 2e itération 
    Assert.IsTrue(periodicTaskUsage.ExecuteIteration().Wait(5000));
    
    //etc... 
} 

Pour faciliter l’exécution des tests, on modifie la méthode PeriodicTask.ExecuteJobPeriodically() pour ne pas lancer la boucle infinie si une périodicité n’est pas précisée. On peut, ainsi, maitriser chaque itération à l’éxtérieur de la classe:

private void ExecuteJobPeriodically(CancellationToken cancellationToken) 
{ 
    if (!this.periodicity.HasValue) return;

    while (true) 
    { 
        Task.Delay(this.periodicity.Value, cancellationToken).Wait(cancellationToken); 
        cancellationToken.ThrowIfCancellationRequested(); 
        this.ExecuteJobOnce(cancellationToken); 
    } 
} 

Ainsi si on dérive de la classe PeriodicTaskUsage de cette façon (cf. classe PeriodicTaskForTest):

internal class PeriodicTaskForTest : PeriodicTaskUsage 
{ 
    public PeriodicTaskForTest(ILogger logger) : 
        base(logger, null) 
    {
    
    } 
    
    public async Task ExecuteIteration() 
    { 
        this.CreateAndLaunchTimer(); 
        
        await this.Timer; 
    }
} 

Avec cette implémentation, on est capable de maitriser l’exécution de chaque itération dans le test avec les lignes:

Assert.IsTrue(periodicTaskUsage.ExecuteIteration().Wait(5000));

L’implémentation de cet exemple se trouve dans le répository gitHub: msoft/PeriodicTask.

Pour l’exécuter, il faut aller dans le répertoire PeriodicTaskTests et lancer l’exécution des tests:

user@debian:~/PeriodicTask/% cd PeriodicTaskTests
user@debian:~/PeriodicTask/PeriodicTaskTests/% dotnet build
user@debian:~/PeriodicTask/PeriodicTaskTests/% dotnet test
Build started, please wait...
Build completed.

Test run for /home/user/PeriodicTask/PeriodicTaskTests/bin/Debug/netcoreapp2.0/PeriodicTaskTests.dll(.NETCoreApp,Version=v2.0)
Microsoft (R) Test Execution Command Line Tool Version 15.5.0
Copyright (c) Microsoft Corporation.  All rights reserved.

Starting test execution, please wait...

Total tests: 1. Passed: 1. Failed: 0. Skipped: 0.
Test Run Successful.
Test execution time: 2.1726 Seconds

Pour conclure…

Cette implémentation n’a rien de très novatrice mais elle permet de facilement mettre en place l’exécution d’une tâche périodique et de la tester. Il existe de nombreuses autres implémentations pour effectuer ce type de traitement. J’espère toutefois que cette implémentation vous a plu. Dans le cas contraire, n’hésiter pas à laisser un commentaire en indiquant les raisons, ça pourrait être intéressant de l’améliorer.

Leave a Reply