Multithreading .NET: lorsque les performances font défaut



La plate-forme .NET fournit de nombreuses primitives de synchronisation prĂ©dĂ©finies et des collections thread-safe. Si vous devez implĂ©menter, par exemple, un cache thread-safe ou une file d'attente de requĂȘtes lors du dĂ©veloppement d'une application, ces solutions prĂȘtes Ă  l'emploi sont gĂ©nĂ©ralement utilisĂ©es, parfois plusieurs Ă  la fois. Dans certains cas, cela entraĂźne des problĂšmes de performances: une longue attente sur les verrous, une consommation de mĂ©moire excessive et une longue rĂ©cupĂ©ration de place.

Ces problĂšmes peuvent ĂȘtre rĂ©solus si nous tenons compte du fait que les solutions standard sont rendues assez gĂ©nĂ©rales - elles peuvent avoir une surcharge dans nos scĂ©narios qui est redondante. Par consĂ©quent, vous pouvez Ă©crire, par exemple, votre propre collection thread-safe efficace pour un cas spĂ©cifique.

Sous la cinĂ©matique se trouve une vidĂ©o et une transcription de mon rapport de la confĂ©rence DotNext , oĂč j'analyse plusieurs exemples oĂč l'utilisation d'outils de la bibliothĂšque standard .NET (Task.Delay, SemaphoreSlim, ConcurrentDictionary) a conduit Ă  des baisses de performances, et je propose des solutions adaptĂ©es Ă  des tĂąches spĂ©cifiques et dĂ©pourvues de ces lacunes.


Au moment du rapport, il travaillait à Kontur. Kontur développe diverses applications pour les entreprises, et l'équipe dans laquelle j'ai travaillé s'occupe des infrastructures et développe divers services de support et bibliothÚques qui aident les développeurs d'autres équipes à créer des services de produits.

L'Ă©quipe Infrastructure construit son entrepĂŽt de donnĂ©es, un systĂšme d'hĂ©bergement d'applications pour Windows et diverses bibliothĂšques pour le dĂ©veloppement de microservices. Nos applications sont basĂ©es sur une architecture de microservice - tous les services interagissent les uns avec les autres sur le rĂ©seau, et, bien sĂ»r, ils utilisent beaucoup de code asynchrone et multithread. Certaines de ces applications sont trĂšs critiques en termes de performances; elles doivent ĂȘtre capables de gĂ©rer un grand nombre de demandes.

De quoi allons-nous parler aujourd'hui?

  • Multithreading et asynchronie dans .NET;
  • Remplissage des primitives et des collections de synchronisation;
  • Que faire si les approches standard ne peuvent pas supporter la charge?

Analysons quelques fonctionnalitĂ©s du travail avec du code multithread et asynchrone dans .NET. Examinons quelques primitives de synchronisation et collections simultanĂ©es, voyons comment elles sont organisĂ©es Ă  l'intĂ©rieur. Nous verrons ce qu'il faut faire s'il n'y a pas suffisamment de performances, si les classes standard ne peuvent pas faire face Ă  la charge, et si quelque chose peut ĂȘtre fait dans cette situation.

Je vais vous raconter quatre histoires qui se sont produites sur notre site de production.

Historique 1: Task.Delay & TimerQueue


Cette histoire est déjà assez bien connue, y compris à ce sujet lors du précédent DotNext. Cependant, il a eu une suite plutÎt intéressante, alors je l'ai ajouté. Alors à quoi ça sert?

1.1 Interrogation et interrogation longue


Le serveur effectue de longues opérations, le client les attend.
Interrogation: le client interroge périodiquement le serveur sur le résultat.
Interrogation longue: le client envoie une demande avec un long délai d'expiration et le serveur répond lorsque l'opération est terminée.

Avantages:

  • Moins de trafic
  • Le client apprend le rĂ©sultat plus rapidement

Imaginez que nous ayons un serveur capable de gérer de longues demandes, par exemple une application qui convertit des fichiers XML en PDF, et qu'il existe des clients qui exécutent ces tùches pour le traitement et souhaitent attendre leur résultat de maniÚre asynchrone. Comment réaliser une telle attente?

La premiÚre façon est le sondage . Le client démarre la tùche sur le serveur, puis vérifie périodiquement l'état de cette tùche, tandis que le serveur renvoie l'état de la tùche ("terminée" / "échoué" / "terminée avec une erreur"). Le client envoie périodiquement des demandes jusqu'à ce que le résultat apparaisse.

La deuxiÚme façon est le long scrutin . La différence ici est que le client envoie des demandes avec de longs délais d'attente. Le serveur, recevant une telle demande, ne signalera pas immédiatement que la tùche n'est pas terminée, mais essaiera d'attendre un certain temps pour que le résultat apparaisse.
Alors, quel est l'avantage d'un sondage long par rapport au vote régulier? PremiÚrement, moins de trafic est généré. Nous faisons moins de demandes sur le réseau - moins de trafic est poursuivi sur le réseau. De plus, le client pourra connaßtre le résultat plus rapidement qu'avec une interrogation réguliÚre, car il n'a pas besoin d'attendre l'intervalle entre plusieurs demandes d'interrogation. Ce que nous voulons obtenir est compréhensible. Comment allons-nous implémenter cela dans le code?
TĂąche: timeout
Nous voulons attendre la tùche avec un délai d'attente
attendre SendAsync ();
Par exemple, nous avons une tùche qui envoie une demande au serveur, et nous voulons attendre son résultat avec un délai d'attente, c'est-à-dire que nous retournerons le résultat de cette tùche ou enverrons une sorte d'erreur. Le code C # ressemblera à ceci:

var sendTask = SendAsync(); var delayTask = Task.Delay(timeout); var task = await Task.WhenAny(sendTask, delayTask); if (task == delayTask) return Timeout; 

Ce code lance notre tùche, dont nous voulons attendre le résultat, et Task.Delay. Ensuite, en utilisant Task.WhenAny, nous attendons notre tùche ou Task.Delay. S'il s'avÚre que Task.Delay est exécuté en premier, puis le temps est écoulé et nous avons un délai d'attente, nous devons renvoyer une erreur.

Ce code, bien sĂ»r, n'est pas parfait et peut ĂȘtre amĂ©liorĂ©. Par exemple, l'annulation de Task.Delay ne ferait pas de mal si SendAsync revenait plus tĂŽt, mais ce n'est pas trĂšs intĂ©ressant pour nous maintenant. L'essentiel est que si nous Ă©crivons un tel code et l'appliquons pour une longue interrogation avec de longs dĂ©lais d'attente, nous aurons des problĂšmes de performances.

1.2 ProblÚmes liés à l'interrogation longue


  • Gros dĂ©lais d'attente
  • De nombreuses requĂȘtes simultanĂ©es
  • => Utilisation Ă©levĂ©e du CPU

Dans ce cas, le problÚme est la consommation élevée de ressources processeur. Il peut arriver que le processeur soit entiÚrement chargé à 100% et que l'application cesse généralement de fonctionner. Il semblerait que nous ne consommions pas du tout les ressources du processeur: nous faisons des opérations asynchrones, attendons une réponse du serveur et le processeur est toujours chargé avec nous.

Face à cette situation, nous avons supprimé un vidage de mémoire de notre application:

  ~*e!clrstack System.Threading.Monitor.Enter(System.Object) System.Threading.TimerQueueTimer.Change(
) System.Threading.Timer.TimerSetup(
) System.Threading.Timer..ctor(
) System.Threading.Tasks.Task.Delay(
) 

Pour analyser le vidage, nous avons utilisé l'outil WinDbg. Nous avons entré une commande qui affiche les traces de pile de tous les threads gérés et avons vu un tel résultat. Nous avons beaucoup de threads en cours qui attendent un verrou. La méthode Monitor.Enter est ce dans quoi la construction de verrouillage en C # se développe. Ce verrou est capturé dans des classes appelées Timer et TimerQueueTimer. Dans Timer, nous venions de Task.Delay lorsque nous avons essayé de les créer. Qu'est ce que c'est? Lorsque Task.Delay démarre, le verrou à l'intérieur de TimerQueue est capturé.

1.3 Verrouiller le convoi


  • De nombreux threads essaient de verrouiller un verrou
  • Sous le verrou, peu de code est exĂ©cutĂ©
  • Le temps est consacrĂ© Ă  la synchronisation des threads, pas Ă  l'exĂ©cution de code.
  • Les blocs de threads sont bloquĂ©s - ils ne sont pas infinis

Nous avions un convoi d'Ă©cluses dans l'application. De nombreux threads tentent de capturer le mĂȘme verrou. Sous ce verrou, un peu de code est exĂ©cutĂ©. Les ressources du processeur ne sont pas dĂ©pensĂ©es ici sur le code d'application lui-mĂȘme, mais sur des opĂ©rations de synchronisation entre les threads sur ce verrou. Il convient Ă©galement de noter une fonctionnalitĂ© liĂ©e Ă  .NET: les threads qui participent au convoi de verrous sont des threads du pool de threads.

Par consĂ©quent, si les threads du pool de threads sont bloquĂ©s, ils peuvent se terminer - le nombre de threads dans le pool de threads est limitĂ©. Il peut ĂȘtre configurĂ©, mais il existe toujours une limite supĂ©rieure. Une fois qu'il est atteint, tous les threads de pool de threads participeront au verrouillage du convoi et tout code impliquant le pool de threads cessera d'ĂȘtre exĂ©cutĂ© dans l'application. Cela aggrave considĂ©rablement la situation.

1.4 TimerQueue


  • GĂšre les temporisateurs dans une application .NET.
  • Les minuteries sont utilisĂ©es dans:
    - Task.Delay
    - CancellationTocken.CancelAfter
    - HttpClient

TimerQueue est une classe qui gĂšre tous les temporisateurs dans une application .NET. Si vous avez dĂ©jĂ  programmĂ© dans WinForms, vous avez peut-ĂȘtre crĂ©Ă© des minuteurs manuellement. Pour ceux qui ne savent pas ce que sont les temporisateurs: ils sont utilisĂ©s dans Task.Delay (c'est juste notre cas), ils sont Ă©galement utilisĂ©s dans CancellationToken, dans la mĂ©thode CancelAfter. En d'autres termes, le remplacement de Task.Delay par CancellationToken.CancelAfter ne nous aiderait en aucune façon. De plus, les temporisateurs sont utilisĂ©s dans de nombreuses classes internes .NET, par exemple, dans HttpClient.

Pour autant que je sache, certaines implĂ©mentations des gestionnaires HttpClient ont des temporisateurs. MĂȘme si vous ne les utilisez pas explicitement, ne dĂ©marrez pas Task.Delay, trĂšs probablement, vous les utilisez quand mĂȘme.

Voyons maintenant comment TimerQueue est organisé à l'intérieur.

  • État global (par domaine d'application):
    - Double liste chaßnée de TimerQueueTimer
    - Verrouiller l'objet
  • Rappels de minuterie de routine
  • Minuteurs non classĂ©s par temps de rĂ©ponse
  • Ajout d'une minuterie: O (1) + verrouillage
  • Retrait de la minuterie: O (1) + verrouillage
  • Minuteurs de dĂ©marrage: O (N) + verrouillage

A l'intĂ©rieur de TimerQueue il y a un Ă©tat global, c'est une liste doublement liĂ©e d'objets de type TimerQueueTimer. TimerQueueTimer contient un lien vers d'autres TimerQueueTimer, voisins dans une liste chaĂźnĂ©e, il contient Ă©galement l'heure de la minuterie et le rappel, qui sera appelĂ© lorsque la minuterie se dĂ©clenche. Cette liste doublement liĂ©e est protĂ©gĂ©e par un objet de verrouillage, juste celui sur lequel le convoi de verrouillage s'est produit dans notre application. Également Ă  l'intĂ©rieur de TimerQueue, il existe une routine qui lance des rappels liĂ©s Ă  nos temporisateurs.

Les temporisateurs ne sont en aucun cas classés par temps de réponse, toute la structure est optimisée pour ajouter / supprimer de nouveaux temporisateurs. Lorsque Routine démarre, il parcourt toute la liste doublement liée, sélectionne les temporisateurs qui devraient fonctionner et les rappelle.

La complexité de l'opération ici est telle. L'ajout et la suppression d'une minuterie se produisent O par unité, et le démarrage des temporisateurs se produit par ligne. De plus, si tout est acceptable avec la complexité algorithmique, il y a un problÚme: toutes ces opérations capturent le verrou, ce qui n'est pas trÚs bon.

Quelle situation peut arriver? Nous avons trop de temporisations accumulées dans TimerQueue, donc lorsque Routine démarre, il verrouille son long fonctionnement linéaire, à ce moment-là, ceux qui essaient de démarrer ou de supprimer des temporisations de TimerQueue ne peuvent rien y faire. Pour cette raison, le convoi d'écluse se produit. Ce problÚme a été corrigé dans .NET Core.
RĂ©duire les conflits de verrouillage du minuteur (coreclr # 14527)
  • Verrouillage du partitionnement
    - Environment.ProcessorCount TimerQueue's TimerQueueTimer
  • Files d'attente sĂ©parĂ©es pour les temporisateurs Ă  durĂ©e de vie courte / longue
  • Minuterie courte: temps <= 1/3 seconde

https://github.com/dotnet/coreclr/issues/14462
https://github.com/dotnet/coreclr/pull/14527
Comment a-t-il été corrigé? Ils ont attaqué TimerQueue: au lieu d'un TimerQueue, qui était statique pour l'ensemble du AppDomain, pour toute l'application, plusieurs TimerQueue ont été créés. Lorsque les threads y arrivent et essaient de démarrer leurs temporisateurs, ces temporisateurs tomberont dans une TimerQueue aléatoire et les threads auront moins de chance d'entrer en collision sur un verrou.

Également dans .NET Core appliquĂ© certaines optimisations. Les minuteries ont Ă©tĂ© divisĂ©es en longue durĂ©e et courte durĂ©e, des TimerQueue sĂ©parĂ©s sont maintenant utilisĂ©s pour elles. La minuterie de courte durĂ©e est sĂ©lectionnĂ©e pour ĂȘtre infĂ©rieure Ă  1/3 de seconde. Je ne sais pas pourquoi une telle constante a Ă©tĂ© choisie. Dans .NET Core, nous n'avons pas rĂ©ussi Ă  dĂ©tecter les problĂšmes de temporisation.



https://github.com/Microsoft/dotnet-framework-early-access/blob/master/release-notes/NET48/dotnet-48-changes.md
https://github.com/dotnet/coreclr/labels/netfx-port-consider

Ce correctif a été rétroporté vers le .NET Framework, version 4.8. La balise netfx-port-consider est indiquée dans le lien ci-dessus, si vous allez dans le référentiel .NET Core, CoreCLR, CoreFX, vous pouvez rechercher ce problÚme qui sera rétroporté vers le .NET Framework, il y en a maintenant une cinquantaine. Autrement dit, le .NET open source a beaucoup aidé, quelques bugs ont été corrigés. Vous pouvez lire le journal des modifications .NET Framework 4.8: de nombreux bugs ont été corrigés, bien plus que dans les autres versions de .NET. Fait intéressant, ce correctif est désactivé par défaut dans le .NET Framework 4.8. Il est inclus dans le fichier entier que vous connaissez appelé App.config

Le paramĂštre dans App.config qui active ce correctif est appelĂ© UseNetCoreTimer. Avant la sortie du .NET Framework 4.8, pour que notre application fonctionne et n'entre pas dans le convoi de verrous, vous deviez utiliser votre implĂ©mentation de Task.Delay. Dans ce document, nous avons essayĂ© d'utiliser un tas binaire afin de comprendre plus efficacement quels temporisateurs devraient ĂȘtre appelĂ©s maintenant.

1.5 Task.Delay: implémentation native


  • Binaryheap
  • Partage
  • Cela a aidĂ©, mais pas dans tous les cas

L'utilisation d'un segment binaire vous permet d'optimiser la routine, qui appelle des rappels, mais aggrave le temps nĂ©cessaire pour supprimer un temporisateur arbitraire de la file d'attente - pour cela, vous devez reconstruire le segment. C'est probablement pourquoi .NET utilise une liste doublement liĂ©e. Bien sĂ»r, l'utilisation d'un tas binaire ne nous aiderait pas ici, nous avons Ă©galement dĂ» travailler sur TimerQueue. Cette solution a fonctionnĂ© pendant un certain temps, mais tout est encore tombĂ© dans le convoi de verrous en raison du fait que les minuteries sont utilisĂ©es non seulement lĂ  oĂč elles s'exĂ©cutent explicitement dans le code, mais aussi dans les bibliothĂšques tierces et le code .NET. Pour rĂ©soudre complĂštement ce problĂšme, vous devez mettre Ă  niveau vers la version 4.8 de .NET Framework et activer le correctif auprĂšs des dĂ©veloppeurs .NET.

1.6 DĂ©lai de tĂąche: conclusions


  • Les piĂšges partout - mĂȘme dans les choses les plus utilisĂ©es
  • Faites des tests de rĂ©sistance
  • Passez Ă  Core, obtenez d'abord des corrections de bugs (et de nouveaux bugs) :)

Quelles sont les conclusions de toute cette histoire? PremiĂšrement, les piĂšges peuvent ĂȘtre localisĂ©s vraiment partout, mĂȘme dans les classes que vous utilisez tous les jours sans penser, par exemple, Ă  la mĂȘme tĂąche, Task.Delay.

Je recommande d'effectuer des tests de résistance de vos propositions. Ce problÚme que nous venons d'identifier au stade des tests de charge. Nous l'avons ensuite tourné plusieurs fois en production dans d'autres applications, mais, néanmoins, les tests de résistance nous ont aidés à retarder le temps avant de rencontrer ce problÚme dans la réalité.

Passez Ă  .NET Core - vous serez le premier Ă  recevoir des corrections de bugs (et de nouveaux bugs). OĂč sans nouveaux bugs?

L'histoire des minuteries est terminée et nous passons à la suivante.

Histoire 2: SemaphoreSlim


L'histoire suivante concerne le célÚbre SemaphoreSlim.

2.1 Limitation du serveur


  • Il est nĂ©cessaire de limiter le nombre de demandes traitĂ©es simultanĂ©ment sur le serveur

Nous voulions implĂ©menter la limitation sur le serveur. Qu'est ce que c'est Vous connaissez probablement tous l'Ă©tranglement du processeur: lorsque le processeur surchauffe, il diminue sa frĂ©quence pour refroidir, ce qui limite ses performances. C'est donc ici. Nous savons que notre serveur peut traiter N requĂȘtes en parallĂšle et ne pas tomber. Que voulons-nous faire? Limitez le nombre de demandes traitĂ©es simultanĂ©ment Ă  cette constante et faites en sorte que si plus de demandes lui parviennent, elles se mettent en file d'attente et attendent que les demandes prĂ©cĂ©dentes soient exĂ©cutĂ©es. Comment rĂ©soudre ce problĂšme? Il est nĂ©cessaire d'utiliser une sorte de primitive de synchronisation.

Semaphore est une primitive de synchronisation sur laquelle vous pouvez attendre N fois, aprÚs quoi celui qui arrive en premier N + et ainsi de suite l'attendra jusqu'à ce que ceux qui l'ont entré plus tÎt libÚrent Semaphore. Il s'avÚre quelque chose comme ceci: deux fils d'exécution, deux ouvriers sont passés sous Semaphore, les autres se sont alignés.



Bien sûr, c'est juste que Semaphore n'est pas trÚs approprié pour nous, il est en synchronisme .NET, donc nous avons pris SemaphoreSlim et écrit ce code:

 var semaphore = new SemaphoreSlim(N); 
 await semaphore.WaitAsync(); await HandleRequestAsync(request); semaphore.Release(); 

Nous crĂ©ons SemaphoreSlim, attendez-le, sous Semaphore nous traitons votre demande, aprĂšs cela nous libĂ©rons Semaphore. Il semblerait que ce soit une implĂ©mentation idĂ©ale de la limitation du serveur, et elle ne peut plus ĂȘtre meilleure. Mais tout est beaucoup plus compliquĂ©.

2.2 Limitation du serveur: complication


  • Traitement des demandes dans l'ordre LIFO
  • SemaphoreSlim
  • Concurrentstack
  • TaskCompletionSource

Nous avons un peu oubliĂ© la logique mĂ©tier. Les requĂȘtes qui aboutissent Ă  la limitation sont de vĂ©ritables requĂȘtes http. En rĂšgle gĂ©nĂ©rale, ils ont un certain dĂ©lai, qui est dĂ©fini par ceux qui ont envoyĂ© cette demande automatiquement, ou un dĂ©lai de l'utilisateur qui appuie sur F5 aprĂšs un certain temps. Par consĂ©quent, si vous traitez les demandes dans un ordre de file d'attente, comme un sĂ©maphore normal, tout d'abord les demandes de la file d'attente qui ont expirĂ© peuvent dĂ©jĂ  ĂȘtre traitĂ©es. Si vous travaillez dans l'ordre de la pile - traitez d'abord toutes les demandes qui sont arrivĂ©es en dernier, un tel problĂšme ne se posera pas.

En plus de SemaphoreSlim, nous avons dû utiliser ConcurrentStack, TaskCompletionSource, pour envelopper beaucoup de code autour de tout cela, afin que tout fonctionne dans l'ordre dont nous avions besoin. TaskCompletionSource est une telle chose, qui est similaire à CancellationTokenSource, mais pas pour CancellationToken, mais pour Task. Vous pouvez créer une TaskCompletionSource, en extraire une tùche, la donner et ensuite dire à TaskCompletionSource que vous devez définir le résultat de cette tùche, et ceux qui attendent cette tùche découvriront ce résultat.

Nous l'avons tous mis en Ɠuvre. Le code est horrible. et, pire que tout, il s'est avĂ©rĂ© inopĂ©rant.

Quelques mois aprĂšs le dĂ©but de son utilisation dans une application assez chargĂ©e, nous avons rencontrĂ© un problĂšme. De la mĂȘme maniĂšre que dans le cas prĂ©cĂ©dent, la consommation du processeur est passĂ©e Ă  100%. Nous avons fait de mĂȘme, enlevĂ© le dĂ©potoir, regardĂ© dans WinDbg, et retrouvĂ© le convoi d'Ă©cluse.



Cette fois, le convoi Lock s'est produit Ă  l'intĂ©rieur de SemaphoreSlim.WaitAsync et SemaphoreSlim.Release. Il s'est avĂ©rĂ© qu'il y a un verrou Ă  l'intĂ©rieur de SemaphoreSlim, il n'est pas sans verrou. Cela s'est avĂ©rĂ© ĂȘtre un inconvĂ©nient assez sĂ©rieux pour nous.



À l'intĂ©rieur de SemaphoreSlim, il y a un Ă©tat interne (un compteur du nombre de travailleurs qui peuvent encore y passer), et une liste doublement liĂ©e de ceux qui attendent sur ce sĂ©maphore. Les idĂ©es ici sont Ă  peu prĂšs les mĂȘmes: vous pouvez attendre dans ce SĂ©maphore, vous pouvez annuler votre attente - quitter cette file d'attente. Il y a une serrure qui vient de ruiner nos vies.

Nous avons décidé: bas avec tout le terrible code que nous devions écrire.



Écrivons notre SĂ©maphore, qui sera immĂ©diatement sans verrou et qui fonctionnera immĂ©diatement dans l'ordre de la pile. L'annulation de l'attente n'est pas importante pour nous.



DĂ©finissez cette condition. Voici le nombre de currentCount - c'est combien plus d'espace est laissĂ© dans le sĂ©maphore. S'il n'y a plus de siĂšge dans Semaphore, ce nombre sera nĂ©gatif et montrera combien de travailleurs sont dans la file d'attente. Il y aura Ă©galement un ConcurrentStack, composĂ© de TaskCompletionSource'ov - c'est juste une pile de waiter'ov d'oĂč ils seront extraits si nĂ©cessaire. Écrivons la mĂ©thode WaitAsync.

 var decrementedCount = Interlocked.Decrement(ref currentCount); if (decrementedCount >= 0) return Task.CompletedTask; var waiter = new TaskCompletionSource<bool>(); waiters.Push(waiter); return waiter.Task; 

D'abord, nous diminuons le compteur, prenons une place dans le SĂ©maphore pour nous-mĂȘmes, si nous avions des places libres, et ensuite nous disons: «Ça y est, vous ĂȘtes passĂ© sous le SĂ©maphore».

S'il n'y avait aucun endroit dans Semaphore, nous créons un TaskCompletionSource, le jetons sur la pile de waiter'ov et renvoyons Task au monde extérieur. Le moment venu, cette tùche fonctionnera et l'ouvrier pourra continuer son travail et passera sous Sémaphore.

Écrivons maintenant la mĂ©thode Release.

 var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { if (waiters.TryPop(out var waiter)) waiter.TrySetResult(true); } 

La méthode Release est la suivante:

  • Un siĂšge gratuit dans le sĂ©maphore
  • Increment currentCount

Si nous pouvons dire par currentCount s'il y a un serveur dans la pile sur lequel nous devons signaler, nous retirons ce serveur de la pile et signalons. Ici, le serveur est un TaskCompletionSource. Question Ă  ce code: il semble logique, mais ça marche mĂȘme? Quels sont les problĂšmes? Il existe une nuance liĂ©e au lieu de lancement de continuation'y et TaskCompletionSource'y.



Considérez ce code. Nous avons créé un TaskCompletionSource et lancé deux tùches. La premiÚre tùche affiche une unité, définit le résultat sur un TaskCompletionSource, puis affiche un diable sur la console. La deuxiÚme tùche attend ce TaskCompletionSource, sa tùche, puis bloque définitivement son thread du pool de threads.

Que va-t-il se passer ici? La tĂąche 2 lors de la compilation sera divisĂ©e en deux mĂ©thodes, la seconde Ă©tant une continuation contenant Thread.Sleep. AprĂšs avoir dĂ©fini le rĂ©sultat de TaskCompletionSource, cette continuation sera exĂ©cutĂ©e dans le mĂȘme thread dans lequel la premiĂšre tĂąche a Ă©tĂ© exĂ©cutĂ©e. Par consĂ©quent, le flux de la premiĂšre tĂąche sera bloquĂ© pour toujours et le deuce vers la console ne sera plus imprimĂ©.

Fait intĂ©ressant, j'ai essayĂ© de modifier ce code, et si je supprimais la sortie de l'unitĂ© de console, la poursuite Ă©tait lancĂ©e sur un autre thread du pool de threads et le diable Ă©tait imprimĂ©. Dans quels cas la suite sera exĂ©cutĂ©e dans le mĂȘme thread, et dans laquelle - arrivera au pool de threads - une question pour les lecteurs.

 var tcs = new TaskCompletionSource<bool>( TaskCreationOptions.RunContinuationsAsynchronously); /* OR */ Task.Run(() => tcs.TrySetResult(true)); 

Pour résoudre ce problÚme, nous pouvons soit créer un TaskCompletionSource avec l'indicateur RunContinuationsAsynchronously correspondant, soit appeler la méthode TrySetResult dans Task.Run/ThreadPool.QueueUserWorkItem afin qu'il ne s'exécute pas sur notre thread. S'il est exécuté sur notre fil, nous pouvons avoir des effets secondaires indésirables. De plus, il y a un deuxiÚme problÚme, nous allons nous y attarder plus en détail.



Regardez les méthodes WaitAsync et Release et essayez de trouver un autre problÚme dans la méthode Release.

TrĂšs probablement, de la trouver si simplement impossible. Il y a une course ici.



Cela est dĂ» au fait que dans la mĂ©thode WaitAsync, le changement d'Ă©tat n'est pas atomique. D'abord, nous dĂ©crĂ©mentons le compteur et ensuite nous poussons le serveur sur la pile. S'il arrive que Release soit exĂ©cutĂ© entre dĂ©crĂ©mentation et push, il peut se fermer pour ne rien extraire de la pile. Cela doit ĂȘtre pris en compte et dans la mĂ©thode Release, attendez que le serveur apparaisse sur la pile.

 var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { Waiter waiter; var spinner = new SpinWait(); while (!waiter.TryPop(out waiter)) spinner.SpinOnce(); waiter.TrySetResult(true); } 

Ici, nous le faisons en boucle jusqu'Ă  ce que nous parvenions Ă  le retirer. Afin de ne pas gĂącher Ă  nouveau les cycles du processeur, nous utilisons SpinWait.

Dans les premiÚres itérations, il tournera en boucle. S'il y a beaucoup d'itérations, le serveur n'apparaßtra pas longtemps, alors notre thread ira à Thread.Sleep, afin de ne pas gaspiller à nouveau les ressources CPU.

En fait, le Sémaphore d'ordre LIFO n'est pas seulement notre idée.
LowLevelLifoSemaphore
  • Synchrone
  • Sous Windows utilise le port d'achĂšvement IO comme une pile Windows

https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs
Il existe un tel sĂ©maphore dans .NET lui-mĂȘme, mais pas dans CoreCLR, pas dans CoreFX, mais dans CoreRT. Il est parfois trĂšs utile de jeter un Ɠil au rĂ©fĂ©rentiel .NET. Il existe un sĂ©maphore appelĂ© LowLevelLifoSemaphore. Ce sĂ©maphore ne nous conviendrait pas de toute façon: il est synchrone.

Remarquablement, sur Windows, il fonctionne via les ports d'achÚvement IO. Ils ont la propriété que les threads peuvent les attendre, et ces threads seront libérés uniquement dans l'ordre LIFO. Cette fonctionnalité y est utilisée, c'est vraiment LowLevel.

2.3 Conclusions:


  • N'espĂ©rez pas que le remplissage du cadre survivra sous votre charge
  • Il est plus facile de rĂ©soudre un problĂšme spĂ©cifique que le cas gĂ©nĂ©ral.
  • Les tests de rĂ©sistance n'aident pas toujours
  • Attention au blocage

Quelles sont les conclusions de toute cette histoire? Tout d'abord, n'espĂ©rez pas que certaines classes du framework que vous utilisez de la bibliothĂšque standard supporteront votre charge. Je ne veux pas dire que SemaphoreSlim est mauvais, il s'est avĂ©rĂ© ĂȘtre inadaptĂ© spĂ©cifiquement dans ce scĂ©nario.

Il s'est avéré beaucoup plus facile pour nous d'écrire notre sémaphore pour une tùche spécifique. Par exemple, il ne prend pas en charge l'annulation de l'attente. Cette fonctionnalité est disponible dans le SemaphoreSlim habituel, nous ne l'avons pas, mais cela nous a permis de simplifier le code.

Le test de charge, bien qu'il aide, peut ne pas toujours aider.

.NET , — . lock, : « ?» CPU 100%, lock', , , - .NET. .

.

3: (A)sync IO


/, .



lock convoy, stack trace Overlapped PinnableBufferCache. lock. : Overlapped PinnableBufferCache?

OVERLAPPED — Windows, /. , . , . , lock convoy. , lock convoy, , .



, , .NET 4.5.1 4.5.2. .NET 4.5.2, , .NET 4.5.2. .NET 4.5.1 OverlappedDataCache, Overlapped — , , . , lock-free, ConcurrentStack, . .NET 4.5.2 : OverlappedDataCache PinnableBufferCache.

? PinnableBufferCache , Overlapped , , — . , , . PinnableBufferCache . , lock-free, ConcurrentStack. , . , , - lock-free list lock'.

3.1 PinnableBufferCache


LockConvoy:


lock convoy , - . list , lock , , .

PinnableBufferCache , . :

 PinnableBufferCache_System.ThreadingOverlappedData_MinCount 

, . : « ! - ». -:

 Environment.SetEnvironmentVariable( "PinnableBufferCache_System.Threading.OverlappedData_MinCount", "10000"); new Overlapped().GetHashCode(); for (int i = 0; i < 3; i++) GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); 

? , Overlapped , , . , , , , PinnableBufferCache lock convoy'. , .

.NET Core PinnableBufferCache , OverlappedData . , , Garbage collector , . .NET Core . .NET Framework, , .

3.2 :


  • .NET Core

, . , .NET , . , , .NET Core. , , -.

key-value .

4: Concurrent key-value collections


.NET concurrent-. lock-free ConcurrentStack ConcurrentQueu, . ConcurrentDictionary, . lock-free , , . ConcurrentDictionary?

4.1 ConcurrentDictionary


:


Avantages:

  • (TryAdd/TryUpdate/AddOrUpdate)
  • Lock-free
  • Lock-free enumeration

, memory-, , . , , .NET Framework. . , , (enumeration) lock-free. , .

, , - .NET. key-value - :



-, bucket'. bucket', . , bucket , .

— , ConcurrentDictionary. ConcurrentDictionary «-» . , , , memory traffic. ConcurrentDictionary, lock'. — .

, Dictionary.



Dictionary , Concurrent, . : buckets, entries. buckets bucket' entries. «-» entries. . «-» int, bucket'.

memory overhead, ConcurrentDictionary Dictionary.



Dictionary. Memory overhea' , . Dictionary overhead - , int'. 8 .

ConcurrentDictionary. ConcurrentDictionary ConcurrentDictionary.Node. , . int hashCode . , table ( 16 ), int hashCode . , 64- 28 overhead'. Dictionary.

memory overhead', ConcurrentDictionary GC , . Benchmark. ConcurrentDictionary , GC.Collect. ?



. ConcurrentDictionary 10 , , , . Dictionary . , , , . .

, ConcurrentDictionary?

4.2


  • TTL
  • Dictionary+lock
  • Sharding

. ConcurrentDictionary. 10 . , . TTL , . Dictionary lock'. , , lock . Dictionary lock' , - , lock. , .

4.3


  • in-memory <Guid,Guid>
  • >10 6

. — , in-memory Guid' Guid, . . - - , . , 15 . . Semaphore ConcurrentDictionary.



, lock-free , overhead GC. , . , , , . , - , , . , , Large Object Heap. ?

, , Dictionary .



Dictionary bucket', Entry. Entry , , , .



Dictionary , , . , - .

, - ? -, , , , . . Dictionary, , buckets, entries, Interlocked. , .
Dictionary
  • ,
  • , ?
    — Resize buckets entries
    — -
    — Dictionary.Entry
    — -

https://blogs.msdn.microsoft.com/tess/2009/12/21/high-cpu-in-net-app-using-a-static-generic-dictionary/
, Dictionary - bucket'. , . , , . , , .

Entry Dictionary. - - . , .



.NET Framework 1.1. Hashtable, Dictionary, object'. MSDN , . , -. . , Hashtable . , .

4.4 Dictionary.Entry



? Dictionary.Entry , , 8 , , , , . Comment faire

 bool writing; int version; this.writing = true; buckets[index] = 
; this.version++; this.writing = false; 

: ( , ) int-. , . , , , , .

 bool writing; int version; while (true) { int version = this.version; bucket = bickets[index]; if (this.writing || version != this.version) continue; break; } 

, , . , . , 8 .

4.5 -


, .



Dictionary bucket , .

Dictionary, . : 0 2. bucket, 1 2. ? 0. , , 2. . , 2, , , 1. 1 2 — bucket. , , . 1 — , bucket. Hashtable , bucket' -. — double hashing .

4.6



Record

  • , resize

La lecture

  • ,

. , Buckets, Entries ( Buckets, Entries). - , , , , .

. , .

: , , , , . , , .



, , — .

? , - 2. - Capacity , . — 2. , . 2. ? , , , . - , , 3. , , , , , .

, Hashtable, . , double hashing. , , , .

, , — , . Hashtable. , — — . . , bucket', - , . .

, , lock-free LOH.



lock-free ? MSDN Hashtable , . , , .



, , , bucket'. Dictionary bucket', -, bucket' . - bucket, bucket . , .

, Large Object Heap.



. CustomDictionary CustomDictionarySegment . Dictionary, , . — Dictionary, . , Large Object Heap. , bucket' . , , , bucket, - - .

. ConcurrentDictionary, .NET, , .

4.7


  • .NET
  • ,

? .NET . . , , . - — - . , , , .

- , , , , . , , , , , . — , , .

Liens utiles



— ConcurrentDictionary. , , ( Diafilm ), .

GitHub. — , , LIFO-Semaphore, . , .
6-7 DotNext 2019 Moscow «.NET: » , .NET Framework .NET Core, , .

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


All Articles