Se servir du multithreading pouraccélérer le temps d'exécution de vos batches.

Ou comment la programmation événementielle permet de contrôler un pool de threads
décuplant la vitesse de traitement par lots.

Immobilis

Il est très fréquent de devoir mettre en place des programmes qui vont exécuter plusieurs fois (des centaines, voire des centaines de milliers de fois) la même tâche. Ces programmes de traitement par lots sont comme des robots qui remplacent l'homme tant le volume de travail est important et les opérations sont répétitives. Ils sont un peu comme les automates des chaînes de montage industrielles. Si la quantité de travail à réaliser est faible et prend peu de temps pour être terminée, assez souvent, le programme réalise le traitement de manière séquentielle, une tâche après l'autre. Par contre, si cela prend plus de temps, il est conseillé de s'y mettre à plusieurs. C'est ce que je vais essayer de vous montrer dans ce tutoriel.

Ce tutoriel présente une solution pratique apportée à une problématique rencontrée dans mon activité professionnelle. Commentez

- Titre : Se servir du multithreading pouraccélérer le temps d'exécution de vos batches.

- Auteur : Immobilis

- Parution : 2 novembre 2012

- 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 ® 2012 Immobilis. 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. Le travail à réaliser

- III. La solution

- III-A. Le service web

- III-B. Première version de la console sans le multithreading

- III-B-1. Le code

- III-B-2. Le test

- III-B-3. Le résultat

- III-C. Deuxième version de la console avec un thread pour chaque requête

- III-C-1. Le code

- III-C-2. Le test

- III-C-3. Le résultat

- III-D. Troisième version de la console avec un pool de threads

- III-D-1. Le code

- III-D-2. Le test

- III-D-3. Le résultat

- III-E. Quatrième version de la console basée sur la gestion d'événements

- III-E-1. Le code

- III-E-1-a. Détail de la classe « Task »

- III-E-1-b. Détail de la classe « TasksManager »

- III-E-1-c. Détail de la classe « Program »

- III-E-2. Le test

- III-E-3. Le résultat

- III-F. Comparaison des méthodes

- IV. Pour aller plus loin

- IV-A. D'autres événements

- IV-B. Utilisez des interfaces pour faire de la généricité

- Conclusion rapide

I - Introduction

Quand il y a beaucoup de travail à faire, la meilleure solution est encore de le faire à plusieurs.
Grâce aux Threads 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 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 ».

Le projet application web

Pour simuler le temps de réponse de la web méthode, nous allons mettre le thread en « dormance » pendant un laps de temps.

Code du web service

[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 2010, par Nicolas 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.

Ajout de la référence de service

Voici une capture de la solution dans son ensemble.

Création du projet console

Et voici le code de la console avec des commentaires :

Code du programme console

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.

Exécution du batch

Contenu du fichier « DumpFile.xml »

<?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 MSDN 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.

Refactorisation de la boucle FOR

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.

Refactorisation de la boucle « for »

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 ».

Refactorisation de la boucle « for »

La 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 « lock » 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.

Déclaration du sémaphore « myLock »

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...
}

Protection de la liste par un sémaphore

// Mise en attente des threads
lock (myLock)
{
// Mémorisation de la réponse
responses.Add(response);
}

La boucle « for » devient:

Instanciation des threads dans la boucle « for »

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 :

Exécution de la première version multithread

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.

1. 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.

2. 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.

3. 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.

4. 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.com.

Temps approximatif d'exécution de la console

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 :

1. La première flèche rouge indique l'appel de la page du web service : http://localhost/Batch/WebService.asmx ;

2. La deuxième flèche rouge indique l'appel de la méthode « HelloWorld » du web service : http://localhost/Batch/WebService.asmx?op=HelloWorld ;

3. 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 ;

4. 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 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) ».

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 « ManualResetEvent ». Grâce à la méthode « ManualResetEvent.Set() », le thread va signaler que sa tâche est terminée. Associé à l'objet « WaitHandle », dont on va appeler la méthode « WaitHandle.WaitAll() », 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 :

Objet servant au passage de paramètres à la méthode « DoWork »

/// <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 » :

Nouvelle implémentation de la méthode « DoWork(object state) »

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 :

Nouvelle implémentation de la méthode « Main »

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 :

Exécution de la troisième version du batch

Quinze secondes ! On dirait bien qu'on touche le bon bout. Et cette fois-ci, le XML est généré :

XML du fichier « DumpFile »

<?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 « Semaphore » 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.

1. 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.

2. 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.

3. La responsabilité d'initialiser le manager sera prise par le programme.

4. 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 :

Code de l'événement spécialisé

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.

« Squelette » de la classe « Task »

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.

« Squelette » de la classe « TasksManager »

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 » :

« 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.

Code complet de la classe « Task »

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.

Code complet de la classe « TasksManager »

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.

Code complet de la classe « Program »

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.

Exécution de la console basée sur la gestion d'événements

Le fichier XML est aussi généré.

Contenu du fichier « DumpFile.xml »

<?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.

1000 appels séquentiels à la web méthode

Selon la méthode de la 3e version (avec les « ManualResetEvent »), il faut 56 minutes environ pour faire les mille appels.

500 fois 2 appels à la web méthode

Enfin, selon la méthode de la 4e version il faut un peu plus de 45 minutes.

1000 appels en flux tendu grâce à la gestion d'événements

1000 appels en flux tendu grâce à la gestion d'événements

À 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.).

Code de la classe « Program »

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 :

Métriques du code

- 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.

Utilisez des interfaces pour faire de la généricité

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è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.0.

Merci à ClaudeLELOUP pour la correction de l'orthographe et de la grammaire ainsi que _max_ et tomlev pour leur aide.