Comment dois-je structurer mes classes pour permettre une simulation multithread?

8

Dans mon jeu, il y a des parcelles de terrain avec des bâtiments (maisons, centres de ressources). Les bâtiments comme les maisons ont des locataires, des chambres, des modules complémentaires, etc., et plusieurs valeurs doivent être simulées en fonction de toutes ces variables.

Maintenant, j'aimerais utiliser AndEngine pour les choses frontales et créer un autre thread pour faire les calculs de simulation (peut-être aussi plus tard inclure l'IA dans ce thread). C'est ainsi qu'un thread entier ne fait pas tout le travail et cause des problèmes tels que le blocage. Cela introduit le problème de la concurrence et de la dépendance .

Le problème de devise est mon thread d'interface utilisateur principal et le thread de calcul devrait tous les deux accéder à tous les objets de simulation. Je dois donc les rendre thread-safe, mais je ne sais pas comment stocker et structurer les objets de simulation pour permettre cela.

Le problème de dépendance est que pour calculer des valeurs, mes calculs dépendent des valeurs d'autres objets.

Quelle serait la meilleure façon de relier mon objet locataire dans le bâtiment à mes calculs? Le coder en dur dans la classe des locataires? Quelle est la bonne façon de faire des algorithmes de "stockage" pour les ajuster facilement?

Une manière paresseuse simple serait de tout regrouper dans une classe qui contient tout l'objet, comme les parcelles de terrain (qui à leur tour détiennent les bâtiments, et cetera). Cette classe contiendrait également l'état du jeu tel que la technologie disponible pour l'utilisateur, les pools d'objets pour des choses telles que les sprites. Mais c'est une façon paresseuse et dangereuse, n'est-ce pas?

Edit: je regardais l'injection de dépendance, mais dans quelle mesure cela fait-il face à une classe qui contient d'autres objets? c'est-à-dire mon terrain, avec un immeuble, qui a un locataire et une foule d'autres valeurs. DI ressemble également à une douleur dans le cul avec AndEngine.

NiffyShibby
la source
Juste une note rapide, il n'y a pas de problème d'accès simultané aux données si l'un des accès est uniquement en lecture seule. Tant que vous conservez votre rendu en lisant uniquement les données brutes pour l'utiliser pour le rendu et en ne mettant pas à jour les données pendant leur traitement, il n'y a pas de problème. Un thread met à jour les données, l'autre thread ne fait que les lire et les restitue.
James
Eh bien, l'accès simultané est toujours un problème, car l'utilisateur peut acheter un terrain, construire un bâtiment sur ce terrain et installer un locataire dans une maison, de sorte que le thread principal crée des données et peut modifier les données. L'accès simultané n'est pas tellement un problème, il s'agit plus de son instance de partage entre le thread principal et le thread enfant.
NiffyShibby
Je parle de dépendance comme d'un problème, il semble que des gens comme Google pensent que cacher la dépendance n'est pas une chose sage. Mes calculs de locataire dépendent du terrain à bâtir, de la construction, de la création d'un sprite à l'écran (je pourrais avoir une relation relationnelle entre un locataire de la construction et la création d'un sprite de locataire ailleurs)
NiffyShibby
Je suppose que ma suggestion devrait être interprétée comme faisant que les choses qui sont délimitées soient des choses qui sont autonomes ou nécessitent un accès en lecture seule aux données gérées par un autre fil. Le rendu serait un exemple de quelque chose que vous pourriez délier comme il le ferait besoin uniquement d'un accès en lecture aux données pour pouvoir les afficher.
James
1
James, même un accès en lecture seule peut être une mauvaise idée si un autre thread est en train d'apporter des modifications à cet objet. Avec une structure de données complexe, cela pourrait provoquer un crash et avec des types de données simples, cela pourrait provoquer une lecture incohérente.
Kylotan

Réponses:

4

Votre problème est intrinsèquement série - vous devez effectuer une mise à jour de la simulation avant de pouvoir la rendre. Le déchargement de la simulation vers un thread différent signifie simplement que le thread d'interface utilisateur principal ne fait rien pendant que le thread de simulation coche (ce qui signifie qu'il est bloqué).

La «meilleure pratique» couramment utilisée pour la concurrence n'est pas de mettre votre rendu sur un thread et votre simulation sur un autre, comme vous le proposez. En fait, je déconseille fortement cette approche. Les deux opérations sont naturellement liées en série, et bien qu'elles puissent être forcées brutalement, elles ne sont pas optimales et ne sont pas évolutives .

Une meilleure approche consiste à rendre des parties de la mise à jour ou du rendu simultanées, mais de laisser la mise à jour et le rendu eux-mêmes toujours en série. Ainsi, par exemple, si vous avez une limite naturelle dans votre simulation (par exemple, si les maisons ne s'influencent jamais dans votre simulation), vous pouvez pousser toutes les maisons dans des seaux de N maisons et faire tourner un tas de fils que chacun traite un et laissez ces threads se joindre avant la fin de l'étape de mise à jour. Cela évolue beaucoup mieux et convient beaucoup mieux à la conception simultanée.

Vous pensez trop au reste du problème:

L'injection de dépendances est un redingue ici: tout ce que signifie l'injection de dépendances est que vous passez ("injectez") les dépendances d'une interface aux instances de cette interface, généralement pendant la construction.

Cela signifie que si vous avez une classe qui modélise un House, qui doit savoir des choses sur le Citycontenu, le Houseconstructeur peut ressembler à ceci :

public House( City containingCity ) {
  m_city = containingCity; // Store in a member variable for later access
  ...
}

Rien de spécial.

L'utilisation d'un singleton n'est pas nécessaire (vous le voyez souvent dans certains des "cadres DI" incroyablement complexes et sur-conçus comme Caliburn qui sont conçus pour les applications GUI "d'entreprise" - cela n'en fait pas une bonne solution). En fait, l'introduction de singletons est souvent l'antithèse d'une bonne gestion des dépendances. Ils peuvent également causer de graves problèmes avec le code multithread car ils ne peuvent généralement pas être rendus thread-safe sans verrous - plus vous devez acquérir de verrous, pire votre problème était adapté à une gestion parallèle.


la source
Je me souviens avoir dit que les singletons étaient mauvais dans mon message d'origine ...
NiffyShibby
Je me souviens avoir dit que les singletons étaient mauvais dans mon message d'origine, mais cela a été supprimé. Je pense que je comprends ce que vous dites. Par exemple, ma petite personne marche sur un écran, car il fait que le thread de mise à jour soit appelé, elle doit le mettre à jour, mais ne peut pas parce que le thread principal utilise l'objet, donc mon autre thread est bloqué. Où comme je devrais mettre à jour entre le rendu.
NiffyShibby
Quelqu'un m'a envoyé un lien utile. gamedev.stackexchange.com/questions/95/…
NiffyShibby
5

La solution habituelle pour les problèmes de concurrence est l' isolement des données .

L'isolement signifie que chaque thread a ses propres données et ne touche pas les données des autres threads. De cette façon, il n'y a pas de problèmes avec la concurrence ... mais alors nous avons un problème de communication. Comment ces threads peuvent-ils fonctionner ensemble s'ils ne partagent aucune donnée?

Il y a deux approches ici.

Le premier est l' immuabilité . Les structures / variables immuables sont celles qui ne changent jamais leur état. Au début, cela peut sembler inutile - comment peut-on utiliser une "variable" qui ne change jamais? Cependant, nous pouvons échanger ces variables! Prenons cet exemple: supposons que vous ayez une Tenantclasse avec un tas de champs, qui doit être dans un état cohérent. Si vous modifiez un Tenantobjet dans le thread A et observez-le en même temps à partir du thread B, le thread B peut voir l'objet dans un état incohérent. Cependant, si Tenantest immuable, le thread A ne peut pas le changer. Au lieu de cela, il crée de nouveaux Tenantavec des champs configurés selon les besoins et les échange avec l'ancien. L'échange n'est qu'un changement vers une référence, qui est probablement atomique, et donc il n'y a aucun moyen d'observer l'objet dans un état incohérent.

La deuxième approche est la messagerie . L'idée derrière cela est que lorsque toutes les données sont "détenues" par un thread, nous pouvons dire à ce thread quoi faire avec les données. Chaque thread de cette architecture a une file d'attente de messages - une liste d' Messageobjets et une pompe de messagerie - qui exécute constamment une méthode qui supprime un message de la file d'attente, l'interprète et appelle une méthode de gestionnaire. Par exemple, supposons que vous ayez exploité une parcelle de terrain, signalant qu'elle doit être achetée. Le thread d'interface utilisateur ne peut pas modifier Plotdirectement l' objet, car il appartient au thread logique (et est probablement immuable). Le thread d'interface utilisateur construit donc un BuyMessageobjet à la place et l'ajoute à la file d'attente du thread logique. Le thread logique, lorsqu'il est en cours d'exécution, prend le message de la file d'attente et appelleBuyPlot(), extraire les paramètres de l'objet message. Cela pourrait renvoyer un message, par exemple BuySuccessfulMessage, demandant au thread d'interface utilisateur de mettre en place un "Maintenant, vous avez plus de terrain!" fenêtre à l'écran. Bien sûr, l'accès à la file d'attente de messages doit être synchronisé avec le verrou, la section critique ou tout autre nom dans AndEngine. Mais il s'agit d'un seul point de synchronisation entre les threads, et les threads sont suspendus pendant une très courte période, donc ce n'est pas un problème.

Ces deux approches sont mieux utilisées en combinaison. Vos threads doivent communiquer avec les messages et avoir des données immuables "ouvertes" pour d'autres threads - par exemple, une liste immuable de tracés pour l'interface utilisateur pour les dessiner.

Notez également que "lecture seule" ne signifie pas nécessairement immuable ! Toute structure de données complexe comme une table de hachage peut changer son état interne sur les accès en lecture, donc vérifiez d'abord avec la documentation.

Ça ne fait rien
la source
Cela semble d'une manière ordonnée, je vais devoir faire des tests avec cela, cela semble assez cher de cette façon, je pensais dans le sens de DI avec une portée de singleton, puis utiliser des verrous pour l'accès simultané. Mais je n'ai jamais pensé à le faire de cette façon, cela pourrait fonctionner: D
NiffyShibby
Eh bien, c'est comme ça que nous faisons la concurrence sur un serveur multithread hautement simultané. Probablement un peu exagéré pour un jeu simple, mais c'est l'approche que j'utiliserais moi-même.
Nevermind
4

Probablement 99% des programmes informatiques écrits dans l'histoire n'utilisaient qu'un seul thread et fonctionnaient bien. Je n'ai aucune expérience de l'AndEngine mais il est très rare de trouver des systèmes qui nécessitent du threading, juste plusieurs qui auraient pu en bénéficier, avec le bon matériel.

Traditionnellement, pour faire de la simulation et de l'interface graphique / rendu dans un seul thread, vous faites simplement un peu de simulation, puis vous effectuez un rendu et vous répétez, généralement plusieurs fois par seconde.

Quand quelqu'un a peu d'expérience de l'utilisation de plusieurs processus ou ne comprend pas complètement ce que signifie la `` sécurité '' des threads (qui est un terme vague qui peut signifier beaucoup de choses différentes), il est trop facile d'introduire de nombreux bogues dans un système. Donc, personnellement, je recommanderais d'adopter l'approche à un seul thread, d'entrelacer la simulation et le rendu, et d'enregistrer tout thread pour des opérations dont vous savez avec certitude qu'elles vont prendre beaucoup de temps et nécessiteront absolument des threads et non un modèle basé sur des événements.

Kylotan
la source
Andengine fait le rendu pour moi, mais je pense toujours que les calculs doivent aller dans un autre thread, car le thread principal de l'interface utilisateur finirait par ralentir s'il n'est pas bloqué si tout est fait dans un thread.
NiffyShibby
Pourquoi ressentez-vous cela? Avez-vous des calculs plus chers qu'un jeu 3D classique? Et savez-vous que la plupart des appareils Android n'ont qu'un seul cœur et ne tirent donc aucun avantage intrinsèque des performances des threads supplémentaires?
Kylotan
Non, mais c'est bien de séparer la logique et de définir clairement ce qui est fait, si vous le gardez dans le même thread, vous devrez référencer la classe principale où cela se déroule ou faire une DI avec une portée singleton. Ce qui n'est pas vraiment un problème. En ce qui concerne le noyau, nous voyons plus d'appareils Android dual core sortir, mon idée de jeu pourrait ne pas fonctionner du tout bien sur un appareil single core tandis que sur un dual core cela pourrait fonctionner assez bien.
NiffyShibby
Donc, tout concevoir en un seul thread ne me semble pas une bonne idée, au moins avec des threads, je peux séparer la logique et à l'avenir ne pas avoir à me soucier d'améliorer les performances comme je l'ai conçu depuis le début.
NiffyShibby
Mais votre problème est toujours intrinsèquement série, vous bloquerez donc probablement vos deux threads en attendant qu'ils se joignent de toute façon, à moins que vous n'ayez isolé les données (donnant au thread de rendu quelque chose à faire pendant que le thread logique tique) et le rendu d'une trame ou alors derrière la simulation. L'approche que vous décrivez n'est pas la meilleure pratique communément acceptée pour la conception de simultanéité.