基于libpmemobj库的C-style编程
本文主要用于总结对PMDK库中libpmemobj中相关内容的学习,通过简单学习libpmemobj可以对基于PMDK的持久内存编程有个大概的了解。PMDK包含了libpmemobj在内的多个库,其中libpmemobj是上层的封装,一般用户可以直接调用这个库提供的API进行持久内存编程。不过libpmemobj主要还是C的实现,相对而言也是偏向底层的,所以如果有更高抽象层次的需求,PMDK提供了对应的C++绑定,实现了C++ style的一些封装,更加有高级语言风格。目前先介绍libpmemobj,这样也有助于后面学习C++ binding。
开始编程之前的准备
Layout
在进行持久编程之前首先需要定义一个layout名,因为PMDK在打开一个持久内存资源池的时候需要制定一个layout,通过layout来标识一个内存池,这是一个字符串。
定义的方式有两种,一种就是直接定义:(其实感觉类似文件名的作用,但是不是文件名)
#define LAYOUT_NAME "my_layout"
或者直接在参数中传入一个字符串就行
另一种通过宏的方式
这种方式下提供了一系列的宏,除了定义layout外还涉及其他的定义主要有
POBJ_LAYOUT_BEGIN(layout_name)
POBJ_LAYOUT_TOID(layout_name, type)
POBJ_LAYOUT_ROOT(layout_name, root)
POBJ_LAYOUT_END(layout_name)
其中TIOD宏用于定义程序中所需要的类型,而ROOT宏用于定义根对象,关于根对象后面会有解释,这两个定义一定要在BEGIN与END之间
除此之外,使用这种方法定义了layout之后,在使用layout_name的时候就需要通过POBJ_LAYOUT_NAME宏来获取layout name
Persistent Pointer
Raw type
对于易失性内存上的编程,一个指针本质上是一个指向对象在虚拟内存地址空间的起始地址,而持久内存中的指针作用也是类似,同样是相当于一个记录了当前对象在内存池中的位置的结构,也有不一样的地方。
最明显的不同就是,持久内存指针包含了两个值。持久内存指针通过一个结构体PMEMoid来定义,其原始定义为:
typedef struct PMEMoid{
uint64_t pool_uuid_lo;
uint64_t off;
}
两个值分别为资源池pool的id,以及该对象在pool中offset,由于一个应用可能有多个资源池,所以通过这两个值就能定位一个持久对象的位置。
当我们需要对一个持久对象进行操作的时候,我们可以需要其在虚拟地址空间中的地址,这个时候可以通过pmembj_direct
函数将一个PMEMoid指针转换为我们所需要的结构的指针,比如:
struct root = pmemobj_direct(my_root);//my_root is a PMEMoid
typed persistent pointer
有了PMEMoid我们可以对持久类型对象进行操作了,但是有一个问题,PMEMoid相当于直接通过一个地址去访问数据,但是这个数据的含义是不知道的,就相当于使用一个void*来对数据进行操作(这个时候就可能会出现不同类型的指针之间相互赋值这种情况,但是编译器并不会认为他是错的)。所以我们需要引入类型系统。PMDK中使用较多的类型系统是基于Named Union实现的TIOD
类型。
TOID类型的实现定义为:
#define TOID(type)\
union _toid_##type##_toid
#define TOID_DECLARE(type)\
TOID(type)\
{\
PMEMoid oid;\
type *_type;\
}
可以看到这里定义了一个\_toid\_##type##\_tiod
类型的union,好吧这不是重点,背后的实现机理大致就是这样,所以当我们需要一个类型化的持久指针的时候就可以这样声明:
struct my_root{
int a;
char name[10];
}
TOID(struct my_root) root;
这里我们就获得了一个my_root类型的持久指针root,在这种情况下,不同类型的TOID之间的赋值则会被编译器拒绝。同时由于我们有了类型,所以当需要访问类型的对象的时候,不再需要pmemobj_direct的转换而是通过两个API:D_RO
以及D_RW
,分别实现读以及写操作
printf("%d\n", D_RO(root)->a);
D_RW(root)->a = 10;
而且两个API内部是自动持久化的,所以我们不用显式地再去调用persist,同时更加符合我们的编程习惯。
根对象
其实最开始一直没有弄懂根对象是干什么用的以及怎么用的,直到看到了一个实际的例子之后才大致理解了根对象的作用。(这个例子是PMDK包中提供的一个使用PMDK编程的用例,是一个基于持久内存的小游戏,https://github.com/pmem/pmdk/blob/master/src/examples/libpmemobj/pminvaders/pminvaders2.c,非常有意思,完美地体现了持久内存的魅力)
首先是根对象(root object)是干什么的。根据官方的说法,根对象的作用就是一个访问持久内存对象的入口点,是一个锚的作用。设想一下程序的所有对象都放到你定义的一个内存池中,那么当我们再次打开这个内存池的时候,如何去访问你之前存放在你内存池中的对象?这就需要根对象,因为根对象是你的内存池中唯一可寻址的对象,其他所有对象都需要从根对象开始访问,所以从根对象开始,就可以获取你在这个内存池中存放的所有持久对象了。
那么有了根对象怎么去使用,根据我的理解以及根对象的定义,当你定义一个新的持久对象的时候就需要将其添加到根对象中,比如:
struct root{
int a;
char buf[10];
struct node n;
};
这个根对象表示我们会用到一个整数a、一个字符数组buf和一个node结构体,我们把它们全部放到根对象里面,那么下次需要访问这些对象的时候就可以从根对象开始访问。
根对象的创建
根对象的创建API有两个,主要区别在于是否是类型化的
首先是非类型化的原始API:pmemobj_root(PMEMobjpool* pop, size_t size)
涉及到的参数主要是内存池指针以及所需要的大小。pmemobj_root
函数的主要作用是create或者resize根对象,根据官方文档的描述,当你初次调用这个函数的时候,如果size大于0并且没有根对象存在,则会分配空间并创建一个根对象。当size大于当前根对象的size的时候会进行重分配并resize。
另一个接口是类型化的API:POBJ_ROOT(PMEMobjpool* pop, TYPE)
这是一个宏,传入的TYPE是根对象的类型,并且最后返回值类型是一个TOID指针,其余的用法与pmemobj_root
一致
需要注意的是一个资源池的根对象是唯一的
如何通过PMDK访问Persistent Memory资源
PMDK是通过将持久内存抽象成资源池的方式进行访问,对应的API主要有三个分别是create、open以及close
PMEMobjpool *pmemobj_create(const char *path, const char *layout, size_t poolsize, mode_t mode)
create函数用于创建一个资源池,通过传入的路径、指定的layout以及大小创建一个持久内存资源池,返回一个PMEMobjpool
类型的指针
PMEMobjpool *pmemobj_open(const char *path, const char *layout)
open函数用于给定路径以及layout然后打开一个资源池(二次使用的时候)
void pmemobj_close(PMEMobjpool *pop)
close用于关闭,每次程序结束之前一定要调用close关闭资源池
事务
在没有事务机制的情况下,当我们想要写一段数据的时候,需要先写数据的长度,然后在写数据,通过这样的方式保证数据在崩溃的时候的一致性,比如:
rootp->len = strlen(buf);
pmemobj_persist(pop, &rootp->len, sizeof (rootp->len));
pmemobj_memcpy_persist(pop, rootp->buf, my_buf, rootp->len);
为了简化这一过程引入了事务机制,首先是最基础的事务机制:
TX_BEGIN(pop){
}TX_ONCOMMIT{
}TX_ONABORT{
}TX_FINALY{
}TX_END
整个事务的流程可以通过这几个宏以及代码块来定义,并且将事务分成了多个阶段,中间的三个阶段为可选的,最基本的一个事务流程是TX_BEGIN-TX_END,这也是最常用的部分,其他的几个部分在嵌套事务中使用较多。
除了基本的事务代码块,libpmemobj还提供了相应的事务操作API。
一个是事务性数据写入API:pmemobj_tx_add_range
&pmemobj_tx_add_range_direct
,add_range函数主要有三个参数:root object、offset以及size,该函数表示我们将会操作[offset, offset+size)这段内存空间,PMDK将会自动在undo log中分配一个新的对象,然后将这段空间的内容记录到undo log中,这样我们就能随机去修改这段空间的内容并且保证一致性。带上direct标志的函数用法一致,区别在于direct函数直接操作的是一段虚拟地址空间。
TX_BEGIN(pop){
pmemobj_tx_add_range(root, 0, sizeof(my_root));
memcpy(rootp->buf, buf, strlen(buf));
}
注意事务性的API一定要在事务内执行
除了这两个函数还有一个TX_MEMCPY,用法与memcpy类似,只不过是针对事务性持久内存。
另一个是事务性的数据分配与回收,分别是TX_NEW以及TX_FREE,TX_NEW以待分配的结构作为参数,返回一个对应类型的TOID指针。需要注意的是NEW和FREE使用之前都需要调用TX_ADD函数,其作用与pmemobj_tx_add_range类似,都是表示我们将会修改这段内存空间。
TOID(my_root) root = POBJ_ROOT(pop);
TX_BEGIN(pop){
TX_ADD(root);
TOID(my_class) classp = TX_NEW(my_class);
D_RO(root)->class = classp;
}TX_END
TX_BEGIN(pop){
TX_ADD(root);
TX_FREE(D_RO(root)->class);
D_RO(root)->class = TOID_NULL(my_class);
}TX_END
这里FREE之后还要给class赋值一个NULL指针,防止访问到无效值
数据持久化
数据持久化的事务性API前面已经提到,以及两个非事务的基本API:
void pmemobj_persist(PMEMobjpool *pop, const void *addr, size_t len);
:用于将[addr, add+size)区间内的数据持久化
void *pmemobj_memcpy_persist(PMEMobjpool *pop, void *dest, const void *src, size_t len);
:作用类似memcpy