web-dev-qa-db-fra.com

Le multi-threading sans verrouillage est pour les vrais experts du threading

Je lisais un réponse que Jon Skeet a donné à une question et il y a mentionné ceci:

En ce qui me concerne, le multi-threading sans verrouillage est destiné aux vrais experts du threading, dont je ne suis pas un.

Ce n'est pas la première fois que j'entends cela, mais je trouve très peu de gens parler de la façon dont vous le faites réellement si vous souhaitez apprendre à écrire du code multithread sans verrouillage.

Donc, ma question est en plus d'apprendre tout ce que vous pouvez sur le filetage, etc. où commencez-vous à essayer d'écrire spécifiquement du code multithread sans verrouillage et quelles sont les bonnes ressources.

À votre santé

85
vdhant

Les implémentations "sans verrouillage" actuelles suivent le même schéma la plupart du temps:

  • * lire un état et en faire une copie **
  • * modifier la copie **
  • faire une opération verrouillée
  • réessayer en cas d'échec

(* facultatif: dépend de la structure/algorithme des données)

Le dernier bit est étrangement similaire à un verrou tournant. En fait, c'est une base spinlock . :)
Je suis d'accord avec @nobugz à ce sujet: le coût des opérations verrouillées utilisées dans le multi-threading sans verrouillage est dominé par les tâches de cache et de cohérence de mémoire qu'il doit effectuer .

Ce que vous gagnez cependant avec une structure de données qui est "sans verrou", c'est que vos "verrous" sont très fins . Cela diminue les chances que deux threads simultanés accèdent au même "verrou" (emplacement mémoire).

L'astuce la plupart du temps est que vous n'avez pas de verrous dédiés - au lieu de cela, vous traitez par exemple tous les éléments d'un tableau ou tous les noeuds d'une liste chaînée en tant que "spin-lock". Vous lisez, modifiez et essayez de mettre à jour s'il n'y a pas eu de mise à jour depuis votre dernière lecture. S'il y en avait, vous réessayez.
Cela rend votre "verrouillage" (oh, désolé, non-verrouillage :) très fin, sans introduire de mémoire supplémentaire ou de ressources supplémentaires.
Le rendre plus fin diminue la probabilité d'attente. Le rendre aussi fin que possible sans introduire de ressources supplémentaires semble très bien, n'est-ce pas?

Cependant, la plupart du plaisir peut provenir de assurer une commande correcte de chargement/magasin .
Contrairement à nos intuitions, les processeurs sont libres de réorganiser les lectures/écritures en mémoire - ils sont très intelligents, en passant: vous aurez du mal à observer cela à partir d'un seul thread. Cependant, vous rencontrerez des problèmes lorsque vous commencerez à effectuer plusieurs threads sur plusieurs cœurs. Vos intuitions tomberont en panne: ce n'est pas parce qu'une instruction se trouve plus tôt dans votre code qu'elle se produira réellement plus tôt. Les processeurs peuvent traiter des instructions dans le désordre: ils aiment particulièrement le faire pour les instructions avec accès à la mémoire, pour masquer la latence de la mémoire principale et mieux utiliser leur cache.

Maintenant, il est sûr contre l'intuition qu'une séquence de code ne coule pas "de haut en bas", au lieu de cela, elle fonctionne comme s'il n'y avait aucune séquence du tout - et peut être appelée "terrain de jeu du diable". Je pense qu'il est impossible de donner une réponse exacte quant aux réapprovisionnements de chargement/magasin qui auront lieu. Au lieu de cela, on parle toujours en termes de mays et mights et canettes et préparez-vous au pire. "Oh, le CPU pourrait réorganiser cette lecture avant cette écriture, il est donc préférable de placer une barrière de mémoire ici, à cet endroit."

Les questions sont compliquées par le fait que même ces mays et mights peuvent différer à travers les architectures CPU. Il pourrait être le cas, par exemple, que quelque chose qui ne se produise pas dans une architecture peut se produire dans une autre.


Pour obtenir le droit de multi-threading "sans verrouillage", vous devez comprendre les modèles de mémoire.
L'obtention du modèle de mémoire et des garanties correctes n'est cependant pas anodine, comme le démontre cette histoire, par laquelle Intel et AMD ont apporté quelques corrections à la documentation de MFENCE provoquant des remous parmi Développeurs JVM . Il s'est avéré que la documentation sur laquelle les développeurs s'appuyaient depuis le début n'était pas si précise en premier lieu.

Les verrous dans .NET entraînent une barrière de mémoire implicite, vous pouvez donc les utiliser en toute sécurité (la plupart du temps, c'est ... voir par exemple ceci Joe Duffy - Brad Abrams - Vance Morrison greatness on paresseux initialisation, verrous, volatils et barrières de mémoire. :) (Assurez-vous de suivre les liens sur cette page.)

En prime, vous aurez vous serez initié au modèle de mémoire .NET lors d'une quête parallèle . :)

Il y a aussi un "oldie but goldie" de Vance Morrison: Ce que chaque développeur doit savoir sur les applications multithread .

... et bien sûr, comme @ Eric mentionné, Joe Duffy est une lecture définitive sur le sujet.

Une bonne STM peut se rapprocher le plus possible d'un verrouillage à grain fin et fournira probablement des performances proches ou égales à une implémentation artisanale. L'un d'eux est STM.NET des projets DevLabs de MS.

Si vous n'êtes pas un fanatique uniquement .NET, Doug Lea a fait un excellent travail dans JSR-166 .
Cliff Click a un point de vue intéressant sur les tables de hachage qui ne repose pas sur le verrouillage des bandes - comme le Java et les tables de hachage simultanées .NET font - et semblent bien évoluer à 750 CPU.

Si vous n'avez pas peur de vous aventurer sur le territoire Linux, l'article suivant fournit plus d'informations sur les composants internes des architectures de mémoire actuelles et comment le partage de lignes de cache peut détruire les performances: Ce que chaque programmeur doit savoir sur la mémoire .

@Ben a fait de nombreux commentaires à propos de MPI: je suis sincèrement d'accord que MPI peut briller dans certains domaines. Une solution basée sur MPI peut être plus facile à raisonner, à implémenter et moins sujet aux erreurs qu'une implémentation de verrouillage à moitié cuit qui essaie d'être intelligent (c'est cependant - subjectivement - également vrai pour une solution basée sur STM). Je parierais également qu'il est plus facile d'écrire correctement un application décente distribuée dans par exemple Erlang, comme le suggèrent de nombreux exemples réussis.

MPI, cependant, a ses propres coûts et ses propres problèmes lorsqu'il est exécuté sur un système unique à plusieurs cœurs . Par exemple. à Erlang, il y a des problèmes à résoudre autour de synchronisation de la planification des processus et des files d'attente de messages .
De plus, dans leur cœur, MPI implémentent généralement une sorte de coopérative ordonnancement N: M pour les "processus légers". Cela signifie par exemple que il y a un changement de contexte inévitable entre les processus légers. Il est vrai que ce n'est pas un "changement de contexte classique" mais surtout une opération de l'espace utilisateur et cela peut être fait rapidement - cependant je doute sincèrement qu'il puisse être placé sous le 20-200 cycles d'une opération interverrouillée . La commutation de contexte en mode utilisateur est certainement plus lente même dans la bibliothèque Intel McRT. La planification N: M avec des processus légers n'est pas nouvelle. LWPs étaient là dans Solaris depuis longtemps. Ils ont été abandonnés. Il y avait des fibres dans NT. Ils sont principalement une relique maintenant. Il y avait des "activations" dans NetBSD. Ils ont été abandonnés. Linux avait sa propre vision du sujet de N: M Il semble être un peu mort maintenant.
De temps en temps, il y a de nouveaux concurrents: par exemple McRT d'Intel , ou plus récemment Planification en mode utilisateur avec ConCRT de Microsoft.
Au niveau le plus bas, ils font ce que fait un planificateur N: M MPI. Erlang - ou tout autre système MPI -), pourrait bénéficier grandement sur les systèmes SMP en exploitant le nouveau UMS .

Je suppose que la question du PO ne concerne pas le bien-fondé et les arguments subjectifs pour/contre toute solution, mais si je devais répondre à cela, je suppose que cela dépend de la tâche: pour construire des structures de données de base de bas niveau et hautes performances qui s'exécutent sur un système unique avec plusieurs cœurs , soit low-lock/"lock- Les techniques gratuites ou une STM donneront les meilleurs résultats en termes de performances et battront probablement une solution MPI à tout moment en termes de performances, même si les rides ci-dessus sont corrigées, par exemple à Erlang).
Pour construire quelque chose de modérément plus complexe qui fonctionne sur un seul système, je choisirais peut-être un verrouillage classique à gros grain ou si les performances sont très préoccupantes, une STM.
Pour construire un système distribué, un système MPI ferait probablement un choix naturel.
Notez qu'il y a implémentations MPI pour . NET aussi (bien qu'ils semblent ne pas être aussi actifs).

96
Andras Vass

Livre de Joe Duffy:

http://www.bluebytesoftware.com/books/winconc/winconc_book_resources.html

Il écrit également un blog sur ces sujets.

L'astuce pour obtenir des programmes à faible verrouillage est de comprendre à un niveau profond précisément quelles sont les règles du modèle de mémoire sur votre combinaison particulière de matériel, système d'exploitation et environnement d'exécution.

Personnellement, je ne suis pas assez intelligent pour faire une programmation à faible verrouillage au-delà d'InterlockedIncrement, mais si vous l'êtes, allez-y. Assurez-vous simplement de laisser beaucoup de documentation dans le code afin que les personnes qui ne sont pas aussi intelligentes que vous ne cassent pas accidentellement l'un de vos invariants de modèle de mémoire et introduisent un bogue impossible à trouver.

27
Eric Lippert

De nos jours, le filetage sans verrouillage n'existe plus. C'était un terrain de jeu intéressant pour les universités et autres, à la fin du siècle dernier, lorsque le matériel informatique était lent et coûteux. algorithme de Dekker a toujours été mon préféré, le matériel moderne l'a mis au pâturage. Ça ne marche plus.

Deux développements ont mis fin à cela: la disparité croissante entre la vitesse de RAM et le CPU. Et la capacité des fabricants de puces de mettre plus d'un cœur de CPU sur une puce.

Le problème de vitesse RAM obligeait les concepteurs de puces à mettre un tampon sur la puce du processeur. Le tampon stocke le code et les données, rapidement accessibles par le cœur du processeur. Et peut être lu et écrit de/vers = RAM à un taux beaucoup plus lent. Ce tampon est appelé cache CPU, la plupart des CPU en ont au moins deux. Le cache de premier niveau est petit et rapide, le second est grand et plus lent. comme le processeur peut lire les données et les instructions du cache de 1er niveau, il s'exécutera rapidement. Un échec de cache est vraiment cher, il met le processeur en veille jusqu'à 10 cycles si les données ne sont pas dans le 1er cache, autant que 200 cycles s'il n'est pas dans le 2ème cache et qu'il doit être lu à partir de la RAM.

Chaque cœur de CPU a son propre cache, ils stockent leur propre "vue" de RAM. Lorsque le CPU écrit des données, l'écriture est effectuée dans le cache qui est ensuite, lentement, vidé dans la RAM. Inévitable, chaque cœur aura désormais une vue différente du contenu RAM. En d'autres termes, un processeur ne sait pas ce qu'un autre processeur a écrit jusqu'à ce que RAM = cycle d'écriture terminé et le CPU rafraîchit sa propre vue.

C'est radicalement incompatible avec le filetage. Vous vous souciez toujours vraiment de l'état d'un autre thread lorsque vous devez lire des données écrites par un autre thread. Pour cela, vous devez programmer explicitement une soi-disant barrière mémoire. Il s'agit d'une primitive CPU de bas niveau qui garantit que tous les caches CPU sont dans un état cohérent et ont une vue à jour de la RAM. Toutes les écritures en attente doivent être vidées dans la RAM, les caches doivent ensuite être actualisés.

Ceci est disponible dans .NET, la méthode Thread.MemoryBarrier () en implémente un. Étant donné que c'est 90% du travail que fait l'instruction de verrouillage (et 95 +% du temps d'exécution), vous n'êtes tout simplement pas en avance en évitant les outils que .NET vous donne et en essayant d'implémenter les vôtres.

19
Hans Passant

Google pour verrouiller les structures de données libres et mémoire transactionnelle logicielle .

Je suis d'accord avec John Skeet sur celui-ci; le filetage sans verrou est le terrain de jeu du diable, et il vaut mieux le laisser aux personnes qui savent qu'elles savent ce qu'elles doivent savoir.

6
Marcelo Cantos

En ce qui concerne le multi-threading, vous devez savoir exactement ce que vous faites. Je veux dire explorer tous les scénarios/cas possibles qui pourraient se produire lorsque vous travaillez dans un environnement multi-thread. Le multithreading sans verrouillage n'est pas une bibliothèque ou une classe que nous incorporons, c'est une connaissance/expérience que nous gagnons au cours de notre voyage sur les threads.

0
bragboy

Même si le filetage sans verrou peut être difficile dans .NET, vous pouvez souvent apporter des améliorations significatives lors de l'utilisation d'un verrou en étudiant exactement ce qui doit être verrouillé et en minimisant la section verrouillée ... cela est également connu sous le nom de minimisation du verrou granularité .

Par exemple, dites simplement que vous devez sécuriser un thread de collection. Ne vous contentez pas de jeter aveuglément un verrou autour d'une méthode itérant sur la collection si elle effectue une tâche gourmande en CPU sur chaque élément. Vous pourriez seulement avoir besoin de verrouiller la création d'une copie superficielle de la collection. Itérer sur la copie peut alors fonctionner sans verrou. Bien sûr, cela dépend fortement des spécificités de votre code, mais j'ai pu résoudre un problème lock convoy avec cette approche.

0
dodgy_coder