Mettre à jour et rendre dans des threads séparés

12

Je crée un moteur de jeu 2D simple et je veux mettre à jour et rendre les sprites dans différents threads, pour savoir comment cela se fait.

J'ai besoin de synchroniser le fil de mise à jour et celui de rendu. Actuellement, j'utilise deux drapeaux atomiques. Le flux de travail ressemble à ceci:

Thread 1 -------------------------- Thread 2
Update obj ------------------------ wait for swap
Create queue ---------------------- render the queue
Wait for render ------------------- notify render done
Swap render queues ---------------- notify swap done

Dans cette configuration, je limite le FPS du fil de rendu au FPS du fil de mise à jour. De plus, j'utilise sleep()pour limiter à la fois le rendu et la mise à jour du FPS du thread à 60, de sorte que les deux fonctions d'attente n'attendront pas beaucoup de temps.

Le problème est:

L'utilisation moyenne du processeur est d'environ 0,1%. Parfois, cela peut aller jusqu'à 25% (dans un PC quadricœur). Cela signifie qu'un thread attend l'autre car la fonction wait est une boucle while avec une fonction test et set, et une boucle while utilise toutes vos ressources CPU.

Ma première question est: existe-t-il une autre façon de synchroniser les deux threads? J'ai remarqué que std::mutex::lockn'utilisez pas le CPU pendant qu'il attend pour verrouiller une ressource, ce n'est donc pas une boucle while. Comment ça marche? Je ne peux pas l'utiliser std::mutexcar je devrai les verrouiller dans un thread et les déverrouiller dans un autre thread.

L'autre question est; comme le programme tourne toujours à 60 FPS, pourquoi son utilisation du processeur passe-t-elle parfois à 25%, ce qui signifie que l'une des deux attentes attend beaucoup? (les deux threads sont tous deux limités à 60 images par seconde, donc ils n'auront idéalement pas besoin de beaucoup de synchronisation).

Edit: Merci pour toutes les réponses. Je veux d'abord dire que je ne démarre pas un nouveau thread à chaque image pour le rendu. Je démarre la boucle de mise à jour et de rendu au début. Je pense que le multithreading peut gagner du temps: j'ai les fonctions suivantes: FastAlg () et Alg (). Alg () est à la fois mon obj de mise à jour et mon obj de rendu et Fastalg () est ma "file d'attente de rendu d'envoi vers" renderer "". En un seul fil:

Alg() //update 
FastAgl() 
Alg() //render

En deux fils:

Alg() //update  while Alg() //render last frame
FastAlg() 

Alors peut-être que le multithreading peut vous faire gagner du temps. (en fait, dans une application mathématique simple, c'est le cas, où alg est un long algorithme et fastalg un plus rapide)

Je sais que le sommeil n'est pas une bonne idée, même si je n'ai jamais eu de problèmes. Est-ce que ce sera mieux?

While(true) 
{
   If(timer.gettimefromlastcall() >= 1/fps)
   Do_update()
}

Mais ce sera une boucle while infinie qui utilisera tout le CPU. Puis-je utiliser le mode veille (un nombre <15) pour limiter l'utilisation? De cette façon, il fonctionnera à, par exemple, 100 images par seconde, et la fonction de mise à jour sera appelée seulement 60 fois par seconde.

Pour synchroniser les deux threads, j'utiliserai waitforsingleobject avec createSemaphore afin de pouvoir verrouiller et déverrouiller dans différents threads (sans utiliser de boucle while), n'est-ce pas?

Liuka
la source
5
"Ne dites pas que mon multithreading est inutile dans ce cas, je veux juste apprendre à le faire" - dans ce cas, vous devez apprendre les choses correctement, c'est-à-dire (a) ne pas utiliser sleep () pour contrôler le cadre rare , jamais jamais , et (b) éviter la conception thread-par-composant et éviter d'exécuter lockstep, au lieu de cela diviser le travail en tâches et gérer les tâches à partir d'une file d'attente de travail.
Damon
1
@Damon (a) sleep () peut être utilisé comme mécanisme de fréquence d'images et est en fait assez populaire, même si je dois convenir qu'il existe de bien meilleures options. (b) L'utilisateur ici souhaite séparer à la fois la mise à jour et le rendu dans deux threads différents. Il s'agit d'une séparation normale dans un moteur de jeu et ce n'est pas si "thread par composant". Cela donne des avantages évidents mais peut poser des problèmes s'il est mal fait.
Alexandre Desbiens
@AlphSpirit: Le fait que quelque chose soit "commun" ne signifie pas que ce n'est pas faux . Sans même entrer dans des temporisations divergentes, la simple granularité du sommeil sur au moins un système d'exploitation de bureau populaire est une raison suffisante, sinon son manque de fiabilité par conception sur chaque système consommateur existant. Expliquer pourquoi séparer la mise à jour et le rendu en deux threads comme décrit n'est pas judicieux et cause plus de problèmes que cela ne prendrait trop de temps. L'objectif du PO est énoncé comme apprendre comment il est fait , ce qui devrait être apprendre comment il est fait correctement . Beaucoup d'articles sur la conception de moteurs MT modernes.
Damon
@Damon Quand j'ai dit que c'était populaire ou commun, je ne voulais pas dire que c'était vrai. Je voulais juste dire qu'il était utilisé par de nombreuses personnes. "... même si je dois convenir qu'il existe de bien meilleures options" signifiait que ce n'était en effet pas un très bon moyen de synchroniser le temps. Désolé pour le malentendu.
Alexandre Desbiens
@AlphSpirit: Pas de soucis :-) Le monde est plein de trucs que beaucoup de gens font (et pas toujours pour une bonne raison), mais quand on commence à apprendre, il faut toujours essayer d'éviter les plus manifestement mauvais.
Damon

Réponses:

25

Pour un moteur 2D simple avec des sprites, une approche à un seul thread est parfaitement bonne. Mais comme vous voulez apprendre à faire du multithreading, vous devez apprendre à le faire correctement.

Ne pas

  • Utilisez 2 threads qui exécutent plus ou moins l'étape de verrouillage, implémentant un comportement à un seul thread avec plusieurs threads. Cela a le même niveau de parallélisme (zéro) mais ajoute une surcharge pour les changements de contexte et la synchronisation. De plus, la logique est plus difficile à comprendre.
  • Utilisez sleeppour contrôler la fréquence d'images. Jamais. Si quelqu'un vous le dit, frappez-le.
    Tout d'abord, tous les moniteurs ne fonctionnent pas à 60 Hz. Deuxièmement, deux minuteries coïncidant au même rythme et fonctionnant côte à côte finiront toujours par se désynchroniser (déposez deux balles de pingpong sur une table de la même hauteur et écoutez). Troisièmement, par sa conception , il sleepn'est ni précis ni fiable. La granularité peut être aussi mauvaise que 15,6 ms (en fait, la valeur par défaut sur Windows [1] ), et un cadre n'est que de 16,6 ms à 60 images par seconde, ce qui ne laisse que 1 ms pour tout le reste. De plus, il est difficile d'obtenir 16,6 pour être un multiple de 15,6 ... En outre, il est autorisé (et parfois!) De revenir uniquement après 30, 50 ou 100 ms, ou encore plus longtemps.
    sleep
  • Utilisez std::mutexpour notifier un autre thread. Ce n'est pas pour ça.
  • Supposons que TaskManager est très bon pour vous dire ce qui se passe, en particulier à en juger par un nombre comme "25% CPU", qui pourrait être dépensé dans votre code, ou dans le pilote en mode utilisateur, ou ailleurs.
  • Avoir un thread par composant de haut niveau (il y a bien sûr quelques exceptions).
  • Créez des threads à des "moments aléatoires", ad hoc, par tâche. La création de threads peut être étonnamment coûteuse et cela peut prendre un temps étonnamment long avant qu'ils ne fassent réellement ce que vous leur avez dit (surtout si vous avez beaucoup de DLL chargées!).

Faire

  • Utilisez le multithreading pour que les choses s'exécutent de manière asynchrone autant que possible. La vitesse n'est pas l'idée principale du filetage, mais de faire les choses en parallèle (donc même si elles prennent plus de temps ensemble, la somme de tous est encore moins).
  • Utilisez la synchronisation verticale pour limiter la fréquence d'images. C'est la seule façon correcte (et non défaillante) de le faire. Si l'utilisateur vous remplace dans le panneau de configuration du pilote d'affichage ("forcer"), qu'il en soit ainsi. Après tout, c'est son ordinateur, pas le vôtre.
  • Si vous devez "cocher" quelque chose à intervalles réguliers, utilisez une minuterie . Les minuteries ont l'avantage d'avoir une précision et une fiabilité bien meilleures par rapport à sleep[2] . En outre, une minuterie récurrente prend correctement en compte le temps (y compris le temps qui s'écoule entre les deux), tandis que le sommeil pendant 16,6 ms (ou 16,6 ms moins measure_time_elapsed) ne le fait pas.
  • Exécutez des simulations physiques qui impliquent l'intégration numérique à un pas de temps fixe (ou vos équations exploseront!), Interpolez les graphiques entre les étapes (cela peut être une excuse pour un thread par composant séparé, mais cela peut également être fait sans).
  • Permet std::mutexà un seul thread d'accéder à une ressource à la fois ("mutuellement exclu") et de se conformer à la sémantique bizarre de std::condition_variable.
  • Évitez que les threads se disputent les ressources. Verrouillez aussi peu que nécessaire (mais rien de moins!) Et maintenez les verrous aussi longtemps que nécessaire.
  • Partagez des données en lecture seule entre les threads (aucun problème de cache et aucun verrouillage nécessaire), mais ne modifiez pas simultanément les données (nécessite une synchronisation et tue le cache). Cela inclut la modification des données qui se trouvent à proximité d' un emplacement que quelqu'un d'autre pourrait lire.
  • Utilisez std::condition_variablepour bloquer un autre thread jusqu'à ce qu'une condition soit remplie. La sémantique de std::condition_variablece mutex supplémentaire est certes assez étrange et tordue (principalement pour des raisons historiques héritées des threads POSIX), mais une variable de condition est la primitive correcte à utiliser pour ce que vous voulez.
    Si vous trouvez std::condition_variabletrop bizarre pour être à l'aise avec cela, vous pouvez également utiliser simplement un événement Windows (légèrement plus lent) à la place ou, si vous êtes courageux, créez votre propre événement simple autour de NtKeyedEvents (implique des choses effrayantes de bas niveau). Comme vous utilisez DirectX, vous êtes déjà lié à Windows de toute façon, donc la perte de portabilité ne devrait pas être un gros problème.
  • Divisez le travail en tâches de taille raisonnable exécutées par un pool de threads de travail de taille fixe (pas plus d'un par cœur, sans compter les cœurs hyperthreadés). Laissez les tâches de finition mettre en file d'attente les tâches dépendantes (synchronisation automatique gratuite). Effectuez des tâches qui comportent chacune au moins quelques centaines d'opérations non triviales (ou une opération de blocage de longue durée comme une lecture de disque). Préférez un accès contigu au cache.
  • Créez tous les threads au démarrage du programme.
  • Profitez des fonctions asynchrones que le système d'exploitation ou l'API graphique offre pour un parallélisme meilleur / supplémentaire, non seulement au niveau du programme mais également sur le matériel (pensez aux transferts PCIe, au parallélisme CPU-GPU, au disque DMA, etc.).
  • 10 000 autres choses que j'ai oublié de mentionner.


[1] Oui, vous pouvez définir le taux de l'ordonnanceur à 1 ms, mais cela est mal vu car il provoque beaucoup plus de changements de contexte et consomme beaucoup plus d'énergie (dans un monde où de plus en plus d'appareils sont des appareils mobiles). Ce n'est pas non plus une solution car cela ne rend toujours pas le sommeil plus fiable.
[2] Une minuterie augmentera la priorité du thread, ce qui lui permettra d'interrompre un autre thread de priorité égale au milieu du quantum et d'être programmé en premier, ce qui est un comportement quasi-RT. Ce n'est bien sûr pas vrai RT, mais ça s'en rapproche. Se réveiller du sommeil signifie simplement que le fil est prêt à être programmé à un moment donné, quand cela est possible.

Damon
la source
Pouvez-vous expliquer pourquoi vous ne devriez pas "avoir un thread par composant de haut niveau"? Voulez-vous dire que l'on ne devrait pas avoir la physique et le mixage audio dans deux threads séparés? Je ne vois aucune raison de ne pas le faire.
Elviss Strazdins
3

Je ne suis pas sûr de ce que vous voulez réaliser en limitant le FPS de la mise à jour et du rendu à 60. Si vous les limitez à la même valeur, vous auriez pu les mettre dans le même thread.

Le but lors de la séparation de la mise à jour et du rendu dans différents threads est d'avoir les deux «presque» indépendants l'un de l'autre, afin que le GPU puisse rendre 500 FPS et que la logique de mise à jour continue à 60 FPS. Vous n'obtenez pas un gain de performances très élevé en procédant ainsi.

Mais vous avez dit que vous vouliez simplement savoir comment cela fonctionne, et ça va. En C ++, un mutex est un objet spécial qui est utilisé pour verrouiller l'accès à certaines ressources pour d'autres threads. En d'autres termes, vous utilisez un mutex pour rendre les données sensibles accessibles par un seul thread à la fois. Pour ce faire, c'est assez simple:

std::mutex mutex;
mutex.lock();
// Do sensible stuff here...
mutex.unlock();

Source: http://en.cppreference.com/w/cpp/thread/mutex

EDIT : assurez-vous que votre mutex est à l'échelle de la classe ou du fichier, comme dans le lien donné, sinon chaque thread créera son propre mutex et vous n'obtiendrez rien.

Le premier thread pour verrouiller le mutex aura accès au code à l'intérieur. Si un deuxième thread essaie d'appeler la fonction lock (), il se bloquera jusqu'à ce que le premier thread le déverrouille. Un mutex est donc une fonction de blocage, contrairement à une boucle while. Les fonctions de blocage ne mettront pas de stress sur le CPU.

Alexandre Desbiens
la source
Et comment fonctionne le bloc?
Liuka
Lorsque le deuxième thread appellera lock (), il attendra patiemment que le premier thread déverrouille le mutex et continuera sur la ligne suivante après (dans cet exemple, le truc sensible). EDIT: Le deuxième thread verrouille alors le mutex pour lui-même.
Alexandre Desbiens
1
Utiliser std::lock_guardou similaire, non .lock()/ .unlock(). RAII n'est pas seulement pour la gestion de la mémoire!
bcrist