Object Pool Design Pattern in C++
Dieser Artikel zeigt die Verwendung des Object Pool Design Patterns in C++ um für bestimmte Voraussetzungen eine signifikant bessere Performance zu erhalten. Der Object Pool ist ein Erzeugungsmuster (Creational patterns), diese Einteilung habe ich im ersten Teil der Serie beschrieben.
Object Pool Design Pattern in C++
Das Object Pool Pattern macht Sinn, wenn die Initialisierung einer Klasse hohe Kosten verursacht (CPU Zeit), es eine geringe Zahl gleichzeitiger Instanzen vorhanden sind, diese aber sehr oft neu erstellt werden. In diesem Fall ist es vorteilhaft die Objekte nicht immer neu zu erstellen und den Speicher ständig neu frei zu geben, sondern eine Art von Ressourcen Pool bereit zu stellen. Nicht verwendete Instanzen können so recycled werden. Anstatt jedesmal die Kosten für das Initialisieren eines neuen Objekts zu haben nimmt man sich ein bestehendes und nicht verwendete. Das erste „umweltfreundliche“ Design Pattern… Außerdem wird der Speicher für das Objekt im Heap nur einmal reserviert und nicht ständig angelegt und wieder freigegeben. Dadurch fragmentiert der Speicher weniger.
Beispiel
Um meinen bisherigen Beispielen im Kontext eines Rollenspiels zu entsprechen soll ein Object Pool die massenweise heranstürmenden Gegner eines Dungeons managen. Durch die Wiederverwendung der instanzierten Objekte lassen sich so ganze Wellen von Gegnern erzeugen, die nicht immer aufwendig im Speicher erstellt werden müssen. Der Source Code ist wie immer auf meiner GitHub Seite zu finden.
Implementierung
In diesem Beispiel wird das Object Pool Design Pattern als einfacher EnemyPool implementiert. Dieser hat eine fixe Größe von 1000 Elementen und ein dementsprechend großes Array an Spider Elementen. Der Pool hat zwei Methoden:
- create
es wird aus dem Pool ein verfügbares Spider Objekt geholt und initialisiert - animate
erledigt Aktionen für alle Elemente im Pool (passende Funktion für eine Game Loop in einem Spiel)
Der interessante Teil dieser Klasse ist die create Methode (EnemyPool.cpp) in der aus dem Pool ein neues Spider Objekt verfügbar gemach wird. Der Code dazu sieht wie folgt aus:
void EnemyPool::create() { //find dead spider for(int i = 0; i < POOL_SIZE; ++i) { if(spiders[i].isAlive() == false) { spiders[i].init("Spider", 50.0, rand() % 10 + 1); break; } } }
Es wird der komplette Pool durchgegangen und für jedes Element geprüft, ob die Spinne tot ist (isAlive() == false). Falls dem so ist wird diese Spinne mit Werten initialisiert. Die Schleife wird daraufhin verlassen. Es ist zu bedenken, dass es hier keine Fehlerprüfung gibt. Sind alle 100 Spinnen am leben, dann wird einfach keine neue erstellt. Alle 100 Spinnen werden zu Beginn mit 0 hitpoins erstellt, was dem Status tot entspricht.
Eine Spinne als Gegner hat einige Werte wie damage und hitpoints. Neben der bereits gezeigten init() Methode zum Initialisieren eines nutzbaren Spider Objekts gibt es noch attack() ind getDamage() die zur Laufzeit über die animate() Methode des Pools aufgerufen werden. Dort erfolgen entsprechende Ausgaben auf der Kommandozeile. Der verursachte Schaden ist dabei zufällig. Fallen die hitpoints eines Spinne auf unter 1, dann ist sie tot und kann in einer neuen Angriffswelle wieder initialisiert werden. Die getDamage() Methode der Spinne (Spider.cpp):
void Spider::getDamage(int damage) { this->hitpoints-=damage; std::cout << this->name << this->counter << " gets " << damage << " damage." << std::endl; if(this->hitpoints < 0) std:cout << this->name << this->counter << " dies" << std::endl; }
In der main Methode wird der Pool angelegt und in mehreren Wellen unterschiedlich viele Spinnen erzeugt und jeweils der Kampf abgewickelt. Durch Einsatz eines Zufallszahlengenerators ist der Ausgang immer anders:
int main(int argc, char **argv) { //initialize random number generator srand(time(NULL)); EnemyPool pool; pool.create(); pool.create(); std::cout << "Attack wave 1 (2 new spiders):" << std::endl; pool.animate(); pool.create(); pool.create(); pool.create(); std::cout << "Attack wave 2 (3 new spiders):" << std::endl; pool.animate(); pool.create(); pool.create(); std::cout << "Attack wave 3 (2 new spiders):" << std::endl; pool.animate(); return 0; }
Stirbt eine Spinne (mit Zahlen durchnummeriert), dann wird sie in der nächsten Angriffswelle wieder verwendet. Das Objekt wird dabei im Speicher nicht neu angelegt, es wird nur das bereits bestehende Spinnenobjekt mit neuen Werten initialisiert.
Kritik
Ein Pool von Objekten benötigt Speicher für nicht verwendete Instanzen. Der Object Pool muss für den Einsatz noch optimiert werden:
- es dürfen nicht zu wenige Objekte verfügbar sein
sonst müssen immer wieder welche zur Laufzeit mit hohen Kosten erzeugt werden - es sollen nicht zu viele Objekte verfügbar sein
im schlimmsten Fall werden viele Objekte erzeugt, die niemals verwendet werden
Bei der Implementierung muss vorab fest stehen, wofür so ein Object Pool verwendet wird. Es gibt da unterschiedliche Ansätze die alle Vor- und Nachteile haben:
- Pool enthält nur gleiche Objekte
man kann spezialisierte Pools erzeugen, die nur Objekte selben Typs speichern. Ein perfektes Beispiel dafür ist ein Partikel Pool. - Pool enthält unterschiedliche Objekte
bei einem allgemeinen Pool ist es vorab schwer festzulegen wie sich dieser verhalten wird. Um diesen nicht zu groß werden zu lassen gibt es zwei mögliche Ansätze:- fixe Anzahl an Objekten
man begrenzt den Pool auf eine maximale Anzahl an Objekten. Müssen diese beispielsweise alle gerendert werden kommt es so zu bestimmten Ereignissen nicht zu einem dramatischen Abfall der FPS (Frames per second) Anzahl. Je nach Einstellung werden nur eine maximale Anzahl Objekte erzeugt, dadurch kann man Effekte anhand der Rechenleistung der Grafikkarte skalieren. Je nach Einstellungen der Effekte kann man so mehr oder weniger komplexe Effekte erzeugen. - fixe Größe von Objekten
dadurch verhindert man, dass der Speicher durch eine Anzahl sehr großer Objekte unnötig strapaziert wird.
- fixe Anzahl an Objekten
Ohne diese Überlegungen vor der Implementierung kann ein korrekt implementierter Object Pool zur Laufzeit trotzdem problematische Auswirkungen auf das Programm haben.
Was passiert, wenn alle Objekte des Pools verwendet werden? Ein Pool kann so implementiert werden, dass er auch selber zur Laufzeit neue Objekte anlegt. Wenn das der Fall ist, muss auch gewährleistet werden, dass nicht verwendete Objekte irgendwann wieder gelöscht werden. Es passiert sonst, dass zur Laufzeit in bestimmten Konstellationen viele ungenutzte Objekte mitgeführt werden und unnötig Speicher belegen.
Fazit
Mit dem Object Pool Design Pattern erhöht man unter bestimmten Voraussetzungen die Performance eines Programms. Zusätzlich hat man die Möglichkeit den Speicherverbrauch besser zu nutzen und Objekte im Pool durch eine maximale Größe oder Anzahl zu begrenzen. Bei Performance kritischen Simulationen wie Spielen ein wichtiges Instrument um die Framerate hoch zu halten.