Avant .NET 4.0, si nous devions utiliser la classe Dictionary dans un environnement multithread, nous n’avions pas d’autre choix que de mettre en place nous-mêmes la synchronisation des threads pour protéger les threads.
De nombreux développeurs ont certainement mis en place une solution thread-safe similaire, soit en créant un tout nouveau type de dictionnaire thread-safe, soit simplement en encapsulant un objet Dictionary dans une classe et en ajoutant un mécanisme de verrouillage à toutes les méthodes, que nous appelons « Dictionary + Locks ».
Mais maintenant, nous avons ConcurrentDictionary. La description thread-safe de la documentation de la classe Dictionary sur MSDN indique que si vous devez utiliser une implémentation thread-safe, utilisez ConcurrentDictionary.
Donc, maintenant que nous avons une classe de dictionnaire thread-safe, nous n’avons plus besoin de l’implémenter nous-mêmes. Super, non ?
Origine du problème
En fait, je n’ai utilisé CocurrentDictionary qu’une seule fois auparavant, dans mon test pour tester sa réactivité. Comme il avait bien réussi les tests, je l’ai immédiatement remplacé par ma classe, passé quelques tests, puis quelque chose a mal tourné.
Alors, qu’est-ce qui a mal tourné ? Tu n’as pas dit sans fil ?
Après d’autres tests, j’ai trouvé la racine du problème. Mais pour une raison quelconque, la version 4.0 de MSDN n’inclut pas de description de la signature de la méthode GetOrAdd qui nécessite de passer un paramètre de type délégué. Après avoir regardé la version 4.5, j’ai trouvé cette note :
Si vous appelez GetOrAdd simultanément sur différents threads, addValueFactory peut être appelé plusieurs fois, mais sa paire clé/valeur peut ne pas être ajoutée au dictionnaire pour chaque appel. C’est le problème auquel j’ai rencontré. Comme cela n’était pas décrit précédemment dans la documentation, j’ai dû faire plus de tests pour confirmer le problème. Bien sûr, le problème que je rencontre est lié à mon utilisation, en général, j’utilise le type de dictionnaire pour mettre en cache certaines données :
Ces données sont très lentes à produire ; Ces données ne peuvent être créées qu’une seule fois, car la seconde création lancera une exception, ou plusieurs créations peuvent entraîner une fuite de ressources, etc. ; J’ai eu un problème avec la deuxième condition. Si les deux threads constatent qu’une donnée n’existe pas, elle sera créée une fois, mais un seul résultat sera sauvegardé avec succès. Et l’autre ?
Si le processus que vous créez propose une exception, vous pouvez utiliser : Catch (pas assez élégant, mais ça résout le problème). Mais que se passe-t-il si une ressource est créée et non recyclée ?
On pourrait dire qu’un objet est créé et sera récupéré à la poubelle s’il n’y est plus référencé. Cependant, considérez ce qui se passerait si la situation décrite ci-dessous se produisait :
Générez du code dynamiquement avec Emit. J’ai utilisé cette approche dans un cadre à distance et j’ai mis toutes les implémentations dans un assembleur qui ne pouvait pas être recyclé. Si un type est créé deux fois, le second existera toujours, même s’il n’a jamais été utilisé. Créez un fil de discussion directement ou indirectement. Par exemple, nous devons construire un composant qui utilise un thread propriétaire pour traiter les messages asynchrones et qui s’appuie sur l’ordre de leur réception. Lorsque le composant est instancié, un thread est créé. Lorsque cette instance composante est détruite, le thread est également terminé. Mais si nous supprimons la référence à l’objet après avoir détruit le composant, le thread ne se termine pas pour une raison quelconque et conserve la référence à l’objet. Alors, si le fil ne meurt pas, l’objet ne sera pas non plus recyclé. Effectuez une opération P/Invocation. Exiger que le nombre de temps de fermeture pour la poignée reçue soit le même que le nombre d’ouvertures. Il existe bien sûr de nombreuses situations similaires. Par exemple, un objet dictionnaire maintiendra une connexion à un service sur un serveur distant, qui ne peut être demandé qu’une seule fois, et s’il est demandé une seconde fois, l’autre service pensera qu’une erreur s’est produite et l’enregistrera dans le journal. (Dans une entreprise pour laquelle j’ai travaillé, il y avait des sanctions légales pour cette condition.) ) Il est donc facile de voir que Dictionary + Locks ne peut pas être remplacé à la hâte par ConcurrentDictionary, même si la documentation indique que c’est sûr pour les threads.
Analysez le problème
Tu ne comprends toujours pas ?
Il est vrai que ce problème peut ne pas survenir avec l’approche Dictionnaire + Verrous. Puisque cela dépend de l’implémentation spécifique, voyons cet exemple simple :
Dans le code ci-dessus, nous maintenons le verrou sur le dictionnaire avant de commencer à interroger la valeur clé. Si la paire clé-valeur spécifiée n’existe pas, elle sera créée directement. En même temps, comme nous détenons déjà un verrou sur ce dictionnaire, nous pouvons ajouter directement des paires clé-valeur au dictionnaire. Ensuite, relâche le verrou du dictionnaire, et retourne le résultat. Si deux threads interrogent la même valeur de clé en même temps, le premier thread qui obtient le verrouillage du dictionnaire terminera la création de l’objet, et l’autre thread attendra la fin de cette création pour obtenir le résultat de valeur de clé créée après avoir obtenu le verrou du dictionnaire.
C’est bien, non ?
Ce n’est vraiment pas le cas ! Je ne pense pas que créer un objet en parallèle comme ça, où un seul objet est utilisé à la fin, ne crée pas le problème que j’ai décrit.
La situation et le problème que j’essaie d’expliquer ne sont pas toujours reproductibles ; dans un environnement parallèle, on peut simplement créer deux objets puis en rejeter un. Alors, comment comparer exactement Dictionary + Locks et ConcurrentDictionary ?
La réponse est : cela dépend de la stratégie d’utilisation des serrures et de la manière dont le dictionnaire est utilisé.
Jeu 1 : Créer le même objet en parallèle
D’abord, supposons qu’un objet puisse être créé deux fois, que se passe-t-il si deux threads créent cet objet en même temps ?
Ensuite, combien de temps passons-nous sur des créations similaires ?
Nous pouvons simplement construire un exemple où l’instanciation d’un objet prend 10 secondes. Lorsque le premier thread crée l’objet 5 secondes plus tard, la deuxième implémentation essaie d’appeler la méthode GetOrAdd pour obtenir l’objet, et comme l’objet n’existe toujours pas, elle commence aussi à créer l’objet.
Dans cette condition, nous avons deux processeurs travaillant en parallèle pendant 5 secondes, et lorsque le premier thread termine de fonctionner, le second thread doit encore continuer à tourner pendant 5 secondes pour terminer la construction de l’objet. Lorsque le second thread termine de construire l’objet, il constate qu’un objet existe déjà, et choisit d’utiliser l’objet existant et de jeter directement l’objet nouvellement créé.
Si le second thread attend simplement et que le second CPU effectue un autre travail (exécuter d’autres threads ou applications, économiser de l’énergie), il obtiendra l’objet désiré après 5 secondes au lieu de 10 secondes.
Ainsi, dans ces conditions, Dictionnaire + Serrures remporte une petite partie.
Jeu 2 : Visitez différents objets en parallèle
Non, la situation que tu as décrite n’est pas du tout vraie !
Eh bien, l’exemple ci-dessus est un peu particulier, mais il décrit le problème, c’est juste que cette utilisation est plus extrême. Alors, considérez ce qui se passe si le premier thread crée un objet, et que le second thread doit accéder à un autre objet clé-valeur, et que cet objet clé-valeur existe déjà ?
Dans ConcurrentDictionary, la conception sans verrouillage rend les lectures très rapides car il n’y a pas de verrouillage sur la lecture. Dans le cas de Dictionnaire + Verrous, l’opération de lecture sera verrouillée de façon mutuellement exclusive, même si c’est une clé complètement différente, ce qui ralentira évidemment l’opération de lecture.
De cette façon, ConcurrentDictionary a retiré un jeu.
Note : Ici, je considère que vous comprenez plusieurs concepts tels que Bucket/Node/Entry dans la classe du dictionnaire, sinon il est recommandé de lire l’article d’Ofir Makmal « Comprendre en profondeur le dictionnaire générique », qui explique bien ces concepts.
Le troisième jeu du jeu : lire la suite et écrire un simple
Que se passe-t-il si vous utilisez Multiple Readers et Single Writer au lieu d’un verrouillage complet sur le dictionnaire dans Dictionary + Locks ?
Si un thread crée un objet et conserve un verrou améliorable jusqu’à ce que l’objet soit créé, le verrou soit mis à niveau en un verrou d’écriture, alors l’opération de lecture peut être effectuée en parallèle.
Nous pouvons aussi résoudre le problème en laissant une opération de lecture inactive pendant 10 secondes. Mais s’il y a beaucoup plus de lectures que d’écritures, nous constaterons que ConcurrentDictionary reste rapide car il implémente des lectures sans verrouillage.
Utiliser ReaderWriterLockSlim pour les dictionnaires rend les lectures plus difficiles, et il est généralement recommandé d’utiliser Full Lock pour les dictionnaires plutôt que ReaderWriterLockSlim.
Ainsi, dans ces conditions, ConcurrentDictionary a gagné une nouvelle partie.
Note : J’ai déjà abordé les cours YieldReaderWriterLock et YieldReaderWriterLockSlim dans des articles précédents. Grâce à ce verrouillage lecture-écriture, la vitesse a été considérablement améliorée (désormais évoluée en SpinReaderWriterLockSlim) et permet d’exécuter plusieurs lectures en parallèle avec peu ou pas d’impact. Même si j’utilise toujours cette méthode, un ConcurrentDictionary sans verrouillage serait évidemment plus rapide.
Partie 4 : Ajouter plusieurs paires clé-valeur
L’affrontement n’est pas encore terminé.
Et si nous avions plusieurs valeurs clés à ajouter, et qu’elles ne se heurtaient pas toutes et étaient assignées dans différents intervalles ?
Au début, cette question me semblait curieuse, mais j’ai passé un test qui ne correspondait pas tout à fait. J’ai utilisé un dictionnaire de types <int, int> et la construction factory de l’objet renvoyait un résultat négatif directement comme clé.
Je m’attendais à ce que ConcurrentDictionary soit le plus rapide, mais il s’est avéré être le plus lent. Dictionnaire + Verrous, en revanche, fonctionne plus rapidement. Pourquoi?
Cela s’explique par le fait que ConcurrentDictionary alloue les nœuds et les place dans différents compartiments, ce qui est optimisé pour répondre à la conception sans verrou des opérations de lecture. Cependant, lors de l’ajout d’éléments clé-valeur, le processus de création d’un nœud devient coûteux.
Même en parallèle, allouer un verrou de nœud prend toujours plus de temps que d’utiliser un verrou complet.
Donc, Dictionnaire + Serrures gagne ce jeu.
Jouer au cinquième jeu : la fréquence des opérations de lecture est plus élevée
Franchement, si nous avions un délégué capable d’instancier rapidement des objets, nous n’aurions pas besoin de dictionnaire. On peut appeler directement le délégué pour récupérer l’objet, non ?
En fait, la réponse est aussi que cela dépend de la situation.
Imaginez que le type de clé est une chaîne et contient des cartes de chemin pour différentes pages du serveur web, et que la valeur correspondante est un type d’objet contenant l’enregistrement des utilisateurs actuels accédant à la page et le nombre de toutes les visites depuis le démarrage du serveur.
Créer un objet comme celui-ci est presque instantané. Et après cela, vous n’avez pas besoin de créer un nouvel objet, il suffit de changer les valeurs enregistrées dedans. Il est donc possible de permettre la création d’un mode deux fois jusqu’à ce qu’une seule instance soit utilisée. Cependant, comme ConcurrentDictionary alloue les ressources des nœuds plus lentement, utiliser Dictionnaire + Verrous entraînera des temps de création plus rapides.
Ainsi, avec cet exemple très spécial, on voit aussi que Dictionary + Locks fonctionne mieux dans cette condition, prenant moins de temps.
Bien que l’allocation des nœuds dans ConcurrentDictionary soit plus lente, je n’ai pas essayé d’y mettre 100 millions d’éléments de données pour tester le temps. Parce que cela prend évidemment beaucoup de temps.
Mais dans la plupart des cas, une fois qu’un élément de données est créé, il est toujours lu. La façon dont le contenu de l’élément de données évolue est une autre question. Donc peu importe combien de millisecondes il faut pour créer un élément de données, car les lectures sont plus rapides (seulement quelques millisecondes plus rapides), mais les lectures ont lieu plus fréquemment.
Ainsi, ConcurrentDictionary a remporté la partie.
Jeu 6 : Créer des objets qui consomment des temps différents
Que se passe-t-il si le temps nécessaire pour créer différents éléments de données varie ?
Créez plusieurs éléments de données qui consomment des temps différents et ajoutez-les au dictionnaire en parallèle. C’est le point fort de ConcurrentDictionary.
ConcurrentDictionary utilise plusieurs mécanismes de verrouillage différents pour permettre l’ajout simultané des éléments de données, mais la logique comme décider quel verrou utiliser, demander à un verrou de modifier la taille du bucket, etc., n’aide pas. La vitesse à laquelle les éléments de données sont placés dans un seau est rapide comme une machine. Ce qui fait vraiment gagner ConcurrentDictionary, c’est sa capacité à créer des objets en parallèle.
Cependant, nous pouvons en fait faire la même chose. Si cela ne nous importe pas si nous créons des objets en parallèle, ou si certains ont été rejetés, nous pouvons ajouter un verrou pour détecter si l’élément de données existe déjà, puis relâcher le verrou, créer l’élément de données, appuyer dessus pour obtenir le verrou, vérifier à nouveau si l’élément de données existe, et si ce n’est pas le cas, ajouter l’élément de données. Le code pourrait ressembler à ceci :
* Notez que j’utilise un dictionnaire de type <int, int>.
Dans la structure simple ci-dessus, Dictionary + Locks fonctionne presque aussi bien que ConcurrentDictionary lors de la création et de l’ajout d’éléments de données en conditions parallèles. Mais il y a aussi le même problème, où certaines valeurs peuvent être générées mais jamais utilisées.
conclusion
Alors, y a-t-il une conclusion ?
À l’heure actuelle, il en reste encore quelques-unes :
Tous les cours de dictionnaire sont très rapides. Même si j’ai créé des millions de données, c’est toujours rapide. Normalement, nous ne créons qu’un petit nombre d’éléments de données, et il y a quelques intervalles de temps entre les lectures, donc nous ne remarquons généralement pas la charge de temps liée à la lecture des éléments de données. Si le même objet ne peut pas être créé deux fois, n’utilisez pas ConcurrentDictionary. Si vous êtes vraiment préoccupé par les performances, Dictionary + Locks pourrait toujours être une bonne solution. Un facteur important est le nombre d’éléments de données ajoutés et supprimés. Mais s’il y a de nombreuses opérations de lecture, c’est plus lent que dans ConcurrentDictionary. Bien que je ne l’aie pas introduit, il y a en réalité plus de liberté pour utiliser le schéma Dictionnaire + Verrous. Par exemple, vous pouvez verrouiller une fois, ajouter plusieurs éléments de données, supprimer plusieurs éléments de données, ou interroger plusieurs fois, etc., puis libérer le verrouillage. En général, évitez d’utiliser ReaderWriterLockSlim s’il y a beaucoup plus de lectures que d’écritures. Les types de dictionnaire sont déjà bien plus rapides que d’obtenir un verrou lecture dans un verrou lecture-écriture. Bien sûr, cela dépend aussi du temps nécessaire pour créer un objet dans un verrou. Donc, je pense que les exemples donnés sont un peu extrêmes, mais ils montrent que l’utilisation de ConcurrentDictionary n’est pas toujours la meilleure solution.
Ressens la différence
J’ai écrit cet article dans l’intention de chercher une meilleure solution.
J’essaie déjà de mieux comprendre comment fonctionne un cours spécifique de dictionnaire (maintenant j’ai l’impression d’être très clair).
On peut dire que Bucket et Node dans ConcurrentDictionary sont très simples. J’ai fait quelque chose de similaire quand j’ai essayé de créer un cours de dictionnaire. La classe classique du dictionnaire peut sembler plus simple, mais en réalité, elle est plus complexe.
Dans ConcurrentDictionary, chaque nœud est une classe complète. Dans la classe Dictionnaire, Node est implémenté en utilisant un type de valeur, et tous les nodes sont conservés dans un énorme tableau, tandis que Bucket sert à indexer dans le tableau. Il est également utilisé à la place de la simple référence d’un nœud à son prochain nœud (après tout, en tant que nœud d’un type struct, il ne peut pas contenir un membre nœud d’un type struct).
Lors de l’ajout ou de la suppression d’un dictionnaire, la classe Dictionnaire ne peut pas simplement créer un nouveau nœud, elle doit vérifier s’il existe un index marquant un nœud supprimé, puis le réutiliser. Ou « Count » est utilisé pour obtenir la position du nouveau nœud dans le tableau. En fait, lorsque le tableau est plein, la classe Dictionnaire force un changement de taille.
Pour ConcurrentDictionary, un Nœud peut être considéré comme un nouvel objet. Supprimer un nœud, c’est simplement supprimer sa référence. Ajouter un nouveau Node peut simplement créer une nouvelle instance de Node. Changer la taille sert uniquement à éviter les conflits, mais ce n’est pas obligatoire.
Donc, si la classe Dictionnaire utilise volontairement des algorithmes plus complexes pour la gérer, comment ConcurrentDictionary pourra-t-il s’assurer qu’elle fonctionne mieux dans un environnement multithread ?
La vérité est que mettre tous les nœuds dans un seul tableau est la manière la plus rapide d’allouer et de lire, même si nous avons besoin d’un autre tableau pour suivre où trouver ces données. Il semble donc qu’avoir le même nombre de buckets utilisera plus de mémoire, mais les nouveaux éléments de données n’ont pas besoin d’être réalloués, aucune nouvelle synchronisation d’objets n’est nécessaire, et la nouvelle collecte des déchets n’a pas lieu. Parce que tout est déjà en place.
Cependant, remplacer le contenu dans un Nœud n’est pas une opération atomique, ce qui est l’un des facteurs qui rendent son thread moins sécurisé. Comme les nœuds sont tous des objets, un nœud est initialement créé, puis une référence distincte est mise à jour pour le pointer (opération atomique ici). Ainsi, le fil de lecture peut lire le contenu du dictionnaire sans verrouillage, et la lecture doit être l’une des valeurs anciennes et nouvelles, et il n’y a aucun risque de lire une valeur incomplète.
Donc, la vérité est que si vous n’avez pas besoin de verrou, la classe Dictionnaire est plus rapide en lectures, car c’est le verrou qui ralentit la lecture.
Cet article est traduit de l’article de Paulo Zemek « Dictionary + Locking versus ConcurrentDictionary » sur CodeProject, et certaines affirmations changeront pour des raisons de compréhension.
|