var
X
=
0
if
:
set
X
=
42
# 这会发生回滚,原因在于
# 下一行
X
>
100
# 这会失败,导致回滚
X
=
0
# 由于回滚,X重新变为0
set
操作。例如,这次,我们可以调用一个C++函数,使用C++修改某个可变变量(即存储到指针上),然后,我们可以使用其他C++函数来读取这个可变变量:SetSomething
(
0
)
# 在c++中实现,设置一个c++
# 变量
if
:
SetSomething
(
42
)
# 这会回滚,原因在于
# 下一行
GetSomething
()
>
100
# 这会失败,导致回滚
GetSomething
()
=
0
# 由于回滚,重新变为0
if
语句的条件)是嵌套的事务,如果条件成功则提交,不成功则中止。允许条件表达式产生效应。如果条件失败,效应将消失,如同从未存在。<no_rollback>
效应,一旦这个问题被修复,我们就会弃用它。<no_rollback>
效应),并且容易出错,导致难以调试的崩溃(比如为已删除的内容注册了一个撤销处理程序)。void
Foo
()
{
TArray
<int> Array
=
{
1
,
2
,
3
};
AutoRTFM::
Transact
(
[
&
] ()
{
Array
.
Push
(
42
);
//
这里的数组是{1、2、3、42}。
AutoRTFM::
Abort
();
});
//
由于中止,这里的数组是{1,2,3}。
}
TArray
做出任何更改即可生效。即使TArray
需要在Push
内部重新分配其后备存储,它也能正常工作。TArray
在这方面甚至并不特殊;AutoRTFM也可以为std::vector
/std::map
,以及虚幻引擎和C++标准库中的大量其他数据结构和函数做到这一点。为了实现这一点,唯一需要进行的更改就是AutoRTFM与底层内存分配器和垃圾回收器(GC)的集成,而且正如我们将在本节中展示的那样,即使是这些更改,幅度也非常小(它们纯粹是累加进来的,而且只有寥寥几行;我们已经将它们添加到了虚幻引擎的许多malloc、GC以及系统malloc/new中)。除此之外,AutoRTFM在实现这一点时,对于那些在事务之外运行的代码路径(即在Transact
调用的动态范围之外),性能不会出现任何下降。最重要的是,即使代码路径(如TArray
和许多其他代码路径)在事务内部使用,那些在事务外部使用的情况也不会产生任何成本。当代码在事务内部执行时,开销会很高(我们在一些内部基准测试中看到了4倍的开销),但还不足以影响《Fortnite》服务器的Tick率。AutoRTFMPass
的任务是对所有代码进行插桩,使其能够遵守Verse的事务语义。但是,在《Fortnite》中,同样的代码可能用于堆栈上没有Verse代码,并且Verse语义没有必要的地方,也可能用于堆栈上有Verse代码,并且可能发生回滚的地方。bCurrentThreadIsInTransaction
位被编码在了程序计数器中。从编译器的角度看,这很简单:它只是克隆你的所有代码,向克隆的函数赋予重整后的名称以免混淆,然后只对克隆进行插桩。原始代码保持未插桩状态,不进行任何日志记录或其他特殊操作,因此运行该代码不会产生任何成本。克隆的代码得到插桩,并且肯定知道自己处于事务中,这甚至使我们能够玩出类似虚拟机的技巧,比如尝试备用的调用约定。AutoRTFMPass
在LLVM管线中的运行时机与Sanitizer相同,即在所有IR优化完成之后,将代码发送到后端之前。这具有重要影响,例如,即使在被插桩的克隆中,也只有在优化后幸存下来的加载和存储才会被插桩。所以,虽然在C++中给非逃逸本地变量赋值在抽象意义上是一种“存储”(并且当clang为该程序创建未优化的LLVM IR时,它会以这种方式表示),但在AutoRTFMPass
开始工作时,这个存储会完全消失。最有可能的是,它将被SSA数据流替换,AutoRTFM无需干预。我们还基于现有的LLVM静态分析实现了一些小的优化,以避免在可证明不必要的情况下对存储进行插桩。AutoRTFM::Transact
。使用lambda调用该函数会使运行时建立一个事务对象,其中包含日志数据结构和其他事务状态。然后,Transact
会调用lambda的克隆。这与 lambda 完全相同,但具有事务插桩,并遵守AutoRTFM ABI。Transact
。在将来,我们将在进入任何Verse执行之前调用Transact
。malloc/free
和相关函数(如自定义分配器)。WriteFile
之类的函数会出错。这是理想的默认行为,因为WriteFile
无法被可靠地撤销——即使我们稍后尝试撤销写入,其他线程(无论是在我们的进程中还是在另一个进程中)可能已经看到了写入,并据此执行了不可恢复的操作。因此,使用AutoRTFM编译现有的C++程序,然后尝试执行Transact
,是一种实用的做法。它要么会正常工作,要么会出现错误,提示你调用了非事务安全函数,并附上关于如何找到该函数及其调用位置的消息。malloc
和相关函数。AutoRTFM采取的方法是不对malloc
的实现进行事务化,但强制调用者用AutoRTFM API封装对malloc
的调用。在像虚幻引擎这样的程序中,这尤其简单明了,因为已经有一个外观API封装了实际的malloc
实现。最好通过查看代码来了解我们的工作。在使用AutoRTFM之前,FMemory::Malloc
大致是这样的: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
指出,括号内的代码块(会被转换成lambda)应该在没有事务插桩的情况下运行。这对于运行时和编译器来说,很容易实现:它只意味着我们发出对lambda的原始(未克隆)版本的调用。这意味着GMalloc->Malloc
在事务中运行,但没有任何事务插桩。我们将此称为“开放式运行”(与意味着运行事务化克隆的封闭式运行相对)。因此,在UE_AUTORTFM_OPEN
返回后,malloc调用将立即运行完成,而没有注册任何回滚。这保持了事务语义,因为malloc已经是线程安全的,因此在malloc对象的指针从事务中泄漏出来之前,其他线程无法观察到这个线程进行了malloc操作。在提交之前,指针不会从事务中泄漏出去,因为Ptr
可以存放的地方要么是其他新分配的内存,要么是AutoRTFM将通过记录写入将要事务化的内存。AutoRTFM::OnAbort
注册一个在中止时释放对象的处理程序。如果事务提交,该处理程序不会运行,因此malloc的对象会被保留下来。但是,如果事务中止,我们将释放该对象。这确保了在中止时没有内存泄漏。OnAbort
变得更容易,因为我们不必担心AutoRTFM会在我们释放对象后,尝试将对象内容覆盖回之前的状态。而且,不记录对新分配内存的写入是一种有用的优化。UE_AUTORTFM_OPEN
只运行代码块。AutoRTFM::OnAbort
是一个空操作;传入的lambda会被忽略。AutoRTFM::DidAllocate
是一个空操作。void
FMemory::
Free
(
void*
Original
)
{
UE_AUTORTFM_ONCOMMIT
(
{
GMalloc
->
Free
(Original);
});
}
UE_AUTORTFM_ONCOMMIT
函数具有以下行为:
Open/OnCommit/OnAbort
API的malloc/GC插桩,只要代码不使用原子操作、锁、系统调用,或者调用我们选择完全不进行事务化的虚幻引擎代码库部分(比如当前的渲染引擎和物理引擎),我们就有了运行 C++ 代码的事务所需的一切。这使得Open
API在整个《Fortnite》中的总使用次数达到了94次。这些使用涉及以适当的粒度用Open
和相关API封装现有代码,而无需更改算法或数据结构。<no_rollback>
效应,以便可以从
if
和for
调用更多代码。这种效应污染了大量函数签名,它之所以存在,是因为大多数C++代码无法进行事务处理。AutoRTFM解决了这个问题。