var
X
=
0
if
:
set
X
=
42
# wird zurück gerollt wegen der
# nächsten Zeile
X
>
100
# schlägt fehl und löst ein Rollback aus
X
=
0
# X ist dank des Rollbacks wieder 0
set
-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 fest
if
:
SetSomething
(
42
)
# wird zurück gerollt wegen der
# nächsten Zeile
GetSomething
()
>
100
# schlägt fehl und löst einen Rollback aus
GetSomething
()
=
0
# ist dank des Rollbacks wieder 0
if
-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.