--->also available in English.

Win32 Assembler Tutorial Teil 3.141

Hallöchen zum 4. Teil des Win32 Assemblerkurses. Diesmal geht es um Synchronisation (passend zur Zeit) von Animationen. Die Hauptgründe, ein Programm zu etwas zu synchronisieren sind:

Auch mehrere Threads in einem Programm kommen in diesem Tutorial vor.

Übrigens fragten viele nach Quellcode für DirectDraw im Vollbildmodus. Wer noch keinen gesehen hat, sollte besonders einen Blick in das Beispielprogramm werfen (steckt mal wieder in einer ZIP-Datei, leider wieder nur angelsächsische Kommentare ... ;)); man sollte auch die Gemeinsamkeiten zum kooperativen Modus überprüfen, die eine Verwendung beider Modi in einem Programm entgegenkommen.

Für die Leute, die mit der Nummer des letzten Teiles nix anfangen konnten:
Die Zahl 2,718... , auch e genannt, kommt häufiger in der Mathematik vor. Manchmal ist sie bei FPU-Berechnungen äußerst nützlich.

Zutaten:

1. Takt halten, bitte!

Man kann Graphikalgorithmen ganz grob in zwei Kategorien einteilen:

Mal schauen, wie man die synchronisieren kann:

Interpolationen
Laufen immer flüssig, aber auf einem langsamen Rechner kriechen sie nur vor sich hin.
Man benötigt irgendeine Art von Verzögerung (=Delay), damit sie nicht zu schnell laufen.
Wenn der PC zu langsam läuft, muß man entweder die Berechnung abbrechen, die Musik anhalten oder loopen oder sonst irgendwas möglichst unauffälliges reincoden.

Funktionsbasierende Animationen
laufen immer zeitsynchron, solange man sie richtig füttert. Ist der Rechner allerdings zu lahm sieht das ganze ziemlich holprig aus (der Diaschau-Effekt)
Wenn die Framerate zu niedrig wird, könnte man die Zeit etwas dehnen, um den Diaschau-Effekt zu verringern, allerdings bekommt man dann wieder die obigen Probleme.

Die folgenden Werte kann man als Eingabewert einer Funktion verwenden:

Der Windows-Systemzeitzähler kann mit timeGetTime aus winmm.dll (ein Bestandteil von Windows-Multimedia) ausgelesen werden. Die Genauigkeit beträgt eine Millisekunde, was meistens reichen sollte. Der Rückgabewert ist die Zeit seit dem Start von Windows. Anstatt den Wert direkt zu verwenden, sollte man lieber die Differenz zweier Rückgabewerte nehmen. (entweder zwischen zwei Berechnungen oder zwischen der aktuellen Berechnung und dem Programmstart), weil der Zähler nach einigen Tagen überläuft. Dies wird immer wichtiger, man denke nur an die Suspend-to-Disk-Funktionen, die langsam Standard werden.

Ein ähnliches Verfahren benützt RDTSC (ab Pentium, außer Cyrix M1), ein 64-Bit-Zähler, der sich mit dem Prozessortakt erhöht. Das Problem damit ist a) daß dieser Befehl unter Umständen nur für das Betriebssystem reserviert ist und b) man zuerst die Taktfrequenz der CPU ermitteln muß. Die QueryPerformanceCounter - Funktion von Windows, die RDTSC benützt, funktioniert ebenso.

Eine andere Quelle der Zeit ist Audioplayback: Man kann vom Audioplayer die momentane Position erhalten, aber die Genauigkeit der meisten Audioroutinen ist meist recht schlecht. Wenn man den Player jedoch selber geschrieben oder zumindest modifiziert hat, kann man sehr genau die aktuelle Position der Audiodaten im Puffer bestimmen (Meist reicht es, den Ausgabepuffer und die Anzahl der Wiederholungen dessen zu kennen). Man bekommt so die beste Genauigkeit zur Musiksynchronisation. Dies funktioniert sowohl mit mod als auch mp3-Dateien

Wie man ein Delay basteln könnte:

Moment mal, ein Delay ist das mieseste, was man coden könnte (siehe Turbo Pascal)!

Aber es gibt ganz nette Variationen davon:
Die beliebteste Methode, eine Verzögerung einzubauen war unter DOS das Warten auf den Strahlrücklauf (Waitretrace). Unter DirectDraw geht das ebenfalls, indem man die Funktion zum Wechseln der Bildschirmseiten benützt (IDirectDrawSurface::Flip). Ganz nebenbei verhindert man so kurzzeitige Grafikfehler bzw. Flimmern. Allerdings kann die Bildwiederholrate irgendwo zwischen 50 und 100 Herz liegen, so daß man sie ggf. bestimmen und im Code berücksichtigen muß, falls man damit die Geschwindigkeit kontrollieren will. Wenn es klappt, immer innerhalb eines Bildaufbaus das nächste zu berechnen (klappt unter Win32 praktisch nie), bekommt man die flüssigste Animation, die möglich ist. Meist wird man eine zusätzliche Methode der Synchronisation benötigen.

Ein Delay kann auch mittels eines Timers realisiert werden.
Als erstes könnte man versuchen, den Animationscode in den Timercallback zu setzen. Allerdings klappt dies nur vernünftig, wenn die Funktion rechtzeitig fertig wird, bevor sie erneut aufgerufen wird.
Na schön, man hat einen neuen Rechner, und alle bei denen das Programm abstürzt sollen sich einen Neuen kaufen? Denkste, reingefallen. Dieses Verfahren würde auf allen Rechnern mehr oder weniger häufig die gleichen Probleme zeigen, da Win32 ein Multitasker ist und somit die benötigte Rechenzeit unter Umständen von anderen Prozessen verbraten wird. Man denke nur daran, was passiert wenn zuwenig Speicher zur Verfügung steht und die Festplatte losrattert.
Bessere Idee: Jedesmal wenn man ein neues Bild erzeugt, setzt man ein Flag. Ist das Bild fertig prüft man dieses Flag solange, bis es gelöscht wird. Um es zu löschen nimmt man den Timer-Callback. Dieses Verfahren verbratet die restliche Rechenzeit in einer Warteschleife.
Man kann diese Schleife auch so realisieren, indem man wartet bis eine bestimmte Zeit verstrichen ist (wie man die Zeit erhält: siehe oben).
Bis jetzt sieht das noch nicht sehr elegant aus, aber dies kann man ändern, indem man den Timer-Callback in Verbindung mit mehreren Threads verwendet (so wie der eine Thread im Beispielcode).

2. Threads und Multitasking

Ein Thread ist ein lauffähiger Programmschnipsel. Alles was man codet kann von einem oder mehreren Threads verwendet oder abgearbeitet werden, auch gleichzeitig.

Jedes gestartete Programm nennt man Prozess. Jeder Prozess besteht aus mindestens einem Thread und kann zumindest alle seine eigenen Threads erzeugen, anhalten, fortsetzen oder beenden (wenn der letzte Thread beendet wird endet auch das Programm).

Ein Programm wird normalerweise nicht schneller, indem man es in mehrere Threads aufteilt (dies trifft je nach Programm zum Teil auch auf Mehrprozessorsysteme zu).

In den folgenden Fällen können Threads nützlich sein: Wenn man unkomprimierte Dateien lädt, empfiehlt es sich, besser FileMapping anstatt einem separaten Thread zu verwenden, da Windows die Datei dann am optimalsten laden kann (die Daten werden spätestens dann in den Speicher geladen, wenn man auf die entsprechenden Speicherstellen zugreift, solange sie nicht schon im Vorraus geladen wurden).

Oft werden Threads auch nur aus Bequemlichkeit verwendet, die meisten Fälle, in denen Threads verwendet werden könnte auch ein einziger erledigen. Ein Programm das mehrere Fenster verwaltet könnte für jedes Fenster einen eigenen Thread erzeugen oder einen Thread für alle Fenster verwenden, welcher dadurch allerdings aufwendiger würde.

Alle Threads eines Prozesses können auf denselben Speicherbereich zugreifen. Dadurch kann man auch globale Variablen zur Kommunikation zwischen den Threads verwenden.
Wenn man allerdings mehrere identische Threads benützt, kann man allerdings den globalen Speicher nicht zur Datenspeicherung eines Threads verwenden, da die Threads ihre Daten gegenseitig überschreiben würden. Deshalb sollte man für lokale Variablen entweder den Stack oder ThreadLocalStorage von Win32 verwenden, eine Funktion, die Speicher jeweils einem Thread zugeordnet bereitstellt.

Auf dem Stack werden die Daten normalerweise in der folgenden Form adressiert:

mov eax,[esp + displacement]

Aus Geschwindigkeitsgründen sollte esp stets ein ganzzahliges Vielfaches von 4 beinhalten (dword alignment).
Displacement besteht aus a)der Position der Speicherstelle, wenn die Speicherstelle mehrere Bytes umfaßt, und b)der Anzahl an Bytes, die seit dem Anlegen der Speicherstelle mittels PUSH oder CALL auf den Stack geschoben wurden.

Wenn man nicht ständig alle auf den Stack gespeicherten und wieder entnommenen DWords im Auge behalten will kann man auch ein Register dazu verwenden, den Anfangswert von ESP zu speichern, meistens ebp, so daß man nur noch die relative Position kennen muß (mit Anweisungen wie Variable equ RelativePosition ist dies so komfortabel wie mit normale Variablen).
Aber auf diese Weise verschwendet man eines der 7 Register, und eine möglichst effiziente Registernutzung ist eines der Hauptziele von ASM, stimmts?

Übrigens: EBP und ESP benützen SS als Standardsegmentregister, so daß man bei der Verwendung anderer Register für Stapelzugriffe ein SS Selektorprefix benötigt. Je nach Implementierung zeigt SS zwar auf den gleichen Speicherbereich wie DS oder ES, hat aber andere Zugriffsrechte. Da Win32 allerdings für DS, ES und SS dieselben Selektoren verwendet braucht man sich hier nicht weiter darum zu kümmern.

Der folgende Code arbeitet identisch, aber mit unterschiedlicher Befehlslänge:
mov eax,[esp+edi] ;uses ss reg by default

und
mov eax,[ss:edi+esp] ;uses ds reg by default, override needed

Die Tatsache, daß Threads gleichzeitig ablaufen, kann Probleme geben, wenn mehrere Threads auf die gleichen Objekte wie Dateien, gesperrte Surfaces, Fenster, ... zugreifen.
Man stelle sich vor, ein Thread sperrt eine Surface mit Lock, um sie zu beschreiben. In der Zwischenzeit wird ein anderer Thread, der die gleiche Surface beschreibt, fertig und gibt diese wieder frei. Wenn der erste Thread auf die Surface zugreifen will, landet er im Nirvana.
Globale Variablen zur Synchronisation lassen sich nur verwenden, wenn sichergestellt ist, daß zwischen dem Zugriff auf die Variable und ihrer Überprüfung eine Veränderung dieser Variable absolut ausgeschlossen ist. Daß auf eine Variable so knapp hintereinander zugegriffen wird passiert zwar eher selten, aber der dadurch erzeugte Fehler ist meist sehr schwer zu finden.

Eine bessere Variante benützt CriticalSections, welche mittels den dazugehörigen Win32-Funktionen zugeordnet werden. Eine CriticalSection ist ein Teil des Programmes, der nicht ablaufen darf, wenn ein anderer Thread sich schon in einer CriticalSection befindet. Falls nötig wird der nachfolgende Thread solange angehalten, bis der erste aus der CriticalSection heraus ist. Auch wenn CriticalSections ziemlich effizient und sehr zuverlässig sind, verringern sie den Grad an Parallelität in der Programmausführung. Wenn weite Teile von Code innerhalb von CriticalSections liegen, sollte man doch lieber alles in einen Thread packen.
Als nächstes folgt eine Möglichkeit Threads zu verwenden, ohne obige Probleme zu bekommen. Für Mehrprozessorsysteme gibt es bessere Möglichkeiten, aber wer hat schon eines?

Eine optimale Möglichkeit, mehrere Threads in der Praxis zu verwenden

Der erste, vom Betriebssystem gestartete Thread initialisiert den Speicher, DirectDraw (falls nötig) und beinhaltet den Message Loop. Er startet und kontrolliert auch alle anderen Threads. Dieser Thread kann entweder ebenfalls Berechnungen ausführen oder nur für die Messages und die Threads zuständig sein.
Dies wird auch als Master-Slave-Modell bezeichnet.

Ein paar Beispiele für passende Slave-Threads:

- Ein Thread, der sich allein um die Audiodaten kümmert, das Soundsystem initialisiert und solange läuft, bis er von außen beendet wird und das Soundsystem deinitialisiert. Oder er beendet sich selbst, etwa weil die Musik komplett abgespielt wurde.

- Ein graphikberechnender Thread, der sich wie der Audiothread verhält. Mehr als einen Thread für die Graphik zu verwenden gibt übrigens nur auf Rechnern mit mehreren CPUs Sinn, da man ansonsten außer den schon genannten Problemen nichts hinzugewinnt.

- Ein sogenannter Arbeitsthread, welcher Daten vorberechnet oder entpackt und sich anschließend beendet. Wenn er nicht rechtzeitig fertig wird, kann man entweder seine Priorität erhöhen oder man wartet, bis er fertig wird.

Wenn das Programm beendet wird, setzt der Hauptthread den anderen Threads ein Flag, daß sie sich beenden sollen und wartet bis diese sich beendet haben. Die Threads direkt abzuschießen kann zu Problemen bei der Deinitialisation und Speicherlöchern führen.

Jedem Thread ist eine bestimmte Priorität zugeordnet, anhand deren der Scheduler die Rechenzeit auf die Threads verteilt. Threads niederer Priorität müssen warten, bis die Threads höherer Priorität fertig sind. Threads gleicher Priorität erhalten jeweils identische Anteile der momentan verfügbaren Rechenzeit.

Ein Thread, der Audiodaten wiedergibt erhält meist HIGH Priorität, damit er, gleich nachdem das Betriebssystem die Vorarbeit erledigt hat, so schnell wie möglich Daten nachliefern kann.

Die meisten Threads harmonieren wunderbar mit der Prioritätsstufe NORMAL.

Threads, die Vor- oder Aufräumarbeiten leisten können IDLE gesetzt werden, sie laufen nur dann, wenn andere Threads Rechenzeit übriglassen, behindern also andere Threads nicht (solange man Wartefunktionen wie GetMessage oder MsgWaitForMultipleObjects verwendet).

Nicht nur Threads besitzen Prioritäten, sondern auch die einzelnen Prozesse. Der Scheduler beachtet zuerst die Prozesspriorität, dann die Priorität der Tasks. Die Prioritätsstufe NORMAL ist meist die geeigneteste.

Übrigens: Manche Leute denken immer noch, daß eine Priorität wie HIGH oder gar REALTIME ihr Programm schneller laufen läßt. In der Praxis wird das ganze meist nur unkooperativer zu anderen Programmen. Es ist sinnvoller, sich gleich beim Coden über den schlimmstmöglichsten Fall Gedanken zu machen. Starke Schwankungen der verfügbaren Rechenzeit sowie größere Pausen in der Programmabarbeitung sollten das Programm nicht destabilisieren oder aus dem Takt bringen. Selbst wenn man nur das eigene Programm auf dem Bildschirm sieht läuft im Hintergrund noch einiges. In der Praxis kommt es selten zu größeren Problemen, aber man weiß ja nie...

3. Der Code zum Tutorial

Das Beispielprogramm besteht aus dem ersten Thread, der sich um das Fenster kümmert, die weiteren Threads startet sowie DDraw initialisiert. Ein Thread wird mit einem Timercallback synchronisiert, zwei benützen timeGetTime dafür und der letzte benützt den Refresh als Delay. Dieser kümmert sich auch um die Bildschirmdarstellung, indem er den gemeinsam benützten Speicherbereich in die Backbuffer Surface kopiert. Dies ist zwar kein Beispiel aus der Realität (es verursacht Bildfehler), aber es zeigt sehr gut, wie das ganze sich zueinander verhält.

Der Quellcode dürfte soweit selbsterklärend sein, mit den ganzen Kommentaren, die er enthält.

Jedes der 4 Sprites benötigt theoretisch 2 Sekunden pro Bildschirmdurchgang. Wie man sieht laufen die Threads mit timeGetTime immer synchron.

Auch der Thread, der alle 25 Millisekunden per Timer reaktiviert wird, läuft weitgehend optimal (auf meinem Rechner tat er das sogar noch, als die Stromsparfunktion den Takt stark verringerte). Er ist kaum aus dem Takt zu bringen.

Der Thread, welcher den Refresh benützt, reicht zwar aus, um eine gewisse Geschwindigkeit beizubehalten, hat aber die Zuverlässigkeit, die er unter DOS besaß, eingebüßt.

Man sollte ruhig ausprobieren, wie sich die Threads verhalten, wenn man ihn auf verschiedenen Prioritäten laufen läßt oder mit anderen Programmen CPU-Zeit abzieht.

4. push dword AllesGelesenUndKapiert ; ExitProcess

Der nächste (und letzte) Teil der Win32Asm - Serie ist eine Sammlung nützlicher Codeschnipsel und Funktionen, gerade auch für Programmierer, die von DOS auf Win32 umsteigen oder portieren wollen.

Bis dann,

T$
Mail an den Autor: webmeister@deinmeister.de

Hauptseite Programmieren Win32Asm Downloads Software Hardware Cartoons+Co Texte Sitemap