Mit der Art und Weise, wie ich mit Dingen umgehe, ist Multithreading kostenlos und im Nachhinein relativ einfach anzuwenden. Aber ich denke zuerst an Daten. Ich weiß nicht, ob dies für alle Domains funktioniert, aber ich werde versuchen zu beschreiben, wie ich vorgehe.
Zunächst geht es also um die gröbsten Daten, die für die Software erforderlich sind und die häufig verarbeitet werden. Wenn es sich um ein Spiel handelt, bei dem es sich möglicherweise um Netze, Geräusche, Bewegungen, Partikelemitter, Lichter, Texturen oder ähnliche Dinge handelt. Und natürlich gibt es viel zu überlegen, wenn Sie sich nur mit Maschen befassen und darüber nachdenken, wie sie dargestellt werden sollen, aber das überspringen wir vorerst. Im Moment denken wir auf der breitesten architektonischen Ebene.
Und mein erster Gedanke ist: "Wie vereinheitlichen wir die Darstellung all dieser Dinge, damit wir für all diese Arten von Dingen ein relativ einheitliches Zugriffsmuster erreichen können?" Und mein erster Gedanke könnte sein, jede einzelne Art von Dingen in einem eigenen zusammenhängenden Array mit einer kostenlosen Art von Liste zu speichern, um freie Räume zurückzugewinnen. Dies führt dazu, dass die API vereinheitlicht wird, sodass wir beispielsweise leichter denselben Code zum Serialisieren von Netzen verwenden können wie Lichter und Texturen, zumindest was den Zugriff auf diese Komponenten betrifft. Je mehr wir vereinheitlichen können, wie alles dargestellt wird, desto mehr nimmt der Code, der auf diese Dinge zugreift, eine einheitliche Form an.
Das ist cool. Jetzt können wir auch mit 32-Bit-Indizes auf diese Dinge verweisen und nur die Hälfte des Speichers eines 64-Bit-Zeigers beanspruchen. Und hey, wir können jetzt Schnittpunkte in linearer Zeit setzen, wenn wir ein paralleles Bitset zuordnen können, z. B. können wir Daten auch sehr billig parallel zu einem dieser Dinge zuordnen, da wir alles indizieren. Oh, und dieses Bitset kann uns einen Satz sortierter Indizes zurückgeben, die in sequentieller Reihenfolge durchlaufen werden, um die Speicherzugriffsmuster zu verbessern, ohne dass dieselbe Cache-Zeile mehrmals in einer einzelnen Schleife neu geladen werden muss. Wir können jeweils 64-Bit testen. Wenn nicht alle 64-Bit gesetzt sind, können wir 64 Elemente gleichzeitig überspringen. Wenn alle festgelegt sind, können wir sie alle gleichzeitig verarbeiten. Wenn einige gesetzt sind, aber nicht alle, können wir mithilfe von FFS-Anweisungen schnell bestimmen, welche Bits gesetzt sind.
Aber oh warte, das ist ziemlich teuer, wenn wir nur ein paar hundert Dinge aus Zehntausenden von Dingen mit Daten verknüpfen wollen. Verwenden wir stattdessen ein spärliches Array wie folgt:
Und hey, jetzt, da wir alles in spärlichen Arrays gespeichert und indiziert haben, wäre es ziemlich einfach, dies zu einer dauerhaften Datenstruktur zu machen.
Jetzt können wir billigere Funktionen ohne Nebenwirkungen schreiben, da sie nicht tief kopieren müssen, was sich nicht geändert hat.
Und hier habe ich bereits einen Spickzettel erhalten, nachdem ich etwas über ECS-Engines gelernt habe, aber jetzt wollen wir uns überlegen, welche Art von allgemeinen Funktionen für jeden Komponententyp ausgeführt werden sollen. Wir können diese "Systeme" nennen. Das "SoundSystem" kann "Sound" -Komponenten verarbeiten. Jedes System ist eine umfassende Funktion, die mit einem oder mehreren Datentypen arbeitet.
Dies führt zu vielen Fällen, in denen für einen bestimmten Komponententyp im Allgemeinen nur ein oder zwei Systeme darauf zugreifen. Hmm, das scheint sicher die Gewindesicherheit zu verbessern und den Gewindekonflikt auf ein Minimum zu reduzieren.
Außerdem versuche ich darüber nachzudenken, wie man homogene Datenübertragungen durchführt. Anstatt wie:
for each thing:
play with it
cuddle it
kill it
Ich versuche es in mehrere, einfachere Durchgänge aufzuteilen:
for each thing:
play with it
for each thing:
cuddle it
for each thing:
kill it
Das erfordert manchmal das Speichern eines Zwischenzustands für den nächsten homogenen verzögerten Durchlauf, aber ich fand, dass dies mir wirklich hilft, den Code beizubehalten und zu überlegen, da ich weiß, dass jede Schleife eine einfachere, einheitlichere Logik hat. Und hey, das scheint die Thread-Sicherheit zu vereinfachen und Thread-Konflikte zu reduzieren.
Und Sie machen einfach so weiter, bis Sie feststellen, dass Sie eine Architektur haben, die wirklich einfach mit dem Vertrauen in die Thread-Sicherheit und -Korrektheit zu parallelisieren ist, aber zunächst mit dem Fokus, Datenrepräsentationen zu vereinheitlichen, vorhersehbarere Speicherzugriffsmuster zu haben und zu reduzieren Speichernutzung, Vereinfachung des Kontrollflusses zu homogeneren Durchläufen, Reduzierung der Anzahl von Funktionen in Ihrem System, die Nebenwirkungen verursachen, ohne dass sehr teure Kosten für das Kopieren entstehen, Vereinheitlichung Ihrer API usw.
Wenn Sie all diese Dinge kombinieren, erhalten Sie in der Regel ein System, das die Menge des gemeinsam genutzten Status minimiert, in dem Sie auf ein Design gestoßen sind, das für Parallelität wirklich freundlich ist. Und wenn ein Status gemeinsam genutzt werden muss, gibt es häufig keine großen Konflikte, wenn es günstig ist, eine Synchronisierung zu verwenden, ohne einen Thread-Verkehrsstau zu verursachen, und außerdem kann er häufig von Ihrer zentralen Datenstruktur verarbeitet werden, die die Darstellung vereinheitlicht ausgerechnet im System, damit Sie keine Thread-Synchronisierungen auf hundert verschiedene Stellen anwenden müssen, sondern nur auf eine Handvoll.
Wenn wir nun einen Drilldown zu einer der komplexeren Komponenten wie Netzen durchführen, wiederholen wir den gleichen Entwurfsprozess, indem wir zunächst über die Daten nachdenken. Und wenn wir das richtig machen, können wir möglicherweise sogar die Verarbeitung eines einzelnen Netzes problemlos parallelisieren, aber das breitere Architekturdesign, das wir bereits erstellt haben, ermöglicht es uns, die Verarbeitung mehrerer Netze zu parallelisieren.