
var X = 0if: set X = 42 # se revierte debido a la # siguiente línea X > 100 # falla y provoca una reversiónX = 0 # X vuelve a 0 gracias a la reversiónset de Verse. Por ejemplo, en su lugar podríamos haber realizado una llamada a una función de C++ para modificar cualquier variable mutable mediante C++ (p. ej., guardar en un puntero) y después usar otras funciones de C++ para leer la variable mutable:SetSomething(0) # se implementa en C++, añade una variable # C++if: SetSomething(42) # se revierte debido a # la siguiente línea GetSomething() > 100 # falla y provoca una reversiónGetSomething() = 0 # vuelve a 0 gracias a la reversiónif, se los denomina transacciones anidadas, que se confirman si dicha condición se completa correctamente o, de lo contrario, se anula. Por su parte, las expresiones condicionales admiten efectos. No obstante, si la condición falla, es como si los efectos nunca hubieran sucedido.<no_rollback>, que dejaremos de usar en cuanto hayamos solucionado el problema.<no_rollback>) y tiende a producir errores que desencadenan fallos difíciles de depurar, como si se registrara un controlador de anulación para algo que se elimina.void Foo()
{
TArray<int> Array = {1, 2, 3}; AutoRTFM::Transact([&] () {
Array.Push(42); // Aquí la matriz es {1, 2, 3, 42}. AutoRTFM::Abort(); }); // Aquí la matriz es {1, 2, 3} debido a Abort.}»TArray y funciona incluso si TArray tiene que reubicar su almacenamiento de seguridad en Push. En este sentido, TArray tampoco es especial. AutoRTFM puede hacer lo mismo tanto para std::vector/std::map como para una gran cantidad de estructuras y funciones del catálogo estándar de C++ y Unreal Engine. No obstante, los únicos cambios necesarios para que esto funcione consisten en integrar AutoRTFM en asignadores de memoria de bajo nivel y el recolector de basura (GC). Además, como veremos en este apartado, hasta esos cambios son sencillos: simplemente hay que añadir unas cuantas líneas más. De hecho, ya las hemos añadido a todas las numerosas funciones malloc y el GC de Unreal Engine, y al malloc/new del sistema). Además, AutoRTFM lo consigue con una regresión del rendimiento mínima para aquellas rutas de código que se ejecuten fuera de la transacción; es decir, fuera del ámbito dinámico de una llamada Transact. Lo más importante de todo es que, incluso cuando se usa una misma ruta de código (como TArray o muchas otras) dentro de las transacciones, aquellas que se emplean fuera de estas no conllevan ningún coste. Si bien es cierto que la sobrecarga es más alta cuando el código se ejecuta dentro de una transacción, en ocasiones hasta cuatro veces más, no es lo suficiente como para que la tasa de tic del servidor de Fortnite se vea afectada.AutoRTFMPass consiste en instrumentar todo el código para que obedezca a la semántica transaccional de Verse. No obstante, se puede usar el mismo código de Fortnite en otros lugares en los que no exista código de Verse en la pila y no sea necesaria las semántica de Verse, así como en aquellos lugares donde Verse está en la pila y es posible una reversión.bCurrentThreadIsInTransaction , por tanto, se codifica en el contador de programa. En el caso del compilador, simplemente clona todo el código, asigna nombres alterados a las funciones clonadas e instrumenta solo a los clones. El código original, en cambio, se deja sin instrumentar y en ningún caso realiza algún registro ni nada especial, por lo que ejecutar dicho código no supone ningún coste. El código clonado, sin embargo, sí recibe instrumentación y es consciente de que se encuentra en una transacción, lo que nos permite trucos como los de una máquina virtual, como reproducir convenciones de llamada alternativas.AutoRTFMPass se ejecuta al mismo tiempo en la canalización de LLVM como herramientas de depuración o «sanitizer»; es decir, después de que se hayan producido todas las optimizaciones de IR e inmediatamente antes de enviar el código al back-end. Esto tiene algunas implicaciones importantes. Por ejemplo, incluso en los clones instrumentados, las únicas cargas y almacenamientos que reciben la instrumentación son aquellos que quedan de la optimización. Por tanto, si bien la asignación a una variable local sin escape en C++ se considera almacenamiento en el sentido abstracto de la palabra —y así lo representaría Clang al crear el IR de LLVM sin optimizar del programa—, dicho almacenamiento desaparecería por completo antes de que AutoRTFMPass cumpla con su función. Lo más probable es que se reemplace por el flujo de datos SSA, del que AutoRTFM no tiene que encargarse. Además, hemos implementado algunas optimizaciones mínimas basadas en los análisis estáticos de LLVM existentes para evitar la instrumentación de almacenamientos en los casos en los que se demuestre que no es necesario.AutoRTFM::Transact. Al realizar una llamada a esta función con una expresión lambda, el tiempo de ejecución crea un objeto de transacción que contiene las estructuras de datos de registro y otros estados de la transacción. Después, Transact realiza una llamada al clon de lambda, que es idéntico a la expresión lambda, pero dispone de instrumentación transaccional y obedece a la ABI de AutoRTFM.Transact cada vez que Verse entra en un contexto de error, como si fuera una condición If. No obstante, en el futuro, lo haremos antes de que se produzca cualquier ejecución de Verse.malloc/free y otras funciones relacionadas, como asignadores personalizados.WriteFile en una transacción. De hecho, este es el comportamiento predeterminado preferido, puesto que no existe ninguna forma fiable de revertir WriteFile. Aunque quisiéramos deshacer la escritura más tarde, algún otro subproceso (ya sea en el nuestro o en cualquier otro) podría haberla visto y realizado alguna operación irreversible según eso. Por tanto, resulta más práctico usar un programa de C++ existente, compilarlo con AutoRTFM e intentar ejecutar un Transact. En este caso, o funciona sin problemas o recibirás un error indicando que has llamado a una función no segura para transacciones junto con un aviso sobre cómo encontrar dicha función y su sitio de llamada.malloc y otras relacionadas funcionen correctamente. Básicamente, el enfoque que adopta AutoRTFM es el de no transaccionalizar la implementación de la función malloc, sino más bien forzar a los autores a que encapsulen las llamadas a malloc con la API de AutoRTFM. Todo esto es especialmente sencillo en programas como Unreal Engine, donde ya existe una API de fachada que encapsula la implementación de la función malloc. Para entender mejor todo esto, echemos un vistazo al código. Antes de AutoRTFM, FMemory::Malloc era algo así: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 para indicar que el bloque cerrado (que se convierte en una lambda) debería ejecutarse sin instrumentación de transacción. Implementar esto es sencillo en el caso del tiempo de ejecución y el compilador: basta con realizar una llamada a la versión original (sin clonar) de la función lambda. Lo que esto implica es que GMalloc->Malloc se ejecuta dentro de la transacción, pero sin ninguna instrumentación transaccional. A esto lo denominamos «ejecución abierta», frente a la ejecución cerrada, que incluye la ejecución del clon transaccionalizado. Así, cuando haya vuelto UE_AUTORTFM_OPEN, la llamada a malloc se debería completar al instante sin que se registre ninguna reversión. De esta forma, se consiguen mantener la semántica transaccional, puesto que la función malloc ya es segura para subprocesos. De igual modo, el hecho de que este subproceso haya realizado una función malloc no es observable para otros subprocesos hasta que el puntero del objeto de la malloc salga de la transacción. No obstante, el puntero no saldrá de la transacción hasta que lo confirmemos, ya que los únicos lugares donde se puede almacenar Ptr son, o bien en la nueva memoria asignada, o bien en la memoria que transaccionalizará AutoRTFM al registrar la escritura.AutoRTFM::OnAbort. De este modo, si la transacción se confirma, el controlador no se ejecutará, por lo que el objeto de la función malloc se mantiene. En cambio, si la transacción se anula, el objeto se liberará. Así nos aseguramos de que no se producen fugas de memoria durante la anulación.OnAbort, puesto que no hay que preocuparse de que AutoRTFM intente sobrescribir el contenido del objeto a algún estado anterior a su liberación. Además, no escribir en la memoria recién asignada es una forma útil de optimización.UE_AUTORTFM_OPEN simplemente ejecuta el bloque de código.AutoRTFM::OnAbort se considera una declaración no-op y simplemente se omite la lambda.AutoRTFM::DidAllocate se considera una declaración no-op.void FMemory::Free(void* Original)
{
UE_AUTORTFM_ONCOMMIT(
{
GMalloc->Free(Original);
});
}UE_AUTORTFM_ONCOMMIT se comporta de la siguiente manera:
Open/OnCommit/OnAbort nos proporciona todo lo necesario para ejecutar código C++ en una transacción, siempre y cuando este no use atómicos, bloqueos, llamadas al sistema o llamadas a partes del código base de Unreal Engine que hemos optado por no transaccionalizar (como motores físicos o de renderización, en nuestro caso). Esto ha supuesto un total de 94 usos de las API Open en todo Fortnite. Lo que implican dichos usos es encapsular el código existente con Open y similares con la granularidad adecuada en lugar de modificar algoritmos y estructuras de datos.<no_rollback>, de modo que se puedan enviar llamadas a más código desde if y for. La razón de que dicho efecto se encuentre ahí, contaminando multitud de firmas de función, radica en que la mayoría del código de C++ no puede ser transaccional. Con AutoRTFM, eso ya no es un problema.