提案:在Go语言中增加对持久化内存的支持
作者:Jerrin Shaji George、Mohit Verma、Rajesh Venkatasubramanian、Pratap Subrahmanyam Jerrin Shaji George, Mohit Verma, Rajesh Venkatasubramanian, Pratap Subrahmanyam.
最后更新。2021年1月20日
讨论地点:https://golang.org/issue/43810。
摘要
持久化存储器是一种新的存储器技术,其有接近DRAM的访问速度,并提供类似磁盘的持久化。Linux和Windows服务器已经支持持久内存,服务器可用的商用硬件现在也已经推出了。关于这项技术的更多细节可以在pmem.io找到。
本文档是为 Go 增加 pmem 支持的提案文档,具体的详细设计可以参考我们发表的2020年USENIX ATC论文[go-pmem](https://www.usenix.org/system/files/atc20-george.pdf)。基于Go 1.15版本的上述设计的实现,可以在以下网站找到此处。
背景
持久化存储是一种新型的随机存取存储器,它提供了持久化的功能。并以类似DRAM的访问速度实现寻址。操作系统提供了将该内存映射到应用程序的虚拟地址的能力。应用程序可以像使用内存一样使用这个mmap区域。更新到持久化内存的数据,即使是崩溃/重启后,这些数据依然能够被正常使用。
使用持久化内存的应用程序在很多方面都有好处。由于数据更新到持久化内存是非易失性的,应用不再需要维护 DRAM 和存储设备之间的数据关系,不需要在DRAM和存储设备之间调配数据。相当一部分的应用程序代码可以直接退役了。
另一个大的优势是显著减少了应用程序重新启动时的启动时间。这是因为应用程序不再需要把持久化的数据和内存中的数据进行转换。商业应用SAP HANA给出的报告可以看到性能有 12 倍的提升:[12x improvement](https://cloud.google.com/blog/topics/partners/available-first-on-google-cloud-intel-optane-dc-persistent-memory)。
这个proposal是要为持久化内存提供原生支持,在Go语言中,我们的设计修改了Go 1.15,引入了一个垃圾收集的持久化的方法。我们还在 Go 编译器中引入了新语义,以支持事务性更新到持久化内存数据结构。我们把我们修改后的Go套件称为go-pmem。使用go-pmem开发的Redis数据库与在NVMe SSD上运行的Redis相比,吞吐量提高了5倍。
提案
我们建议在Go中增加对持久化内存编程的本地支持。这需要在Go中提供以下功能。
支持持久化的内存分配 对持久化内存堆对象进行垃圾收集。 修改持久化内存数据结构需要保证“崩溃时的一致性” 使应用程序能够在崩溃/重新启动后恢复。 支持应用程序从持久化内存中恢复存储的数据。
为了支持这些功能,我们扩展了Go运行时,并添加了一个新的SSA pass。我们的实现在后文中阐述。
理由
现在已经存在一些库,如Intel PMDK,为C和C++开发人员提供了支持持久化内存编程的开发工具。其他编程语言,如Java和Python,正在探索如何支持。
例如:
Java - https://bugs.openjdk.java.net/browse/JDK-8207851 Python - https://pynvm.readthedocs.io/en/v0.3.1/
但是目前还没有哪种语言原生地对持久化内存进行支持。我们认为这是对推广pmem技术的一种障碍。这个提案就是要让Go成为第一个原生完全支持持久化内存的语言。
为什么要改变语言?
C库暴露了一个与现有编程模型明显不同(而且复杂)的编程模型。内存管理对于一个语言的外部库来说其实是很困难的。漏掉一个 "free "调用就会导致内存泄漏,而在持续化内存中,如果发生泄漏就是永久性的,不会在应用重新启动后消失。在Go这样有运行时的语言中,使本来只给垃圾收集管理的内存让外部库可见还是很困难的。为了能提供事务性的语义,需要对持久化内存的写操作进行定制和组织,这也需要对语言进行修改。经过我们的实践,对Go的编译器和运行时进行增量修改还是比较容易的。
兼容性
我们目前的修改保留了Go 1.x未来兼容性的承诺。它做到了不会破坏不使用任何持久化内存功能的程序的兼容性。
说到这里,我们承认我们目前的设计还存在一些缺点。
我们将内存分配器元数据存储在持久化内存中。当一个程序重新启动,我们使用这些元数据来重新创建内存的程序状态:分配器和垃圾收集器的相关状态也包括在其中。与任何持久化数据一样,我们需要维护这个元数据的数据布局。任何对Go内存分配器的数据结构修改都可能会破坏我们持久化的元数据。可以通过开发一个离线工具来解决这个问题。这样我们可以将升级时的数据格式转换功能嵌入到go-pmem中。
目前我们增加了三个新的Go关键字:pnew, pmake和txn。持久化内存分配API和txn用来划分事务性的数据结构的更新。我们已经探讨了一些方法来避免下文所述的语言变化。
a) pnew/pmake
在未来的Go版本中,对泛型的支持可以帮助我们避免引入这些内存分配函数。它们可以是普通的Go导出函数
func Pnew[T any](_ T) *T {
ptr := runtime.pnew(T)
return ptr
}
func Pmake[T any](_ T, len, cap int) []T {
slc := runtime.pmake([]T, len, cap)
return slc
}
"runtime.pnew "和 "runtime.pmake "将是特殊的函数,可以取一个新的函数。类型作为参数。它们的行为与new()
和make() 这两个 API
非常相似。不过它们是在持久化内存堆中分配对象的。
b) txn
一个替代的方案是定义一个新的Go规则,确定一个事务性的代码块。可以用如下语法:
//go:transactional
{
// transactional data updates
}
还有一种方法可以是使用闭包,并借助一些运行时和编译器的变化。例如。
runtime.Txn() foo()
这比较类似于Go编译器在编译期间存储mrace/msan flag的做法。在这行代码的情况下,foo会被事务性地执行。
playground代码 [code](https://go2goplay.golang.org/p/WRUTZ9dr5W3),展示了一个完整的代码示例,以及我们建议的替代方案。
Implementation
我们的实现是基于Go 1.15版本的Go源代码的fork。我们的实现为Go增加了三个新的关键字:pnew、pmake和txn。pnew和pmake是持久化的内存分配API,而txn是用来标志持久化内存事务块。
pnew - func pnew(Type) *Type
就像new
一样,pnew
也会创建一个Type
参数的零值对象。并返回一个指向该对象的指针。
pmake - func pmake(t Type, size ...IntType) Type
pmake
API用于在持久化内存中创建slice。语义pmake
和Go中的make
完全一样。目前暂时不支持在 pmem 中创建 map 和 channel。
txn
txn() {
// transaction data updates
}
我们对Go的代码修改可以分为两部分--运行时修改和编译器-SSA修改。
runtime 的变化
我们扩展了Go的运行时以支持持久化的内存分配。垃圾收集器现在可以在持久堆和易失堆中工作。mspan
数据基础架构有一个额外的数据成员 "memtype",用于区分持久化和易失性的span。我们还扩展了各种内存分配器在mcache、mcentral和mheap中的数据结构,将持久内存和易失性内存的元数据进行了区分。垃圾回收器现在就可以理解这些不同的span类型,并正确地根据memtype来进行不同的处理了。
持久化内存是以64MB的倍数来管理的。每个持久化内存领域在其头部分有一些元数据,这些元数据是为了方便在应用程序崩溃或重新启动时恢复堆。这里会存储两种类型的元数据:
GC堆类型位 - 每个对象的 GC 堆类型 bit 都会被拷贝到 metadata 段以在程序后续的执行中继续进行使用
Span表 - 捕获该arena上每个span的元数据,以使程序在下次执行时,可以根据这些元数据重建堆
我们在运行时包中添加了以下API来管理持久化内存。
func PmemInit(fname string) (unsafe.Pointer, error)
。
用于初始化持久化内存。它采用持久化内存文件的路径作为输入,返回应用程序的根指针和一个错误值。
func SetRoot(addr unsafe.Pointer) (err Error)
。
用于设置应用程序的根指针。所有应用程序的数据在持久化内存挂起这个根指针。
func GetRoot() (addr unsafe.Pointer)
。
返回使用SetRoot()设置的根指针。
func InPmem(addr unsafe.Pointer) bool
。
返回addr
是否指向持久化内存中的数据。
func PersistRange(addr unsafe.Pointer, len uintptr)
。
刷新地址范围(addr,addr+len)内的所有缓存,以确保任何更新到这个内存范围的数据都会被持久存储。
编译器-SSA变化
修改parser以识别三个新的token--
pnew
,pmake
,和txn
。我们增加一个新的SSA pass,将所有的存储操作都写入到持久化内存。因为持久化内存中的数据可以在崩溃后存活,所以更新持久化内存中的数据必须是事务性的。
对Go AST和SSA进行了修改,现在用户可以将通过将一个块封装在
txn()
块中,将这段Go代码作为事务性代码。为了做到这一点,我们在Go中添加了一个名为 txn
的新关键字。然后,一个新的SSA pass将寻找 txn 块中所有对持久化内存地址的store( OpStore
/OpMove
/OpZero
)操作,并将这些操作的老数据存储在 撤销日志中。该操作将在进行实际的内存更新之前完成。
go-pmem packages
我们开发了两个包,使go-pmem的编写持久化存储器的应用更容易。
pmem包
它提供了一个简单的Init(fname string) bool
API,应用程序可以用它来实现初始化持久化内存。函数返回结果表示是不是第一次初始化,如果是则返回 true。如果不是的话,未完成的事务都会被 revert。
pmem包还提供了命名对象,这些名字可以和持久化内存中的对象关联起来。用户可以字符串名字来创建和获取这些对象。
transaction包
事务包提供了撤消日志记录的实现,这些日志记录用于支持程序的崩溃后恢复,保证崩溃时的一致性。
Example Code
下面是一个使用go-pmem编写的简单的链表应用程序。
// 一个简单的链接列表应用程序。在第一次调用时,它会创建一个
// 命名为 "dbRoot "的持久化内存指针,它持有指向第一个
// 也是链接列表中的最后一个元素。每次运行时,一个新的节点都会被添加
// 链接的列表和列表的所有内容都被打印出来。
package main
import (
"github.com/vmware/go-pmem-transaction/pmem"
"github.com/vmware/go-pmem-transaction/transaction"
)
const (
// Used to identify a successful initialization of the root object
magic = 0x1B2E8BFF7BFBD154
)
// Structure of each node in the linked list
type entry struct {
id int
next *entry
}
// The root object that stores pointers to the elements in the linked list
type root struct {
magic int
head *entry
tail *entry
}
// A function that populates the contents of the root object transactionally
func populateRoot(rptr *root) {
txn() {
rptr.magic = magic
rptr.head = nil
rptr.tail = nil
}
}
// Adds a node to the linked list and updates the tail (and head if empty)
func addNode(rptr *root) {
entry := pnew(entry)
txn() {
entry.id = rand.Intn(100)
if rptr.head == nil {
rptr.head = entry
} else {
rptr.tail.next = entry
}
rptr.tail = entry
}
}
func main() {
firstInit := pmem.Init("database")
var rptr *root
if firstInit {
// Create a new named object called dbRoot and point it to rptr
rptr = (*root)(pmem.New("dbRoot", rptr))
populateRoot(rptr)
} else {
// Retrieve the named object dbRoot
rptr = (*root)(pmem.Get("dbRoot", rptr))
if rptr.magic != magic {
// An object named dbRoot exists, but its initialization did not
// complete previously.
populateRoot(rptr)
}
}
addNode(rptr) // Add a new node in the linked list
}
推荐阅读