lundi 27 septembre 2010

Theading en C# - synchronisation et méthodes de threading

Joseph Albahari (auteur de C# in a nutshell) met à disposition une documentation très complète sur le Threading en C#.
Que cela soit sur son site internet ou le document pdf, la lecture des 80 pages vaut largement le détour.

Il faudra cependant s'accrocher un peu car le sujet et retourné dans tous les sens.
Joseph Albahari aborde également de façon approfondie un élément essentiel du threading, à savoir les méthodes de synchronisation.

Contenu de cet article
Il est impossible de résumer le threading C# dans un seul article.
J'ai décidé de seulement énumérer les méthodes de synchronisation. C'est un sujet sur lequel il existe une grande littérature et l'article de Joseph Albahari suffit amplement pour ce sujet.
Je vais par contre fournir une vue globale des différentes méthodes permettant d'implémenter le multithreading en C# (il y en a 8).
Si mon résumé est assez sommaire, Joseph Albahari fournit bien évidemment une information détaillée sur chacun des points.

Finalement, cet article est encore loin d'être complet, je voudrais y ajouter des exemples, parler du Local Storage et bien entendu, il me reste encore toute la section des synchronisations non bloquantes (Wait/pulse, Memory Barriers, Volatiles, etc).
Les exemples seront certainement produits dans d'autres articles qui seront repris en référence depuis cet article.
Le reste du sujet sera certainement traité dans une autre publication.


La synchronisation
C'est aussi que l'on aborde les méthodes de synchronisations:
  • Locking, les objets de synchronisation, Join
  • AutoResetEvent et ManualResetEvent
  • Mutex, Sémaphore
    EventWaitHandle, WaitOne, WaitAll, SignalAndWait.
  • Implémentation d'un Acknowledgement
    Utilisation de deux AutoResetEvent pour implementer une processus ready/go
  • Implémentation d'un Producer-Consumer queue.
    Attention, le framework .Net propose également une implémentation d'un ProducerConsumerQueue.
    L'article permet cependant de se faire une idée de l'utilité d'une telle structure.
  • Wait and Pulse
    largement décrit
  • ReaderWriterLockSlim 
L'article "Sync C# - Méthodes de synchronisation" à été spécifiquement créé pour traiter de ce sujet particulier.

Le threading
Sur le plan des threads, on aborde inévitablement la création des threads.
Il faut savoir qu'il existe bien des façons de mettre en place le threading dans le Framework .Net.
En voici un relevé exhaustif basé sur l'excellent document de Joseph Albahari (déjà évoqué dans l'introduction).

1) Thread
Principe d'implémentation basé sur une méthode appelée via un delegate.

L'article de Joseph Albahari (voir ci-dessus) s'étend longuement sur l'utilisation de la classe Thread.
Il est également possible de créer sa propre classe dérivée de Thread.
  • Permet l'utilisation de Join()
  • Nécessite l'implémentation d'un Try/Catch

2) BackgroundWorker
BackGroundWorker est une classe helper disponible dans le namespace System.ComponentModel.

Voici les caractéristiques principales:
  • Permet l'interaction avec WinForms.
  • Utilise des delegates pour attacher la méthode de tavaille (DoWork)
  • Permet de démarrer le worker avec un paramètre.
    RunWorkerAsync("Hello World");
  • Capture les exception (signalé lors de l'achèvement du Worker).
  • Utilise des évènements pour signaler la progession (avec possibilité de cancel)
    Voir
    WorkerReportsProgress, ProgressChanged, WorkerSupportsCancellation.
  • Signaler l'achèvement avec état cancelled  ou Error
    Voir RunWorkerCompleted.
  • Autorise la surcharge.
Avantages:
  • Facile à coder (utilisation de delagate).
  • Interaction avec WinForms (progression).
  • Gestion des exceptions par le BackgroundWorker.
Ressources:

3) ThreadPool.RegisterWaitForSingleObject
Le ThreadPooling de ce type est idéal pour les tâches threadés qui s'attendent les unes les autres (synchronisation avec ManualResetEvent).
Cela évite la création de nombreuses instances de thread qui passent un temps non négligeable en état bloqué.

Ici, c'est le gestionnaire du thread pool qui attend le signal d'exécution (via le ManualResetEvent) et alloue le thread d'exécution à "la demande".
Par exemple, pour 20 tâches planifiées qui s'attendent les unes les autres, seul 5 threads physique concurrents pourrait être vraiment nécessaires pour l'exécution. C'est ce que l'on obtiendra en utilisant le ThreadPool.

  • Enregistre un DoWork delegate et un ManuelResetEvent (WaitHandle) sur le ThreadPool.
  • La tâche démarre lorsqu'on signale le WaitHandle.
  • RegisterWaitForSingleObject accepte un paramètrage (passé à la méthode DoWork)
  • Ne jamais utiliser Abord (qui empêche le recyclage du thread sur le ThreadPool)
  • Nécessite l'implémentation d'un Try/Catch dans la méthode DoWork
Avantages: 
  • Minimiser l'utilisation des ressources processeur (diminue le nbre de Thread physique nécessaire à l'exécution)... donc diminue le nbre de context switching sur le scheduler du système d'exploitation.
  • Threadpool limite le nbre de thread a 25 (par défaut). 
Ressource:

4) ThreadPool.QueueUserWorkItem
Permet d'enfiler (enqueue) des tâches exécutées immédiatement sur le ThreadPool.
Pour rappel, le ThreadPool gère un pool de 25 threads par défaut.

Ainsi, si l'on enfile 50 tâches d'un coup, seules les 25 premières seront schedulée, les autres tâches resterons dans la file d'attente (en attente de libération d'un thread).
  • Tâche enfilée démarre immédiatement.
  • Recommandations identiques à ThreadPool.RegisterWaitForSingleObject.
Ressources:
5) Asynchronous Delegate
Probablement l'une des méthodes les plus faciles et les plus intéressantes de mettre the multithreading en oeuvre. Par ailleurs, elle exploite toujours le ThreadPool.
Cette méthode se base sur un delegate et l'interface IAsyncResult (permettant le rendez-vous).
  • EndInvoke est utilisé au point de rendez-vous.
  • Les exceptions sont attrapées et relancées aux point de rendez-vous (IAsyncResult.EndInvoke).
  • EndInvoke supporte de façon transparente plusieurs ref/out paramètres en provenance de la méthode déléguée.
  • L'objet IAsyncResult retourné par BeginInvoke dispose d'une propriété IsCompleted.
  • BeginInvoke peut recevoir plusieurs paramètres (comme défini par la signature du delegate)
  • BeginInvoke peut recevoir des paramètres optionnels une fonction callback et un DataObject (tout deux optionnels).
Ressources:
6) Asynchronous Methods
Pattern particulier permettant de déclarer sur un objet une méthode dont l'exécution sera asynchrone.
Ce type de méthode commence par Begin (pour les méthodes de démarrage) et End (pour les méthodes rendez-vous).
Les méthodes asynchrones permettent d'implémenter un traitement ou l'activité dépasse largement le nombre de thread disponible. Mais bien entendu, dans ce cas, l'exécution ne se fait pas vraiment en parallèle (cela ne pourrait d'ailleurs pas être garanti).
A titre d'exemple, la stack d'un TCP Socket Serveur serait traitée à l'aide de méthodes asynchrones.
Pouvoir assurer un tel  débit de traitement, le pattern "Asynchronous Méthod" doit être implémenté très scrupuleusement afin de maximiser les performance et de maintenir la complexité au minimum.
Joseph Albahari traite ce sujet dans son livre sur C# 3.
Ex: NetworkStream.BeginRead.

7) Asynchronous Event  ou "event-based asynchronous pattern"
Egalement un pattern particulier, son implémentation est idéale pour afficher un rapport de progression ou pour être averti d'un évènement de cancellation. Ce pattern convient idéalement aux applications WinForms ou il se montre plutôt approprié lors de développement de composant.
Ce pattern est approprié lorsqu'il est nécessaire de mette en place des opérations asynchrones que le client peut gérer à l'aide du modèle event/delegate.

  • Basé sur la déclaration d'une xxxxAsync et d'un évènement yyyComplete.Ex: WebClient.DownloadStringAsync et Webclient.DownloadStringComplete
  • La méthode Async fait une exécution asynchrone et appelle l'événement Complete lorsque le traitement est terminé.
  • S'il s'agit d'une pure exécution asynchrone (et non l'implementation d'une notification dans votre propre composant), le même résultat peut être atteint en utilisant les BackgroundWorker.
  • Ne pas utiliser ce pattern si le client de la classe à besoin d'un WaitHandle ou IAsynResult.
Le pattern peut-être implémenté deux façons différents:
  • Pour supporter un seul appel à la fois (non réentrant).
    Dans ce cas, l'évènement xxxComplete doit être exécuté avant de pouvoir refaire un nouvel appel à la méthode xxxAsync.
  • Pour supporter des appels réentrant.
    Il est possible d'appeler plusieurs fois la méthode xxxAsync et il y aura autant d'évènement xxComplete correspondant. Cependant, lors de l'appel il faut passer un objet identificateur (userState aussi appelé TaskId) permettant d'identifier l'appel, l'exécution asynchrone et l'évènement xxxComplete de façon univoque.
Ressource:


8) Timer
Très pratique pour l'implémentation de tâches répétitives, il est important de savoir qu'il existe plusieurs implémentations du Timer dans le Framework .Net. Bien entendu, chacune de ces implémentation à ses propres spécificités.


8.1) Timer - System.Threading.Timer
Fonctionne sur le ThreadPool. Les méthodes callback ne sont donc pas synchronisées avec le thread principal. 
Cette implémentation de Timer convient particulièrement aux routines nécessitant du temps de processing.
Il faut donc utiliser control.Invoke pour toutes les intéractions WinForms.

8.2) Timer - System.Timers.Timer
Founit une implémentation de type composant visuel pour System.Threading.Timer.
Publie donc des propriétés et événement pour facilité le coding de l'application.
Comme il utilise toujours le threadpool, la méthode Callback n'est pas synchrone avec le thread principal.

8.3) Timer - Windows.Forms.Timer
Présente une interface similaire à System.Timers.Timer mais est radicalement différent en ce qui concerne l'implémentation.
Cette implémentation n'utilise pas le threadpool et fonctionne donc de façon synchrone avec le thread principal.
Si cela est un avantage indéniable pour la mise-à-jour de l'interface WinForm le revers de la médaille est de taille.
En effet, ce Timer ne convient pas pour les routines nécessitant beaucoup de ressource car le timer fonctionnant sur le thread principal, il est bloqué durant l'exécution de la méthode callback du timer.
L'exécution de la méthode callback de ce timer doit donc être aussi brève que possible.

    ReadyQueue et WaitQueue
    L'article aborde également le paradigme ReadyQueue et WaitQueue basé sur le wait and pulse.
    Malheureusement, ce paradigme n'est pas des plus faciles a comprendre en quelques mots d'anglais.

    Source: Joseph Albahari
    Pour ce que j'ai compris:
    Ce paradigme permet de coder des ConsumerProducerQueue qui rempli une Waiting Queue (objets en attente d'ajout dans la queue de traitement, généralement poussé par un Thread A) et en alternance, permet de vider la Ready Queue (ces mêmes objets en attente d'extraction pour être traité par un Thread B).


    Ainsi, je recommande également la lecture de l'article "Thread synchronization: Wait and Pulse demystified" de Nicholas Nicholas Butler sur CodeProject.
    Il en démontre l'usage des ReadyQueue et WaitQueue à l'aide de sa classe BlockingQueue.
    Le logiciel crée une BlockingQueue d'integer qu'un thread A remplis pendant qu'un autre thread B vide la queue.

    Wait and Pulse
    C'est une méthode de synchronisation dite non-bloquante.
    En effet, dans les scénarios classique de threading, une synchronisation (par exemple pour accéder à une variable partagée) est dite "bloquante".
    Le thread bloqué (en attente de la synchro) est déschédulé dans l'attente de la libération de la ressource.
    Dans un environnement à fort accès concurrent, cela peut représenter un désavantage car le thread bloqué est retiré pendant un certain temps par le scheduler (alors que la synchronisation pourrait être obtenue dans un délai très court).
    Pour répondre a ce problème spécifique, le framework .Net à mis en place la synchronisation Wait and Pulse permettant à deux (ou plusieurs threads) de partager en même temps le même objet de synchronisation (lock) et de s'avertir mutuellement de la libération de la ressource (en évitant aux threads d'être dé-schedulé).

    L'implémentation d'un Wait And Pulse doit scrupuleusement suivre le pattern.
    En effet, comme les deux threads partagent l'intérieur du même lock ne pas suivre scrupuleusement le pattern causerait inévitablement des problèmes de synchronisations (et beaucoup de cheveux blancs).



      Aucun commentaire: