8 libpmemobj-cpp 版本
8.1 简介
PMDK包含几个分离的库,每一个都为专门的用途而设计。最灵活和最强大的是libpmemobj。它遵循持久内存编程模型,而不需要修改编译器。旨在为低层系统软件和语言创建者的开发者们提供帮助,libpmemobj提供了分配器、事务,以及自动管理对象的方法。因为它不修改编译器,所以它的API冗长并且使用了很多宏。
为了使持久内存编程更容易,更不易于出错,PMDK的libpmemobj提供了更高层的语言支持,C++版本的libpmemobj-cpp,也称libpmemobj++。C++特性丰富,有大量的开发者基础,并且不断地被完善,更新到新的编程标准。
libpmemobj-cpp 设计的主要目的是聚焦于易失性程序的修改在数据结构上,而不是代码上。换句话说,libpmemobj-cpp为那些想修改易失性应用的开发者设计,提供方便的API来修改结构、以及轻微修改函数的类。本章讲述如何利用C++的元数据编程特性来使持久内存编程更容易。它也描述了使用提供的持久化容器的很多C++惯用法。最后,讨论了持久内存编程的C++标准限制,包括对象生命周期和存储在持久内存中的对象的内部布局。
本章介绍如何利用C++语言元编程特性,使持久内存编程更容易。它还描述了如何通过提供持久容器来使其更符合C++的习惯用法。最后,我们讨论了持久内存编程的C++标准限制,包括对象的生存期和存储在持久内存中的对象的内部布局。
8.2 元编程的利用
元编程是一种计算机程序有能力将其他程序视为其数据的技术。它意味着一个程序可以被设计来读取、生成、分析或转换其他程序,甚至在运行时修改自己。在某些情况下,这允许程序员最小化表示解决方案的代码行数,从而减少开发时间。它还允许程序更大的灵活性,以有效地处理新情况,而无需重新编译。
对于libpmemobj cpp库,在使用类型安全容器封装PMEMoids(持久内存对象id)方面付出了相当大的努力。使用模板和元编程,而不是一组复杂的宏来提供类型安全性。这大大简化了原生C libpmemobj API。
8.2.1 为持久内存准备的指针
存储网络工业协会(SNIA)创建的持久内存编程模型是基于内存映射文件的。PMDK将此模型用于其体系结构和设计实现。我们在第三章讨论了SNIA编程模型。
大多数操作系统实现了地址空间布局随机化(ASLR)。ASLR是一种防止利用内存损坏漏洞的计算机安全技术。为了防止攻击者可靠地跳转到(例如)内存中某个被利用的特定函数,ASLR随机排列进程关键数据区域的地址空间位置,包括可执行文件的基位置以及堆栈、堆和库的位置。由于ASLR,每次应用程序执行时,可以在进程地址空间的不同地址映射文件。因此,不能使用存储绝对地址的传统指针。每次执行时,传统指针可能会指向未初始化的内存,对其取消引用可能会导致段错误。或者它可能指向有效的内存范围,但不是用户希望它指向的内存范围,从而导致意外和不确定的行为。
为了解决持久内存编程中的这个问题,需要一种不同类型的指针。libpmemobj引入了一个名为PMEMoid的C结构,它由内存池的标识符和从内存池开始的偏移量组成。这个宽指针被封装在libpmemobj C++的模板类pmem::obj::persistent_ptr。C和C++实现都具有相同的16字节占用率。提供了来自原始PMEMoid的构造函数,使得C API与C++的混用成为可能。pmem::obj::persistent_ptr在概念和实现上与C++ 11中引入的智能指针(std::shared_ptr, std::auto_ptr, std::unique_ptr, and std::weak_ptr)相似,但有一个很大的不同,即它不能管理对象的生命周期。
除了operator*、operator->、operator[]、typedefs与std::pointer_traits、std::iterator_traits兼容外,pmem::obj::persistent_ptr还定义了持久化其内容的方法。Pmem::obj::persistent_ptr可用于标准库的算法和容器中。
8.2.2 事务Transactions
对于大多数希望在持久内存中使用的非平凡算法来说,一次原子地修改超过8字节的存储是必不可少的。通常,一个逻辑操作需要多个存储。例如,插入到一个简单的基于列表的队列需要两个独立的存储区:尾部指针和最后一个元素的下一个指针。为了使开发人员能够原子地修改更大数量的数据,对于电源故障中断,PMDK库在其一些库中提供事务支持。C++语言绑定将这些事务封装成两个概念:一个是基于资源获取的初始化(RAII)习惯用法,另一个是基于可调用的std::function对象。另外,由于一些C++标准问题,范围事务有两种形式:手动和自动。在本章中,我们只描述使用std::function对象的方法。有关基于RAII的事务的信息,请参阅libpmemobj cpp文档(https://pmem.io/pmdk/cpp_obj/)。使用std::function的方法声明为:
void pmem::obj::transaction::run(pool_base &pop, std::function<void ()> tx, Locks&... locks)
locks参数是一个变量模板。由于std::function,可以传入大量类型以运行。首选方法之一是将lambda函数作为tx参数传递。这使得代码更紧凑,更易于分析。清单8-1显示了如何使用lambda在事务中执行工作。
Listing 8-1. Function object transaction
45 // execute a transaction
46 pmem::obj::transaction::run(pop, [&]() {
47 // do transactional work
48 });
当然,这个API不仅仅限于lambda函数。任何可调用的目标都可以作为tx传递,例如函数、绑定表达式、函数对象和指向成员函数的指针。由于run是一个普通的静态成员函数,所以它的优点是能够抛出异常。如果在事务执行过程中引发异常,则会自动中止该异常,并重新引发活动异常,从而不会丢失有关中断的信息。如果基础C库由于任何原因失败,事务也被中止,并且会引发C++库异常。开发人员不再承担检查上一个事务状态的任务。
Libpmemobj-cpp事务为持久内存驻留同步原语(如pmem::obj::mutex、pmem::obj::shared_mutex和pmem::obj::timed_mutex)提供入口点。libpmemobj确保在第一次尝试获取锁时正确地重新初始化所有锁。pmem锁的使用是完全可选的,没有pmem锁就可以执行事务。提供的锁的数量是任意的,并且类型可以自由混合。锁一直保持到给定事务的结束,或者在嵌套的情况下保持到最外面的事务。这意味着,当事务由try catch语句括起来时,在到达catch子句之前释放锁。这在某些事务中止清理需要修改共享状态时非常重要。在这种情况下,需要按照正确的顺序重新获取必要的锁。
8.2.3 快照Snapshotting
在修改事务中的数据之前,C库需要手动快照。C++绑定则自动完成所有的快照,以减少程序员犯错的概率。pmem::obj::p模板包装类是此机制的基本构建块。它设计用于基本类型,而不是复合类型,如类或PODs(Plain Old Data简单旧数据、仅具有字段且不具有任何面向对象特性的结构)。这是因为它没有定义operator->(),并且不可能实现operator.()。pmem::obj::p的实现基于operator=()。每次调用赋值运算符时,由p包装的值都将更改,库需要对旧值进行快照。除了快照之外,p<>模板还确保变量正确持久化,并在必要时刷新数据。清单8-2提供了一个使用p<>模板的示例。
Listing 8-2. Using the p<> template to persist values correctly
39 struct bad_example {
40 int some_int;
41 float some_float;
42 };
43
44 struct good_example {
45 pmem::obj::p<int> pint;
46 pmem::obj::p<float> pfloat;
47 };
48
49 struct root {
50 bad_example bad;
51 good_example good;
52 };
53
54 int main(int argc, char *argv[]) {
55 auto pop = pmem::obj::pool<root>::open("/daxfs/file", "p");
56
57 auto r = pop.root();
58
59 pmem::obj::transaction::run(pop, [&]() {
60 r->bad.some_int = 10;
61 r->good.pint = 10;
62
63 r->good.pint += 1;
64 });
65
66 return 0;
67 }
- 第39-42行: 这里,我们声明了一个包含两个变量的错误示例结构——一些变量是int,一些变量是float。将此结构存储在持久内存中并修改它是危险的,因为数据不会自动快照;
- 第44-47行: 我们用两个p<>类型变量(pint和pfloat)声明了良好的示例结构。此结构可以安全地存储在持久内存中,因为事务中对pint或pfloat的每次修改都将执行快照;
- 第55-57行: 这里,我们打开一个已经使用pmempool命令创建的持久内存池,并获取一个指向存储在根变量中的根对象的指针;
- 第60行: 我们修改了bad_example结构中的整数值。此修改不安全,因为我们不会将此变量添加到事务中;因此,如果出现意外的应用程序或系统崩溃或电源故障,则不会正确地将其持久化;
- 第61行: 这里,我们修改由p<>模板包装的整数值。这是安全的,因为operator=()将自动对元素进行快照;
- 第63行: 在p<>上使用算术运算符(如果底层类型支持它)也是安全的。
8.2.4 内存分配Allocating
与std::shared_ptr一样,pmem::obj::persistent_ptr带有一组分配和释放函数。这有助于分配内存和创建对象,以及销毁和释放内存。对于持久性内存,这一点尤其重要,因为所有分配和对象构造/销毁都必须以原子方式完成,以防电源故障中断。事务分配使用完美的转发和可变模板来构建对象。这使得对象创建类似于调用构造函数,并且与std::make_shared相同。然而,事务数组的创建要求对象是默认可构造的。创建的数组可以是多维的。必须在事务中调用pmem::obj::make_persistent和pmem::obj::make_persistent_array;否则,将引发异常。在对象构造过程中,可以进行其他事务性分配,这使得这个API非常灵活。持久内存的特殊性要求引入pmem::obj::delete_persistent函数,它会销毁对象和对象数组。由于pmem::obj::persistent_ptr不会自动处理所指对象的生存期,因此用户需要负责处理不再使用的对象。清单8-3显示了事务分配的示例。
原子分配的行为不同,因为它们不返回指针。开发人员必须提供对函数参数的引用。因为原子分配不能在事务的上下文中执行,所以实际的指针分配必须通过其他方式完成。例如,通过重做日志操作。清单8-3还提供了一个原子分配的示例。
Listing 8-3. Example of transactional and atomic allocations
39 struct my_data {
40 my_data(int a, int b): a(a), b(b) {
41
42 }
43
44 int a;
45 int b;
46 };
47
48 struct root {
49 pmem::obj::persistent_ptr<my_data> mdata;
50 }; 51 52 int main(int argc, char *argv[]) {
53 auto pop = pmem::obj::pool<root>::open("/daxfs/file", "tx");
54
55 auto r = pop.root();
56
57 pmem::obj::transaction::run(pop, [&]() {
58 r->mdata = pmem::obj::make_persistent<my_data>(1, 2);
59 });
60
61 pmem::obj::transaction::run(pop, [&]() {
62 pmem::obj::delete_persistent<my_data>(r->mdata);
63 });
64 pmem::obj::make_persistent_atomic<my_data>(pop, r->mdata, 2, 3);
65
66 return 0;
67 }
- 第58行: 这里,我们以事务方式分配my_data对象。传递给make_persistent参数将被转发到assignment to r->mdata构造函数。注意,对r->mdata的赋值将执行旧持久指针值的快照;
- 第62行: 这里,我们删除my_data对象。delete_persistent将调用对象的析构函数并释放内存;
- 第64行: 我们原子化地分配my_data对象。无法在事务内部调用此函数。
8.1 C++标准限制
C++语言限制和持久内存编程范式意味着对存储在持久内存上的对象的严重限制。由于libpmemobj和SNIA编程模型,应用程序可以通过内存映射文件访问持久内存,以利用其字节可寻址性。此处不进行序列化,因此应用程序必须能够直接从持久性内存媒体读取和修改,即使在应用程序关闭和重新打开后,或在电源故障事件后也是如此。
从C++和libpmemobj库的角度看,前面意味着什么?主要是四个问题:
- 对象生命周期;
- 事务中对象快照;
- 固定在存储对象的媒体布局上;
- 指针作为对象成员。
这四个问题在下面的四个章节中描述。
8.3.1 对象生命周期
对象的生存期在C++标准[basic.life]章节描述。标准见:https://isocpp.org/std/the-standard。
------------
The lifetime of an object or reference is a runtime property of the object or reference.
A variable is said to have vacuous initialization if it is default- initialized and,
if it is of class type or a (possibly multi-dimensional) array thereof,
that class type has a trivial default constructor. The lifetime of an object of type T begins when:
(1.1) storage with the proper alignment and size for type T is obtained, and
(1.2) its initialization (if any) is complete (including vacuous initialization) ([dcl.init]),
except that if the object is a union member or subobject thereof,
its lifetime only begins if that union member is the initialized member
in the union ([dcl.init.aggr], [class.base.init]), or as described
in [class. union]. The lifetime of an object of type T ends when:
(1.3) if T is a non-class type, the object is destroyed, or
(1.4) if T is a class type, the destructor call starts, or
(1.5) the storage which the object occupies is released,
or is reused by an object that is not nested within o ([intro.object]).
------------
标准规定,对象的属性仅在给定对象的生存期内适用。在这种情况下,持久存储器编程问题类似于通过网络传输数据,其中C++应用程序被赋予字节数组,但可能能够识别发送的对象的类型。但是,该对象不是在此应用程序中构造的,因此使用它将导致未定义的行为。
这个问题是众所周知的,并且正在由WG21 C++标准委员会工作组处理。https://isocpp.org/std/the-committee网站和http://www.open-std.org/jtc1/sc22/wg21/)。来自C++标准的观点,目前,没有一种可行的方法来克服对象寿命障碍,并且停止依赖于未定义行为。libpmemobj-cpp是用各种C++ 11编译器和用例场景测试和验证的。libpmemobj cpp用户的唯一建议是,在开发持久内存应用程序时,他们必须记住这一限制。
8.3.2 平凡类型
事务是libpmemobj的核心。这就是为什么在设计C++版本时,尽可能小心地实现libpmemobj-cpp,以使它们尽可能容易使用。开发人员不必知道实现细节,也不必担心因为使用基于undo日志机制而对修改后的数据进行快照。实现了一个特殊的半透明模板属性类,以自动向事务undo日志添加变量修改,如“快照”部分所述。
但是快照数据意味着什么?答案很简单,但是C++的结果不是。libpmemobj通过使用memcpy() 将给定长度的数据从指定地址复制到另一个地址来实现快照。如果事务中止或发生系统断电,则当内存池重新打开时,数据将从undo日志中写入。考虑下面列出的C++对象的定义,并考虑MycPy*() 在其上的后果。
Listing 8-4. An example showing an unsafe memcpy() on an object
35 class nonTriviallyCopyable {
36 private:
37 int* i;
38 public:
39 nonTriviallyCopyable (const nonTriviallyCopyable & from)
40 {
41 /* perform non-trivial copying routine */
42 i = new int(*from.i);
43 }
44 };
深浅复制是最简单的例子。问题的要点是,通过手动复制数据,我们可能会打破依赖复制构造函数的对象的固有行为。任何共享的或唯一的指针都是另一个很好的例子——通过使用memcpy()简单地复制它,我们打破了使用该类时所做的”deal”,这可能会导致泄漏或崩溃。 当应用程序手动复制对象的内容时,它必须处理许多更复杂的细节。C++ 11标准提供了一个<type_traits>类型特性和td::is_trivially_copyable,确保给定类型满足TriviallyCopyable的要求。参考C++标准,当一个平凡的可复制类是一个类:
-它没有非平凡的拷贝构造函数时,一个对象满足TriviallyCopyable的要求。12.8条),
-没有非平凡的移动构造函数(12.8条),
-没有非平凡的复制赋值运算符(13.5.3条, 12.8条),
-没有非平凡的移动赋值运算符(13.5.3条, 12.8条),并且
-有一个平凡的析构函数(12.4条).
平凡类是具有平凡的默认构造函数的类(12.1款)而且是可以复制的。
[注意:特别是,一个微不足道的可复制类或微不足道的类没有虚拟函数或虚拟基类。]
C++标准定义了如下非平凡方法:
如果X类的复制/移动构造函数不是用户提供的,并且满足以下条件则是平凡的:
-X类没有虚函数,则是平凡的(10.3款)没有虚拟基类(10.1款),并且
-选择用来复制/移动每个直接基类子对象的构造函数是平凡的,
-对于类类型(或其数组)的X的每个非静态数据成员,选择复制/移动该成员的构造函数是平凡的;
否则,复制/移动构造函数是平凡的。
这意味着,如果复制或移动构造函数不是用户提供的,则它是平凡的。类中没有虚拟对象,并且递归地类的所有成员和基类都是如此。正如你所见,C++标准和libpmemobj事务实现限制了在持久内存上存储的可能对象类型,以满足平凡类型的要求,但是必须考虑对象的布局。
8.3.3 对象布局
对象表示(也称为布局)在编译器、编译器标志和应用程序二进制接口(ABI)之间可能有所不同。编译器可能会进行一些与布局相关的优化,并且可以随意改变具有相同说明符类型的成员的顺序,例如,public然后是protected,然后是public。另一个与未知对象布局相关的问题与多态类型有关。目前,在重新打开内存池之后,没有可靠且可移植的方法来实现vtable重建,因此永久内存不支持多态对象。如果我们想使用内存映射文件将对象存储在持久内存中,并遵循SNIA NVM编程模型,我们必须确保以下转换始终有效:
someType A = *reinterpret_cast<someType*>(mmap(...));
存储对象类型的位表示必须始终相同,并且我们的应用程序应该能够从内存映射文件中检索存储对象,而无需序列化。可以确保特定类型满足上述要求。C++ 11提供了另一种称为std::is_standard_layout。该标准提到它与其他语言的通信是有用的,例如为原生C++库创建语言绑定,这就是为什么标准布局类具有相同的C结构或联合的内存布局。一般规则是,标准布局类必须与所有非静态数据成员具有相同的访问控制。我们在本节的开头提到了这一点:C++兼容的编译器可以自由地打乱顺序来访问同一类定义的范围。
使用继承时,整个继承树中只有一个类可以具有非静态数据成员,而第一个非静态数据成员不能是基类类型,因为这可能会破坏别名规则。否则,它不是标准布局类。 C++11定义的std::is_standard_layout如下:
一个standard-layout标准布局的类是这样一个类:
— 没有非标准布局类(或此类类型的数组)或引用类型的非静态数据成员
— 没有虚函数或者虚基类 (10.1),
— 所有非静态成员有相同访问控制(Clause 11)
— 没有非标准布局基类
— 在大多数派生类中没有非静态数据成员,并且最多有一个基类具有非静态数据成员,
或者没有基类具有非静态数据成员
— 没有与第一个非静态数据成员类型相同的基类。
标准布局结构是用类键结构或类键类定义的标准布局类。
标准布局联合是用类键联合定义的标准布局类。
[ 注:标准布局类对于与用其他编程语言编写的代码通信非常有用,
他们的布局在9.2中进行规范]
在讨论了对象布局之后,我们研究了指针类型的另一个有趣的问题,以及如何将它们存储在持久内存中。
8.3.4 指针Pointers
在前面的章节中,我们引用了C++标准的部分内容。我们正在描述类型的限制,这些类型对快照和复制是安全的,并且我们可以在不考虑固定布局的情况下进行二进制转换。但是指针呢?在处理持久内存编程模型时,我们如何在对象中处理它们?考虑清单8-5中的代码片段,它提供了一个使用易失性指针作为类成员的类的示例。
Listing 8-5. Example of class with a volatile pointer as a class member
39 struct root {
40 int* vptr1;
41 int* vptr2;
42 };
43
44 int main(int argc, char *argv[]) {
45 auto pop = pmem::obj::pool<root>::open("/daxfs/file", "tx");
46
47 auto r = pop.root();
48
49 int a1 = 1;
50
51 pmem::obj::transaction::run(pop, [&](){
52 auto ptr = pmem::obj::make_persistent<int>(0);
53 r->vptr1 = ptr.get();
54 r->vptr2 = &a1;
55 });
56
57 return 0;
58 }
- 第39-42行: 我们创建一个根结构,其中有两个易失性指针作为成员。
- 第51-52行: 我们的应用程序正在事务性地分配两个虚拟地址。一个指向堆栈上的整数,另一个指向持久内存上的整数。如果应用程序在事务执行后崩溃或退出,并且我们再次执行应用程序,会发生什么情况?由于变量a1位于堆栈上,旧值消失了。但分配给vptr1的值是多少?即使它驻留在持久内存中,易失性指针也不再有效。使用ASLR,如果调用mmap(),我们不能保证再次获得相同的虚拟地址。指针可能指向某物、空或垃圾。
如前面例子所示,认识到在持久内存中存储易失性内存指针是设计错误是很重要的。然而,使用pmem::obj::persistent_ptr<>类模板是安全的。它提供了仅有的一个在应用崩溃后安全访问特定内存的方法。然而,pmem::obj::persistent_ptr<>类型不满足TriviallyCopyable需求,因为有显示定义的构造函数。因此,具有pmem::obj::persistent_ptr<>成员的对象不能通过std::is_trivially_copyable验证检查。每个持久性内存开发人员都应该始终检查在这种特定情况下是否可以复制pmem::obj::persistent_ptr<>,并且它不会导致错误和持久性内存泄漏。开发人员应该意识到std::is_ trivially_copyable只是一个语法检查,它不测试语义。在此上下文中使用pmem::obj::persistent_ptr<>会导致未定义的行为。这个问题没有单一的解决办法。在编写这本书时,C++标准还没有完全支持持久内存编程,因此开发人员必须确保复制pmem::obj::persistent_ptr<>在每种情况下都是安全的。
8.3.5 限制小结
C++ 11为持久内存编程提供了一些非常有用的类型特征。这些是:
• template <typename T>
struct std::is_pod;
• template <typename T>
struct std::is_trivial;
• template <typename T>
struct std::is_trivially_copyable;
• template <typename T>
struct std::is_standard_layout;
它们是相互关联的。最普遍和限制性的是图8-1所示的POD类型的定义。
我们之前提到,持久内存驻留类必须满足以下要求:
• std::is_trivially_copyable
• std::is_standard_layout
如果需要的话,持久内存开发人员可以自由地使用更严格的类型特征。但是,如果我们想使用持久指针,就不能依赖类型特征,而且我们必须注意与使用memcpy() 复制对象和对象的布局表示相关的所有问题。对于持久内存编程,需要在C++标准体组内进行上述概念和特征的格式描述或标准化,以便可以正式设计和实现。在此之前,开发人员必须了解管理未定义对象生存期行为的限制和限制。
8.4 简化持久性
考虑一个简单的队列实现,如清单8-6所示,它将元素存储在易失性DRAM中。
Listing 8-6. An implementation of a volatile queue
33 #include <cstdio>
34 #include <cstdlib>
35 #include <iostream>
36 #include <string>
37
38 struct queue_node {
39 int value;
40 struct queue_node *next;
41 };
42
43 struct queue {
44 void
45 push(int value)
46 {
47 auto node = new queue_node;
48 node->value = value;
49 node->next = nullptr;
50
51 if (head == nullptr) {
52 head = tail = node;
53 } else {
54 tail->next = node;
55 tail = node;
56 }
57 }
58
59 int
60 pop()
61 {
62 if (head == nullptr)
63 throw std::out_of_range("no elements");
64
65 auto head_ptr = head;
66 auto value = head->value;
67
68 head = head->next;
69 delete head_ptr;
70
71 if (head == nullptr)
72 tail = nullptr;
73
74 return value;
75 }
76
77 void
78 show()
79 {
80 auto node = head;
81 while (node != nullptr) {
82 std::cout << "show: " << node->value << std::endl;
83 node = node->next;
84 }
85
86 std::cout << std::endl;
87 }
88
89 private:
90 queue_node *head = nullptr;
91 queue_node *tail = nullptr;
92 };
- 第38-40行: 我们声明队列节点结构的布局。它存储一个整数值和指向列表中下一个节点的指针;
- 第44-57行: 我们实现了push() 方法,该方法分配新节点并设置其值;
- 第59-75行: 我们实现了pop() 方法,删除队列中的第一个元素;
- 第77-87行: show() 方法遍历列表并将每个节点的内容打印到标准输出。
前面的队列实现将int类型的值存储在链表中,并提供三种基本方法:push()、pop()和show()。在本节中,我们将演示如何修改volatile结构,以使用libpmemobj-cpp绑定将元素存储在持久内存中。所有修饰符方法都应提供原子性和一致性属性,这些属性将通过使用事务得到保证。
改变易失性应用程序以开始利用持久内存应该依赖于修改结构和类,只需稍微修改函数。我们开始先通过更改队列节点的布局来修改队列节点结构,如清单8-7所示。
Listing 8-7. A persistent queue implementation – modifying the queue_node struct
38 #include <libpmemobj++/make_persistent.hpp>
39 #include <libpmemobj++/p.hpp>
40 #include <libpmemobj++/persistent_ptr.hpp>
41 #include <libpmemobj++/pool.hpp>
42 #include <libpmemobj++/transaction.hpp>
43
44 struct queue_node {
45 pmem::obj::p<int> value;
46 pmem::obj::persistent_ptr<queue_node> next;
47 };
48
49 struct queue { ...
100 private:
101 pmem::obj::persistent_ptr<queue_node> head = nullptr;
102 pmem::obj::persistent_ptr<queue_node> tail = nullptr;
103 };
如您所见,所有修改都被限制为用pmem:obj::persistent_ptr替换易失性指针,并开始使用p<>属性。接下来,我们修改一个push() 方法,如清单8-8所示。
Listing 8-8. A persistent queue implementation – a persistent push() method
50 void
51 push(pmem::obj::pool_base &pop, int value)
52 {
53 pmem::obj::transaction::run(pop, [&]{
54 auto node = pmem::obj::make_persistent<queue_node>();
55 node->value = value;
56 node->next = nullptr;
57
58 if (head == nullptr) {
59 head = tail = node;
60 } else {
61 tail->next = node;
62 tail = node;
63 }
64 });
65 }
所有修饰符方法都必须知道它们应该在哪个持久内存池上操作。对于单个内存池来说,这很简单,但是如果应用程序内存映射来自不同文件系统的文件,我们需要跟踪哪个池有哪些数据。我们引入了pmem::obj::pool_base类型的附加参数来解决此问题。在方法定义中,我们用一个C++的lambda表达式[&]用事务包来封装代码,以保证修改的原子性和一致性。我们不在堆栈上分配新节点,而是调用pmem::obj::make_ persistent<>()在持久内存上事务性地分配它。清单8-9显示了pop() 方法的修改。
Listing 8-9. A persistent queue implementation – a persistent pop() method
67 int
68 pop(pmem::obj::pool_base &pop)
69 {
70 int value;
71 pmem::obj::transaction::run(pop, [&]{
72 if (head == nullptr)
73 throw std::out_of_range("no elements");
74
75 auto head_ptr = head;
76 value = head->value;
77
78 head = head->next;
79 pmem::obj::delete_persistent<queue_node>(head_ptr);
80
81 if (head == nullptr)
82 tail = nullptr;
83 });
84
85 return value;
86 }
pop()的逻辑包装在libpmemobj-cpp事务中。唯一的附加修改是用事务pmem::obj::delete_persistent<>()交换对volatile delete的调用。show() 方法不修改易失性内存DRAM或persistent memory上的任何内容,因此我们不需要对其进行任何更改,因为pmem:obj::persistent_ ptr实现了operator->。要开始使用此队列示例的持久版本,我们的应用程序可以将其与根对象相关联。清单8-10给出了一个使用持久队列的示例应用程序。
Listing 8-10. Example of application that uses a persistent queue
39 #include "persistent_queue.hpp"
40
41 enum queue_op {
42 PUSH,
43 POP,
44 SHOW,
45 EXIT,
46 MAX_OPS,
47 };
48
49 const char *ops_str[MAX_OPS] = {"push", "pop", "show", "exit"};
50
51 queue_op
52 parse_queue_ops(const std::string &ops)
53 {
54 for (int i = 0; i < MAX_OPS; i++) {
55 if (ops == ops_str[i]) {
56 return (queue_op)i;
57 }
58 }
59 return MAX_OPS;
60 }
61
62 int
63 main(int argc, char *argv[])
64 {
65 if (argc < 2) {
66 std::cerr << "Usage: " << argv[0] << " path_to_pool" << std::endl;
67 return 1;
68 }
69
70 auto path = argv[1];
71 pmem::obj::pool<queue> pool;
72
73 try {
74 pool = pmem::obj::pool<queue>::open(path, "queue");
75 } catch(pmem::pool_error &e) {
76 std::cerr << e.what() << std::endl;
77 std::cerr << "To create pool run: pmempool create obj --layout=queue -s 100M path_to_pool" << std::endl;
78 }
79
80 auto q = pool.root();
81
82 while (1) {
83 std::cout << "[push value|pop|show|exit]" << std::endl;
84
85 std::string command;
86 std::cin >> command;
87
88 // parse string
89 auto ops = parse_queue_ops(std::string(command));
90
91 switch (ops) {
92 case PUSH: {
93 int value;
94 std::cin >> value;
95
96 q->push(pool, value);
97
98 break;
99 }
100 case POP: {
101 std::cout << q->pop(pool) << std::endl;
102 break;
103 }
104 case SHOW: {
105 q->show();
106 break;
107 }
108 case EXIT: {
109 exit(0);
110 }
111 default: {
112 std::cerr << "unknown ops" << std::endl;
113 exit(0);
114 }
115 }
116 }
117 }
8.5 生态系统
libpmemobj C++绑定的总体目标是创建一个友好且不易出错的API,用于持久内存编程。即使有了持久内存池分配器、创建和管理事务的方便接口、自动快照类模板和智能持久指针,设计使用持久内存的应用程序,对于C++程序员仍有许多细微之处具有挑战性。使持久化编程更容易的自然步骤是为程序员提供高效和有用的容器。
8.5.1 持久容器
C++标准库容器集合是持久内存程序员可能想要使用的东西。容器通过使用分配器的allocation/creation和deallocation/destruction来管理持有对象的生存期。为C++ STL(标准模板库)容器实现自定义持久分配器有两个主要缺点:
- 实现细节
- STL容器不使用对持久内存编程来说是最优的算法;
- 持久内存容器应该具有持久性和一致性属性,而不是每个STL方法都能保证强异常安全性;
- 持久性内存容器的设计应该考虑到碎片限制。
- 内存布局
- STL不能保证容器布局在新的库版本中保持不变。
由于这些障碍,libpmemobj-cpp包含了一组自定义的、从头实现的、在媒体布局和算法上进行优化的容器,以充分利用持久内存的潜力和特性。这些方法保证原子性、一致性和持久性。除了特定的内部实现细节之外,libpmemobj-cpp持久内存容器还有一个众所周知的类似STL的接口,它们可以与STL算法一起工作。
8.5.2 持久容器示例
由于libpmemobj-cpp设计的主要目标是将易失性程序的修改集中在数据结构上,而不是代码上,因此libpmemobj cpp持久容器的使用几乎与STL对应容器的使用相同。清单8-11展示了一个持久向量示例来展示这一点。
Listing 8-11. Allocating a vector transactionally using persistent containers
33 #include <libpmemobj++/make_persistent.hpp>
34 #include <libpmemobj++/transaction.hpp>
35 #include <libpmemobj++/persistent_ptr.hpp>
36 #include <libpmemobj++/pool.hpp>
37 #include "libpmemobj++/vector.hpp"
38
39 using vector_type = pmem::obj::experimental::vector<int>;
40
41 struct root {
42 pmem::obj::persistent_ptr<vector_type> vec_p;
43 };
44 ...
63
64 /* creating pmem::obj::vector in transaction */
65 pmem::obj::transaction::run(pool, [&] {
66 root->vec_p = pmem::obj::make_persistent<vector_type> (/* optional constructor arguments */);
67 });
68
69 vector_type &pvector = *(root->vec_p);
清单8-11显示必须使用transaction在持久内存中创建和分配pmem::obj::vector,以避免引发异常。向量类型构造函数可以通过内部打开另一个事务来构造对象。在这种情况下,内部事务将扁平化为外部事务。vector的接口和语义类似于std::vector,如清单8-12所示。
Listing 8-12. Using persistent containers
71 pvector.reserve(10);
72 assert(pvector.size() == 0);
73 assert(pvector.capacity() == 10);
74
75 pvector = {0, 1, 2, 3, 4};
76 assert(pvector.size() == 5);
77 assert(pvector.capacity() == 10);
78
79 pvector.shrink_to_fit();
80 assert(pvector.size() == 5);
81 assert(pvector.capacity() == 5);
82
83 for (unsigned i = 0; i < pvector.size(); ++i)
84 assert(pvector.const_at(i) == static_cast<int>(i));
85
86 pvector.push_back(5);
87 assert(pvector.const_at(5) == 5);
88 assert(pvector.size() == 6);
89
90 pvector.emplace(pvector.cbegin(), pvector.back());
91 assert(pvector.const_at(0) == 5);
92 for (unsigned i = 1; i < pvector.size(); ++i)
93 assert(pvector.const_at(i) == static_cast<int>(i - 1));
每个修改持久内存容器的方法都在隐式事务中进行修改,以确保完全异常安全。如果在另一个事务的作用域内调用这些方法中的任何一个,则该操作在该事务的上下文中执行;否则,它在自己的作用域中是原子的。在pmem::obj::vector上迭代的工作方式与std::vector完全相同。我们可以对循环或迭代器使用基于范围的索引运算符。还可以使用std::algorithms处理pmem::obj::vector,如清单8-13所示。
Listing 8-13. Iterating over persistent container and compatibility with STD algorithms
95 std::vector<int> stdvector = {5, 4, 3, 2, 1};
96 pvector = stdvector;
97
98 try {
99 pmem::obj::transaction::run(pool, [&] {
100 for (auto &e : pvector)
101 e++;
102 /* 6, 5, 4, 3, 2 */
103
104 for (auto it = pvector.begin(); it != pvector.end(); it++)
105 *it += 2;
106 /* 8, 7, 6, 5, 4 */
107
108 for (unsigned i = 0; i < pvector.size(); i++)
109 pvector[i]--;
110 /* 7, 6, 5, 4, 3 */
111
112 std::sort(pvector.begin(), pvector.end());
113 for (unsigned i = 0; i < pvector.size(); ++i)
114 assert(pvector.const_at(i) == static_cast<int> (i + 3));
115
116 pmem::obj::transaction::abort(0);
117 });
118 } catch (pmem::manual_tx_abort &) {
119 /* expected transaction abort */
120 } catch (std::exception &e) {
121 std::cerr << e.what() << std::endl;
122 }
123
124 assert(pvector == stdvector); /* pvector element's value was rolled back */
125
126 try {
127 pmem::obj::delete_persistent<vector_type>(&pvector);
128 } catch (std::exception &e) {
129 }
如果存在活动事务,则使用前面任何方法访问的元素都将被快照。当begin() 和end()返回迭代器时,快照将在迭代器取消引用阶段发生。请注意,快照仅对可变元素执行。对于常量迭代器或索引运算符的常量版本,不会向事务添加任何内容。这就是为什么必须尽可能使用常量限定的函数重载,如cbegin() 或cend() 。如果当前事务中发生对象快照,则不会执行相同内存地址的第二个快照,因此不会产生性能开销。这将减少快照的数量,并可以显著降低事务对性能的影响。还要注意,pmem::obj::vector确实定义了方便的构造函数,并比较了以std::vector作为参数的运算符。
8.6 本章小结
本章介绍libpmemobj-cpp库。它使得创建应用程序不易出错,并且与标准C++ API相似,使得修改现有的易失性程序更容易使用持久内存。我们还列出了这个库的局限性以及在开发过程中必须考虑的问题。