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.
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 autant 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 ». Seuls des éléments spécifiques (tels que les boucles) peuvent être correctement multithread, et il y a de nombreuses 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 une à la variable» dans un sens abstrait; la CPU charge la valeur de nombre
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 nombre
. Les deux threads sont en conflit, et à la fin de la boucle, nombre
peut ne pas être égal à 100.
.NET fournit une fonctionnalité pour aider à gérer cela: le fermer à clé
mot-clé. Cela n’empêche pas d’effectuer des modifications, mais cela permet de gérer la concurrence en n’autorisant qu’un seul thread à la fois à 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. Un modèle courant consiste donc à créer au préalable un objet de verrouillage et à l'utiliser 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 pour
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, il s'agit du code entier 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 devez garder à l'esprit la concurrence lors du choix des éléments à verrouiller, car les lectures ne sont pas toujours sécurisées pour les threads. 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 fermer à clé
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 l'ajout ou le remplacement d'une valeur.
Imbriqué est une classe qui englobe certaines opérations de mémoire comme l'ajout, le remplacement et la comparaison. Les méthodes sous-jacentes sont implémentées au niveau du processeur et garanties atomiques, et beaucoup plus rapides que la norme fermer à clé
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 simplement ne pas utiliser Interlocked, il est utile dans le cadre d'une opération plus large et constitue toujours une accélération.
Il y a aussi Incrément
et Décrémenter
pour ++
et -
opérations, ce qui vous fera économiser deux frappes solides. Ils enveloppent littéralement Ajouter (nombre de références, 1)
sous le capot, il n'y a donc pas d'accélération spécifique à leur utilisation.
Vous pouvez aussi utiliser Échange, une méthode générique qui définira une variable égale à la valeur qui lui est passée. 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 aurait pu être modifiée avant d'exécuter Interlocked.Exchange.
ComparerExchange vérifiera l'égalité de deux valeurs et la remplacera 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 y a aussi files d'attente et piles simultanées, et OrderablePartitioner
, qui peut diviser les sources de données pouvant être commandées 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 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 pour
et pour chaque
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 Faire quelque chose()
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 fermer à clé
objets partagés pour apporter des modifications.
Pour atténuer certains des problèmes de blocage, Parallèle.Pour
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.
Prenons 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 liste
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écouvrir 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
retourne 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
.Pause()
, 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 appeler aucune API Unity, sinon le jeu plantera. Il est possible de l'utiliser avec parcimonie en effectuant des opérations d'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.