var
X
=
0
if
:
set
X
=
42
# isso é revertido por causa da
# linha seguinte
X
>
100
# isso falha, causando uma reversão
X
=
0
# X volta a 0 devido à reversão
set
em Verse. Por exemplo, em vez disso, poderíamos ter chamado uma função C++, que tivesse modificado alguma variável mutável usando C++ (ou seja, armazenando em um ponteiro), e então poderíamos ter usado outras funções C++ para ler a variável mutável:SetSomething
(
0
)
# implementado em C++, define
# uma variável C++
if
:
SetSomething
(
42
)
# isso é revertido por causa da
# linha seguinte
GetSomething
()
>
100
# isso falha, causando uma reversão
GetSomething
()
=
0
# volta a 0 devido à reversão
if
, são transações aninhadas, que passarão por commit se a condição for bem-sucedida e serão canceladas caso contrário. É permitido que as expressões condicionais tenham efeitos. Se a condição falhar, será como se os efeitos nunca tivessem acontecido.<no_rollback>
, que será descontinuado assim que esse problema for corrigido.<no_rollback>
) e é propenso a erros, levando a falhas difíceis de depurar, como se um manipulador de desfazer fosse registrado para algo que é excluído.void
Foo
()
{
TArray
<int> Array
=
{
1
,
2
,
3
};
AutoRTFM::
Transact
(
[
&
] ()
{
Array
.
Push
(
42
);
//
Aqui a matriz é {1, 2, 3, 42}.
AutoRTFM::
Abort
();
});
//
Aqui a matriz é {1, 2, 3}, devido ao Abort.
}
TArray
. Funciona mesmo que o TArray
precise realocar o armazenamento de apoio dentro do Push
. O TArray
não é nem mesmo especial nesse aspecto; o AutoRTFM pode fazer isso com std::vector
/std::map
, bem como com uma infinidade de outras estruturas de dados e funções na Unreal Engine e na biblioteca padrão C++. As únicas alterações necessárias para que isso funcione são a integração do AutoRTFM com os alocadores de memória de baixo nível e o coletor de lixo (GC), e, como mostraremos nesta seção, mesmo essas alterações são pequenas (são só aditivas e têm apenas algumas linhas; nós adicionamos as alterações a todos os vários mallocs da Unreal Engine, ao GC da Unreal Engine e ao malloc/new do sistema). E o AutoRTFM consegue isso com regressão ao desempenho zero para os caminhos de código executados fora de uma transação (portanto, fora do escopo dinâmico de uma chamada Transact
). Um ponto fundamental é que, mesmo que um caminho de código (como o TArray
e muitos outros) seja usado dentro de transações, os usos fora das transações não geram custo. Quando o código está sendo executado dentro de uma transação, a sobrecarga é alta (vimos 4x em alguns benchmarks internos), mas não o suficiente para prejudicar a taxa de tick do servidor do Fortnite.AutoRTFMPass
é instrumentar todo o código para que ele possa obedecer a semântica transacional Verse. Mas o mesmo código no Fortnite pode ser usado em alguns lugares em que não há código Verse na pilha e a semântica Verse é desnecessária, bem como em outros lugares em que o código Verse está na pilha e uma reversão é possível.bCurrentThreadIsInTransaction
é codificado no contador de programa. Do ponto de vista do compilador, é simples: ele clona todo o seu código, dá nomes diferentes às funções clonadas e, em seguida, instrumenta apenas os clones. O código original continua não instrumentado e nunca faz nenhum registro nem nada de especial; portanto, a execução desse código resulta em custo zero. O código clonado recebe instrumentação e sabe que está em uma transação, o que nos permite até fazer truques semelhantes aos de uma VM, como experimentar convenções de chamadas alternativas.AutoRTFMPass
é executado no mesmo ponto do pipeline do LLVM que os higienizadores, ou seja, após todas as otimizações de RI terem ocorrido e antes de enviar o código para o backend. Isso tem implicações importantes, como o fato de que, mesmo nos clones instrumentados, os únicos armazenamentos e cargas que recebem instrumentação são os que sobreviveram à otimização. Portanto, embora a atribuição a uma variável local sem escape em C++ seja um "armazenamento" no sentido abstrato (e a clang representaria isso dessa forma ao criar a RI do LLVM não otimizada para esse programa), o armazenamento já terá sido totalmente eliminado até o momento em que o AutoRTFMPass
fizer seu trabalho. Provavelmente, ele vai ser substituído pelo fluxo de dados SSA, com que o AutoRTFM não precisa se preocupar. Também implementamos algumas pequenas otimizações baseadas em análises estáticas de LLVM existentes para evitar a instrumentação de armazenamentos quando pudermos provar que isso não é necessário.AutoRTFM::Transact
. Chamar essa função com um lambda faz com que o tempo de execução configure um objeto de transação que contenha as estruturas de dados de registro e outros estados de transação. Em seguida, o Transact
chama o clone do lambda. Ele é exatamente como o lambda, mas tem instrumentação transacional e obedece a ABI do AutoRTFM.Transact
em resposta à entrada do código Verse em um contexto de falha, como uma condição if. No futuro, vamos chamar o Transact
antes de entrar em qualquer execução de código Verse.malloc/free
e funções relacionadas (como alocadores personalizados).WriteFile
em uma transação é um erro por padrão. Esse é o comportamento padrão desejável, pois o WriteFile
não pode ser desfeito de forma confiável. Mesmo que tentássemos desfazer a gravação mais tarde, alguma outra thread (em nosso processo ou em algum outro) poderia ter visto a gravação e realizado uma ação irrecuperável com base nisso. Sendo assim, é prático pegar um programa C++, compilá-lo com o AutoRTFM e tentar fazer um Transact
. Ou ele vai funcionar corretamente, ou você vai receber um erro informando que chamou uma função não segura para transações, junto com uma mensagem sobre como encontrar essa função e seu local de chamada.malloc
e as funções relacionadas. O AutoRTFM adota a abordagem de não transacionalizar a implementação do malloc
, mas de forçar os autores de chamada a encapsular as chamadas para o malloc
com a API do AutoRTFM. Isso é especialmente simples em programas como a Unreal Engine, em que já existe uma API de fachada que encapsula a implementação real do malloc
. A melhor forma de entender o que fazemos é examinar o código. Antes do AutoRTFM, o FMemory::Malloc
era mais ou menos assim: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 dizer que o bloco delimitado (que é transformado em um lambda) deve ser executado sem instrumentação de transação. Para o tempo de execução e o compilador, isso é simples de implementar: significa apenas que emitimos uma chamada para a versão original (não clonada) do lambda. Ou seja, o GMalloc->Malloc
é executado dentro da transação, mas sem nenhuma instrumentação transacional. Chamamos isso de "execução aberta" (em oposição à execução fechada, que significa executar o clone transacionalizado). Portanto, após o retorno do UE_AUTORTFM_OPEN
, a chamada malloc teria sido executada até a conclusão imediatamente, sem nenhum registro de reversão. Isso mantém a semântica transacional, pois o malloc já é seguro em relação a threads, e, portanto, o fato de essa thread ter feito um malloc não é observável por outras threads até que o ponteiro para o objeto que recebeu um malloc vaze da transação. O ponteiro não vazará da transação até fazermos o commit, pois os únicos locais em que Ptr
pode ser armazenado são outras memórias recém-alocadas ou a memória que o AutoRTFM transacionalizará registrando a gravação.AutoRTFM::OnAbort
. Se a transação passar por commit, esse manipulador não vai ser executado e, portanto, o objeto que recebeu malloc vai sobreviver. Mas, se a transação for cancelada, vamos liberar o objeto. Isso garante que não haja vazamentos de memória decorrentes do cancelamento.OnAbort
, pois não precisamos nos preocupar com a possibilidade de o AutoRTFM tentar sobrescrever o conteúdo do objeto de volta a algum estado anterior depois de já termos feito a liberação dele. Além disso, não registrar gravações na memória recém-alocada é uma otimização útil.UE_AUTORTFM_OPEN
apenas executará o bloco de código.AutoRTFM::OnAbort
será uma instrução no-op; o lambda transmitido será simplesmente ignorado.AutoRTFM::DidAllocate
é uma instrução no-op.void
FMemory::
Free
(
void*
Original
)
{
UE_AUTORTFM_ONCOMMIT
(
{
GMalloc
->
Free
(Original);
});
}
UE_AUTORTFM_ONCOMMIT
tem o seguinte comportamento:
Open/OnCommit/OnAbort
oferece tudo que é necessário para executar o código C++ em uma transação, desde que esse código não use atômicas, bloqueios, chamadas de sistema ou chamadas para as partes da base de código da Unreal Engine que optamos por não transacionar (como os mecanismos de renderização e física, atualmente). Isso resultou em um total de 94 usos das APIs Open
em todo o Fortnite. Nesses usos, ocorre o encapsulamento do código existente com o Open
e correlatos na granularidade adequada, em vez da alteração de algoritmos ou estruturas de dados.<no_rollback>
, para que mais código possa ser chamado a partir de
if
e for
. Esse efeito só existe, poluindo muitas assinaturas de funções, porque a maior parte do código C++ não pode ser transacional. O AutoRTFM corrige esse problema.