I. Introduction▲
Quand il y a beaucoup de travail à faire, la meilleure solution est encore de le faire à plusieurs.
Grâce aux ThreadsThreads les développeurs ont le pouvoir de mobiliser une grande équipe.
Chaque thread va se voir confier une tâche à réaliser. Il va partir avec dans son coin et reviendra quand sa mission sera terminée. Toute la difficulté va être de garder
un œil attentif sur son activité. Il faut tout de même vérifier qu'il :
- commence effectivement à travailler ;
- ne reste pas coincé sur une étape qu'il ne parvient pas à terminer ;
- ne se dispute pas avec un autre thread ;
- nous dise quand il a terminé ;
- que tous ses copains aient terminé avant de déclarer que tout le travail a bien été fait.
Comme beaucoup d'objets du Framework .NET, un thread s'utilise de manière très simple. Il suffit de passer dans le constructeur le nom de la méthode à exécuter et démarrer le thread pour que cela marche. Vous trouverez de nombreuses réponses à vos questions dans la FAQ.
Dans ce tutoriel, nous allons réaliser plusieurs versions de la même application jusqu'à satisfaire un certain nombre d'attentes. Je décris ma démarche pour garder le contrôle sur le nombre de threads exécutant les requêtes. Dans le cas contraire, je risque d'en générer des milliers au risque de submerger le service web. Je ne peux donc pas faire une simple boucle sur toutes les requêtes à traiter, y associer un thread et les lancer tous en même temps.
II. Le travail à réaliser▲
Afin de rester proche d'un besoin réel, nous dirons que le travail à réaliser consiste à :
Besoins du client | Résultat |
---|---|
Faire dix appels à la web méthode « HelloWorld » | ou |
Conserver toutes les réponses dans un fichier | ou |
Mesurer le temps de chaque appel | ou |
Mesurer le temps d'exécution total | ou |
Conserver ces mesures à des fins de statistiques. | ou |
Nous garderons aussi un œil sur les performances de IIS.
III. La solution▲
Le programme sera réalisé avec Visual Studio 2012 UltimateTélécharger Visual Studio 2012 Ultimate.
III-A. Le service web▲
Après avoir créé une nouvelle solution, ajoutez un premier projet de type « Application ASP.Net vide ». Ajoutez un nouvel élément de type « Web service ».
Pour simuler le temps de réponse de la web méthode, nous allons mettre le thread en « dormance » pendant un laps de temps.
[WebService(Namespace =
"http://immobilis.developpez.com/multithreading"
)]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
[System.ComponentModel.ToolboxItem(false)]
public
class
WebService :
System.
Web.
Services.
WebService
{
///
<
summary
>
/// Cette web méthode fait une pose d'une durée aléatoire comprise entre 5 et 10 secondes. Elle renvoie une chaîne de caractères.
///
<
/summary
>
///
<
param
name
=
"name"
>
Le nom à utiliser dans la réponse
<
/param
>
///
<
returns
><
/returns
>
[WebMethod]
public
string
HelloWorld
(
string
name)
{
int
i =
0
;
if
(
Convert.
ToBoolean
(
ConfigurationManager.
AppSettings[
"RunningModeRandom"
]
))
{
Random r =
new
Random
((
int
)DateTime.
Now.
Ticks);
i =
r.
Next
(
1000
,
10000
);
}
else
{
i =
3000
;
}
Thread.
Sleep
(
i);
return
string
.
Format
(
"Hello {0}. I slept for {1} ms"
,
name,
i);
}
}
Afin que les tests ne soient pas trop perturbés par un redémarrage de l'application web à chaque lancement de la console, nous allons publier l'application sur le serveur IIS local. Pour savoir comment faire, vous pouvez lire ce tutoriel : Déploiement Web avec Visual Studio 2010Déploiement Web avec Visual Studio 2010, par Nicolas EspritNicolas Esprit. Si vous n'avez pas IIS sur votre ordinateur, faites les tests avec le serveur de développement de Visual Studio.
III-B. Première version de la console sans le multithreading▲
III-B-1. Le code▲
Ajoutez un projet « Console » à la solution. Cette application sera notre « batch ». Référencez le web service de l'application web précédemment déployée sur IIS ou bien laissez Visual Studio découvrir tout seul celui de la solution.
Voici une capture de la solution dans son ensemble.
Et voici le code de la console avec des commentaires :
class
Program
{
///
<
summary
>
/// Liste de chaines de caractères pour mettre les réponses en mémoire
///
<
/summary
>
static
List<
string
>
responses =
new
List<
string
>(
);
///
<
summary
>
/// Méthode principale
///
<
/summary
>
///
<
param
name
=
"args"
><
/param
>
static
void
Main
(
string
[]
args)
{
// Création d'une instance du client SOAP
WebServiceSoapClient client =
new
WebServiceSoapClient
(
);
// Création d'instances de Stopwatch pour mesurer les temps
Stopwatch sw1 =
new
Stopwatch
(
);
Stopwatch sw2 =
new
Stopwatch
(
);
string
response =
string
.
Empty;
sw1.
Start
(
);
// Réalisation de 10 appels successifs
for
(
int
i =
0
;
i <
10
;
i++
)
{
sw2.
Restart
(
);
response =
string
.
Format
(
"{0}. Durée de l'appel: {1} ms"
,
client.
HelloWorld
(
"Immobilis"
),
(
sw2.
ElapsedMilliseconds));
sw2.
Stop
(
);
Console.
WriteLine
(
response);
// Mémorisation de la réponse
responses.
Add
(
response);
}
sw1.
Stop
(
);
client.
Close
(
);
// Création d'un serializer pour transformer la liste de réponses en une chaine de caractères
XmlSerializer xs =
new
XmlSerializer
(
typeof
(
List<
string
>
));
// Écriture du contenu de la liste dans un fichier sous la forme de XML
xs.
Serialize
(
File.
CreateText
(
@"DumpFile.xml"
),
responses);
Console.
WriteLine
(
"temps écoulé {0} s"
,
(
sw1.
ElapsedMilliseconds /
1000
));
Console.
ReadLine
(
);
}
}
III-B-2. Le test▲
Une première exécution permet de réaliser toutes les opérations en environ 30 secondes.
<?xml version="1.0" encoding="utf-8"?>
<ArrayOfString
xmlns
:
xsi
=
"http://www.w3.org/2001/XMLSchema-instance"
xmlns
:
xsd
=
"http://www.w3.org/2001/XMLSchema"
>
<string>
Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3324 ms</string>
<string>
Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3002 ms</string>
<string>
Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3002 ms</string>
<string>
Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3002 ms</string>
<string>
Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3003 ms</string>
<string>
Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3002 ms</string>
<string>
Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3003 ms</string>
<string>
Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3002 ms</string>
<string>
Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3003 ms</string>
<string>
Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3002 ms</string>
</ArrayOfString>
III-B-3. Le résultat▲
Satisfaisons-nous tous les besoins ?
Besoins du client | Résultat |
---|---|
Faire dix appels à la web méthode « HelloWorld » | |
Conserver toutes les réponses dans un fichier | |
Mesurer le temps de chaque appel | |
Mesurer le temps d'exécution total | |
Conserver ces mesures à des fins de statistiques. |
Tout est bon et nous allons donc essayer d'améliorer ce temps.
III-C. Deuxième version de la console avec un thread pour chaque requête▲
III-C-1. Le code▲
Une particularité des threads est qu'il faut isoler les ressources (variables, fichiers) qu'ils utilisent. À moins de prendre des précautions, ils ne doivent utiliser que leurs propres objets. Dans le cas contraire, plusieurs threads peuvent tenter d'accéder à la même ressource et il se produit des « collisions » lors de l'écriture ou de la lecture. Dans notre cas, on ne peut pas les laisser se partager le client SOAP chargé d'interroger le service web, ni le stopwatch chargé de mesurer le temps de réponse de chaque appel. Par contre, ils vont devoir se partager l'utilisation de la liste de chaînes « responses ». Nous verrons comment gérer cette difficulté.
La documentation MSDNMSDN nous montre que pour instancier un thread, il faut passer au constructeur le nom d'une méthode. Cette dernière peut admettre des paramètres, mais ne doit pas avoir de valeur de retour (« void »).
Afin d'exécuter les requêtes sur des threads différents, nous devons refactoriser la boucle « for ». En essayant d'extraire la méthode de cette portion de code, Visual Studio nous propose de passer plusieurs paramètres.
Comme on ne souhaite pas partager d'objets entre les threads, nous allons donc refactoriser l'implémentation de la méthode « Main » comme ci-dessous.
static
void
Main
(
string
[]
args)
{
// Création d'une instance de Stopwatch pour mesurer le temps d'exécution
Stopwatch sw1 =
new
Stopwatch
(
);
sw1.
Start
(
);
for
(
int
i =
0
;
i <
10
;
i++
)
{
// Création d'une instance du client SOAP
WebServiceSoapClient client =
new
WebServiceSoapClient
(
);
Stopwatch sw2 =
new
Stopwatch
(
);
string
response =
string
.
Empty;
// Réalisation de 1 appel
sw2.
Restart
(
);
response =
string
.
Format
(
"{0}. Durée de l'appel: {1} ms"
,
client.
HelloWorld
(
"Immobilis"
),
(
sw2.
ElapsedMilliseconds));
sw2.
Stop
(
);
Console.
WriteLine
(
response);
// Mémorisation de la réponse
responses.
Add
(
response);
client.
Close
(
);
}
sw1.
Stop
(
);
// Création d'un serializer pour transformer la liste de réponses en une chaine de caractères
XmlSerializer xs =
new
XmlSerializer
(
typeof
(
List<
string
>
));
// Écriture du contenu de la liste dans un fichier sous la forme de XML
xs.
Serialize
(
File.
CreateText
(
@"DumpFile.xml"
),
responses);
Console.
WriteLine
(
"temps écoulé {0} s"
,
(
sw1.
ElapsedMilliseconds /
1000
));
Console.
ReadLine
(
);
}
Une nouvelle tentative pour extraire la méthode donne le résultat attendu. La méthode ne prend aucun paramètre et ne renvoie aucun objet. Nommons cette méthode « DoWork ».
private
static
void
DoWork
(
)
{
// Création d'une instance du client SOAP
WebServiceSoapClient client =
new
WebServiceSoapClient
(
);
Stopwatch sw2 =
new
Stopwatch
(
);
string
response =
string
.
Empty;
// Réalisation de 10 appels successifs
sw2.
Restart
(
);
response =
string
.
Format
(
"{0}. Durée de l'appel: {1} ms"
,
client.
HelloWorld
(
"Immobilis"
),
(
sw2.
ElapsedMilliseconds));
sw2.
Stop
(
);
Console.
WriteLine
(
response);
// Mémorisation de la réponse
responses.
Add
(
response);
client.
Close
(
);
}
Avant d'exécuter cette nouvelle version, nous devons protéger les accès à la variable « responses ». Pour ce faire, nous allons utiliser un « sémaphore binaire » lors de l'ajout de la réponse dans la liste. Ce sémaphore est un objet privé statique en lecture seule dont la portée est limitée à la classe « Program ». Il faut utiliser l'instruction « locklock » pour protéger la section de code imbriquée entre les accolades. Ainsi, chaque thread devant accéder à la liste devra attendre que le thread qui a « locké » la section soit sorti.
class
Program
{
///
<
summary
>
/// Liste de chaines de caractères pour mettre les réponses en mémoire
///
<
/summary
>
static
List<
string
>
responses =
new
List<
string
>(
);
///
<
summary
>
/// Sémaphore
///
<
/summary
>
static
readonly
object
myLock =
new
object
(
);
// Reste du code...
}
// Mise en attente des threads
lock
(
myLock)
{
// Mémorisation de la réponse
responses.
Add
(
response);
}
La boucle « for » devient:
for
(
int
i =
0
;
i <
10
;
i++
)
{
Thread thread =
new
Thread
(
new
ThreadStart
(
DoWork));
thread.
Start
(
);
}
III-C-2. Le test▲
En exécutant une nouvelle fois le batch, nous obtenons le résultat ci-dessous :
III-C-3. Le résultat▲
Ouh là… Les résultats ne ressemblent pas du tout à ceux que nous espérions :
Besoins du client | Résultat |
---|---|
Faire dix appels à la web méthode « HelloWorld » | |
Conserver toutes les réponses dans un fichier | |
Mesurer le temps de chaque appel | |
Mesurer le temps d'exécution total | |
Conserver ces mesures à des fins de statistiques. |
- Le temps d'exécution total est de zéro seconde. C'est très/trop rapide ! Que s'est-il passé ? En fait, le code principal a continué à s'exécuter sans attendre le résultat des appels. La console ne s'est pas arrêtée, et les réponses se sont affichées après que le thread principal a atteint la fin de sa routine.
- Les indications affichées par la console sont incompréhensibles. Comme je n'ai pas pris la précaution de nommer les threads, on ne sait plus qui fait quoi.
- La durée des appels augmente très fortement. On passe de trois à douze secondes. La raison est la suivante : comme toutes les requêtes sont envoyées en même temps, le serveur web les met en attente. La dernière a été retardée de plus de neuf secondes.
- Le fichier « DumpFile.xml » est vide.
Question piège : quel est le temps total d'exécution ? Est-ce la somme des temps de chaque appel ? Eh bien non. Comme tous les appels sont envoyés en même temps, ils ne peuvent plus se cumuler.
Est-ce que pour autant le temps d'exécution est celui du temps le plus élevé ? Voyons voir… Pour le moment, nous ne disposons d'aucun moyen pour faire cette mesure mis à part un chronomètre. Voici une copie d'écran avec le chronomètre de www.chronometre-en-ligne.comwww.chronometre-en-ligne.com.
Cette mesure empirique semble démontrer que le temps d'exécution est bien celui du thread le plus lent. Au final, nous avons gagné 18 secondes. C'est plutôt pas mal.
Malgré ce résultat positif, pouvons-nous garder cette méthode ? Dans la mesure où le calcul des temps est faux, c'est non et c'est bien suffisant. En dehors de ça, observons les performances grâce à l'image ci-dessous (faites attention à l'échelle de mesure). Celle-ci nous montre certains compteurs de performances de IIS :
- La première flèche rouge indique l'appel de la page du web service : http://localhost/Batch/WebService.asmx ;
- La deuxième flèche rouge indique l'appel de la méthode « HelloWorld » du web service : http://localhost/Batch/WebService.asmx?op=HelloWorld ;
- La troisième flèche rouge indique l'exécution de la console. La ligne rose indique que dès le début, la console ouvre autant de connexions que de threads. Puis, IIS traite les demandes au fur et à mesure trois par trois. C'est ce qu'indiquent les lignes vertes et jaunes ;
- Quand le navigateur et la console sont fermés, le nombre de connexions actives (ligne rose) retombe.
Avec quelques appels, on ne remarque rien de particulier. Cependant, en général, lors des traitements par batches, il y a beaucoup de données à traiter. Que se passe-t-il si nous souhaitons traiter 1000 appels au lieu de dix. Le résultat est indiqué dans l'image ci-dessous. Faites attention, les échelles ont changé !
Le batch ouvre 1000 connexions ! La ligne en dents de scie est le nombre d'ouvertures de sessions. Le serveur web traite les requêtes dans l'ordre trois par trois. À ce rythme, cela va prendre beaucoup de temps et elles risquent d'expirer (TimeOut). De plus, si de vrais internautes se connectent au même moment, ils pourraient bien ne pas avoir la patience d'attendre. Il faut donc que nous contrôlions mieux le nombre de requêtes simultanées.
Les bases du multithreading étant posées, dans les prochaines versions, nous allons voir plusieurs méthodes pour conserver un nombre fini de threads en action. L'idée est de ne démarrer un nouveau thread que lorsqu'un thread a terminé sa tâche. Sachant que mon serveur web local ne traite que trois requêtes à la fois, nous allons essayer de limiter le nombre de requêtes simultanées à deux.
III-D. Troisième version de la console avec un pool de threads▲
III-D-1. Le code▲
Le pool de threadspool de threads est un réservoir de threads. Grosso modo, le Framework y crée et réutilise des threads en fonction des besoins de l'application et des caractéristiques du système. Il s'utilise assez facilement. Nous allons nous servir de la signature suivante « ThreadPool.QueueUserWorkItem (WaitCallback, Object)ThreadPool.QueueUserWorkItem ».
Nous ne l'avons pas abordée jusqu'à présent, mais la méthode cible du thread peut admettre des paramètres. Ceux-ci sont passés sous le type « object » et il faut les « caster » dans la méthode cible pour les utiliser. Ce paramètre va nous permettre de passer un nouvel objet : un « ManualResetEventManualResetEvent ». Grâce à la méthode « ManualResetEvent.Set()ManualResetEvent.Set », le thread va signaler que sa tâche est terminée. Associé à l'objet « WaitHandleWaitHandle », dont on va appeler la méthode « WaitHandle.WaitAll()WaitHandle.WaitAll Method », nous allons pouvoir suspendre le déroulement de la méthode « Main » et attendre que tous les threads aient fini leur travail.
Et maintenant un peu de code avec tout d'abord, l'objet « StateInfos » qui sera passé en paramètre :
///
<
summary
>
/// Objet de transport de données
///
<
/summary
>
public
class
StateInfos
{
///
<
summary
>
/// Numéro d'ordre du thread
///
<
/summary
>
public
int
Index {
get
;
set
;
}
///
<
summary
>
/// Propriété permettant d'envoyer un signal
///
<
/summary
>
public
ManualResetEvent ManualEvent {
get
;
set
;
}
}
Puis, la nouvelle implémentation de la méthode « DoWork » :
private
static
void
DoWork
(
object
state)
{
var
obj =
state as
StateInfos;
// Création d'une instance du client SOAP
WebServiceSoapClient client =
new
WebServiceSoapClient
(
);
Stopwatch sw2 =
new
Stopwatch
(
);
string
response =
string
.
Empty;
sw2.
Restart
(
);
response =
string
.
Format
(
"Thread {0}: {1}. Durée de l'appel: {2} ms"
,
obj.
Index,
client.
HelloWorld
(
"Immobilis"
),
(
sw2.
ElapsedMilliseconds));
sw2.
Stop
(
);
Console.
WriteLine
(
response);
lock
(
myLock)
{
// Mémorisation de la réponse
responses.
Add
(
response);
}
client.
Close
(
);
// Déclenchement de l'événement pour indiquer que la tâche est terminée
obj.
ManualEvent.
Set
(
);
}
Enfin, la méthode « Main » doit aussi être adaptée :
static
void
Main
(
string
[]
args)
{
// Instanciation du tableau de ManualResetEvent qui va nous permettre de suivre deux threads simultanés
var
resetEvents =
new
ManualResetEvent[
2
];
resetEvents[
0
]
=
new
ManualResetEvent
(
false
);
resetEvents[
1
]
=
new
ManualResetEvent
(
false
);
// Création d'une instance de Stopwatch pour mesurer le temps d'exécution
Stopwatch sw1 =
new
Stopwatch
(
);
sw1.
Start
(
);
// Lancement de 5 * 2 = 10 threads successifs
for
(
int
i =
0
;
i <
5
;
i++
)
{
for
(
int
j =
0
;
j <
2
;
j++
)
{
var
stateInfos =
new
StateInfos {
Index =
string
.
Format
(
"{0}-{1}"
,
i,
j),
ManualEvent =
resetEvents[
j]
};
ThreadPool.
QueueUserWorkItem
(
new
WaitCallback
(
DoWork),
stateInfos);
}
WaitHandle.
WaitAll
(
resetEvents);
resetEvents[
0
].
Reset
(
);
resetEvents[
1
].
Reset
(
);
}
sw1.
Stop
(
);
// Création d'un serializer pour transformer la liste de réponses en une chaine de caractères
XmlSerializer xs =
new
XmlSerializer
(
typeof
(
List<
string
>
));
// Écriture du contenu de la liste dans un fichier sous la forme de XML
xs.
Serialize
(
File.
CreateText
(
@"DumpFile.xml"
),
responses);
Console.
WriteLine
(
"temps écoulé {0} s"
,
(
sw1.
ElapsedMilliseconds /
1000
));
Console.
ReadLine
(
);
}
III-D-2. Le test▲
Je crois que je n'ai rien oublié. Voici ci-dessous le résultat de la troisième tentative :
Quinze secondes ! On dirait bien qu'on touche le bon bout. Et cette fois-ci, le XML est généré :
<?xml version="1.0" encoding="utf-8"?>
<ArrayOfString
xmlns
:
xsi
=
"http://www.w3.org/2001/XMLSchema-instance"
xmlns
:
xsd
=
"http://www.w3.org/2001/XMLSchema"
>
<string>
Thread 0-0: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3345 ms</string>
<string>
Thread 0-1: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3345 ms</string>
<string>
Thread 1-1: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 2998 ms</string>
<string>
Thread 1-0: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 2998 ms</string>
<string>
Thread 2-0: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3010 ms</string>
<string>
Thread 2-1: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3010 ms</string>
<string>
Thread 3-0: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3009 ms</string>
<string>
Thread 3-1: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3010 ms</string>
<string>
Thread 4-1: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3010 ms</string>
<string>
Thread 4-0: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3010 ms</string>
</ArrayOfString>
III-D-3. Le résultat▲
On dirait bien que tous les besoins sont satisfaits !
Besoins du client | Résultat |
---|---|
Faire dix appels à la web méthode « HelloWorld » | |
Conserver toutes les réponses dans un fichier | |
Mesurer le temps de chaque appel | |
Mesurer le temps d'exécution total | |
Conserver ces mesures à des fins de statistiques. |
Jetons un œil sur les performances :
Même si ce n'est pas mal du tout, dans le cas de mon usage, ce n'est pas la version que je vais garder. Tout d'abord, parce que tant que le signal de tous les « ManualResetEvent » n'a pas été envoyé, aucun autre thread ne sera lancé. Cela signifie que si une requête met un peu de temps à se terminer le travail va prendre du retard. Il faudrait réussir à maintenir un flux tendu.
Ensuite, parce que la classe « WaitHandle » ne peut gérer qu'un nombre limité d'éléments. Ainsi, on ne peut aller au-delà de 64 éléments dans le tableau. Toute tentative provoque une erreur de type « NotSupportedException » : « Le nombre de WaitHandles doit être inférieur ou égal à 64 ». Soixante-quatre, c'est déjà beaucoup, mais comme je n'aime pas trop avoir de limites, je vais vous proposer une autre méthode.
En passant, j'ai découvert la classe « SemaphoreSemaphore » en rédigeant ce tuto. Comme elle est assez intéressante, je voudrais simplement mentionner qu'elle permet de limiter le nombre de threads qui vont exécuter la méthode cible. Cependant, elle n'empêche pas le thread principal de continuer à s'exécuter. Du coup, cela ne répond pas aux besoins.
III-E. Quatrième version de la console basée sur la gestion d'événements▲
III-E-1. Le code▲
Cette méthode est loin d'être la plus simple , mais elle permet de maintenir un flux constant. En termes de vitesse, je pense que c'est la plus optimisée.
La première chose à faire va être de répartir le code dans plusieurs classes selon les responsabilités.
-
La responsabilité de l'appel à la web méthode sera la tâche à réaliser par une classe « Task ». Elle présentera :
- une propriété portant le nom de la tâche ;
- un événement « OnCompleted », indiquant que la tâche a été réalisée ;
- une méthode « DoWork », permettant de lancer le travail à réaliser.
-
La responsabilité de la gestion des tâches sera prise par une classe « TasksManager ». Elle présentera :
- un constructeur ;
- une propriété portant la collection de tâches à réaliser ;
- un événement « OnTaskCompleted », indiquant qu'une tâche s'est terminée ;
- un événement « OnCompleted », indiquant que toutes les tâches ont été réalisées ;
- une méthode « DoWork », permettant de lancer le traitement des tâches.
- La responsabilité d'initialiser le manager sera prise par le programme.
- Nous allons aussi créer un événement spécialisé pour transmettre des données lorsque les événements sont déclenchés.
Voici le code de l'argument d'événement :
class
TaskEventArgs :
EventArgs
{
///
<
summary
>
/// Nom de la tâche
///
<
/summary
>
public
string
Name {
get
;
set
;
}
///
<
summary
>
/// Un message quelconque à transmettre
///
<
/summary
>
public
string
Message {
get
;
set
;
}
}
Voici le « squelette » du code de la tâche.
class
Task
{
public
event
EventHandler<
TaskEventArgs>
OnCompleted;
public
string
Name {
get
;
set
;
}
public
void
DoWork
(
object
state)
{
// Code à implémenter pour exécuter la tâche à réaliser
}
}
Voici le « squelette » du gestionnaire de tâches.
class
TasksManager
{
readonly
object
myLock =
null
;
public
event
EventHandler<
TaskEventArgs>
OnTaskCompleted;
public
event
EventHandler<
TaskEventArgs>
OnCompleted;
public
List<
Task>
MyTasks {
get
;
private
set
;
}
public
List<
Thread>
MyThreads {
get
;
private
set
;
}
public
TasksManager
(
)
{
myLock =
new
object
(
);
MyTasks =
new
List<
Task>(
);
MyThreads =
new
List<
Thread>(
);
}
public
void
DoWork
(
)
{
// Code à implémenter pour lancer les tâches via des threads
}
}
Voici le « squelette » de la classe « Program » :
class
Program
{
static
void
Main
(
string
[]
args)
{
var
todoList =
new
List<
Task>(
);
for
(
int
i =
0
;
i <
10
;
i++
)
{
todoList.
Add
(
new
Task {
Name =
i.
ToString
(
) }
);
}
var
manager =
new
TasksManager
(
);
manager.
OnTaskCompleted +=
manager_OnTaskCompleted;
manager.
OnCompleted +=
manager_OnCompleted;
manager.
MyTasks.
AddRange
(
todoList);
manager.
DoWork
(
);
}
static
void
manager_OnTaskCompleted
(
object
sender,
TaskEventArgs e)
{
var
obj =
sender as
Task;
Console.
WriteLine
(
"La tâche {0} est terminée: {1}"
,
obj.
Name,
e.
Message);
}
static
void
manager_OnCompleted
(
object
sender,
TaskEventArgs e)
{
Console.
WriteLine
(
"C'est fini"
);
Console.
ReadLine
(
);
}
}
III-E-1-a. Détail de la classe « Task »▲
Seule la méthode « DoWork » est modifiée. On y ajoute le code nécessaire à l'appel de la web méthode et à la mesure du temps de réponse. La réponse qui était précédemment ajoutée à la liste des réponses est cette fois assignée à la propriété « Message » de l'argument d'événement. Ce dernier est passé au gestionnaire de l'événement.
class
Task
{
///
<
summary
>
/// Événement déclenché quand la tâche est terminée
///
<
/summary
>
public
event
EventHandler<
TaskEventArgs>
OnCompleted;
///
<
summary
>
/// Nom de la tâche
///
<
/summary
>
public
string
Name {
get
;
set
;
}
///
<
summary
>
/// Méthode cible du thread
///
<
/summary
>
///
<
param
name
=
"state"
>
Tout type d'objet selon besoins
<
/param
>
public
void
DoWork
(
object
state)
{
// Création d'une instance du client SOAP
WebServiceSoapClient client =
new
WebServiceSoapClient
(
);
Stopwatch sw2 =
new
Stopwatch
(
);
string
response =
string
.
Empty;
sw2.
Restart
(
);
response =
string
.
Format
(
"Thread {0}: {1}. Durée de l'appel: {2} ms"
,
this
.
Name,
client.
HelloWorld
(
"Immobilis"
),
(
sw2.
ElapsedMilliseconds));
sw2.
Stop
(
);
client.
Close
(
);
OnCompleted
(
this
,
new
TaskEventArgs {
Name =
this
.
Name,
Message =
response }
);
}
}
III-E-1-b. Détail de la classe « TasksManager »▲
C'est dans cette classe que les choses se compliquent quelque peu. La méthode « DoWork » va tout d'abord lancer les deux premières tâches. Quand l'une des tâches aura fini son travail, elle déclenchera l'événement « OnCompleted ». Le « TasksManager », qui est abonné à cet événement, pourra passer à la tâche suivante et ainsi de suite.
class
TasksManager
{
///
<
summary
>
/// Sémaphore binaire
///
<
/summary
>
readonly
object
myLock =
null
;
///
<
summary
>
/// Événement indiquant qu'une tâche est terminée
///
<
/summary
>
public
event
EventHandler<
TaskEventArgs>
OnTaskCompleted;
///
<
summary
>
/// Événement indiquant que tout le travail a été réalisé
///
<
/summary
>
public
event
EventHandler<
TaskEventArgs>
OnCompleted;
///
<
summary
>
/// Liste des tâches à traiter
///
<
/summary
>
public
List<
Task>
MyTasks {
get
;
private
set
;
}
///
<
summary
>
/// Liste des threads traitant les tâches
///
<
/summary
>
public
List<
Thread>
MyThreads {
get
;
private
set
;
}
public
TasksManager
(
)
{
myLock =
new
object
(
);
MyTasks =
new
List<
Task>(
);
MyThreads =
new
List<
Thread>(
);
}
///
<
summary
>
/// Méthode appelée par le programme principal
///
<
/summary
>
public
void
DoWork
(
)
{
// On prend les deux premiers éléments de la liste car on ne veut pas saturer le serveur.
// Cette quantité est à ajuster en fonction de vos besoins et contraintes
var
firstTasks =
MyTasks.
Take
(
2
).
ToList
(
);
foreach
(
var
task in
firstTasks)
{
lock
(
myLock)
{
// On supprime les éléments sélectionnés au fur et à mesure pour éviter de les retraiter
MyTasks.
Remove
(
task);
}
// Création et lancement des threads
CreateThreadAndStartIt
(
task);
// Pour la démonstration du flux tendu, on marque un petit temps d'arrêt pour décaler
// légèrement la création des threads dans le temps. À supprimer en application réelle.
Thread.
Sleep
(
500
);
}
PauseAndWait
(
);
OnCompleted
(
this
,
new
TaskEventArgs
(
));
}
///
<
summary
>
/// Créée et démarre un thread
///
<
/summary
>
///
<
param
name
=
"task"
><
/param
>
void
CreateThreadAndStartIt
(
Task task)
{
Thread thread =
new
Thread
(
new
ParameterizedThreadStart
(
ProceedTask));
thread.
Name =
string
.
Format
(
"Thread_{0}"
,
task.
Name);
thread.
Start
(
task);
lock
(
myLock)
{
MyThreads.
Add
(
thread);
}
}
///
<
summary
>
/// Méthode cible du démarrage du thread
///
<
/summary
>
///
<
param
name
=
"obj"
><
/param
>
void
ProceedTask
(
object
obj)
{
var
task =
obj as
Task;
task.
OnStarted +=
task_OnStarted;
task.
OnCompleted +=
task_OnCompleted;
// On ne passe pas de paramètre à la tâche. Elle dispose déjà de tout ce dont elle a besoin.
task.
DoWork
(
null
);
}
///
<
summary
>
/// Implémentation de l'événement indiquant qu'une tâche est terminée
///
<
/summary
>
///
<
param
name
=
"sender"
><
/param
>
///
<
param
name
=
"e"
><
/param
>
void
task_OnCompleted
(
object
sender,
TaskEventArgs e)
{
OnTaskCompleted
(
sender,
e);
ProceedNextTask
(
);
}
///
<
summary
>
/// Méthode permettant de passer à la tâche suivante
///
<
/summary
>
void
ProceedNextTask
(
)
{
// Sécurisation de la liste
lock
(
myLock)
{
// Recherche de la première prochaine tâche
var
task =
MyTasks.
FirstOrDefault
(
);
// S'il y a encore une tâche à traiter
if
(
task !=
null
)
{
// On la retire de la liste
MyTasks.
Remove
(
task);
// On créée un nouveau thread pour la traiter
CreateThreadAndStartIt
(
task);
}
else
{
// Rien à faire, il n'y a plus de tâche
}
}
}
///
<
summary
>
/// Méthode permettant de mettre le manager en attente afin de laisser le temps
/// à toutes les tâches de se terminer.
///
<
/summary
>
void
PauseAndWait
(
)
{
int
alive =
0
;
lock
(
myLock)
{
// Première recherche du nombre de threads encore "vivants"
alive =
MyThreads.
Where
(
x =>
x.
IsAlive).
Count
(
);
}
// On tourne tant que le nombre de threads encore vivants n'est pas égal à 0.
while
(
alive >
0
)
{
lock
(
myLock)
{
// Recherche du nombre de threads encore "vivants"
alive =
MyThreads.
Where
(
x =>
x.
IsAlive).
Count
(
);
// Petit nettoyage de la liste
MyThreads.
RemoveAll
(
delegate
(
Thread t)
{
return
!
t.
IsAlive;
}
);
}
// On marque une pause pour éviter de trop solliciter les processeurs
Thread.
Sleep
(
1000
);
}
}
}
III-E-1-c. Détail de la classe « Program »▲
J'ai ajouté de quoi stocker les réponses, protéger la liste contre les accès concurrents et écrire les réponses stockées dans le fichier.
class
Program
{
// Membre pour stocker les réponses
static
List<
string
>
responses =
new
List<
string
>(
);
// Sémaphore binaire
static
readonly
object
myLock =
new
object
(
);
static
void
Main
(
string
[]
args)
{
var
todoList =
new
List<
Task>(
);
for
(
int
i =
0
;
i <
10
;
i++
)
{
todoList.
Add
(
new
Task {
Name =
i.
ToString
(
) }
);
}
var
manager =
new
TasksManager
(
);
manager.
OnTaskCompleted +=
manager_OnTaskCompleted;
manager.
OnCompleted +=
manager_OnCompleted;
manager.
MyTasks.
AddRange
(
todoList);
// Création d'une instance de Stopwatch pour mesurer le temps d'exécution
Stopwatch sw1 =
new
Stopwatch
(
);
sw1.
Start
(
);
manager.
DoWork
(
);
sw1.
Stop
(
);
// Création d'un serializer pour transformer la liste de réponses en une chaine de caractères
XmlSerializer xs =
new
XmlSerializer
(
typeof
(
List<
string
>
));
// Écriture du contenu de la liste dans un fichier sous la forme de XML
xs.
Serialize
(
File.
CreateText
(
@"DumpFile.xml"
),
responses);
Console.
WriteLine
(
"temps écoulé {0} s"
,
(
sw1.
ElapsedMilliseconds /
1000
));
Console.
ReadLine
(
);
}
static
void
manager_OnTaskCompleted
(
object
sender,
TaskEventArgs e)
{
var
obj =
sender as
Task;
lock
(
myLock)
{
responses.
Add
(
e.
Message);
}
Console.
WriteLine
(
"La tâche {0} est terminée:
\r\n\t
{1}"
,
obj.
Name,
e.
Message);
}
static
void
manager_OnCompleted
(
object
sender,
TaskEventArgs e)
{
Console.
WriteLine
(
"C'est fini"
);
}
}
III-E-2. Le test▲
Avec dix appels, le résultat de cette 4e version est identique à celui de la version avec le pool de threads.
Le fichier XML est aussi généré.
<?xml version="1.0" encoding="utf-8"?>
<ArrayOfString
xmlns
:
xsi
=
"http://www.w3.org/2001/XMLSchema-instance"
xmlns
:
xsd
=
"http://www.w3.org/2001/XMLSchema"
>
<string>
Thread 0: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3383 ms</string>
<string>
Thread 1: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3144 ms</string>
<string>
Thread 2: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3003 ms</string>
<string>
Thread 3: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3005 ms</string>
<string>
Thread 4: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3002 ms</string>
<string>
Thread 5: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3008 ms</string>
<string>
Thread 6: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3004 ms</string>
<string>
Thread 7: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3003 ms</string>
<string>
Thread 8: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3006 ms</string>
<string>
Thread 9: Hello Immobilis. I slept for 3000 ms. Durée de l'appel: 3002 ms</string>
</ArrayOfString>
III-E-3. Le résultat▲
Et les besoins ?
Besoins du client | Résultat |
---|---|
Faire dix appels à la web méthode « HelloWorld » | |
Conserver toutes les réponses dans un fichier | |
Mesurer le temps de chaque appel | |
Mesurer le temps d'exécution total | |
Conserver ces mesures à des fins de statistiques. |
Pourquoi penser que cette méthode est meilleure (dans le contexte des besoins) que celle avec le pool de threads ? Voyons cela dans la dernière section en poussant un peu les curseurs…
III-F. Comparaison des méthodes▲
Nous allons maintenant tenter de mettre en évidence la gestion en flux tendu. Pour ce faire nous allons passer la web méthode pour qu'elle fonctionne en mode aléatoire. Elle va donc renvoyer une réponse après un temps d'attente de une à dix secondes. Puis, nous ferons 1000 appels. Le temps de prendre un ou deux cafés et nous verrons laquelle des versions donne la meilleure moyenne.
Selon la méthode de la 1re version (sans le multithreading), il faut 89 minutes environ pour faire les mille appels.
Selon la méthode de la 3e version (avec les « ManualResetEvent »), il faut 56 minutes environ pour faire les mille appels.
Enfin, selon la méthode de la 4e version il faut un peu plus de 45 minutes.
À l'évidence, les résultats sont convaincants et favorisent l'approche événementielle.
IV. Pour aller plus loin▲
IV-A. D'autres événements▲
La gestion d'événements nous permet de recevoir et transmettre des signaux. Pour autant, ils peuvent vite être difficiles à gérer quand ils deviennent trop nombreux. Il en est tout de même certains qui sont intéressants :
- les événements de démarrage peuvent nous indiquer qu'un travail commence. Ainsi, il est possible de déclencher le démarrage du chronomètre global sur le début du traitement des tâches par le manager. Ce déclenchement ne figure plus dans la méthode « Main ». En conséquence, la méthode contient moins de code et elle est plus facile à maintenir ;
- les événements d'erreur. Il est important de pouvoir capturer les erreurs, quand la requête HTTP échoue par exemple. Si le manager s'abonne aux événements d'erreur des tâches, il peut annuler les threads responsables de la tâche.
Notez aussi que grâce à eux, nous pouvons transmettre facilement des informations à faire apparaître dans les interfaces. Cela nous affranchit avantageusement du type d'application développé (Win 32, Web, etc.).
class
Program
{
// Membre pour stocker les réponses
static
List<
string
>
responses =
new
List<
string
>(
);
// Sémaphore binaire
static
readonly
object
myLock =
new
object
(
);
// Liste de tâches
static
readonly
List<
Task>
todoList =
new
List<
Task>(
);
// Création d'une instance de Stopwatch pour mesurer le temps d'exécution
static
Stopwatch sw1 =
new
Stopwatch
(
);
static
void
Main
(
string
[]
args)
{
FillTheList
(
);
var
manager =
new
TasksManager
(
);
manager.
OnStarted +=
manager_OnStarted;
manager.
OnTaskStarted +=
manager_OnTaskStarted;
manager.
OnTaskError +=
manager_OnTaskError;
manager.
OnTaskCompleted +=
manager_OnTaskCompleted;
manager.
OnCompleted +=
manager_OnCompleted;
manager.
MyTasks.
AddRange
(
todoList);
manager.
DoWork
(
);
}
static
void
FillTheList
(
)
{
for
(
int
i =
0
;
i <
10
;
i++
)
{
todoList.
Add
(
new
Task {
Name =
i.
ToString
(
) }
);
}
}
static
void
manager_OnStarted
(
object
sender,
TaskEventArgs e)
{
sw1.
Start
(
);
}
static
void
manager_OnTaskStarted
(
object
sender,
TaskEventArgs e)
{
var
obj =
sender as
Task;
Console.
WriteLine
(
"La tâche {0} est commencée"
,
obj.
Name);
}
static
void
manager_OnTaskError
(
object
sender,
TaskEventArgs e)
{
// Faire quelque chose pour revenir à l'état initial
}
static
void
manager_OnTaskCompleted
(
object
sender,
TaskEventArgs e)
{
var
obj =
sender as
Task;
lock
(
myLock)
{
responses.
Add
(
e.
Message);
}
Console.
WriteLine
(
"La tâche {0} est terminée:
\r\n\t
{1}"
,
obj.
Name,
e.
Message);
}
static
void
manager_OnCompleted
(
object
sender,
TaskEventArgs e)
{
sw1.
Stop
(
);
Console.
WriteLine
(
"temps écoulé {0} s"
,
(
sw1.
ElapsedMilliseconds /
1000
));
// Création d'un serializer pour transformer la liste de réponses en une chaine de caractères
XmlSerializer xs =
new
XmlSerializer
(
typeof
(
List<
string
>
));
// Écriture du contenu de la liste dans un fichier sous la forme de XML
xs.
Serialize
(
File.
CreateText
(
@"DumpFile.xml"
),
responses);
Console.
WriteLine
(
"C'est fini"
);
Console.
ReadLine
(
);
}
}
Cette implémentation améliore la qualité du code comme l'indiquent les métriques ci-dessous :
- La console 1 est celle sans thread (III-B) ;
- La console 2 est celle avec un thread pour chaque requête (III-C) ;
- La console 3 est celle avec le pool de threads (III-D) ;
- La console 4 est celle avec la gestion des événements (III-E). Malgré un nombre de lignes de code plus élevé, l'indice de maintenabilité est le meilleur.
IV-B. Utilisez des interfaces pour faire de la généricité▲
À l'usage, vous vous rendrez compte que les événements à gérer sont souvent les mêmes. Ainsi, vous pourrez extraire les interfaces qui vous permettront d'ajouter de la généricité dans la gestion de vos batches. Avec le même programme, vous pourrez manipuler différents types de tâches.
Conclusion rapide▲
Voici donc plusieurs solutions pour booster certaines des opérations de vos applications. Gardez tout de même toujours un œil sur les compteurs. Ne faites pas d'excès de vitesse. Si vous voulez un peu plus de détails sur la gestion d'événements, jetez un œil sur cet article : Délégués et évènementsDélégués et évènements. Lisez aussi celui-ci à propos des nouveautés de C# 5, notamment la nouvelle façon de faire appel aux méthodes asynchrones : Les nouveautés de C# 5.0Les nouveautés de C# 5.0.
Merci à ClaudeLELOUPClaudeLELOUP pour la correction de l'orthographe et de la grammaire ainsi que _max__max_ et tomlevtomlev pour leur aide.