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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
static
void
Main
(
string
[]
args)
{
Timer timer =
new
Timer
(
taskBody,
null
,
0
,
PERIOD);
Console.
WriteLine
(
"En attente de la fin des tâches..."
);
Thread.
Sleep
(
10000
);
timer.
Change
(
0
,
Timeout.
Infinite);
Console.
WriteLine
(
"Tâches terminées"
);
Console.
ReadLine
(
);
}
private
static
void
taskBody
(
object
p)
{
Console.
WriteLine
(
"Tac !"
) ;
}
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
public
static
void
periodicTask1
(
object
p)
{
DateTime startTime =
DateTime.
Now;
int
count =
0
;
while
(
true
)
{
TimeSpan delta =
DateTime.
Now -
startTime.
AddMilliseconds
(
count *
PERIOD);
taskBody
(
"1"
,
DateTime.
Now,
Convert.
ToInt32
(
delta.
TotalMilliseconds));
count++;
Thread.
Sleep
(
PERIOD);
}
}
private
static
void
taskBody
(
string
thread,
DateTime date,
int
delta)
{
Console.
WriteLine
(
"Thread {0} : {1} (décalage = {2})"
,
thread,
date.
ToString
(
"hh:MM:ss.ffff"
),
delta);
}
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.
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
public
static
void
periodicTask2
(
object
p)
{
DateTime date =
DateTime.
Now;
DateTime startTime =
DateTime.
Now;
int
count =
0
;
int
next;
while
(
true
)
{
date =
date.
AddMilliseconds
(
PERIOD);
TimeSpan delta =
DateTime.
Now -
startTime.
AddMilliseconds
(
count *
PERIOD);
taskBody
(
"1"
,
DateTime.
Now,
Convert.
ToInt32
(
delta.
TotalMilliseconds));
count++;
next =
Convert.
ToInt32
((
date -
DateTime.
Now).
TotalMilliseconds);
if
(
next >=
0
)
{
Thread.
Sleep
(
next);
}
else
{
// On n'attend pas, on exécute directement l'instance suivante
}
}
}
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.
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
public
static
void
periodicTask3
(
object
p)
{
DateTime date =
DateTime.
Now;
DateTime startTime =
DateTime.
Now;
int
count =
0
;
int
next;
while
(
true
)
{
date =
date.
AddMilliseconds
(
PERIOD);
TimeSpan delta =
DateTime.
Now -
startTime.
AddMilliseconds
(
count *
PERIOD);
Thread occ =
new
Thread
((
) =>
taskBody
(
"3"
,
DateTime.
Now,
Convert.
ToInt32
(
delta.
TotalMilliseconds)));
occ.
Start
(
);
count++;
next =
Convert.
ToInt32
((
date -
DateTime.
Now).
TotalMilliseconds);
if
(
next >=
0
)
{
Thread.
Sleep
(
next);
}
else
{
raiseOccurenceDelayed
(
"3"
);
}
}
}
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
public
static
async
void
periodicTask1
(
object
p)
{
CancellationToken cancellationToken =
(
CancellationToken)p;
DateTime startTime =
DateTime.
Now;
int
count =
0
;
while
(!
cancellationToken.
IsCancellationRequested)
{
TimeSpan delta =
DateTime.
Now -
startTime.
AddMilliseconds
(
count *
PERIOD);
taskBody
(
"1"
,
DateTime.
Now,
Convert.
ToInt32
(
delta.
TotalMilliseconds));
count++;
await
Task.
Delay
(
PERIOD,
cancellationToken);
}
}
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▲
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
public
static
async
void
periodicTask2
(
object
p)
{
CancellationToken cancellationToken =
(
CancellationToken)p;
DateTime date =
DateTime.
Now;
DateTime startTime =
DateTime.
Now;
int
count =
0
;
int
next;
while
(!
cancellationToken.
IsCancellationRequested)
{
date =
date.
AddMilliseconds
(
PERIOD);
TimeSpan delta =
DateTime.
Now -
startTime.
AddMilliseconds
(
count *
PERIOD);
taskBody
(
"1"
,
DateTime.
Now,
Convert.
ToInt32
(
delta.
TotalMilliseconds));
count++;
next =
Convert.
ToInt32
((
date -
DateTime.
Now).
TotalMilliseconds);
if
(
next >=
0
)
{
await
Task.
Delay
(
next,
cancellationToken);
}
else
{
raiseOccurenceDelayed
(
"2"
);
}
}
}
IV-C. Implémentation sans décalage et avec réentrance▲
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
public
static
async
void
periodicTask3
(
object
p)
{
CancellationToken cancellationToken =
(
CancellationToken)p;
DateTime date =
DateTime.
Now;
DateTime startTime =
DateTime.
Now;
int
count =
0
;
int
next;
while
(!
cancellationToken.
IsCancellationRequested)
{
date =
date.
AddMilliseconds
(
PERIOD);
TimeSpan delta =
DateTime.
Now -
startTime.
AddMilliseconds
(
count *
PERIOD);
Task occ =
Task.
Run
((
) =>
taskBody
(
"3"
,
DateTime.
Now,
Convert.
ToInt32
(
delta.
TotalMilliseconds)));
count++;
next =
Convert.
ToInt32
((
date -
DateTime.
Now).
TotalMilliseconds);
if
(
next >=
0
)
{
await
Task.
Delay
(
next,
cancellationToken);
}
else
{
raiseOccurenceDelayed
(
"3"
);
}
}
}
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.
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.