Aktualisieren und rendern Sie in separaten Threads

11

Ich erstelle eine einfache 2D-Spiel-Engine und möchte die Sprites in verschiedenen Threads aktualisieren und rendern, um zu erfahren, wie es gemacht wird.

Ich muss den Update-Thread und den Render-Thread synchronisieren. Derzeit verwende ich zwei Atomflags. Der Workflow sieht ungefähr so ​​aus:

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

In diesem Setup beschränke ich die FPS des Render-Threads auf die FPS des Update-Threads. Außerdem beschränke ich sleep()die FPS des Render- und Update-Threads auf 60, damit die beiden Wartefunktionen nicht lange warten.

Das Problem ist:

Die durchschnittliche CPU-Auslastung liegt bei 0,1%. Manchmal sind es bis zu 25% (in einem Quad-Core-PC). Dies bedeutet, dass ein Thread auf den anderen wartet, da die Wartefunktion eine while-Schleife mit einer Test- und Set-Funktion ist und eine while-Schleife alle Ihre CPU-Ressourcen verwendet.

Meine erste Frage lautet: Gibt es eine andere Möglichkeit, die beiden Threads zu synchronisieren? Ich habe festgestellt, dass std::mutex::lockdie CPU nicht verwendet wird, während sie darauf wartet, eine Ressource zu sperren, sodass es sich nicht um eine while-Schleife handelt. Wie funktioniert es? Ich kann nicht verwenden, std::mutexda ich sie in einem Thread sperren und in einem anderen Thread entsperren muss.

Die andere Frage ist; Da das Programm immer mit 60 FPS ausgeführt wird, warum springt die CPU-Auslastung manchmal auf 25%, was bedeutet, dass eine der beiden Wartezeiten viel wartet? (Die beiden Threads sind beide auf 60 fps begrenzt, sodass sie im Idealfall nicht viel Synchronisation benötigen.)

Edit: Danke für alle Antworten. Zuerst möchte ich sagen, dass ich nicht in jedem Frame einen neuen Thread zum Rendern starte. Ich starte sowohl die Aktualisierungs- als auch die Renderschleife am Anfang. Ich denke, Multithreading kann einige Zeit sparen: Ich habe die folgenden Funktionen: FastAlg () und Alg (). Alg () ist sowohl mein Update-Objekt als auch mein Render-Objekt und Fastalg () ist meine "Render-Warteschlange an" Renderer "senden. In einem einzigen Thread:

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

In zwei Threads:

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

Vielleicht kann Multithreading gleichzeitig Zeit sparen. (Tatsächlich in einer einfachen mathematischen Anwendung, in der alg ein langer Algorithmus und fastalg ein schnellerer Algorithmus ist)

Ich weiß, dass Schlaf keine gute Idee ist, obwohl ich nie Probleme habe. Wird das besser?

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

Dies ist jedoch eine Endlosschleife, die die gesamte CPU belegt. Kann ich den Schlaf (eine Zahl <15) verwenden, um die Nutzung einzuschränken? Auf diese Weise wird es beispielsweise mit 100 fps ausgeführt, und die Aktualisierungsfunktion wird nur 60 Mal pro Sekunde aufgerufen.

Um die beiden Threads zu synchronisieren, verwende ich waitforsingleobject mit createSemaphore, damit ich in verschiedenen Threads sperren und entsperren kann (ohne Verwendung einer while-Schleife), nicht wahr?

Liuka
quelle
5
"Sagen Sie nicht, dass mein Multithreading in diesem Fall nutzlos ist, ich möchte nur lernen, wie es geht" - in diesem Fall sollten Sie die Dinge richtig lernen, dh (a) verwenden Sie nicht sleep (), um den seltenen Frame zu steuern , nie , und (b) zu vermeiden Gewinde-per-Komponenten - Design und vermeiden Sie laufen Lockstep, anstatt Split Arbeit in Aufgaben und Griff Aufgaben aus einer Arbeitswarteschlange.
Damon
1
@Damon (a) sleep () kann als Frameratenmechanismus verwendet werden und ist in der Tat sehr beliebt, obwohl ich zustimmen muss, dass es weitaus bessere Optionen gibt. (b) Der Benutzer möchte hier sowohl Update als auch Rendering in zwei verschiedenen Threads trennen. Dies ist eine normale Trennung in einer Spiel-Engine und nicht so "Thread-per-Component". Es bietet klare Vorteile, kann aber bei falscher Ausführung zu Problemen führen.
Alexandre Desbiens
@AlphSpirit: Die Tatsache, dass etwas "häufig" ist, bedeutet nicht, dass es nicht falsch ist . Ohne auf unterschiedliche Timer einzugehen, ist die bloße Granularität des Ruhezustands auf mindestens einem gängigen Desktop-Betriebssystem Grund genug, wenn nicht sogar die Unzuverlässigkeit pro Design auf jedem vorhandenen Verbrauchersystem. Es wäre zu unklug zu erklären, warum es unklug ist, Update und Rendering wie beschrieben in zwei Threads zu trennen und mehr Probleme zu verursachen, als es wert ist. Das Ziel des OP ist es, zu lernen, wie es gemacht wird , und zu lernen, wie es richtig gemacht wird . Viele Artikel zum modernen MT-Motordesign.
Damon
@Damon Als ich sagte, es sei beliebt oder üblich, wollte ich nicht sagen, dass es richtig ist. Ich meinte nur, dass es von vielen Leuten benutzt wurde. "... obwohl ich zustimmen muss, dass es weitaus bessere Optionen gibt" bedeutete, dass es in der Tat keine sehr gute Möglichkeit ist, die Zeit zu synchronisieren. Entschuldigen Sie das Missverständnis.
Alexandre Desbiens
@AlphSpirit: Keine Sorge :-) Die Welt ist voll von Dingen, die viele Leute tun (und das nicht immer aus gutem Grund), aber wenn man anfängt zu lernen, sollte man immer noch versuchen, die offensichtlich falschen zu vermeiden.
Damon

Antworten:

25

Für eine einfache 2D-Engine mit Sprites ist ein Single-Threaded-Ansatz vollkommen gut. Da Sie jedoch lernen möchten, wie man Multithreading ausführt, sollten Sie lernen, es richtig zu machen.

Unterlassen Sie

  • Verwenden Sie zwei Threads, die mehr oder weniger im Sperrschritt ausgeführt werden und ein Single-Thread-Verhalten mit mehreren Threads implementieren. Dies hat den gleichen Grad an Parallelität (Null), erhöht jedoch den Overhead für Kontextwechsel und Synchronisation. Außerdem ist die Logik schwerer zu verstehen.
  • Verwenden Sie sleepdiese Option , um die Bildrate zu steuern. Noch nie. Wenn dir jemand sagt, dass du es tun sollst, schlag ihn.
    Erstens arbeiten nicht alle Monitore mit 60 Hz. Zweitens werden zwei Timer, die mit der gleichen Geschwindigkeit nebeneinander laufen, immer nicht mehr synchron sein (lassen Sie zwei Pingpong-Bälle aus derselben Höhe auf einen Tisch fallen und hören Sie zu). Drittens sleepist von Natur aus weder genau noch zuverlässig. Die Granularität kann bis zu 15,6 ms betragen (tatsächlich die Standardeinstellung unter Windows [1] ), und ein Frame ist nur 16,6 ms bei 60 fps, sodass für alles andere nur 1 ms übrig bleiben. Außerdem ist es schwierig, 16,6 zu einem Vielfachen von 15,6 zu machen.
    Außerdem sleepdarf (und wird es manchmal!) Erst nach 30 oder 50 oder 100 ms oder einer noch längeren Zeit zurückkehren.
  • Verwenden Sie std::mutexdiese Option, um einen anderen Thread zu benachrichtigen. Dafür ist es nicht da.
  • Angenommen, TaskManager kann Ihnen gut sagen, was los ist, insbesondere anhand einer Zahl wie "25% CPU", die in Ihrem Code, im Usermode-Treiber oder an einem anderen Ort ausgegeben werden könnte.
  • Haben Sie einen Thread pro übergeordneter Komponente (es gibt natürlich einige Ausnahmen).
  • Erstellen Sie Threads zu "zufälligen Zeiten" ad hoc pro Aufgabe. Das Erstellen von Threads kann überraschend teuer sein und es kann überraschend lange dauern, bis sie genau das tun, was Sie ihnen gesagt haben (insbesondere, wenn viele DLLs geladen sind!).

Tun

  • Verwenden Sie Multithreading, damit die Dinge so oft wie möglich asynchron ausgeführt werden . Geschwindigkeit ist nicht die Hauptidee des Einfädelns, sondern das parallele Ausführen (selbst wenn sie insgesamt länger dauern, ist die Summe aller Dinge immer noch geringer).
  • Verwenden Sie die vertikale Synchronisierung, um die Bildrate zu begrenzen. Dies ist der einzig richtige (und nicht fehlerfreie) Weg, dies zu tun. Wenn der Benutzer Sie im Bedienfeld des Anzeigetreibers überschreibt ("Ausschalten"), ist dies auch der Fall. Immerhin ist es sein Computer, nicht deins.
  • Wenn Sie in regelmäßigen Abständen etwas "ankreuzen" müssen, verwenden Sie einen Timer . Timer haben den Vorteil, dass sie im Vergleich zu sleep[2] eine viel bessere Genauigkeit und Zuverlässigkeit aufweisen . Außerdem berücksichtigt ein wiederkehrender Timer die Zeit korrekt (einschließlich der dazwischen liegenden Zeit), während das Schlafen für 16,6 ms (oder 16,6 ms minus gemessene_Zeit_verstrichen) dies nicht tut.
  • Führen Sie Physiksimulationen aus, die eine numerische Integration zu einem festgelegten Zeitschritt beinhalten (oder Ihre Gleichungen explodieren!), Und interpolieren Sie Grafiken zwischen den Schritten (dies kann eine Entschuldigung für einen separaten Thread pro Komponente sein, kann aber auch ohne erfolgen).
  • Verwenden std::mutex diese , um jeweils nur einen Thread auf eine Ressource zugreifen zu lassen ("gegenseitig ausschließen") und die seltsame Semantik von einzuhalten std::condition_variable.
  • Vermeiden Sie, dass Threads um Ressourcen konkurrieren. Sperren Sie so wenig wie nötig (aber nicht weniger!) Und halten Sie die Schlösser nur so lange wie unbedingt nötig.
  • Teilen Sie schreibgeschützte Daten zwischen Threads (keine Cache-Probleme und keine Sperrung erforderlich), ändern Sie jedoch nicht gleichzeitig Daten (muss synchronisiert werden und beendet den Cache). Dazu gehört das Ändern von Daten in der Nähe eines Ortes, den möglicherweise jemand anderes liest.
  • Verwenden Sie std::condition_variablediese Option , um einen anderen Thread zu blockieren, bis eine Bedingung erfüllt ist. Die Semantik std::condition_variabledieses zusätzlichen Mutex ist zwar ziemlich seltsam und verdreht (meistens aus historischen Gründen, die von POSIX-Threads geerbt wurden), aber eine Bedingungsvariable ist das richtige Grundelement für das, was Sie wollen.
    Falls Sie findenstd::condition_variable zu seltsam mit ihm bequem zu sein, können Sie auch einfach ein Windows - Ereignis verwenden (etwas langsamer) statt oder, wenn Sie mutig sind, bauen Sie Ihre eigenen einfachen Fall um NtKeyedEvents (betrifft die Sachen unheimlich niedrigen Niveau). Wenn Sie DirectX verwenden, sind Sie ohnehin bereits an Windows gebunden, sodass der Verlust der Portabilität kein großes Problem sein sollte.
  • Teilen Sie die Arbeit in Aufgaben mit angemessener Größe auf, die von einem Worker-Thread-Pool mit fester Größe ausgeführt werden (nicht mehr als eine pro Kern, ohne Hyperthread-Kerne). Lassen Sie das Beenden von Aufgaben abhängige Aufgaben in die Warteschlange stellen (kostenlose, automatische Synchronisierung). Führen Sie Aufgaben aus, die jeweils mindestens einige hundert nicht triviale Vorgänge enthalten (oder einen Vorgang zum Blockieren der Länge wie das Lesen einer Festplatte). Bevorzugen Sie den Cache-zusammenhängenden Zugriff.
  • Erstellen Sie alle Threads beim Programmstart.
  • Nutzen Sie die asynchronen Funktionen, die das Betriebssystem oder die Grafik-API für eine bessere / zusätzliche Parallelität bieten, nicht nur auf Programmebene, sondern auch auf der Hardware (denken Sie an PCIe-Übertragungen, CPU-GPU-Parallelität, Festplatten-DMA usw.).
  • 10.000 andere Dinge, die ich vergessen habe zu erwähnen.


[1] Ja, Sie können die Rate des Schedulers auf 1 ms senken, aber dies ist verpönt, da es viel mehr Kontextwechsel verursacht und viel mehr Strom verbraucht (in einer Welt, in der immer mehr Geräte mobile Geräte sind). Es ist auch keine Lösung, da es den Schlaf immer noch nicht zuverlässiger macht.
[2] Ein Timer erhöht die Priorität des Threads, wodurch er einen anderen Thread mit gleicher Priorität in der Mitte des Quanten unterbrechen und zuerst geplant werden kann, was ein Quasi-RT-Verhalten ist. Es ist natürlich keine echte RT, aber es kommt sehr nahe. Das Aufwachen aus dem Ruhezustand bedeutet lediglich, dass der Thread jederzeit planbar ist, wann immer dies möglich ist.

Damon
quelle
Können Sie bitte erklären, warum Sie nicht "einen Thread pro übergeordneter Komponente haben" sollten? Meinen Sie damit, dass man Physik und Audio nicht in zwei getrennten Threads mischen sollte? Ich sehe keinen Grund, dies nicht zu tun.
Elviss Strazdins
3

Ich bin nicht sicher, was Sie erreichen möchten, indem Sie die FPS von Update und Render auf 60 beschränken. Wenn Sie sie auf denselben Wert beschränken, hätten Sie sie einfach in denselben Thread einfügen können.

Das Ziel beim Trennen von Update und Rendering in verschiedenen Threads besteht darin, beide "fast" unabhängig voneinander zu haben, damit die GPU 500 FPS rendern kann und die Update-Logik weiterhin 60 FPS erreicht. Auf diese Weise erzielen Sie keinen sehr hohen Leistungsgewinn.

Aber Sie sagten, Sie wollten nur wissen, wie es funktioniert, und es ist in Ordnung. In C ++ ist ein Mutex ein spezielles Objekt, mit dem der Zugriff auf bestimmte Ressourcen für andere Threads gesperrt wird. Mit anderen Worten, Sie verwenden einen Mutex, um sinnvolle Daten jeweils nur einem Thread zugänglich zu machen. Dazu ist es ganz einfach:

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

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

BEARBEITEN : Stellen Sie sicher, dass Ihr Mutex klassen- oder dateiweit ist, wie im angegebenen Link angegeben. Andernfalls erstellt jeder Thread seinen eigenen Mutex und Sie erreichen nichts.

Der erste Thread, der den Mutex sperrt, hat Zugriff auf den darin enthaltenen Code. Wenn ein zweiter Thread versucht, die Funktion lock () aufzurufen, wird er blockiert, bis der erste Thread ihn entsperrt. Ein Mutex ist also im Gegensatz zu einer while-Schleife eine Blockierungsfunktion. Blockierungsfunktionen belasten die CPU nicht.

Alexandre Desbiens
quelle
Und wie funktioniert der Block?
Liuka
Wenn der zweite Thread lock () aufruft, wartet er geduldig darauf, dass der erste Thread den Mutex entsperrt, und fährt in der nächsten Zeile fort (in diesem Beispiel das sinnvolle Zeug). BEARBEITEN: Der zweite Thread sperrt dann den Mutex für sich.
Alexandre Desbiens
1
Verwenden Sie std::lock_guardoder ähnliches, nicht .lock()/ .unlock(). RAII dient nicht nur der Speicherverwaltung!
bcrist