.NET: outils pour travailler avec le multithreading et l'asynchronie. 2e partie

Je publie l'article original sur Habr, dont la traduction est publiée sur le blog Codingsight .

Je continue de créer une version texte de mon exposé lors de la réunion multithreading. La première partie peut être trouvée ici ou ici , là, il s'agissait plus de l'ensemble d'outils de base pour démarrer un thread ou une tâche, des façons de voir leur statut et quelques petites choses comme PLinq. Dans cet article, je souhaite me concentrer davantage sur les problèmes pouvant survenir dans un environnement multithread et sur les moyens de les résoudre.

Table des matières





À propos des ressources partagées


Il est impossible d'écrire un programme qui fonctionnerait dans plusieurs threads, mais n'aurait pas une seule ressource partagée: même si cela fonctionne à votre niveau d'abstraction, puis en descendant d'un ou plusieurs niveaux en dessous, il s'avère qu'il existe toujours une ressource commune. Je vais donner quelques exemples:

Exemple # 1:

Craignant d'éventuels problèmes, vous avez fait fonctionner les threads avec différents fichiers. Par fichier à diffuser. Il vous semble que le programme ne possède pas une seule ressource commune.

Après avoir descendu plusieurs niveaux plus bas, nous comprenons qu'il n'y a qu'un seul disque dur et que le pilote ou le système d'exploitation devront résoudre les problèmes d'accès à celui-ci.

Exemple # 2:

Après avoir lu l' exemple # 1, vous avez décidé de placer les fichiers sur deux machines distantes différentes avec deux morceaux de fer et des systèmes d'exploitation physiquement différents. Nous gardons 2 connexions différentes via FTP ou NFS.

Après avoir descendu plusieurs niveaux plus bas, nous comprenons que rien n'a changé et que le pilote de la carte réseau ou le système d'exploitation de la machine sur laquelle le programme s'exécute devra résoudre le problème de l'accès concurrentiel.

Exemple # 3:

Ayant perdu une partie considérable de vos cheveux pour tenter de prouver la possibilité d'écrire un programme multi-thread, vous refusez complètement les fichiers et décomposez les calculs en deux objets différents, les liens vers chacun d'eux ne sont disponibles que pour un seul flux.

Je cloue les douze derniers clous dans le cercueil de cette idée: un runtime et un garbage collector, un planificateur de threads, physiquement une RAM et de la mémoire, un processeur sont toujours des ressources partagées.

Nous avons donc découvert qu'il est impossible d'écrire un programme multithread sans une seule ressource partagée à tous les niveaux d'abstraction sur toute la largeur de la pile technologique. Heureusement, chacun des niveaux d'abstraction, en règle générale, résout partiellement ou complètement les problèmes d'accès concurrentiel ou l'interdit simplement (exemple: tout cadre d'interface utilisateur interdit de travailler avec des éléments de différents threads), par conséquent, les problèmes surviennent le plus souvent avec des ressources partagées sur votre niveau d'abstraction. Pour les résoudre, introduisez le concept de synchronisation.

Problèmes possibles lorsque vous travaillez dans un environnement multi-thread


Les erreurs dans le logiciel peuvent être divisées en plusieurs groupes:

  1. Le programme ne produit aucun résultat. Se bloque ou se fige.
  2. Le programme renvoie un résultat incorrect.
  3. Le programme produit le résultat correct, mais ne satisfait pas à l'une ou l'autre des exigences non fonctionnelles. Fonctionne trop longtemps ou consomme trop de ressources.

Dans un environnement multithread, les deux principaux problèmes à l'origine des erreurs 1 et 2 sont le blocage et la condition de concurrence critique .

Impasse


Deadlock - deadlock. Il existe de nombreuses variantes différentes. Les plus courants sont les suivants:



Pendant que le thread # 1 faisait quelque chose, le thread # 2 a bloqué la ressource B , un peu plus tard le thread # 1 a bloqué la ressource A et essaie de verrouiller la ressource B , malheureusement cela ne se produira jamais, car Le thread # 2 ne libérera la ressource B qu'après avoir verrouillé la ressource A.

Condition de course


Race-Condition - condition de course. La situation dans laquelle le comportement et le résultat des calculs effectués par le programme dépendent du travail du planificateur de threads d'exécution.
Le désagrément de cette situation réside précisément dans le fait que votre programme peut ne pas fonctionner qu'une seule fois sur cent voire sur un million.

La situation est aggravée par le fait que les problèmes peuvent aller de pair, par exemple: avec un certain comportement du planificateur de threads, un blocage se produit.

En plus de ces deux problèmes conduisant à des erreurs évidentes dans le programme, il y a aussi ceux qui peuvent ne pas conduire à un résultat de calcul incorrect, mais plus de temps ou de puissance de traitement sera dépensé pour l'obtenir. Deux de ces problèmes sont: attente occupée et famine de thread .

Attente occupée


Busy-Wait est un problème dans lequel le programme consomme des ressources processeur non pas pour les calculs, mais pour l'attente.

Souvent, un tel problème dans le code ressemble à ceci:

while(!hasSomethingHappened) ; 

Ceci est un exemple de code extrêmement mauvais car Un tel code occupe complètement un cœur de votre processeur sans rien faire d'utile. Cela peut être justifié si et seulement s'il est extrêmement important de traiter une modification d'une valeur dans un autre thread. Et en parlant rapidement, je parle du cas où vous ne pouvez pas attendre même quelques nanosecondes. Dans d'autres cas, c'est-à-dire dans tout ce qui peut produire un cerveau sain, il est plus raisonnable d'utiliser les variétés ResetEvent et leurs versions Slim. À leur sujet ci-dessous.

Peut-être que l'un des lecteurs proposera de résoudre le problème du chargement complet d'un cœur avec une attente inutile en ajoutant des constructions comme Thread.Sleep (1) à la boucle. Cela résoudra vraiment le problème, mais en créera un autre: le temps de réponse au changement sera en moyenne d'une demi-milliseconde, ce qui peut ne pas être beaucoup, mais catastrophiquement plus que vous ne pourriez utiliser les primitives de synchronisation de la famille ResetEvent.

Fil de famine


Thread-Starvation est un problème où le programme a trop de threads fonctionnant simultanément. Qu'est-ce que cela signifie exactement ces flux qui sont occupés par des calculs, et pas seulement en attente d'une réponse de la part de n'importe quel E / S. Avec ce problème, tout le gain de performances possible de l'utilisation de threads est perdu, car Le processeur passe beaucoup de temps à changer de contexte.
Il est pratique de rechercher de tels problèmes à l'aide de divers profileurs, voici un exemple de capture d'écran du profileur dotTrace lancé en mode Timeline.


(L'image est cliquable)

Dans le programme qui ne souffre pas de la faim en streaming, il n'y aura pas de couleur rose sur les graphiques reflétant les flux. De plus, dans la catégorie Sous-systèmes, il est clair que 30,6% du programme attendait le CPU.

Lorsqu'un tel problème est diagnostiqué, il est résolu tout simplement: vous avez démarré trop de threads à la fois, démarrez moins ou pas tous à la fois.

Outils de synchronisation



Interlocked


C'est peut-être le moyen le plus léger de synchroniser. Interlocked est une collection d'opérations atomiques simples. Une opération atomique est appelée une opération au moment où rien ne peut se produire. Dans .NET, Interlocked est représenté par la classe statique du même nom avec un certain nombre de méthodes, chacune implémentant une opération atomique.

Pour réaliser l'horreur des opérations non atomiques, essayez d'écrire un programme qui démarre 10 threads, chacun faisant un million d'incréments de la même variable, et à la fin de leur travail imprimez la valeur de cette variable - malheureusement, elle sera très différente de 10 millions, de plus Chaque fois que le programme démarre, ce sera différent. Cela se produit parce que même une opération aussi simple qu'un incrément n'est pas atomique, mais implique d'extraire une valeur de la mémoire, d'en calculer une nouvelle et de l'écrire. Ainsi, deux threads peuvent effectuer simultanément chacune de ces opérations, auquel cas l'incrément sera perdu.

La classe Interlocked fournit des méthodes Increment / Decrement; il est facile de deviner ce qu'elles font. Ils sont pratiques à utiliser si vous traitez des données dans plusieurs threads et envisagez quelque chose. Un tel code fonctionnera beaucoup plus rapidement que le verrou classique. Si Interlocked est utilisé pour la situation décrite dans le dernier paragraphe, le programme distribuera de manière stable 10 millions dans n'importe quelle situation.

La méthode CompareExchange remplit, à première vue, une fonction assez peu évidente, mais toute sa présence vous permet de mettre en œuvre de nombreux algorithmes intéressants, en particulier la famille sans verrouillage.

 public static int CompareExchange (ref int location1, int value, int comparand); 

La méthode prend trois valeurs: la première est transmise par référence et il s'agit de la valeur qui sera remplacée par la seconde, si au moment de la comparaison, location1 correspond à comparand, la valeur d'origine de location1 sera retournée. Cela semble assez déroutant, car il est plus facile d'écrire du code qui effectue les mêmes opérations que CompareExchange:

 var original = location1; if (location1 == comparand) location1 = value; return original; 

Seule une implémentation dans la classe Interlocked sera atomique. Autrement dit, si nous écrivions ce code nous-mêmes, une situation aurait pu se produire lorsque la condition location1 == comparand avait déjà été remplie, mais au moment où l'expression location1 = value a été exécutée, un autre thread avait changé la valeur de location1 et il serait perdu.

Nous pouvons trouver un bon exemple d'utilisation de cette méthode dans le code que le compilateur génère pour tout événement C #.

Écrivons une classe simple avec un événement MyEvent:

 class MyClass { public event EventHandler MyEvent; } 

Générons le projet dans la configuration Release et ouvrons l'assembly à l'aide de dotPeek avec l'option Show Compiler Generated Code activée:

 [CompilerGenerated] private EventHandler MyEvent; public event EventHandler MyEvent { [CompilerGenerated] add { EventHandler eventHandler = this.MyEvent; EventHandler comparand; do { comparand = eventHandler; eventHandler = Interlocked.CompareExchange<EventHandler>(ref this.MyEvent, (EventHandler) Delegate.Combine((Delegate) comparand, (Delegate) value), comparand); } while (eventHandler != comparand); } [CompilerGenerated] remove { // The same algorithm but with Delegate.Remove } } 

Ici, vous pouvez voir que dans les coulisses, le compilateur a généré un algorithme plutôt sophistiqué. Cet algorithme protège contre la situation de perte d'un abonnement à un événement lorsque plusieurs threads s'abonnent à cet événement simultanément. Écrivons la méthode add plus en détail, en nous rappelant ce que fait la méthode CompareExchange en arrière-plan

 EventHandler eventHandler = this.MyEvent; EventHandler comparand; do { comparand = eventHandler; // Begin Atomic Operation if (MyEvent == comparand) { eventHandler = MyEvent; MyEvent = Delegate.Combine(MyEvent, value); } // End Atomic Operation } while (eventHandler != comparand); 

C'est déjà un peu plus clair, bien qu'il ait probablement encore besoin d'une explication. En mots, je décrirais cet algorithme comme suit:

Si MyEvent est toujours le même qu'au moment où nous avons commencé à exécuter Delegate.Combine, puis écrivez-y ce que Delegate.Combine renvoie, et sinon, cela n'a pas d'importance, essayons à nouveau et répétons jusqu'à ce qu'il sorte.


Aucun abonnement à l'événement ne sera donc perdu. Vous devrez résoudre un problème similaire si vous souhaitez soudainement implémenter un tableau dynamique sans verrou thread-safe. Si plusieurs flux se précipitent pour y ajouter des éléments, il est important qu'ils soient tous ajoutés à la fin.

Monitor.Enter, Monitor.Exit, lock


Ce sont les conceptions les plus couramment utilisées pour la synchronisation des threads. Ils implémentent l'idée d'une section critique: c'est-à-dire que le code écrit entre les appels à Monitor.Enter, Monitor.Exit sur une ressource peut être exécuté à la fois dans un seul thread. L'instruction de verrouillage est du sucre syntaxique autour des appels Entrée / Sortie enveloppés dans try-finally. Une fonctionnalité intéressante de l'implémentation d'une section critique dans .NET est la possibilité de la ressaisir pour le même flux. Cela signifie qu'un tel code s'exécutera sans problème:

 lock(a) { lock (a) { ... } } 

Il est peu probable, bien sûr, que quelqu'un écrive de cette façon, mais si vous étalez ce code dans plusieurs méthodes en profondeur, cette fonction peut vous faire économiser des ifs. Afin de rendre une telle astuce possible, les développeurs .NET ont dû ajouter une restriction - seule une instance d'un type de référence peut être utilisée comme objet de synchronisation, et plusieurs octets sont implicitement ajoutés à chaque objet où l'identifiant de flux sera écrit.

Cette fonctionnalité de la section critique en c # impose une limitation intéressante au fonctionnement de l'instruction lock: vous ne pouvez pas utiliser l'instruction d'attente à l'intérieur de l'instruction lock. Au début, cela m'a surpris, car une construction similaire Monitor -Enter / Exit similaire se compile. Quelle est la question? Ici, il est nécessaire de relire attentivement le dernier paragraphe à nouveau, puis d'y ajouter quelques connaissances sur le principe asynchrone / attendent: le code après attendre ne sera pas nécessairement exécuté sur le même thread que le code avant attendre, cela dépend du contexte de synchronisation et de la présence ou aucun appel à ConfigureAwait. Il s'ensuit que Monitor.Exit peut s'exécuter sur un thread autre que Monitor.Enter, qui lèvera une SynchronizationLockException . Si vous ne le croyez pas, vous pouvez exécuter le code suivant dans une application console: il lèvera une SynchronizationLockException.

 var syncObject = new Object(); Monitor.Enter(syncObject); Console.WriteLine(Thread.CurrentThread.ManagedThreadId); await Task.Delay(1000); Monitor.Exit(syncObject); Console.WriteLine(Thread.CurrentThread.ManagedThreadId); 

Il est à noter que dans WinForms ou une application WPF, ce code fonctionnera correctement s'il est appelé à partir du thread principal. il y aura un contexte de synchronisation qui implémente un retour au thread d'interface utilisateur après l'attente. Dans tous les cas, vous ne devez pas jouer avec la section critique dans le contexte du code contenant l'opérateur d'attente. Dans ces cas, il est préférable d'utiliser des primitives de synchronisation, qui seront discutées plus loin.

En parlant du travail de la section critique dans .NET, il convient de mentionner une autre caractéristique de sa mise en œuvre. La section critique de .NET fonctionne en deux modes: le mode d'attente d'attente et le mode noyau. L'algorithme spin-wait est commodément représenté comme le pseudo-code suivant:

 while(!TryEnter(syncObject)) ; 

Cette optimisation vise la capture la plus rapide de la section critique en peu de temps, en partant du principe que si la ressource est occupée maintenant, elle est sur le point de se libérer. Si cela ne se produit pas dans un court laps de temps, alors le thread va attendre en mode noyau, ce qui, comme pour en revenir, prend du temps. Les développeurs .NET ont optimisé le scénario de verrouillage court autant que possible, malheureusement, si de nombreux threads commencent à déchirer la section critique, cela peut entraîner une charge CPU élevée et soudaine.

SpinLock, SpinWait


Puisque j'ai mentionné l'algorithme de rotation d'attente, il convient de mentionner les structures BCL SpinLock et SpinWait. Ils doivent être utilisés s'il y a lieu de croire qu'il sera toujours possible de verrouiller très rapidement. D'un autre côté, cela ne vaut guère la peine de s'en souvenir avant que les résultats du profilage montrent que c'est l'utilisation d'autres primitives de synchronisation qui est le goulot d'étranglement de votre programme.

Monitor.Wait, Monitor.Pulse [Tout]


Cette paire de méthodes doit être considérée ensemble. Avec leur aide, différents scénarios Producteur-Consommateur peuvent être mis en œuvre.

Producteur-consommateur - un modèle de conception multi-processus / multi-threads supposant la présence d'un ou plusieurs threads / processus qui produisent des données et un ou plusieurs processus / threads qui traitent ces données. Utilise généralement une collection partagée.

Ces deux méthodes ne peuvent être appelées que si le thread qui les provoque a un verrou pour le moment. La méthode Wait libère le verrou et se bloque jusqu'à ce qu'un autre thread appelle Pulse.

Pour démontrer le travail, j'ai écrit un petit exemple:

 object syncObject = new object(); Thread t1 = new Thread(T1); t1.Start(); Thread.Sleep(100); Thread t2 = new Thread(T2); t2.Start(); 

(J'ai utilisé l'image, pas le texte, pour montrer visuellement l'ordre d'exécution des instructions)

Analyser: définissez un délai de 100 ms au début du deuxième flux, en particulier pour garantir que son exécution démarre plus tard.
- T1: le flux de la ligne n ° 2 démarre
- T1: le flux de la ligne # 3 entre dans la section critique
- T1: Ligne # 6 le ruisseau s'endort
- T2: le flux de la ligne 3 démarre
- T2: la ligne # 4 se fige en attendant une section critique
- T1: la ligne # 7 libère la section critique et se fige en attendant la sortie de Pulse
- T2: la ligne # 8 entre dans la section critique
- T2: la ligne # 11 informe T1 en utilisant la méthode Pulse
- T2: la ligne # 14 quitte la section critique. Jusque-là, T1 ne peut pas continuer l'exécution.
- T1: la ligne # 15 se réveille
- T1: la ligne # 16 quitte la section critique

MSDN a une remarque importante concernant l'utilisation des méthodes Pulse / Wait, à savoir: Monitor ne stocke pas les informations d'état, ce qui signifie que si la méthode Pulse est appelée avant l'appel de la méthode Wait, cela peut entraîner un blocage. Si cette situation est possible, il est préférable d'utiliser l'une des classes de la famille ResetEvent.

L'exemple précédent montre clairement comment fonctionnent les méthodes Wait / Pulse de la classe Monitor, mais laisse toujours des questions sur le moment où il doit être utilisé. Un bon exemple serait une telle implémentation de BlockingQueue <T>, d'autre part, l'implémentation de BlockingCollection <T> de System.Collections.Concurrent utilise SemaphoreSlim pour la synchronisation.

ReaderWriterLockSlim


Il s'agit de ma primitive de synchronisation bien-aimée, représentée par la classe d'espace de noms System.Threading du même nom. Il me semble que de nombreux programmes fonctionneraient mieux si leurs développeurs utilisaient cette classe au lieu du verrou habituel.

Idée: plusieurs threads peuvent lire, une seule écriture. Dès que le flux déclare son désir d'écrire, de nouvelles lectures ne peuvent pas être démarrées, mais attendent la fin de l'enregistrement. Il y a aussi le concept de verrou de lecture évolutif, qui peut être utilisé si vous comprenez pendant le processus de lecture que vous avez besoin d'écrire quelque chose, un tel verrou sera converti en verrou d'écriture en une seule opération atomique.

Il existe également une classe ReadWriteLock dans l'espace de noms System.Threading, mais elle est fortement recommandée pour les nouveaux développements. La version mince permettra d'éviter un certain nombre de cas conduisant à des blocages, en plus elle vous permet de capturer rapidement le verrou, car prend en charge la synchronisation en mode d'attente d'attente avant de quitter pour le mode noyau.

Si au moment de la lecture de cet article, vous ne connaissiez pas encore cette classe, je pense que vous avez maintenant rappelé quelques exemples du code écrit récemment, où une telle approche des verrous permettrait au programme de fonctionner efficacement.

L'interface de la classe ReaderWriterLockSlim est simple et directe, mais son utilisation peut difficilement être qualifiée de pratique:

 var @lock = new ReaderWriterLockSlim(); @lock.EnterReadLock(); try { // ... } finally { @lock.ExitReadLock(); } 

J'aime envelopper son utilisation dans une classe, ce qui rend son utilisation beaucoup plus pratique.
Idée: pour créer des méthodes Read / WriteLock qui retournent un objet avec la méthode Dispose, alors cela leur permettra d'être utilisé dans l'utilisation et par le nombre de lignes, il ne différera guère du verrou habituel.

 class RWLock : IDisposable { public struct WriteLockToken : IDisposable { private readonly ReaderWriterLockSlim @lock; public WriteLockToken(ReaderWriterLockSlim @lock) { this.@lock = @lock; @lock.EnterWriteLock(); } public void Dispose() => @lock.ExitWriteLock(); } public struct ReadLockToken : IDisposable { private readonly ReaderWriterLockSlim @lock; public ReadLockToken(ReaderWriterLockSlim @lock) { this.@lock = @lock; @lock.EnterReadLock(); } public void Dispose() => @lock.ExitReadLock(); } private readonly ReaderWriterLockSlim @lock = new ReaderWriterLockSlim(); public ReadLockToken ReadLock() => new ReadLockToken(@lock); public WriteLockToken WriteLock() => new WriteLockToken(@lock); public void Dispose() => @lock.Dispose(); } 

Une telle astuce vous permet d'écrire simplement plus loin:

 var rwLock = new RWLock(); // ... using(rwLock.ReadLock()) { // ... } 


Famille ResetEvent


J'inclus les classes ManualResetEvent, ManualResetEventSlim, AutoResetEvent dans cette famille.
Les classes ManualResetEvent, sa version Slim et la classe AutoResetEvent peuvent être dans deux états:
- Un armé (non signalé), dans cet état, tous les threads qui ont appelé WaitOne se figent jusqu'à ce que l'événement passe à un état signalé.
- L'état abaissé (signalé), dans cet état, tous les flux suspendus sur l'appel WaitOne sont libérés. Tous les nouveaux appels WaitOne sur un événement en panne passent conditionnellement instantanément.

La classe AutoResetEvent diffère de la classe ManualResetEvent en ce qu'elle entre automatiquement dans un état armé après avoir libéré exactement un thread. Si plusieurs threads se bloquent en attendant AutoResetEvent, l'appel Set ne libérera qu'un arbitraire, contrairement à ManualResetEvent. ManualResetEvent libérera tous les threads.

Regardons un exemple du fonctionnement d'AutoResetEvent:
 AutoResetEvent evt = new AutoResetEvent(false); Thread t1 = new Thread(T1); t1.Start(); Thread.Sleep(100); Thread t2 = new Thread(T2); t2.Start(); 


L'exemple montre que l'événement entre automatiquement dans un état armé (non signalé) uniquement en lâchant le fil suspendu à l'appel WaitOne.

Contrairement à ReaderWriterLock, la classe ManualResetEvent n'est pas marquée comme obsolète et n'est pas recommandée pour une utilisation après l'apparition de sa version Slim. La version mince de cette classe est utilisée efficacement pour de courtes attentes, comme Cela se passe en mode Spin-Wait, la version régulière convient aux longues.

Outre les classes ManualResetEvent et AutoResetEvent, la classe CountdownEvent existe également. Cette classe est pratique pour l'implémentation d'algorithmes, où la partie qui a réussi à être parallélisée est suivie par la partie de rassemblement des résultats. Cette approche est connue sous le nom de fork-join . Un excellent article est consacré au travail de cette classe, je ne vais donc pas l'analyser en détail ici.

Conclusions


  • Lorsque vous travaillez avec des threads, deux problèmes entraînant des résultats incorrects ou manquants sont la condition de concurrence critique et le blocage.
  • Les problèmes qui font que le programme passe plus de temps ou de ressources - famine de threads et attente occupée
  • .NET est riche en synchronisation de threads
  • Il existe 2 modes d'attente de verrouillage - Spin Wait, Core Wait. Certaines primitives de synchronisation des threads .NET utilisent les deux
  • Interlocked est un ensemble d'opérations atomiques, utilisé dans les algorithmes sans verrouillage, est la primitive de synchronisation la plus rapide
  • L'opérateur de verrouillage et Monitor.Enter / Exit implémentent l'idée d'une section critique - un morceau de code qui ne peut être exécuté que par un thread à la fois
  • Les méthodes Monitor.Pulse / Wait sont pratiques pour l'implémentation de scripts Producer-Consumer
  • ReaderWriterLockSlim peut être plus efficace qu'un verrouillage normal dans des scripts où la lecture parallèle est acceptable
  • La famille de classes ResetEvent peut être utile pour la synchronisation des threads.

Source: https://habr.com/ru/post/fr459514/


All Articles