C'était récemment une question qui m'a été posée lors d'une projection et cela m'a fait réfléchir. Je ne me suis jamais totalement décidé sur la fin de la conversation et j'ai creusé. Aucune des réponses que j'ai vues ne m'a semblé satisfaisante.
La question est essentiellement "comment implémenteriez-vous une carte de hachage sécurisée pour les threads?" Évidemment, protégez avec un mutex, mais vous devez ensuite faire face à la contention de plusieurs threads en attente sur un seul mutex, ce qui signifie que l'accès n'est pas vraiment simultané, etc. Nous sommes finalement arrivés à une conception simple de n compartiments dans la table de hachage, avec un mutex correspondant à un compartiment. Assez facile, et c'est là que je vois beaucoup de réponses à cette question sur d'autres sites s'arrêter. Il ne gère pas ce qui se passe lorsque la table de hachage est redimensionnée ou pour quelque raison que ce soit, la table est remaniée.
Il était supposé qu'un thread gérerait le rehachage, donc notre conception simple devient n + 1 mutex, et le thread de gestion doit verrouiller tous les n + 1 mutex pour ressasser. Cependant, n'importe quel thread qui attend sur l'un des n compartiments peut devenir propriétaire d'un mutex qui est maintenant associé à un autre compartiment (après une refonte). La seule réponse que je pouvais trouver avec est retournée à une carte de hachage non simultanée.
Je crois que Java a un certain type de carte de hachage simultanée hors de la boîte, mais je vis dans un monde C++. J'ai passé l'écran mais je suis toujours très curieux à ce sujet car je n'ai jamais eu ça moment "a-ha" et cela ressemble à une application pratique.
Je pense que les verrous en lecture-écriture peuvent être une réponse quelque peu appropriée, mais encore une fois, je pense qu'il y a toujours une mise en garde lors du remaniement.
J'ai lutté avec ça. J'ai fait quelques itérations sur ma conception ...
Première solution : Il suffit d'avoir une table de hachage avec un verrou global.
Deuxième solution : Attendez la table de hachage de taille fixe gratuite. Nous devons comprendre qu'il est possible d'implémenter un ensemble de hachage sécurisé sans fil avec une taille fixe. Pour ce faire, nous allons utiliser un tableau, où l'index est le hachage, nous utiliserons le sondage linéaire et toutes les opérations vont être verrouillées.
Donc, oui, nous aurons un verrouillage. Cependant, un thread ne sera jamais en attente de rotation ou en attente sur une poignée d'attente. Il faut du travail pour savoir comment garantir qu'ils sont tous atomiques.
Troisième solution : Verrouillez la croissance. L'approche naïve consiste à verrouiller la structure, créer un nouveau tableau, copier toutes les données, puis déverrouiller. Cela fonctionne, il est sûr pour les threads. Nous pouvons nous en sortir avec un verrouillage uniquement sur la croissance car tant que la taille ne change pas, nous pouvons travailler comme dans la solution précédente.
Sauf que je n'aime pas les temps d'arrêt. Il y a des threads en attente de la fin de la copie. On peut faire mieux ...
Quatrième solution : Croissance coopérative. Tout d'abord, implémentez une machine d'état sécurisée pour les threads afin que la structure puisse passer d'une utilisation normale à une croissance et inversement. Ensuite, chaque thread qui souhaite effectuer une opération, au lieu d'attendre, coopérera pour copier les données dans le nouveau tableau. Comment? Nous pouvons avoir un index que nous incrémentons, encore une fois avec une opération verrouillée ... chaque thread incrémente l'index, prend l'élément, calcule où le placer dans le nouveau tableau, l'écrit là puis boucle jusqu'à ce que l'opération soit terminée.
Il y a un problème: il ne rétrécit pas.
Cinquième solution : tableau clairsemé. Au lieu d'utiliser un tableau, nous pouvons utiliser un arbre de tableaux et l'utiliser comme un tableau clairsemé. Sa taille sera suffisante pour allouer toutes les valeurs que notre fonction de hachage peut générer. Maintenant, les threads peuvent noeuds selon les besoins. Nous garderons une trace de l'utilisation de chaque nœud, c'est-à-dire combien d'enfants sont occupés et combien de threads l'utilisent actuellement. Lorsque l'utilisation atteint zéro, nous pouvons le supprimer ... attendez ... le verrouillage? il s'avère que nous pouvons faire une solution optimiste: nous gardons les références aux données du nœud, chaque thread en écrira une après l'opération. Le thread qui a trouvé 0 sera effacé, puis lu (un autre thread aurait pu l'écrire), puis copié vers la deuxième référence. Toutes les opérations interverrouillées.
Au fait, ces opérations atomiques? Oui, il s'avère que ce sont des modèles courants que nous pouvons résumer. À la fin, je n'ai que DoMayIncrement
pour les opérations qui peuvent ou non ajouter des éléments, DoMayDecrement
pour celles qui peuvent ou non être supprimées, et Do
pour tout le reste. Ils prendront des rappels avec des références aux valeurs, dont le code de rappel n'a pas besoin de savoir exactement où ils sont stockés, et nous construisons en plus de cela des opérations plus spécifiques et ajoutons des tests pour gérer les collisions en cours de route.
Oh, j'ai oublié, pour minimiser les allocations, je mets en commun les tableaux qui composent les nœuds de l'arbre.
Sixième solution : Phil Bagwell's Ideal Hash Trees . Elle est similaire à ma cinquième solution. La principale différence est que l'arbre ne se comporte pas comme un tableau clairsemé où toutes les valeurs sont insérées à la même profondeur. Au lieu de cela, la profondeur des branches d'arbre est dynamique. Lorsqu'une collision se produit, l'ancien noeud est remplacé par une branche où l'ancien noeud est réinséré en plus du nouveau noeud. Cela devrait rendre la solution de Bagwell plus efficace en mémoire dans le cas moyen. Bagwell regroupe également des tableaux. Bagwell ne précise pas le rétrécissement.
La cinquième solution est celle que j'utilise actuellement pour rétroporter/polyfill ConcurrentDictionary<TKey, TValue>
vers .NET 2.0. Plus précisément, mon backport de ConcurrentDictionary<TKey, TValue>
s'enroule autour d'un type personnalisé ThreadSafeDictionary<TKey, TValue>
qui utilise un Bucket<KeyValuePair<TKey, TValue>>
qui implémentent des opérations atomiques sur un BucketCore
qui est mon truc de tableau épars thread-safe, qui a la logique de réduire et de grandir.
Une "table de hachage avec une véritable concurrence" a quand même des problèmes. Si le thread A recherche une clé, alors que le thread B supprime ou ajoute exactement la même clé, le meilleur résultat possible est que vous ne savez pas si A trouvera la clé ou non.
Vous aurez besoin de plus que les opérations habituelles pour être atomique. Par exemple, vous avez besoin d'une opération qui recherche une clé, la supprime et la renvoie à l'appelant, de manière atomique, sinon vos algorithmes rencontreront des problèmes. Si vous ajoutez une clé au dictionnaire et la recherchez immédiatement, rien ne garantit qu'elle est toujours là. Du plaisir partout.
La toute première étape consiste donc à concevoir les opérations que vous prendrez en charge de manière sécurisée pour les threads. La prochaine chose est de les rendre threadsafe. La chose la plus simple consiste à utiliser un mutex. Tout simplement parce qu'une table de hachage doit être threadsafe, cela ne signifie pas qu'elle est beaucoup utilisée. Je pense que Java a quelques astuces dans sa manche pour faire des verrous non contestés assez rapidement. Ensuite, j'aimerais voir si vous pouvez utiliser un verrou de rotation, qui devrait être efficace parce que les recherches de table de hachage devraient être rapides Vous devriez probablement essayer de faire la comparaison des clés en dehors de la serrure.
Imaginez que nous voulons ajouter deux nouvelles paires (clé, valeur) dans votre hashmap :
Maintenant, comme première remarque, il est facile de créer un verrouiller la liste des favoris . Cela résoudrait le dernier problème
Vous pouvez exploiter cette fonctionnalité et faire de chaque compartiment de la table de hachage une liste chaînée (sans verrou) (vide), lors de la construction, avant que la table de hachage ne soit utilisée simultanément. Cela résout le premier problème: l'accès à un compartiment passe toujours par une liste chaînée sans verrouillage.
Avec une telle structure, vous auriez une table de hachage sans verrou simultanée Nice, tant que vous n'avez pas à réorganiser les compartiments (par exemple, si vous souhaitez augmenter ou réduire dynamiquement le nombre de compartiments ou modifier la fonction de hachage).
Modifier
Si vous verrouilliez la table entière, tous les avantages de nos structures sans verrou disparaîtraient, car nous devions acquérir le verrou global.
Il est très difficile de le garder sans verrou, donc l'idée suivante doit être vérifiée. L'idée pourrait être de travailler avec une table fantôme que vous créeriez une fois que vous commencerez le redimensionnement et qui contiendra les nouveaux compartiments:
Je n'ai pas fait l'analyse détaillée, mais je pense que le pire qui puisse arriver est qu'un écrivain écrit une nouvelle valeur dans l'ombre alors qu'un lecteur vient de la manquer et continue de lire l'ancienne, ce qui serait de facto comme si cela se produirait avant la mise à jour. Un autre mauvais cas serait un thread qui commence à regarder dans la table actuelle, est mis en attente et reprend une fois que l'ancienne table est supprimée. Il n'est pas totalement clair comment éviter cela. Peut-être un nombre d'utilisation de threads qui ont sauté sur l'ancienne table?
TLDR: Il existe de nombreuses façons différentes de gérer ce genre de choses, et vous ne pouvez même pas commencer à choisir entre elles tant que vous n'avez pas défini précisément les comportements dont vous avez besoin et les comportements sur lesquels vous êtes flexible afin de pouvoir au moins affiner votre liste de modèles de concurrence applicables.
Vous seriez mieux servi en élaborant d'abord une définition plus précise de ce dont vous avez besoin.
Vous demandez une table de hachage, mais votre structure de données doit-elle vraiment être une table de hachage et rien d'autre? (Et si oui, pourquoi?) Ou peut-il s'agir d'une structure de données qui vous donne des recherches de valeurs-clés rapides et qui est modifiable d'une manière ou d'une autre?
Votre commentaire "Evidemment protéger avec un mutex" pourrait être lu comme impliquant un modèle d'accès concurrentiel sérialisé tel qu'une fois qu'un thread commence à faire un changement, les autres threads ne doivent jamais voir les données d'avant le changement. Mais est-ce aussi vraiment nécessaire? Ou seriez-vous d'accord (ou peut-être même mieux) avec un modèle de concurrence "multiversion" où un thread obtient une version spécifique de la structure de données et toutes les recherches proviennent de cette version jusqu'à ce qu'il demande une version ultérieure. (Si un thread souhaite effectuer plusieurs recherches qui doivent être cohérentes, le modèle sérialisé implique qu'il doit se verrouiller avant la première lecture et maintenir le verrouillage utilisé, il a terminé toutes les lectures pour cette transaction.)
Si, par exemple, vous êtes d'accord avec une structure de données qui vous donne un dictionnaire de valeurs-clés et que vous pouvez vivre avec (ou même exiger) une concurrence multi-version, vous pouvez plutôt utiliser un arbre de hachage avec une référence à elle stockée dans une variable globale.
Les lectures lisent simplement la variable globale pour obtenir la racine de l'arborescence, puis utilisent cette copie aussi longtemps que nécessaire pour rechercher les valeurs de cette version de l'arborescence, ce qui vous donne de la cohérence.
Pour les écritures, vous avez au moins deux choix, selon le type de compromis de performances que vous souhaitez effectuer. Vous pouvez verrouiller la variable globale avant d'apporter des modifications au dictionnaire et maintenir le verrou jusqu'à ce que vos modifications soient effectuées. Cela ne bloquera pas les lecteurs, mais bloquera les autres écrivains. Vous pouvez également apporter vos modifications à une version existante de l'arborescence, mais stocker la nouvelle arborescence uniquement si la version actuelle est toujours la même que la version que vous avez commencé à modifier, sinon abandonner. Cela permet d'économiser le coût du verrou, mais impose des coûts plus élevés pour la récupération après un conflit entre les threads d'écriture (et peut-être aussi plus de complexité dans la récupération si vous ne pouvez pas facilement "rejouer" les modifications que vous vouliez apporter par rapport à la nouvelle version de l'arborescence) .
Les structures de données thread-safe (aka "concurrent") (telles que les cartes de hachage) ne sont jamais entièrement "simultanées" dans le sens où deux threads ou plus ont un accès totalement simultané à n'importe quelle partie d'entre eux à tout moment. "Concurrent" tel qu'appliqué aux structures de données signifie simplement qu'elles sont thread-safe, c'est-à-dire que deux threads ou plus peuvent tenter un accès simultané à tout moment, et la structure maintiendra la cohérence pour tous les lecteurs/rédacteurs. En pratique, cela signifie que les threads seront bloqués dans certaines circonstances; des structures concurrentes bien écrites emploieront des stratégies appropriées pour minimiser l'occurrence du blocage et le retard dû au blocage lorsqu'il se produit.