
var X = 0if: set X = 42 # wird zurück gerollt wegen der # nächsten Zeile X > 100 # schlägt fehl und löst ein Rollback ausX = 0 # X ist dank des Rollbacks wieder 0set-Operation in Verse. Beispielsweise hätten wir stattdessen eine C++-Funktion abrufen können, die mit C++ eine veränderliche Variable modifiziert (also z. B. in einen Zeiger speichert), und hätten dann andere C++-Funktionen benutzen können, um die veränderliche Variable zu lesen:SetSomething(0) # implementiert in C++, legt eine C++ # Variable festif: SetSomething(42) # wird zurück gerollt wegen der # nächsten Zeile GetSomething() > 100 # schlägt fehl und löst einen Rollback ausGetSomething() = 0 # ist dank des Rollbacks wieder 0if-Anweisung, sind verschachtelte Transaktionen, die bestätigt werden, wenn die Bedingung erfolgreich ist, und andernfalls abgebrochen werden. Bedingte Ausdrücke dürfen Effekte haben. Wenn die Bedingung fehlschlägt, ist es so, als ob die Effekte nie eingetreten wären.<no_rollback>-Effekt bezeichnet, den wir abschaffen werden, sobald dieses Problem behoben ist.<no_rollback>-Effekt) und fehleranfällig ist, was zu Abstürzen führt, die schwer zu debuggen sind, etwa wenn ein Undo-Handler für etwas registriert ist, das gelöscht wird.void Foo()
{
TArray<int> Array = {1, 2, 3}; AutoRTFM::Transact([&] () {
Array.Push(42); // Das Array ist hier {1, 2, 3, 42}. AutoRTFM::Abort(); }); // Aufgrund des Abort ist das Array hier {1, 2, 3}.}TArray. Dies funktioniert sogar dann, wenn TArray seinen Sicherungsspeicher innerhalb von Push neu zuweisen muss. TArray ist in dieser Hinsicht nicht einmal besonders; AutoRTFM kann das für std::vector/std::map sowie eine Vielzahl anderer Datenstrukturen und Funktionen in der Unreal Engine und der C++-Standardbibliothek tun. Die einzigen hierfür nötigen Änderungen sind die Integration von AutoRTFM mit Low-Level-Allokatoren und dem Garbage Collector (GC) – und wie wir in diesem Abschnitt zeigen werden, sind selbst diese Änderungen geringfügig (sie sind lediglich ergänzend und es handelt sich um nur wenige Zeilen; wir haben sie zu den vielen mallocs der Unreal Engine, zu Unreal Engines GC und zum malloc/new-System hinzugefügt). Darüber hinaus erreicht AutoRTFM das komplett ohne Performance-Verschlechterung für die Codepfade, die außerhalb einer Transaktion (also außerhalb des dynamischen Bereichs eines Transact -Aufrufs) ausgeführt werden. Selbst wenn ein Codepfad (wie TArray und viele andere) innerhalb von Transaktionen verwendet wird, fallen für diese Verwendung außerhalb von Transaktionen keine Kosten an. Wenn Code innerhalb einer Transaktion ausgeführt wird, ist der Overhead groß (einige interne Benchmarks zeigten eine vierfache Erhöhung), aber nicht groß genug, um die Tick-Rate des Fortnite- Servers zu beeinträchtigen.AutoRTFMPass besteht darin, den gesamten Code so zu instrumentieren, dass er die transaktionale Semantik von Verse befolgen kann. In Fortnite kann derselbe Code jedoch auch an einigen Stellen verwendet werden, an denen sich kein Verse-Code im Stapel befindet und die Semantik von Verse unnötig ist, sowie an anderen Stellen, an denen sich Verse im Stapel befindet und ein Rollback möglich ist.bCurrentThreadIsInTransaction im Programmzähler codiert. Aus der Sicht des Compilers ist das einfach: Er klont einfach Ihren gesamten Code, gibt den geklonten Funktionen verschlüsselte Namen und instrumentiert dann nur die Klone. Der Originalcode bleibt uninstrumentiert und führt keinerlei Protokollierung oder andere besondere Vorgänge durch, sodass für die Ausführung dieses Codes keine Kosten anfallen. Der geklonte Code versteht Instrumentierung und weiß definitiv, dass er sich in einer Transaktion befindet, weshalb wir sogar VM-ähnliche Tricks ausführen und etwa mit alternativen Aufrufkonventionen herumspielen können.AutoRTFMPass wird am selben Punkt in der LLVM-Pipeline ausgeführt wie die Sanitizer – also nachdem alle IR-Optimierungen durchgeführt wurden und kurz bevor der Code an das Backend gesendet wird. Das hat einige wichtige Implikationen, etwa dass selbst in den instrumentierten Klonen nur die Ladungen und Speicherungen instrumentiert werden, die die Optimierung überlebt haben. Während also die Zuweisung an eine nicht-entkommende lokale Variable in C++ eine abstrakter "Speicherung" ist (den Clang beim Erstellen der nicht optimierten LLVM-IR für dieses Programm entsprechend darstellen würde), wird der Speicher komplett verschwunden sein, sobald AutoRTFMPass sein Ding macht. Vermutlich wird er durch den SSA-Datenfluss ersetzt werden, mit dem sich AutoRTFM nicht herumschlagen muss. Außerdem haben wir ein paar kleine Optimierungen implementiert, die auf bestehenden statischen LLVM-Analysen basieren, um möglichst keine Speicher zu instrumentieren, wenn wir nachweisen können, dass das nicht nötig ist.AutoRTFM::Transact. Wenn diese Funktion mit einem Lambda aufgerufen wird, richtet die Laufzeit ein transaktionales Objekt ein, das die Protokolldatenstrukturen und andere transaktionale Zustände enthält. Anschließend ruft Transact den Klon des Lambda auf. Er ist eine genaue Kopie des Lambda, aber verfügt über Transaktionsinstrumentierung und folgt dem AutoRTFM-API.Transact auf, wenn Verse in einen Fehlerkontext wie eine if-Bedingung eintritt. In Zukunft werden wir Transact aufrufen, noch bevor irgendeine Verse-Ausführung startet.malloc/free und ähnliche Funktionen (wie benutzerdefinierte Allokatoren).WriteFile oder Ähnlichem in einer Transaktion standardmäßig ein Fehler. Das ist ein wünschenswertes Standardverhalten, da WriteFile nicht zuverlässig rückgängig gemacht werden kann, denn selbst wenn wir später versucht hätten, den Schreibvorgang rückgängig zu machen, könnte ein anderer Thread (entweder in unserem Prozess oder einem anderen Prozess) den Schreibvorgang gesehen und auf dieser Grundlage etwas anderes getan haben, was nicht wiederherstellbar ist. Dies macht es praktisch, ein vorhandenes C++-Programm zu nehmen, es mit AutoRTFM zu kompilieren und zu versuchen, einen Transact durchzuführen. Das wird entweder ordnungsgemäß funktionieren, oder Sie erhalten eine Fehlermeldung, dass Sie eine nicht transaktionssichere Funktion aufgerufen haben, sowie einen Hinweis, wie Sie diese Funktion und ihre Aufrufstelle finden.malloc und verwandte Funktionen. AutoRTFM verfolgt den Ansatz, die Implementierung von malloc nicht zu transaktionalisieren, sondern Aufrufer dazu zu zwingen, Aufrufe an malloc mit der AutoRTFM-API zu umschließen. Dies ist besonders einfach in Programmen wie der Unreal Engine, die bereits über eine Fassaden-API verfügen, die die eigentliche malloc-Implementierung umschließt. Sie können am besten verstehen, was wir tun, indem Sie sich den Code ansehen. Vor AutoRTFM sah FMemory::Malloc in etwa so aus:void* FMemory::Malloc(SIZE_T Count, uint32 Alignment)
{
return GMalloc->Malloc(Count, Alignment);
}void* FMemory::Malloc(SIZE_T Count, uint32 Alignment)
{
void* Ptr;
UE_AUTORTFM_OPEN(
{
Ptr = GMalloc->Malloc(Count, Alignment);
});
AutoRTFM::OnAbort([Ptr]
{
Free(Ptr);
});
return AutoRTFM::DidAllocate(Ptr, Count);
}UE_AUTORTFM_OPEN um anzugeben, dass der eingeschlossene Block (der in ein Lambda umgewandelt wird) ohne Transaktionsinstrumentierung ausgeführt werden soll. Das ist für die Laufzeit und den Compiler einfach zu implementieren: Es bedeutet lediglich, dass wir einen Aufruf an die ursprüngliche (nicht geklonte) Version des Lambda senden. Es bedeutet, dass GMalloc->Malloc innerhalb der Transaktion ausgeführt wird – allerdings ohne transaktionale Instrumentierung. Wir nennen das eine "offene Ausführung" (im Gegensatz zur geschlossenen Ausführung, bei der es sich um die Ausführung des transaktionalisierten Klons handelt). Nachdem UE_AUTORTFM_OPEN zurückgegeben wurde, wäre der malloc-Aufruf also sofort vollständig ausgeführt worden, ohne dass Zurücksetzungen registriert worden wären. Auf diese Weise bleibt die Transaktionssemantik erhalten, da malloc bereits Thread-sicher ist. Daher ist die Tatsache, dass dieser Thread einen malloc ausgeführt hat, für andere Threads erst sichtbar, wenn der Zeiger auf das malloc-Objekt die Transaktion verlässt. Der Zeiger kann erst nach einer Bestätigung aus der Transaktion austreten, da die einzigen Orte, an denen Ptr gespeichert werden kann, entweder anderer neu zugewiesener Speicher oder Speicher sind, den AutoRTFM durch Protokollierung des Schreibvorgangs transaktionalisiert.AutoRTFM::OnAbort freizugeben. Wenn die Transaktion bestätigt wird, wird dieser Handler nicht ausgeführt, sodass das mallocierte Objekt überlebt. Wenn die Transaktion jedoch abbricht, geben wir das Objekt frei. Das sorgt dafür, dass es bei Abbrüchen nicht zu Speicherlecks kommt.OnAbort auszuführen, da wir uns keine Sorgen darüber machen müssen, dass AutoRTFM versuchen wird, den Inhalt des Objekts wieder in einen früheren Zustand zu überschreiben, nachdem wir es bereits freigegeben haben. Außerdem ist es eine nützliche Optimierung, keine Schreibvorgänge in neu zugewiesenen Speicher zu protokollieren.UE_AUTORTFM_OPEN nur den Codeblock aus.AutoRTFM::OnAbort ein No-Op; das übergebene Lambda wird einfach ignoriert.AutoRTFM::DidAllocate ist ein No-Op.void FMemory::Free(void* Original)
{
UE_AUTORTFM_ONCOMMIT(
{
GMalloc->Free(Original);
});
}UE_AUTORTFM_ONCOMMIT weist dieses Verhalten auf:
Open/OnCommit/OnAbort APIs erhalten, bietet uns alles, was wir zum Ausführen von C++-Code in einer Transaktion benötigen, solange dieser Code keine Atomics, Sperren, Systemaufrufe oder Aufrufe von Teilen der Unreal Engine-Codebasis verwendet, die wir überhaupt nicht transaktionalisiert haben (wie derzeit die Rendering- und Physik-Engines). Dies hat zu insgesamt 94 Anwendungen der Open APIs in Fortnite geführt. In diesen Anwendungen wird vorhandener Code mithilfe von Open und seinen Freunden in einer geeigneten Granularität umschlossen, anstatt Algorithmen oder Datenstrukturen zu verändern.<no_rollback> -Effekt, damit mehr Code aus if und for aufgerufen werden kann. Dieser Effekt ist nur deshalb vorhanden (und verschmutzt viele Funktionssignaturen), weil C++-Code meistens nicht transaktional sein kann. AutoRTFM behebt dieses Problem.