Danke, 7x7 und meine anderen Unterstützer! Ich will hier wohl noch mal einen Versuch wagen, ZeHa von Threads zu überzeugen...
7x7 hat geschrieben:ZeHa hat geschrieben:Damit müssen aber wieder beide Threads aufeinander warten, weil Du ja immer wieder die Daten vom einen Buffer in den nächsten kopieren mußt, damit der andere Thread damit weiterarbeiten kann. Das ganze wird dadurch sogar noch langsamer als wenn es single-threaded wäre.
Genau das, was du da sagst, stimmt eben nicht!
ZeHa, mir scheint, als hättest du einen Verständnisfehler. Mit Threads ist man möglicherweise gleich schnell oder schneller als Singlethreaded, aber nie langsamer!
Ich beginne mal mit deiner letzten GameLoop. Die scheint mir komisch. Wenn ich sie richtig deute, hälst du alle Updates solange hin, bis mal wieder ein Rendering nötig wird. Wieso? Schau mal, hier eine singlethreaded GameLoop, in der Updates immer Vorrang haben und nicht durch das Rendering (das ja nur alle 16 ms für 60 FPS stattfinden soll) ausgebremst wird:
Code: Alles auswählen
Define lastTime
While running
currentTime = ElapsedMilliseconds()
controls()
update()
If currentTime - lastTime > #DELTA_TIME ; #DELTA_TIME sei 16 ms, das entspricht 60 FPS
lastTime = currentTime
render()
EndIf
Delay(1)
Wend
Darauf bin ich schon als Anfänger bei meinen ersten Spiele-Erfahrungen gekommen.
Du siehst, die ganze while-Schleife wird permanent ausgeführt, natürlich mit einem Delay(1). Doch der render()-Teil wird nur alle 16 ms ausgeführt. Somit haben wir ganz nach deinen Vorlieben eine single-threaded GameLoop, doch mit flüssigen Updates und einer rein theoretisch konstanten FPS-Rate, ohne jedoch die GraKa zum Glühen zu bringen!
Schauen wir uns folgendes an:
Ziemlich simpel, he? Doch dieses Konstrukt ist nur so stark wie sein schwächstes Glied. console()? Nein, die Eingaben dürften schnell eingelesen sein. update() oder render()? Kommt drauf an. Habe ich viele upzudatende Variablen und richtig heavy Wegfindungs-Routinen und ein paar KIs am Start, wird vielleicht update() länger als render() dauern. Hat update() jedoch nicht viel zu tun und es werden trotzdem aufwendige 3D Effekte eingesetzt, dann kann render() länger als update() dauern.
Egal welche Prozedur hier am längsten dauert: Sie bremst alle anderen aus. Auch mein obiger Code mit der 'besseren' while-Schleife ist nicht ganz perfekt, denn auch dort könnte es sein, dass update() immer länger als render() braucht. In dem Fall wäre die If-Abfrage sogar unnütz! Diese Konstruktion lohnt sich nur, wenn die Dauer von console()+update()+netzwerk()+sound()+festplatte() < render(). Ich hoffe, du hast verstanden, was ich meine.
Kommen wir jetzt zu multi-threading. Warum hat das Vorteile gegenüber single-threading? Ganz einfach: Es arbeiten alle Programmteile unabhängig voneinander. Egal ob man Threads über globale Arrays/Listen kommunizieren lässt, wie es mein Vorschlag war ... oder ob der Main Thread die ganze Arbeit an die einzelnen Threads verteilt, wie PMV es macht. Ich erkläre aber mal lieber meinen Ansatz, das kann ich bessser erklären.
So wie man unter Unix Pipes verwendet, um Programme miteinander zu verknüpfen, so verknüpfst du alle Programmteile aka Threads zu einer Kette:
ls listet den Verzeichnisinhalt auf, übergibt diesen String an grep. grep filtert alles außer conf-Datien und übergibt die gefilterte Liste an sort. sort sortiert die Liste und gibt sie aus.
console() liest des Benutzers Eingaben und übergibt die Werte an update(). (Ohne Eingabe kann keine Spielfigur bewegt werden und ohne Figur braucht man nichts darzustellen.)
update() wurschtelt herum, berechnet hier und da und übergibt die Positionsvariablen an render(). (Ohne Positionsvariablen braucht man nichts zu rendern.)
render() stellt das Bild dar. Dann fängt's von vorn an.
Man sieht, beim simplen Single-threading ist der Nachfolger vom Vorgänger abhängig. Setzt man das ganze jedoch 1:1 mit Threads um und baut schön Mutexe ein, dann hat man Threads, die aufeinander warten. Hier hast du Recht, ich hab das gleiche Ergebnis wie beim Single-Threading.
Wir wollen aber nicht aufeinander warten. Wenn ein Thread in der Kette fertig ist kann er entweder auf den Vorgänger warten (schlecht) oder mit den alten Daten weiterarbeiten und nochmal durchlaufen (gut). Dazu 3 Beispiele:
1. Betrachten wir console() -> update(). Angenommen, update() sei mal vor console() fertig. Warum sollte es auf neue Eingaben warten? Wenn man eine KI hat, kann die doch gar nicht weiterdenken, sondern muss auch auf console() warten... Würde update() aber einfach weiterlaufen und beim nächsten Durchlauf die Eingaben einlesen, dann könnte die KI weiterdenken; komplexe Wegfindungsroutinen, die viel Zeit brauchen, weiterlaufen; usw.
2. Betrachten wir den Schluss der Kette, update() -> render(). Meist ist es doch so, dass die update()-Routine, besonders bei kleineren Games, schnell durchläuft. Sagen wir in <=8 ms. Die render() Routine hat ein Delay(16) in der Schleife. So erreicht render() eine konstante Framerate!
Spätestens hier sollte klar sein: Das Spiel läuft flüssiger, als beim bloßen Single-threading!!! Pro render() können 2x update() ausgeführt werden! Das ist natürlich nur Theorie, denn das Verhältnis 8:16 Millisekunden ist nur ausgedacht. Dennoch gibt's praktisch nur Vorteile: Ist update() schneller, dann "denkt" das Spiel flüssiger. Ist render() schneller, bleibt wenigstens das Bild "scharf", weil immer konstant 60 Frames angezeigt werden können, einige davon jedoch doppelt. Ich denke da z. B. auch an die Vertikale Bildsynchronisation. Siehe den FlipModus bei OpenScreen(). Solltest du #PB_Screen_WaitSynchronization verwenden, brauchst du faktisch kein Delay(16) in der render()-Schleife, da FlipBuffers() dich automatisch ausbremst. Dank der Threads bist du diese Sorgen jedoch los.
3. Je nach Größe des Spiels kann eine GameLoop recht komplex werden. Holen wir doch mal network() und sound() ins Boot (ich lagere sound() analog zu render() aus, über den Sinn lässt sich streiten). Dann sähe die Kette so aus:
Code: Alles auswählen
+––> network()
network() –––+ +––> sound()
console() –––+––> update() –––+––> render()
Hier leistet update() wahrlich das meiste! Es sei denn, man kann update() selbst noch irgendwie parallelisieren. Doch der Reihe nach...
Erst mal müssen die Eingaben gelesen werden. Netzwerkdaten der anderen Spieler kommen auch noch rein. Dabei wird network() praktisch immer langsamer als console() sein. Würde man console, network() und update() wie beim single-threading hintereinander schalten, würde network() alles ausbremsen. Gibt es umgekehrt jedoch Hänger im System, weil beispielsweise console() (oder noch ein anderer Thread der jedwede Art von Eingaben einliest) muckt, dann bleiben wenigstens der Datenepmfang und update() unberührt. Fakt ist: update() kann unabhängig von Art, Schnelligkeit und Anzahl der Vorgänger arbeiten! Sollte update() mal keine Daten von einem seiner Vorgänger erhalten haben, rechnet update() einfach mit den alten Daten vom letzten Durchlauf weiter. Dies passiert z. B. bereits dann, wenn der Benutzer einfach mal keine Tasten drückt. In dem Fall rechnet update() mit den letzten Daten weiter und lässt die Spielfigur da stehen wo sie auch vorher schon stand.
Übrigens: Wie kommen die Daten vom Vorgänger zum Nachfolger? Daten weiterreichen oder abholen lassen? Ich persönlich würde das einfacherere Prinzip ausprobieren: Threadsafe an und alle Variablen globalisieren, sodass jeder Thread die Daten des Vorgängers auslesen kann...
Nachdem update() nun fertig ist, brauchen wir uns auch nicht darum sorgen, wann und wie oft die Ergebnisse von network(), sound() und render() abgeholt werden. Bei sound() steckt ja, wie hier angesprochen, nicht viel hinter. Wie gesagt, man könnte dies auch in update() reinpacken. Doch wieso nicht alles konsequent logisch aufteilen? (Wie gesagt, man könnte so den sound()-Thread deaktivieren, wenn Init()-Sound fehlschlägt. Andernfalls müsste man im update()-Thread jeden PlaySound()-Befehl mit If sound : EndIf umgeben.) Dann käme sicherlich render(), welches mehr Zeit als sound() benötigt. Aber: Leistungsschwankungen (Virenscanner, Downloads, Hintergrundprogramme) machen sich bei render() bestimmt eher als bei sound() bemerkbar. Scheiß drauf, wir haben Threads! Dann hat update() halt mal Ergebnisse bereitgestellt bevor render() fertig war und sie abholen konnte. Dann zeichnet render() halt ab dem nächsten Frame wieder mit aktuellen Daten. network() ist hier sicherlich der chaotischste Faktor. Jeder Benutzer hat eine andere Bandbreite, Latenzzeit, Downloads im Hintergrund, usw. Es ist schwer, sich beim Single-threading genau darauf einzustellen; hier jedoch prinzipell vernachlässigbar, weil network() das letzte Glied der Multi-Threading-Kette ist.
Wie erwähnt gibt es weitere Vorteile:
- Man nutzt automatisch mehrere Prozessorkerne aus.
- Oder man kann, wenn man es geschickt anstellt, die Auflösung in-Game ändern: Das InitSprite() steckt ja in render(). Bei einem Auflösungs-Wechsel müsste man also nur diesen Thread neu starten und nicht die ganze Anwendung (Modularisierung). Dadurch spart man sich ein erneutes Aufrufen von InitSound(), InitKeyboard(), InitNetwork(), ...
- Man könnte bei Programmstart ein Intro-Video anzeigen und im Hintergrund läuft ein Thread der schon mal die Sprites lädt.
- Dank ThreadPriority() können Threads unterschiedlich priorisiert werden, damit sie im Zusammenspiel noch flüssiger laufen.
Eine Sache noch: Der Datenaustausch. Natürlich könnte man in den Threadsafe Modus schalten und jeden Thread die globalen Variablen des Vorgängers auslesen lassen. Das würde theoretisch laut Doku gehen, wenn Vorgänger schreibt und gleichzeitig Nachfolger liest. Aber das ist natürlich stümpferhaft, wenn der Nachfolger langsamer liest als der Vorgänger schreibt. Es enstünde beim Nachfolger eine Dateninkonsistenz durch eine Race Condition (richtig so?). Man müsste die Daten mit Mutexe schützen, sodass Daten nur ganz oder gar nicht vom Vorgänger abholt werden können. Dabei würde ein Nachfolger seinen Vorgänger im schlimmsten Fall für die komplette Dauer des Lesevorgangs blockieren. Ich glaube jedoch, dass das nicht weiter tragisch ist, denn das Kopieren/Einlesen der Daten geht sehr schnell. Noch schneller geht es vielleicht mit Methoden wie CopyMemory(). Da werden einem schon raffinierte Methoden einfallen. Man kann das sogar weiter auf die Spitze treiben, indem man von TryLockMutex() Gebrauch macht. So kann ein Thread situationsabhängig gesteuert werden und alternative Aufgaben durchführen, wenn er gerade nicht lesen oder schreiben kann.
So, ich hoffe ich konnte dir, ZeHa, zumindest eine kleine Erleuchtung bringen. Ist doch ganz schön viel geworden. Tippe ja auch seit gut 2 Stunden.
Unbezahlt. Nur für dich, du Sturkopf!
(War nur Spaß... Hey, ich wollte den Kaffee-Smiley schon immer mal sinnvoll verwenden
). Ich hoffe, dieser Beitrag ist auch für andere interessant, die hier schmökern.
Gute Nacht, allerseits.