Programmer avec le pool de threads en C#

Quatrième partie : tâches périodiques

François DORIN

Le pool de threads est un mécanisme facile d'utilisation permettant de tirer profit de la parallélisation. L'amélioration des performances ou de l'expérience utilisateur ne sont que deux exemples classiques.

C'est une série de quatre tutoriels dans laquelle vous allez apprendre à programmer un pool de threads en C#.

Dans cette quatrième et dernière partie, nous allons découvrir comment réaliser des tâches périodiques.

Commentez

- Titre : Programmer avec le pool de threads en C#

- Auteur : François DORIN

- Parution : 15 février 2017

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2017 François DORIN. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Utilisation de timer

- III. Implémentation de tâches périodiques à base de threads

- III-A. Simulation d'un timer

- III-B. Implémentation sans décalage

- III-C. Implémentation sans décalage et avec réentrance

- IV. Implémentation de tâches périodiques en utilisant le Pool de threads

- IV-A. Implémentation de base

- IV-B. Implémentation sans décalage

- IV-C. Implémentation sans décalage et avec réentrance

- V. Résumé

- VI. Bilan

- VII. Code source

- VIII. Remerciements

I - Introduction

Afin de pouvoir appréhender au mieux le contenu de ce tutoriel, il est nécessaire de faire un point sur la terminologie utilisée.

Une tâche périodique est une tâche dont l'activation est régulière et le délai entre deux activations successives est constant.

Une tâche sporadique est une tâche caractérisée par un délai minimum entre deux activations successives.

Cette classification nous amène également à introduire la notion d'instance, et de bien distinguer cette notion de celle de tâche :

- une tâche correspond à du code exécutable ;

- une instance d'une tâche correspond à une occurrence (ou exécution) de cette tâche.

Les notions de tâches périodique ou sporadique sont très proches, surtout pour des néophytes. Aussi, par abus de langage, dans le cadre de ce tutoriel, ne sera employé que le terme de « périodique » avec le sens commun qui est « exécution régulière ». On relâche donc la contrainte sur le délai entre deux activations successives.

II - Utilisation de timer

Première possibilité pour réaliser une tâche périodique : utiliser un timer. Pour cet exemple, nous allons utiliser System.Threading.Timer.

Il existe d'autres Timer venant avec le framework .Net. On peut citer par exemple System.Timer ou System.Windows.Forms.Timer (liste non exhaustive). Le premier n'est pas inclus dans .Net Standard et le second est optimisé pour une utilisation avec les Winform. Ici, le choix s'est porté sur System.Threading.Timer qui a la portée la plus large.

Utilisation d'un timer

1. static void Main(string[] args)

2. {

3. Timer timer = new Timer(taskBody, null, 0, PERIOD);

4. Console.WriteLine("En attente de la fin des tâches...");

5. Thread.Sleep(10000);

6. timer.Change(0, Timeout.Infinite);

7. Console.WriteLine("Tâches terminées");

8. Console.ReadLine();

9. }

11. private static void taskBody(object p)

12. {

13. Console.WriteLine("Tac !") ;

14. }

L'utilisation du timer est relativement simple en soi :

- le premier paramètre correspond à la fonction qui sera exécutée à intervalles réguliers ;

- le second correspond à un objet qui sera passé en paramètre à la méthode précédemment spécifiée dans le premier paramètre ;

- le troisième est un délai, en ms, à attendre avant de lancer le timer. Ce paramètre n'a d'incidence que sur le premier déclenchement du Timer. Une valeur de 0 indique qu'il n'y a pas de délai et que le timer peut commencer immédiatement ;

- le quatrième est la période désirée du timer.

Après l'appel au constructeur, le timer est automatiquement exécuté. Pour l'arrêter, il faut changer sa période en précisant une période « infinie » (cf. ligne 6).

Il y a un détail important au niveau de la période du timer. Il ne s'agit pas de l'intervalle entre le début d'une occurrence et le début de la suivante, mais de l'intervalle entre la fin d'une occurrence et le début de la suivante. De cela, découlent plusieurs choses :

- il n'est pas possible d'utiliser un timer pour exécuter une tâche de manière strictement périodique. On ne peut que préciser le temps séparant deux occurrences successives ;

- il existe un décalage entre chaque occurrence qui s'accumule au fur et à mesure que le temps passe.

Dans les chapitres suivants, nous allons justement voir comment dépasser ces limitations, en utilisant des threads, puis async/await.

III - Implémentation de tâches périodiques à base de threads

III-A - Simulation d'un timer

L'idée, pour implémenter soi-même une tâche périodique, est d'écrire une fonction avec une boucle infinie. La boucle contient le code que l'on souhaite exécuter de manière périodique. Ainsi, à chaque itération de la boucle, la tâche est exécutée.

Implémentation basique à base de threads

1. public static void periodicTask1(object p)

2. {

3. DateTime startTime = DateTime.Now;

4. int count = 0;

5. while (true)

6. {

7. TimeSpan delta = DateTime.Now - startTime.AddMilliseconds(count * PERIOD);

8. taskBody("1", DateTime.Now, Convert.ToInt32(delta.TotalMilliseconds));

9. count++;

10. Thread.Sleep(PERIOD);

11. }

12. }

14. private static void taskBody(string thread, DateTime date, int delta)

15. {

16. Console.WriteLine("Thread {0} : {1} (décalage = {2})", thread, date.ToString("hh:MM:ss.ffff"), delta);

17. }

Cette implémentation de base est similaire à celle présente dans le timer du chapitre précédent, et souffre donc des mêmes limitations. Par exemple, il suffit d'exécuter ce code pour constater que le décalage augmente avec le temps.

Simulation d'un timer

Dans l'exemple ci-dessus, la période T est de 5 unités de temps. Les réveils de la tâche sont matérialisés par des flèches vers le haut, tandis que les flèches vers le bas symbolisent la fin d'une occurrence de la tâche.

Avec une période de 5 unités de temps, on aurait pu s'attendre à ce que la tâche se réveille à l'instant t=0, t=5, t=10, etc. On constate que ce n'est pas le cas et qu'il y a un décalage.

Et le décalage augmente avec le temps, car comme précédemment, la période choisie T n'est pas l'intervalle de temps entre le début de deux instances successives, mais l'intervalle de temps entre la fin d'une instance et le début de la suivante.

Nous allons donc maintenant modifier cette implémentation afin d'éviter ce décalage.

III-B - Implémentation sans décalage

Pour éviter le décalage des occurrences, le principe est de calculer pour chaque occurrence la date à laquelle l'occurrence suivante doit s'exécuter, et d'attendre uniquement le temps nécessaire pour respecter cette contrainte, au lieu d'attendre à chaque fois la période définie.

Le code ci-dessous illustre ce principe :

Implémentation sans décalage

1. public static void periodicTask2(object p)

2. {

3. DateTime date = DateTime.Now;

4. DateTime startTime = DateTime.Now;

5. int count = 0;

6. int next;

7. while (true)

8. {

9. date = date.AddMilliseconds(PERIOD);

10. TimeSpan delta = DateTime.Now - startTime.AddMilliseconds(count * PERIOD);

12. taskBody("1", DateTime.Now, Convert.ToInt32(delta.TotalMilliseconds));

14. count++;

15. next = Convert.ToInt32((date - DateTime.Now).TotalMilliseconds);

16. if (next >= 0)

17. {

18. Thread.Sleep(next);

19. }

20. else

21. {

22. // On n'attend pas, on exécute directement l'instance suivante

23. }

24. }

25. }

Au début de chaque occurrence, on calcule la date à laquelle l'occurrence suivante doit s'exécuter. Une fois que l'occurrence courante s'est exécutée, on détermine le temps à attendre avant la prochaine occurrence grâce à la date actuelle et à la date de la prochaine occurrence calculée au tout début de cette occurrence.

Sans décalage

Un cas intéressant à prendre en compte est le cas où la durée d'une occurrence est supérieure à la période de la tâche. Dans ce cas, que se passe-t-il ? L'occurrence suivante démarre alors que l'occurrence en cours n'est pas terminée ? L'occurrence démarre immédiatement après l'occurrence en cours ?

Le comportement va dépendre de l'implémentation choisie. Actuellement, le code démarre simplement l'occurrence suivante dès que l'occurrence courante a terminé son exécution (cf. occurrences 3 et 4). Cela signifie que deux occurrences de la même tâche ne peuvent pas s'exécuter en parallèle.

Nous allons voir comment implémenter cet autre comportement (exécution en parallèle) dans la section suivante.

III-C - Implémentation sans décalage et avec réentrance

Lorsque plusieurs occurrences d'une même tâche peuvent s'exécuter en même temps, on dit que la tâche est « réentrante ».

Il y a des précautions à prendre avec ce type de tâches, qui doivent être threadsafe. Mais cela dépasse le cadre de ce tutoriel.

Pour gérer la réentrance, l'idée est qu'au lieu d'exécuter les occurrences de la tâche au sein même de la boucle while, la boucle while va lancer une tâche (non périodique celle-ci !) qui va exécuter une occurrence.

Ainsi, il sera possible à plusieurs occurrences de s'exécuter en même temps.

Implémentation sans décalage et avec réentrance

1. public static void periodicTask3(object p)

2. {

3. DateTime date = DateTime.Now;

4. DateTime startTime = DateTime.Now;

5. int count = 0;

6. int next;

7. while (true)

8. {

9. date = date.AddMilliseconds(PERIOD);

10. TimeSpan delta = DateTime.Now - startTime.AddMilliseconds(count * PERIOD);

11. Thread occ = new Thread(() => taskBody("3", DateTime.Now, Convert.ToInt32(delta.TotalMilliseconds)));

12. occ.Start();

13. count++;

15. next = Convert.ToInt32((date - DateTime.Now).TotalMilliseconds);

16. if (next >= 0)

17. {

18. Thread.Sleep(next);

19. }

20. else

21. {

22. raiseOccurenceDelayed("3");

23. }

24. }

25. }

Sans décalage et avec réentrance

IV - Implémentation de tâches périodiques en utilisant le Pool de threads

Et le pool de threads dans tout cela ? Actuellement, nous l'avons un peu laissé de côté… Voyons donc comment nous pouvons parvenir à la même chose, mais en se basant cette fois-ci sur le pool de threads.

Si vous avez lu les articles précédents, la mise en œuvre en utilisant le pool de threads ne devrait pas poser de problèmes particuliers. Il s'agit, avant tout, d'utiliser les bonnes API.

Aussi, au lieu de simplement changer les API et afin d'exploiter les notions abordées dans les articles précédents, nous allons aussi mettre en œuvre un jeton d'annulation, permettant d'interrompre une tâche périodique.

IV-A - Implémentation de base

Afin de mettre en place un mécanisme permettant d'annuler la tâche, nous allons nous servir d'un jeton d'annulation (CancellationToken). Ce jeton d'annulation sera simplement un paramètre de la tâche périodique.

Le principe est qu'au lieu d'avoir une boucle while avec une condition toujours vraie, nous allons vérifier si une annulation a été demandée.

Implémentation de base

1. public static async void periodicTask1(object p)

2. {

3. CancellationToken cancellationToken = (CancellationToken)p;

4. DateTime startTime = DateTime.Now;

5. int count = 0;

6. while (!cancellationToken.IsCancellationRequested)

7. {

8. TimeSpan delta = DateTime.Now - startTime.AddMilliseconds(count * PERIOD);

9. taskBody("1", DateTime.Now, Convert.ToInt32(delta.TotalMilliseconds));

10. count++;

11. await Task.Delay(PERIOD, cancellationToken);

12. }

13. }

Remarquez aussi la présence du jeton d'annulation au niveau du Task.Delay permettant ainsi d'annuler la tâche pendant l'attente de la prochaine occurrence.

IV-B - Implémentation sans décalage

Implémentation sans décalage

1. public static async void periodicTask2(object p)

2. {

3. CancellationToken cancellationToken = (CancellationToken)p;

4. DateTime date = DateTime.Now;

5. DateTime startTime = DateTime.Now;

6. int count = 0;

7. int next;

8. while (!cancellationToken.IsCancellationRequested)

9. {

10. date = date.AddMilliseconds(PERIOD);

11. TimeSpan delta = DateTime.Now - startTime.AddMilliseconds(count * PERIOD);

13. taskBody("1", DateTime.Now, Convert.ToInt32(delta.TotalMilliseconds));

15. count++;

16. next = Convert.ToInt32((date - DateTime.Now).TotalMilliseconds);

17. if (next >= 0)

18. {

19. await Task.Delay(next, cancellationToken);

20. }

21. else

22. {

23. raiseOccurenceDelayed("2");

24. }

25. }

26. }

IV-C - Implémentation sans décalage et avec réentrance

Implémentation sans décalage et avec réentrance

1. public static async void periodicTask3(object p)

2. {

3. CancellationToken cancellationToken = (CancellationToken)p;

4. DateTime date = DateTime.Now;

5. DateTime startTime = DateTime.Now;

6. int count = 0;

7. int next;

8. while (!cancellationToken.IsCancellationRequested)

9. {

10. date = date.AddMilliseconds(PERIOD);

11. TimeSpan delta = DateTime.Now - startTime.AddMilliseconds(count * PERIOD);

12. Task occ = Task.Run(() => taskBody("3", DateTime.Now, Convert.ToInt32(delta.TotalMilliseconds)));

13. count++;

15. next = Convert.ToInt32((date - DateTime.Now).TotalMilliseconds);

16. if (next >= 0)

17. {

18. await Task.Delay(next, cancellationToken);

19. }

20. else

21. {

22. raiseOccurenceDelayed("3");

23. }

24. }

25. }

V - Résumé

Il est maintenant temps de faire un petit résumé de ce que nous venons de voir au cours de ce tutoriel.

Nous avons vu trois grandes méthodes pour gérer la périodicité des tâches :

- une première méthode, où la « période » définit en réalité le délai minimum séparant deux occurrences successives d'une même tâche ;

- une seconde, où la période est bien une période en tant que telle, permettant ainsi d'éviter le phénomène de dérive des horloges ;

- une troisième, où en plus d'éviter le phénomène de dérive des horloges, on autorise la réentrance des tâches.

Synthèse

Il n'y a pas une méthode qui est meilleure qu'une autre. Tout dépend des besoins !

Par exemple, pour réaliser une méthode de sauvegarde automatique au sein d'une application, l'aspect important est le côté régulier de la tâche. La dérive des horloges n'a pas d'impact majeur. Par contre, il est fortement souhaitable d'interdire la réentrance dans ce genre de mécanisme !

Par contre, pour réaliser une tâche de supervision qui envoie des logs sur le réseau, la réentrance peut être souhaitée. Chaque occurrence collecte des données puis les envoie et une occurrence n'est terminée qu'une fois les données effectivement envoyées. Un problème réseau ponctuel peut très bien perturber leur fonctionnement et allonger considérablement la durée d'exécution de chaque occurrence. Sans réentrance, des données pourraient être perdues !

Les implémentations à base de threads ou en utilisant le pool de threads, si elles semblent équivalentes au premier abord, présentent quelques différences subtiles. Et c'est aussi pourquoi j'ai présenté les deux approches.

S'il est nécessaire d'avoir un contrôle fin sur les tâches (par exemple, de modifier leur priorité), alors n'envisagez pas d'utiliser le pool de threads, qui ne permet pas cela. Seule la création manuelle de threads permet de personnaliser les tâches avec précision.

L'approche utilisant le pool de threads, quant à elle, est souvent moins gourmande en ressources dans le sens où elle nécessite généralement moins de threads.

VI - Bilan

L'heure est venue de faire un petit bilan de toutes les notions abordées au cours de cette série de tutoriels.

Maintenant vous savez :

- comment implémenter de l'asynchronisme dans vos applications (partie 1) ;

- les différences entre l'utilisation des threads et du pool de threads et les avantages et inconvénients de ces deux approches (partie 1) ;

- gérer les exceptions au sein des tâches (partie 1) ;

- lier des tâches entre elles (partie 2) ;

- annuler des tâches (partie 2) ;

- utiliser des fabriques de tâches (partie 2) ;

- utiliser async/await (partie 3) ;

- écrire des tâches périodiques (ce tutoriel).

En espérant que cette série de tutoriels vous a plu et vous a été, est, ou sera utile un jour !

VII - Code source

Le code source des exemples de ce tutoriel est disponible au téléchargement.

VIII - Remerciements

Je remercie Claude LELOUP pour sa relecture orthographique et LittleWhite pour sa relecture technique.