1 sur 1 ont trouvé cela utile - Évaluez ce sujet

Les nouveaux mécanismes de synchronisation sous Vista

Paru le 13 décembre 2006
Par Arnaud Debaene, MVP

Sur cette page

INTRODUCTION INTRODUCTION
DE NOUVEAUX MECANISMES DE SYNCHRONISATION, MAIS POURQUOI FAIRE AU JUSTE ? DE NOUVEAUX MECANISMES DE SYNCHRONISATION, MAIS POURQUOI FAIRE AU JUSTE ?
LES VERROUS EN LECTURE/ECRITURE (SLIM READER/WRITER LOCKS) LES VERROUS EN LECTURE/ECRITURE (SLIM READER/WRITER LOCKS)
LES VARIABLES CONDITIONNELLES (CONDITION VARIABLES / CONDVARS) LES VARIABLES CONDITIONNELLES (CONDITION VARIABLES / CONDVARS)

INTRODUCTION

Pour les gens qui lisent régulièrement MSDN, il pourrait sembler que les seules nouveautés de Windows Vista pour un développeur d’applications sont l’intégration en natif du framework .NET, ainsi que les trois nouveaux composants que sont Windows Presentation Foundation, Windows Communication Foundation et Windows Workflow Foundation.

Bien que ces éléments soient effectivement très importants et pleins de promesses, ils auraient tendance à nous faire oublier tout un tas d’autres nouveautés disponibles dans Vista. Il serait d’ailleurs illusoire de vouloir tout couvrir, donc je vais me concentrer sur les nouveaux mécanismes de synchronisation entre threads qui sont disponibles pour les développeurs natifs dans Vista, et plus particulièrement sur deux nouveaux outils :

  • Les verrous en lecture/écriture

  • Les variables conditionnelles

Mais avant cela, un petit point général sur le multithreading, et pourquoi il va devenir de plus en plus important dans l’avenir.

DE NOUVEAUX MECANISMES DE SYNCHRONISATION, MAIS POURQUOI FAIRE AU JUSTE ?

Beaucoup de gens ne s’en sont pas encore rendu compte, mais depuis environ 2 ans l’informatique subit une évolution technologique aussi importante que la programmation orientée objet ou bien les mécanismes de mémoire virtuelle en leur temps. De quoi s’agit-il exactement ?

Et bien, les fondeurs de micro-processeurs n’arrivent tout simplement plus à améliorer les performances de leurs puces en accélérant les horloges ou en rendant plus complexes leurs circuits internes. Ils se heurtent principalement à deux difficultés : la chaleur dégagée et la réduction de la taille des jonctions P-N sur le silicium, qui sont en train d’atteindre des limites physiques difficilement contournables.

La solution trouvée par les fabricants, est la suivante : plutôt que de compliquer encore d’avantage un cœur de processeur sur une puce, et bien mettons plusieurs cœurs plus simples – et donc plusieurs processeurs logiques - sur la même puce ! On a donc tout d’abord vu les processeurs HyperThread, puis les processeurs bi-cœur, et il ne va pas se passer longtemps avant que les processeurs quadri-cœur, octo-cœur et « N-cœur » fassent leur apparition sur le marché.

En quoi cela concerne-t-il un développeur d’applications me direz-vous ? Et bien tout simplement, pour tirer partie de la puissance de calcul grandissante de ces nouveaux processeurs, il n’y a pas le choix : il faut programmer en multithread. Le parallélisme, qui était jusqu’à présent réservé aux serveurs haut de gamme ou à quelques domaines d’applications très particuliers (calculs scientifiques, etc…), est en train de débarquer sur le PC de monsieur-tout-le-monde.

Cependant, la programmation multithread a la réputation d’être difficile et source d’erreurs, et il y a de bonnes raisons à cela. Les outils actuels dont nous disposons pour écrire du code parallèle laissent la place à des tas d’erreurs potentielles : les deadlocks, les race-conditions, les inversions de priorité, etc. Par ailleurs, le modèle actuel de thread n’est pas combinable, ce qui veut dire que si vous avez deux composants thread-safe et que vous les mettre ensemble dans un programme, il n’y a aucune garantie que le résultat soit thread-safe Dans ces conditions, il est pour le moins délicat d’écrire du code multi-thread de qualité industrielle.

La difficulté vient du fait que la question du parallélisme recoupe des tas d’aspects du développement logiciel. Citons entre autres :

  • Le modèle de visibilité mémoire. Quand, et sous quelle condition, une modification d’une variable dans un thread est-elle visible pour les autres threads ? Actuellement, cette question toute simple est le plus souvent sans réponse formelle (seul Java à un modèle mémoire, et .NET 2.0 définit un modèle, mais il n’a pas été publiquement publié pour l’instant). Résultat des courses : la réponse dépend (entre autres) du ou des processeurs utilisés ainsi que des détails généralement non documentés du compilateur. Bref, il nous manque une couche d’abstraction sur le sujet.

  • Les mécanismes de caches des processeurs, et comment maintenir les caches des différents processeurs en cohérence les uns avec les autres.

  • Trouver des abstractions au niveau librairie ou même langage de programmation qui soient plus expressives et moins dangereuses que le modèle actuel de threads et de verrous. Citons par exemple tout ce qui concerne la programmation lock-free ainsi que le projet Concur de Herb Sutter (le patron de Visual C++).

Il est donc probable que l’on voie de grandes évolutions dans ce domaine dans les années à venir.

Cependant, en attendant, nous devons faire les meilleurs programmes possibles avec les outils à notre disposition, et justement Vista nous propose quelques nouveaux outils pour nous aider à réaliser du code multithread fiable et correct.

LES VERROUS EN LECTURE/ECRITURE (SLIM READER/WRITER LOCKS)

Lorsque l’on veut que plusieurs threads accèdent simultanément à une même variable, il faut protéger les accès à cette variable en utilisant un verrou qui garantit qu’un seul thread à la fois fera ces accès, évitant ainsi les races-conditions de lecture et de mise à jour simultanées de la variable. Généralement, on emploie une section critique (CRITICAL_SECTION) comme verrou.

Il y a cependant moyen d’être plus efficace qu’avec une section critique. Si par exemple tous les accès à votre variable partagée sont des accès en lecture uniquement, il n’y a en fait pas besoin de sérialiser les accès : plusieurs threads peuvent bien lire en même temps la même adresse mémoire. Si personne ne modifie les données au même moment, il n’y pas de problème particulier. Dans ce cas de figure, si on utilise une section critique comme verrou, les accès en lecture des différents threads sont inutilement exclusifs les uns par rapport aux autres : en fait, ce sont uniquement les accès en écriture qui requièrent un accès exclusif à la variable.

Les verrous en lecture/écriture (Slim Reader/Writer Lock, ou SRW) sont conçus exactement pour répondre à ce besoin. Ils fonctionnent comme des sections critiques mais permettent de spécifier si on veut acquérir le verrou pour un accès en lecture ou bien en écriture :

  • Plusieurs threads peuvent simultanément acquérir le verrou en lecture.

  • Par contre, acquérir un accès en écriture est exclusif, ce qui signifie que, pour acquérir un SRW en écriture, aucun autre thread ne doit posséder le verrou, ni en lecture, ni en écriture.

Comportement de threads pour l’acquisition d’un SRW

Les SRW permettent donc de réduire le taux de contention pour l’accès à une variable.

L’API d’utilisation des verrous en lecture/écriture est assez simple :

Fonction

Utilisation

VOID WINAPI InitializeSRWLock(
  PSRWLOCK SRWLock
);

Initialise un verrou en lecture/écriture. Très similaire à InitializeCriticalSection

VOID WINAPI AcquireSRWLockShared(
  PSRWLOCK SRWLock
);

Acquiert un verrou en mode lecture (accès partagé).

VOID WINAPI AcquireSRWLockExclusive(
  PSRWLOCK SRWLock
);

Acquiert un verrou en mode écriture (mode exclusif).

VOID WINAPI ReleaseSRWLockShared(
  PSRWLOCK SRWLock
);

Relâche un verrou qui a été acquis par AcquireSRWLockShared

VOID WINAPI ReleaseSRWLockExclusive(
  PSRWLOCK SRWLock
);

Relâche un verrou qui a été acquis par AquireSRWLockExclusif

Les verrous en lecture/écriture sont extrêmement légers en termes d’occupation mémoire (chacun utilise uniquement un pointeur) et de performances (s’il n’y a pas de contention, les appels n’impliquent pas de passage en mode noyau). Ils sont généralement plus efficaces que les sections critiques, mais imposent en contrepartie quelques limitations :

  • Ils ne supportent pas la récursion.

  • Il n’y a pas d’équivalent à TryEnterCriticalSection : les appels à AcquireSRWLock* sont forcément bloquants si le verrou est déjà acquis par un d’autre thread.

  • Ils ne supportent pas de spin count comme les sections critiques.

  • Ils ne supportent pas les mécanismes d’aides au debogage et à la détection de deadlock qui ont été introduits pour les sections critiques dans Windows XP.

Pour les cas simples d’utilisation comme la protection d’une variable contre les accès concurrents, ils proposent cependant une alternative intéressante aux sections critiques.

Enfin certains algorithmes avancés utilisant des verrous en lecture/écriture réclament la capacité de dégrader de manière atomique (c'est-à-dire sans relâcher le verrou) un verrou en écriture en verrou en lecture, ou au contraire de transformer de manière atomique un verrou en lecture en verrou en écriture. L’implémentation de Vista ne propose pas ces opérations.

LES VARIABLES CONDITIONNELLES (CONDITION VARIABLES / CONDVARS)

Les variables conditionnelles (condvars pour les intimes…) sont une primitive de synchronisation qui nous vient du monde Unix. Elles sont en effet définies dans la norme POSIX pthreads. Les unixiens vous diront d’ailleurs – non sans raison – que c’est un mécanisme bien plus fiable que les Events tels que nous les connaissons sous Win32 (avec notamment PulseEvent, fonction dangereuse car généralement mal utilisée - tant et si bien qu’elle est marquée comme deprecated dans MSDN). Et bien réjouissons-nous, les condvars sont enfin disponibles sous Windows !

De quoi s’agit-il exactement ? Une condvar est un objet sur lequel un thread peut se bloquer en attente. Un autre thread peut ensuite débloquer un ou tous les threads qui sont en attente sur cet objet. Les condvars sont donc idéales pour la communication inter-threads – par exemple quand un thread veut notifier un autre qu’un résultat de calcul est disponible.

Les condvars sont implémentées sous Vista en mode User tout comme les sections critiques. Cela signifie qu’elles ne peuvent pas être partagées entre processus, mais en échange il aura une coûteuse transition vers le mode noyau qui si le thread est effectivement bloqué lors d’un appel.

Quelques lignes de code valant mieux qu’un long discours, l’exemple suivant va nous montrer une utilisation typique d’une condvar. Cet exemple implémente une hypothétique file d’attente producteur/consommateur où un ou plusieurs threads producteurs produisent des items qui sont mis dans une file d’attente de taille finie. Ces items sont parallèlement consommés par un ou plusieurs threads consommateurs, le tout de manière thread-safe. Afin de simplifier l’exemple, seules les fonctions des threads producteurs et consommateurs sont codées, et on suppose que tous les objets (sections critiques, condvars, file d’attente, etc…) ont été initialisées. Par ailleurs, comme d’habitude dans les exemples, il s’agit d’un code minimaliste qui ne teste pas les retours de fonctions, etc. Ne faites pas cela dans du code de production !

/*
NOTE : Ce code est incomplet et ne compile pas tel quel. Il n’est là qu’à titre d’exemple 
pour démontrer l’utilisation des variables conditionnelles
*/

CRITICAL_SECTION verrou_file;  
//verrou pour l'accès exclusif à la file d'attente
CONDITION_VARIABLE file_pas_vide;  
//condvar signalée lorsque la file d'attente contient au moins un élément
CONDITION_VARIABLE file_pas_pleine; 
//condvar signalée lorsque la file d'attente n'est pas pleine

//fonctions de manipulation de la liste. Ces fonctions ne sont pas elle-mêmes therad-safe !
extern bool File_Pleine() ;   // retourne vrai si la file est pleine
extern bool File_Vide() ;  // retourne vrai si la file est vide
extern void Inserer_Item_Dans_Liste(Item i) ;
extern Item Extraire_Item_De_Liste();

DWORD WINAPI Thread_Producteur(PVOID param)
{
   while (true)
   {
      Item i=ProduireItem();  //produire un nouvel item
      EnterCriticalSection(&verrou_file); //acquérir le verrou d'accès à la file
            
      while (File_Pleine())
      {
         //la file d'attente est pleine : on doit attendre qu'un consommateur l'est vidée
         SleepConditionVariableCS(&file_pas_pleine, &verrou_file, INFINITE);
      }

      /*insérer le nouvel item dans la liste. On peut le faire parce qu'on a la garantie 
        que l'on a bien verrouillé verrou_file à ce moment, et que la liste n'est pas pleine*/
      Inserer_Item_Dans_Liste(i);

      //rendre la main pour que quelqu'un d'autre puisse accéder à la file d'attente
      LeaveCriticalSection(&verrou_file);

      //signaler à un éventuel consommateur en attente qu'il y a un item disponible dans la file
      WakeConditionVariable(&file_pas_vide);
   }
}

DWORD WINAPI Thread_Consommateur(PVOID param)
{
   while (true)
   {
      EnterCriticalSection(&verrou_file); //acquérir le verrou d'accès à la file

      while (File_Vide())
      {
         //la file est vide : attendre qu'un producteur produise un item
         SleepConditionVariableCS(&file_pas_vide, &verrou_file, INFINITE);
      }

      //extraire l'item de la file d'attente
      Item i=Extraire_Item_De_Liste();

      LeaveCriticalSection(&verrou_file);

      /*comme on vient d'extraire un élément de la file, celle-ci n'est plus pleine.
        On le signale à un éventuel producteur qui serait en attente de pouvoir insérer
        un nouvel item dans la file*/
      WakeConditionVariable(&file_pas_pleine);

      //consommer l'item
      ConsommerItem(i);
   }
}

Il est possible de réaliser la même fonctionnalité de file bloquante avec des sémaphores ou d’autres objets de synchronisation tant que l’on a qu’un seul producteur et un seul consommateur. Par contre, réaliser un scénario multi-producteurs / multi-consommateurs sans condvars est particulièrement délicat.

Nous allons revenir en détail sur cet exemple et l’expliquer, mais tout d’abord, passons rapidement l’API des condvars en revue :

Fonction

Utilisation

VOID WINAPI InitializeConditionVariable(
  PCONDITION_VARIABLE ConditionVariable
);

Initialise une condvar. Il n’y a pas grand-chose à dire sur cette fonction : il est juste nécessaire d’initialiser une condvar avant de l’utiliser.

BOOL WINAPI SleepConditionVariableCS(
  PCONDITION_VARIABLE ConditionVariable,
  PCRITICAL_SECTION CriticalSection,
  DWORD dwMilliseconds
);

De manière atomique, libère la section critique passée en paramètre et bloque le thread appelant. Le thread sera réveillé par un appel à WakeConditionVariable ou WakeAllConditionVariable, ou bien lorsque le timeout expirera

Lors du retour de cette fonction, on a la garantie que la section critique aura été ré-acquise par le thread appelant.

BOOL WINAPI SleepConditionVariableSRW(
  PCONDITION_VARIABLE ConditionVariable,
  PSRWLOCK SRWLock,
  DWORD dwMilliseconds,
  ULONG Flags
);

Idem que SleepConditionVariableCS, mais l’objet passé en paramètre est un verrou en lecture/écriture plutôt qu’une section critique.
Le paramètre Flags permet de spécifier si le verrou est acquis en mode lecture (accès partagé) ou en mode écriture (accès exclusif).

VOID WINAPI WakeConditionVariable(
  PCONDITION_VARIABLE ConditionVariable
);

Réveille un des threads actuellement en attente sur la condvar.

VOID WINAPI WakeAllConditionVariable(
  PCONDITION_VARIABLE ConditionVariable
);

Réveille tous les threads actuellement en attente sur la condvar.

La particularité des condvars, c’est que lors d’un appel à une fonction SleepConditionVariable*, on doit passer un autre objet de synchronisation : soit une section critique, soit un verrou en lecture/écriture - bref un verrou qui garantit un accès exclusif à une ressource. Le thread appelant doit avoir acquis ce verrou externe lors de l’appel.

La fonction SleepConditionVariable* effectue alors plusieurs opérations de manière atomique :

  • Elle relâche le verrou externe.

  • Elle met le thread courant en sommeil.

Par la suite, lorsque le thread se réveille (soit parce que la condvar a été notifiée, soit à cause du timeout), le thread tente de ré-acquérir le verrou externe. On a la garantie que lors du retour de la fonction SleepConditionVariable*, ce verrou a été réacquis par le thread appelant.

Notes : Ce fonctionnement a deux effets de bord :

  • Tout d’abord, si on passe un timeout fini, il se peut que l’appel à Sleep prenne au total plus de temps que le timeout. En effet, si lorsque le timeout claque le verrou externe est acquis par quelqu’un d’autre, il faut attendre qu’il soit libéré avant de pouvoir retourner de la fonction SleepConditionVariable*.

  • Ensuite, si lors d’un appel à SleepConditionVariable* le verrou externe est acquis par un autre thread, le thread réveillé ne retournera de son appel que lorsque le verrou sera libéré. De même, lors d’un appel à WakeAllConditionVariable, il y a compétition entre tous les threads qui étaient en attente pour ré- acquérir le verrou externe. Un seul de ces threads va donc revenir immédiatement de son appel à SleepConditionVariable* : les autres doivent attendre leur tour.

Par ailleurs, il faut noter que, contrairement aux Events Win32, les condvars ne mémorisent pas leur état : si un thread appelle WakeConditionVariable (ou WakeAllConditionVariable) alors que aucun thread n’est en attente, il ne se passe rien et cet appel est « perdu » : le prochain thread qui appellera SleepConditionVariable* sera bloqué jusqu’à un autre appel à Wake Une condvar n’a donc pas d’état « signalé » : la méthode Wake agit de manière ponctuelle uniquement.

Différences de comportement entre un Event Win32 et une condvar.

Maintenant nous pouvons mieux comprendre l’exemple de la file producteur/consommateur :

  • Le producteur attend sur file_pas_pleine jusqu’à ce qu’il y ait de la place dans la file d’attente, puis il insère son item dans la file et enfin signale file_pas_vide, pour indiquer aux consommateurs qu’un item est disponible dans la file.

  • Le consommateur attend sur file_pas_vide jusqu’à ce qu’il y ait au moins un item dans la file d’attente. Il peut alors récupérer cet item et le traiter, sans oublier de signaler que la file n’est désormais plus pleine en signalant file_pas_pleine.

  • Tous les accès à la file d’attente sont protégés par la section critique verrou_file ; en effet, à chaque appel à une fonction de manipulation de la file, le thread a acquis verrou_file. Par contre, à chaque fois qu’un thread se met en sommeil par un appel à SleepConditionVariableCS, il relâche ce verrou, laissant ainsi la possibilité à d’autres threads de manipuler la file d’attente.

Les esprits affutés auront sans doute remarqué une curiosité dans cet exemple : pourquoi les appels à SleepConditionVariableCS sont-ils dans une boucle while ? Si nous reprenons par exemple le code du consommateur :

while (File_Vide())
 {
    //la file est vide : attendre qu'un producteur produise un item
    SleepConditionVariableCS(&file_pas_vide, &verrou_file, INFINITE);
 }

Au premier abord, il semblerait plus naturel d’écrire :

if (File_Vide())
{
   //la file est vide : attendre qu'un producteur produise un item
   SleepConditionVariableCS(&file_pas_vide, &verrou_file, INFINITE);
}

Pourquoi cette boucle while ? Cela nous force à appeler File_Vide() une fois de plus que nécessaire !
La réponse vient de la norme pthreads qui définit les variables conditionnelles : cette norme spécifie en effet que les condvars peuvent être sujettes à des « réveils intempestifs » (spurious wakeups dans la langue de Shakespeare). Autrement dit, il se peut que, rarement, un thread en attente retourne de son appel à SleepConditionVariable* alors que le timeout n’a pas claqué, et que ni WakeConditionVariable, ni WakeAllConditionVariable n’est été appelé.
Cette clause peut sembler stupide au premier abord. Elle a en fait été introduite afin de faciliter l’implémentation des condvars sur certaines architectures multi-processeurs asymétriques.
Par ailleurs, cette règle force le programmeur à retester son prédicat au moment d’agir sur sa liste, ce qui est une bonne chose dans un environnement multithread et asynchrone ou plusieurs opérations peuvent avoir lieu en même temps. Imaginez par exemple le cas où il y a plusieurs producteurs et plusieurs consommateurs et où tous les appels WakeConditionVariable sont remplacés par WakeAllConditionVariable (et pourquoi pas, un producteur qui met en file plusieurs items à la fois, et un consommateur qui extrait à chaque passage tous les items disponibles). Dans ce cas, la boucle while devient obligatoire.
C’est donc une bonne idée de toujours utiliser cette forme idiomatique avec les condvars : les appels à SleepConditionVariable* doivent être dans une boucle while qui teste notre prédicat.

L’utilisation des variables conditionnelles n’est pas évidente au premier abord, mais il s’agit d’outils de synchronisation extrêmement précieux, permettant de construire des scénarios complexes tout en raisonnant de manière fiable sur les flux de données et d’exécution entre les différents threads.
Dans de nombreux cas, elles remplacent de manière avantageuse les Events Win32 qui, même s’ils semblent plus simples d’utilisation, sont beaucoup plus délicats à mettre en œuvre de manière correcte !

Une dernière note : pour celles et ceux d’entre vous qui travaillent avec le framework .NET, les variables conditionnelles sont également disponibles grâce aux méthodes Wait, Pulse et PulseAll de la classe Monitor.

Cela vous a-t-il été utile ?
(1500 caractères restants)
© 2013 Microsoft. Tous droits réservés.