Comment effectuer une lecture multithread de manière sûre et efficace dans .NET
Le multithreading peut être utilisé pour accélérer considérablement les performances de votre application, mais aucune accélération n’est gratuite – la gestion des threads parallèles nécessite une programmation minutieuse et, sans les précautions appropriées, vous pouvez rencontrer des conditions de concurrence, des blocages et même des plantages.
Sommaire
Qu’est-ce qui rend le multithreading difficile?
Sauf indication contraire de votre programme, tout votre code s’exécute sur le «fil de discussion principal». Depuis le point d’entrée de votre application, il parcourt et exécute toutes vos fonctions l’une après l’autre. Cela a une limite aux performances, car il est évident que vous ne pouvez en faire beaucoup que si vous devez tout traiter un à la fois. La plupart des processeurs modernes ont six cœurs ou plus avec 12 threads ou plus, il reste donc des performances sur la table si vous ne les utilisez pas.
Cependant, ce n’est pas aussi simple que «d’activer le multithreading». Seules des choses spécifiques (telles que les boucles) peuvent être correctement multithread, et il y a beaucoup de considérations à prendre en compte lors de cette opération.
Le premier et le plus important problème est conditions de course. Celles-ci se produisent souvent lors des opérations d’écriture, lorsqu’un thread modifie une ressource partagée par plusieurs threads. Cela conduit à un comportement où la sortie du programme dépend du thread qui termine ou modifie quelque chose en premier, ce qui peut conduire à un comportement aléatoire et inattendu.
Celles-ci peuvent être très, très simples – par exemple, vous devez peut-être garder un compte courant de quelque chose entre les boucles. Le moyen le plus évident de le faire est de créer une variable et de l’incrémenter, mais ce n’est pas thread-safe.
Cette condition de concurrence se produit parce qu’il ne s’agit pas simplement d ‘«en ajouter un à la variable» dans un sens abstrait; la CPU charge la valeur de number
dans le registre, en ajoutant un à cette valeur, puis en stockant le résultat en tant que nouvelle valeur de la variable. Il ne sait pas qu’entre-temps, un autre thread essayait également de faire exactement la même chose et a chargé une valeur bientôt incorrecte de number
. Les deux threads sont en conflit, et à la fin de la boucle, number
peut ne pas être égal à 100.
.NET fournit une fonctionnalité pour aider à gérer cela: le lock
mot-clé. Cela n’empêche pas d’apporter des modifications purement et simplement, mais cela aide à gérer la concurrence en n’autorisant qu’un seul thread à la fois pour obtenir le verrou. Si un autre thread essaie d’entrer une instruction de verrouillage pendant qu’un autre thread est en cours de traitement, il attendra jusqu’à 300 ms avant de continuer.
Vous ne pouvez verrouiller que des types de référence, donc un modèle courant crée au préalable un objet de verrouillage et l’utilise comme substitut au verrouillage du type de valeur.
Cependant, vous remarquerez peut-être qu’il y a maintenant un autre problème: impasses. Ce code est le pire des cas, mais ici, c’est presque exactement la même chose que de faire un simple for
boucle (en fait un peu plus lent, car les threads et les verrous supplémentaires sont une surcharge supplémentaire). Chaque thread essaie d’obtenir le verrou, mais un seul à la fois peut avoir le verrou, de sorte qu’un seul thread à la fois peut réellement exécuter le code à l’intérieur du verrou. Dans ce cas, c’est tout le code de la boucle, donc l’instruction lock supprime tous les avantages du threading et ralentit tout simplement.
En règle générale, vous souhaitez verrouiller si nécessaire chaque fois que vous devez effectuer des écritures. Cependant, vous voudrez garder à l’esprit la concurrence lors du choix des éléments à verrouiller, car les lectures ne sont pas toujours sûres pour les threads non plus. Si un autre thread écrit sur l’objet, sa lecture à partir d’un autre thread peut donner une valeur incorrecte ou provoquer une condition particulière pour renvoyer un résultat incorrect.
Heureusement, il existe quelques astuces pour faire cela correctement où vous pouvez équilibrer la vitesse du multithreading tout en utilisant des verrous pour éviter les conditions de course.
Utiliser Interlocked pour les opérations atomiques
Pour les opérations de base, en utilisant le lock
déclaration peut être exagérée. Bien que ce soit très utile pour verrouiller avant des modifications complexes, c’est trop de surcharge pour quelque chose d’aussi simple que d’ajouter ou de remplacer une valeur.
Interlocked est une classe qui englobe certaines opérations de mémoire telles que l’ajout, le remplacement et la comparaison. Les méthodes sous-jacentes sont implémentées au niveau du CPU et garanties atomiques, et beaucoup plus rapides que la norme lock
déclaration. Vous voudrez les utiliser autant que possible, bien qu’ils ne remplacent pas entièrement le verrouillage.
Dans l’exemple ci-dessus, remplacer le verrou par un appel à Interlocked.Add()
accélérera beaucoup l’opération. Bien que cet exemple simple ne soit pas plus rapide que de ne pas utiliser Interlocked, il est utile dans le cadre d’une opération plus large et reste une accélération.
Il y a aussi Increment
et Decrement
pour ++
et --
opérations, ce qui vous fera économiser deux frappes solides. Ils enveloppent littéralement Add(ref count, 1)
sous le capot, il n’y a donc pas d’accélération spécifique à leur utilisation.
Vous pouvez également utiliser Exchange, une méthode générique qui définira une variable égale à la valeur qui lui est transmise. Cependant, vous devez être prudent avec celui-ci – si vous le définissez sur une valeur que vous avez calculée à l’aide de la valeur d’origine, ce n’est pas thread-safe, car l’ancienne valeur pourrait avoir été modifiée avant d’exécuter Interlocked.Exchange.
CompareExchange vérifie l’égalité de deux valeurs et remplace la valeur si elles sont égales.
Utiliser les collections Thread Safe
Les collections par défaut dans System.Collections.Generic
peuvent être utilisés avec le multithreading, mais ils ne sont pas entièrement thread-safe. Microsoft fournit des implémentations thread-safe de certaines collections dans System.Collections.Concurrent
.
Parmi ceux-ci, citons le ConcurrentBag
, une collection générique non ordonnée, et ConcurrentDictionary,
un dictionnaire thread-safe. Il existe également des files d’attente et des piles simultanées, et OrderablePartitioner
, qui peut diviser les sources de données commandables telles que les listes en partitions séparées pour chaque thread.
Cherchez à paralléliser les boucles
Souvent, l’endroit le plus simple pour le multithread est dans les grandes boucles coûteuses. Si vous pouvez exécuter plusieurs options en parallèle, vous pouvez obtenir une accélération considérable du temps de fonctionnement global.
La meilleure façon de gérer cela est d’utiliser System.Threading.Tasks.Parallel
. Cette classe fournit des remplacements pour for
et foreach
boucles qui exécutent les corps de boucle sur des threads séparés. Il est simple à utiliser, mais nécessite une syntaxe légèrement différente:
De toute évidence, le hic ici est que vous devez vous assurer DoSomething()
est thread-safe et n’interfère pas avec les variables partagées. Cependant, ce n’est pas toujours aussi simple que de simplement remplacer la boucle par une boucle parallèle, et dans de nombreux cas, vous devez lock
objets partagés pour apporter des modifications.
Pour atténuer certains des problèmes de blocage, Parallel.For
et Parallel.ForEach
fournir des fonctionnalités supplémentaires pour gérer l’état. En gros, toutes les itérations ne seront pas exécutées sur un thread séparé – si vous avez 1 000 éléments, cela ne créera pas 1 000 threads; il va créer autant de threads que votre CPU peut gérer et exécuter plusieurs itérations par thread. Cela signifie que si vous calculez un total, vous n’avez pas besoin de verrouiller pour chaque itération. Vous pouvez simplement passer autour d’une variable de sous-total et, à la toute fin, verrouiller l’objet et apporter des modifications une fois. Cela réduit considérablement les frais généraux sur les très grandes listes.
Jetons un œil à un exemple. Le code suivant prend une grande liste d’objets et doit sérialiser chacun séparément en JSON, se terminant par un List<string>
de tous les objets. La sérialisation JSON est un processus très lent, donc diviser chaque élément sur plusieurs threads est une grande accélération.
Il y a un tas d’arguments, et beaucoup à déballer ici:
- Le premier argument prend un IEnumerable, qui définit les données sur lesquelles il boucle. Il s’agit d’une boucle ForEach, mais le même concept fonctionne pour les boucles For de base.
- La première action initialise la variable de sous-total local. Cette variable sera partagée à chaque itération de la boucle, mais uniquement à l’intérieur du même thread. Les autres threads auront leurs propres sous-totaux. Ici, nous l’initialisons dans une liste vide. Si vous calculiez un total numérique, vous pourriez
return 0
ici. - La deuxième action est le corps de la boucle principale. Le premier argument est l’élément courant (ou l’index dans une boucle For), le second est un objet ParallelLoopState que vous pouvez utiliser pour appeler
.Break()
, et la dernière est la variable de sous-total.- Dans cette boucle, vous pouvez opérer sur l’élément et modifier le sous-total. La valeur que vous renvoyez remplacera le sous-total de la boucle suivante. Dans ce cas, nous sérialisons l’élément en une chaîne, puis ajoutons la chaîne au sous-total, qui est une liste.
- Enfin, la dernière action prend le sous-total «résultat» une fois toutes les exécutions terminées, ce qui vous permet de verrouiller et de modifier une ressource en fonction du total final. Cette action s’exécute une fois, à la toute fin, mais elle s’exécute toujours sur un thread distinct, vous devrez donc verrouiller ou utiliser des méthodes interverrouillées pour modifier les ressources. Ici, nous appelons
AddRange()
pour ajouter la liste des sous-totaux à la liste finale.
Multithreading Unity
Une dernière remarque: si vous utilisez le moteur de jeu Unity, vous devez être prudent avec le multithreading. Vous ne pouvez pas appeler d’API Unity, sinon le jeu plantera. Il est possible de l’utiliser avec parcimonie en effectuant des opérations API sur le thread principal et en alternant chaque fois que vous avez besoin de paralléliser quelque chose.
Cela s’applique principalement aux opérations qui interagissent avec la scène ou le moteur physique. Les mathématiques Vector3 ne sont pas affectées et vous êtes libre de les utiliser à partir d’un thread séparé sans problèmes. Vous êtes également libre de modifier les champs et les propriétés de vos propres objets, à condition qu’ils n’appellent aucune opération Unity sous le capot.