OMSI Plugin Framework IV: Unterschied zwischen den Versionen

Aus OMSIWiki
Wechseln zu:Navigation, Suche
K
K
 
Zeile 48: Zeile 48:
 
Zusammen mit dem Mutex ergibt sich nun auch eine Verwendung für '''CEvent'''. Ein Event wird in der Programmierung ganz allgemein dazu benutzt, einem Thread irgendetwas zu signalisieren. Und soetwas müssen wir auch im Framework tun. Da ja der Nebenthread die meiste Zeit seines Lebens schläft, wäre es nicht so gut, wenn der Hauptthread einfach die Nachricht in die Warteschlange packt und dann noch womöglich auf die Bearbeitung wartet. Das könnte ein ziemlich langes Warten werden, da so ein Thread einen äußerst festen Schlaf hat. Aber da gibt es ja die Events. Der Schlafzustand des Nebenthreads ist nämlich an ein solches Event gebunden, da er die Funktion '''''WaitFor''''' des Events aufgerufen hat. Nachdem der Hauptthread die Nachricht in die Warteschlange gepackt hat (Zutritt bekommt er ja ohne weiteres, da der Nebenthread ja schläft), ruft er die Funktion '''''Raise''''' des Events auf. Dadurch wird der Nebenthread augenblicklich unsanft aus dem Schlummer gerissen und setzt seine Arbeit fort. Der gleiche Mechanismus wird auch für die Writable-Variablen und Trigger verwendet. Der Hauptthread packt die entsprechende Nachricht in die Warteschlange und legt sich nun seinerseits schlafen. Sobald der Nebenthread die Variable oder den Trigger bearbeitet hat, ruft er wiederum die Funktion '''''Raise''''' eines weiteren Events auf, um damit den Hauptthread wieder zu erwecken.
 
Zusammen mit dem Mutex ergibt sich nun auch eine Verwendung für '''CEvent'''. Ein Event wird in der Programmierung ganz allgemein dazu benutzt, einem Thread irgendetwas zu signalisieren. Und soetwas müssen wir auch im Framework tun. Da ja der Nebenthread die meiste Zeit seines Lebens schläft, wäre es nicht so gut, wenn der Hauptthread einfach die Nachricht in die Warteschlange packt und dann noch womöglich auf die Bearbeitung wartet. Das könnte ein ziemlich langes Warten werden, da so ein Thread einen äußerst festen Schlaf hat. Aber da gibt es ja die Events. Der Schlafzustand des Nebenthreads ist nämlich an ein solches Event gebunden, da er die Funktion '''''WaitFor''''' des Events aufgerufen hat. Nachdem der Hauptthread die Nachricht in die Warteschlange gepackt hat (Zutritt bekommt er ja ohne weiteres, da der Nebenthread ja schläft), ruft er die Funktion '''''Raise''''' des Events auf. Dadurch wird der Nebenthread augenblicklich unsanft aus dem Schlummer gerissen und setzt seine Arbeit fort. Der gleiche Mechanismus wird auch für die Writable-Variablen und Trigger verwendet. Der Hauptthread packt die entsprechende Nachricht in die Warteschlange und legt sich nun seinerseits schlafen. Sobald der Nebenthread die Variable oder den Trigger bearbeitet hat, ruft er wiederum die Funktion '''''Raise''''' eines weiteren Events auf, um damit den Hauptthread wieder zu erwecken.
  
Abschließend in diesem Abschnitt noch ein paar Worte zur Klasse CThread. Ein Thread ist nichts anderes als ein Stück Programmcode. Jedes Programm hat mindestens einen Thread. Damit ein Thread ausführbar ist, muss er eine Hauptfunktion besitzen. Der Thread läuft nun solange, bis die Hauptfunktion endet. War das der letzte (oder einzige) Thread des Programmes, endet dann auch das Programm selbst. Da Windows mit C++-Klassen rein gar nichts anfangen kann, ist die Hauptfunktion in zwei Teile aufgegliedert. Der erste Teil, die Funktion '''''ThreadFunction''''' ist der Teil, den Windows zu Gesicht bekommt. Glücklicherweise kann man einer Threadfunktion einen Parameter mitgeben. Dieser Parameter ist im Framework nun ein Zeiger auf die Instanz der Klasse '''CThread'''. Über diesen Zeiger ruft die Funktion '''''ThreadFunction''''' nun den zweiten Teil der Hauptfunktion auf, die Funktion '''''MainLoop'''''. Wie der Name schon andeutet, ist das nun die wirkliche Hauptfunktion. Im Framework wird die Hauptfunktion durch das Setzen des Feldes
+
Abschließend in diesem Abschnitt noch ein paar Worte zur Klasse '''CThread'''. Ein Thread ist nichts anderes als ein Stück Programmcode. Jedes Programm hat mindestens einen Thread. Damit ein Thread ausführbar ist, muss er eine Hauptfunktion besitzen. Der Thread läuft nun solange, bis die Hauptfunktion endet. War das der letzte (oder einzige) Thread des Programmes, endet dann auch das Programm selbst. Da Windows mit C++-Klassen rein gar nichts anfangen kann, ist die Hauptfunktion in zwei Teile aufgegliedert. Der erste Teil, die Funktion '''''ThreadFunction''''' ist der Teil, den Windows zu Gesicht bekommt. Glücklicherweise kann man einer Threadfunktion einen Parameter mitgeben. Dieser Parameter ist im Framework nun ein Zeiger auf die Instanz der Klasse '''CThread'''. Über diesen Zeiger ruft die Funktion '''''ThreadFunction''''' nun den zweiten Teil der Hauptfunktion auf, die Funktion '''''MainLoop'''''. Wie der Name schon andeutet, ist das nun die wirkliche Hauptfunktion. Im Framework wird die Hauptfunktion durch das Setzen des Feldes
 
'''''m_bTerminate''''' auf '''true''' beendet. '''CThread''' besitzt, da sie ja von '''CWaitableObject''' abgeleitet ist, auch eine '''''WaitFor'''''-Funktion. Dadurch könnte z.B. ein anderer Thread auf die Beendigung dieses Threads warten. Anders als beim '''CMutex''' muss hier aber kein '''''Release''''' aufgerufen werden. Das übernimmt Windows freundlicherweise für uns. Die Beendigung eines Threads löst automatisch ein ''Release'' aus.
 
'''''m_bTerminate''''' auf '''true''' beendet. '''CThread''' besitzt, da sie ja von '''CWaitableObject''' abgeleitet ist, auch eine '''''WaitFor'''''-Funktion. Dadurch könnte z.B. ein anderer Thread auf die Beendigung dieses Threads warten. Anders als beim '''CMutex''' muss hier aber kein '''''Release''''' aufgerufen werden. Das übernimmt Windows freundlicherweise für uns. Die Beendigung eines Threads löst automatisch ein ''Release'' aus.
  

Aktuelle Version vom 26. November 2012, 07:51 Uhr

Das OMSI Plugin Framework

Allgemeines

In diesem Kapitel werde ich den Aufbau und die Arbeitsweise des Frameworks detailliert beschreiben. Vielleicht hast Du dich die ganze Zeit schon gefragt, wozu so ein Framework-Dingens überhaupt gut ist. Ganz allgemein gesagt, soll Dich ein Framework von immer wiederkehrenden Standardaufgaben entlasten und Dir eine einheitliche Schnittstelle bieten, mit der Du bestimmte Aufgaben erledigen kannst. Im Falle des OMSI Plugin Frameworks übernimmt es auch noch einen etwas schwierigeren Teil der Programmierung, das sogenannte Multithreading. Was es damit auf sich hat, wird im Abschnitt Arbeitsweise näher erläutert.

Das OMSI Plugin Framework besteht aus einer handvoll C++-Klassen, die Deine Variablen und Trigger übersichtlich organisieren, den Umgang mit den 4 Funktionen der Plugin-Schnittstelle regeln und eine einheitliche Schnittstelle zur Bearbeitung der Variablen und Trigger schaffen. Für diese Klassen gibt es eine ausführliche Online-Dokumentation (in exzellentem Denglisch ;-) verfasst).

Da es kaum möglich ist, von Anfang an ein Plugin völlig fehlerfrei zu programmieren (das geht Profis auch nicht anders), gibt es zwei Debug-Hilfen, den OMSI Plugin Log Viewer und das OMSI Plugin Variables Display. Das sind zwei eigenständige Programme, deren Bedienung in einem extra Kapitel erläutert wird.

Außerdem erweitert das OMSI Plugin Framework das Microsoft Visual Studio noch um einen weiteren Compiler. Dieser Compiler hat die Aufgabe, aus einer sehr einfach aufgebauten Textdatei, die die Variablen- und Triggerdeklaration enthält, zum Einen die .opl-Datei für den OMSI zu generieren und zum Anderen zwei weitere Dateien zu erzeugen, die direkt in den Code Deines Plugins einfließen. Diese beiden Dateien enthalten dann Deine Variablen und Trigger in einer für den C++-Compiler nutzbaren Form.

Das ist noch nicht alles. Weiterhin ist eine neue Umgebungsvariable für das Visual Studio vorbereitet, mit der es besonders einfach ist, nach jeder Compilierung die erzeugte DLL und die dazu passende .opl-Datei automatisch in den plugins-Ordner des OMSI zu kopieren.

Die Arbeitsweise des Frameworks

Sobald der OMSI Deine DLL geladen hat, ist Dein Plugin sozusagen Bestandteil des OMSI selbst. Die Aufrufe der 4 Funktionen geschehen somit im Context des OMSI. Um nun der Regel Nummer 1 Rechnung zu tragen, hat das Plugin beim Aufruf der Funktion Start einen weiteren Thread gestartet, der die Aufrufe der Funktionen AccessVariable und AccessTrigger bearbeitet. Das hat für Variablen, auf die nur lesend zugegriffen werden soll den Vorteil, das nur sehr wenig CPU-Zeit für die direkte Bearbeitung der Funktionsaufrufe beansprucht wird. Writable-Variablen und Trigger können davon natürlich nicht profitieren, da ja hier immer auf das Ergebnis der Verarbeitung in Deinem Plugin gewartet werden muss. Für Readonly-Variablen ist noch ein weiterer Beschleunigungsmechanismus implementiert: die Bearbeitungsfunktion in Deinem Plugin wird nur dann aufgerufen, wenn sich der Wert der Variablen seit dem letzten Aufruf verändert hat.

An dieser Stelle ein paar Anmerkungen zum Multithreading. Multithreading ist keinesfalls das Allheilmittel gegen schlechte Frameraten. Ganz im Gegenteil. Wenn man es mit dem Multithreading übertreibt, kann man sogar ganz schnell das umgekehrte Ergebnis erreichen: das Programm wird noch langsamer. Der Grund dafür ist, dass der Prozessor genügend Reserven haben muss, um tatsächlich mehrere Threads parallel auszuführen. Ein Threadwechsel kostet - wie soll es auch anders sein - natürlich auch wieder zusätzliche CPU-Zeit. Mit anderen Worten: auf einem älteren AMD Athlon-XP ein Multithread-Programm auszuführen, bringt gar nichts. Erst wenn der Prozessor wenigstens einen zweiten CPU-Kern hat, kann die Ausführung des Programmes deutlich beschleunigt werden. Wir gehen aber jetzt einfach mal davon aus, dass heutzutage mindestens Dual-Cores oder besser in einem Rechner stecken. Wenn ein Spieler den OMSI tatsächlich noch auf einer alten Single-Core-Maschine laufen lässt, wird er a) ja sowieso wenig Freude am OMSI haben und b) sollte er sich überlegen, ob er dann noch Plugins installiert. Das ist der knallharte Kompromiss, den wir mit diesem Framework eingehen.

Weiter gehts mit der Arbeitsweise. Bei Aufrufen von AccessVariable und AccessTrigger legt das Plugin eine entsprechende Nachricht in der internen Nachrichtenwarteschlange ab und weckt dann den zusätzlichen Thread auf. Bis dahin verbraucht dieser Thread keinerlei CPU-Zeit. Ist die Variable readonly, wird die Kontrolle sofort wieder an den OMSI übergeben. Das geht sehr schnell und belastet den OMSI nur wenig. Der jetzt aktivierte zusätzliche Thread startet nun, parallel zum OMSI, die Verarbeitung der Variaben, indem er die Funktion OnMessage in Deinem Plugin aufruft und legt sich danach wieder schlafen. Bei einer Writable-Variablen und einem Trigger wird ebenfalls OnMessage aufgerufen, allerdings muss der OMSI nun auf das Ende der Bearbeitung warten. Diese Schleife wiederholt sich solange, bis der Spieler das Spiel beendet. Von diesen internen Dingen bekommst Du in Deiner OnMessage-Funktion nichts mit. Das eben ist genau der Vorteil eines Frameworks.

Die Klassen im Detail

CWaitableObject, CEvent, CMutex und CThread

Diese 4 Klassen kapseln Windows-Elemente in einer C++-Klasse. Dies vereinfacht den Zugriff auf diese Elemente. CWaitableObject ist dabei die Basis für die anderen drei Klassen. Der Name leitet sich davon ab, dass Programmcode im wahrsten Sinne des Wortes auf solche Elemente warten kann. Das wird über die Funktion WaitFor in den Klassen realisiert. In CWaitableObject ist diese Funktion noch abstract, erst CEvent, CMutex und CThread erfüllen sie mit Leben. Der Partner für WaitFor ist die Funktion Release. Nach jedem WaitFor muss man Release aufrufen (außer bei einem Thread), sonst kann es zu einem sogenannten Deadlock im Programm kommen. Ein Deadlock bedeutet, das zwei (oder mehr) Threads auf die Freigabe ein und derselben Resource warten. Passiert so etwas, kommt das Programm zum Stillstand. Für den Anwender sieht es so aus, als ob das Programm abgestürzt sei. Technisch stimmt das zwar nicht, aber das spielt für den Anwender keine Rolle. Er kann nicht mehr weiter spielen, arbeiten usw. Solche race conditions, also das gegenseitige Warten auf eine Resource sind die große Schwierigkeit bei der Multithread-Programmierung. Sie sind mitunter schwer zu entdecken und eine häufige Ursache - neben den Endlosschleifen - für vermeintliche Programmabstürze. Wie kann es nun zu solchen race conditions kommen? Betrachten wir das mal konkret am Beispiel des OMSI Plugin Framework. Kernelement ist hier eine sogenannte Warteschlange für Nachrichten. Eine Warteschlange ist nichts anderes, als eine Datenstruktur, die auf der einen Seite Elemente entgegen nimmt und am anderen Ende der Schlange wieder ausliefert (FIFO-Prinzip: first in, first out). In einer Single-Thread-Umgebung ist eine solche Warteschlange kein Problem. Anders aber in einer Multithread-Umgebung. Nehmen wir mal den Fall, der OMSI hat gerade AccessVariable aufgerufen. Unter der Herrschaft des Hauptthreads wird nun ein Element an das Ende der Warteschlange gepackt. Noch bevor der Hauptthread damit fertig ist, also die Gesamtanzahl der Nachrichten in der Schlange noch nicht erhöht hat, greift der Nebenthread ebenfalls auf die Warteschlange zu und fragt die Anzahl der Nachrichten ab. Er würde z.B. fälschlicherweise 0 geliefert bekommen. Das ist allerdings der harmlosere Fall. Betrachten wir das Ganze jetzt mal von der anderen Seite. Der Nebenthread holt gerade die erste Nachricht aus der Warteschlange ab und entfernt sie aus der Schlange. Just in diesem Moment packt der Hauptthread eine neue Nachricht am anderen Ende in die Warteschlange hinein. Der Nebenthread bekommt davon aber gar nichts mit. Er räumt fein säuberlich den Platz für die eben entfernte Nachricht. Im Speicher sieht es dann so aus:

Vor dem Abholen der Nachricht durch den Nebenthread

|Nachricht_1|Nachricht_2|Nachricht_3|Letzte_Nachricht|freier_Platz_1|

Nebenthread hat Nachricht geholt, aber noch nicht vollständig aufgeräumt (die Anzahl an Nachrichten beträgt noch 4)

|Nachricht_2|Nachricht_3|Letzte_Nachricht|freier_Platz_1|freier_Platz_2|

Hauptthread packt neue Nachricht in die Schlange. Da der Nebenthread die Anzahl noch nicht angepasst hat, geht der Hauptthread davon aus, dass noch 4 Nachrichten in der Schlange sind

|Nachricht_2|Nachricht_3|Letzte_Nachricht|freier_Platz_1|neue_Nachricht|

Und schon ist es passiert. Der Inhalt von freier Platz_1 ist völlig zufällig und kann später, wenn diese Nachricht durch den Nebenthread abgeholt wird, nur zu irgendwelchem Unfug führen. Das muss also unter allen Umständen vermieden werden. Da kommt nun die Klasse CMutex ins Spiel. Der Name leitet sich vom englischen mutually exclusive (sich gegenseitig ausschließend) ab. Mit einem Mutex wird das vorherige Szenario nun wie folgt abgewickelt: Bevor ein Thread Zugriff auf die Warteschlange nimmt, ruft er die WaitFor-Funktion des Mutex auf. Hat eine anderer Thread das bereits vor ihm getan, wird Windows ihm den Zutritt verweigern und der Thread muss warten (daher der Name). Er muss nun solange warten, bis der erste Thread Release aufruft. Vergißt Du nun, die Funktion Release aufzurufen, wird folgendes passieren: Irgendwann ist dieser Thread ja wieder dran und ruft wieder WaitFor auf. Da aber der andere Thread durch das vergessene Release noch immer in WaitFor wartet und sich damit sozusagen einen Platz in der ersten Reihe reserviert hat, wird Windows nun auch diesem Thread den Zutritt verwehren. Da haben wir eine race condition, die zu einem deadlock geführt hat. Thread 1 wartet darauf, das Thread 2 fertig wird und umgekehrt.

Zusammen mit dem Mutex ergibt sich nun auch eine Verwendung für CEvent. Ein Event wird in der Programmierung ganz allgemein dazu benutzt, einem Thread irgendetwas zu signalisieren. Und soetwas müssen wir auch im Framework tun. Da ja der Nebenthread die meiste Zeit seines Lebens schläft, wäre es nicht so gut, wenn der Hauptthread einfach die Nachricht in die Warteschlange packt und dann noch womöglich auf die Bearbeitung wartet. Das könnte ein ziemlich langes Warten werden, da so ein Thread einen äußerst festen Schlaf hat. Aber da gibt es ja die Events. Der Schlafzustand des Nebenthreads ist nämlich an ein solches Event gebunden, da er die Funktion WaitFor des Events aufgerufen hat. Nachdem der Hauptthread die Nachricht in die Warteschlange gepackt hat (Zutritt bekommt er ja ohne weiteres, da der Nebenthread ja schläft), ruft er die Funktion Raise des Events auf. Dadurch wird der Nebenthread augenblicklich unsanft aus dem Schlummer gerissen und setzt seine Arbeit fort. Der gleiche Mechanismus wird auch für die Writable-Variablen und Trigger verwendet. Der Hauptthread packt die entsprechende Nachricht in die Warteschlange und legt sich nun seinerseits schlafen. Sobald der Nebenthread die Variable oder den Trigger bearbeitet hat, ruft er wiederum die Funktion Raise eines weiteren Events auf, um damit den Hauptthread wieder zu erwecken.

Abschließend in diesem Abschnitt noch ein paar Worte zur Klasse CThread. Ein Thread ist nichts anderes als ein Stück Programmcode. Jedes Programm hat mindestens einen Thread. Damit ein Thread ausführbar ist, muss er eine Hauptfunktion besitzen. Der Thread läuft nun solange, bis die Hauptfunktion endet. War das der letzte (oder einzige) Thread des Programmes, endet dann auch das Programm selbst. Da Windows mit C++-Klassen rein gar nichts anfangen kann, ist die Hauptfunktion in zwei Teile aufgegliedert. Der erste Teil, die Funktion ThreadFunction ist der Teil, den Windows zu Gesicht bekommt. Glücklicherweise kann man einer Threadfunktion einen Parameter mitgeben. Dieser Parameter ist im Framework nun ein Zeiger auf die Instanz der Klasse CThread. Über diesen Zeiger ruft die Funktion ThreadFunction nun den zweiten Teil der Hauptfunktion auf, die Funktion MainLoop. Wie der Name schon andeutet, ist das nun die wirkliche Hauptfunktion. Im Framework wird die Hauptfunktion durch das Setzen des Feldes m_bTerminate auf true beendet. CThread besitzt, da sie ja von CWaitableObject abgeleitet ist, auch eine WaitFor-Funktion. Dadurch könnte z.B. ein anderer Thread auf die Beendigung dieses Threads warten. Anders als beim CMutex muss hier aber kein Release aufgerufen werden. Das übernimmt Windows freundlicherweise für uns. Die Beendigung eines Threads löst automatisch ein Release aus.


[zum Kapitel 3] [zum Inhaltsverzeichnis] [zum Kapitel 5]