代码示例:如何在多线程环境中使用持久内存开发套件 (PMDK)

文件:下载
许可:BSD 3-clause
面向……优化 
操作系统:Linux* 内核版本 4.3 或更高版本
硬件:模拟:参考如何使用动态随机访问内存 (DRAM) 模拟永久性内存
软件:
(编程语言、工具、IDE、框架)
英特尔® C++ 编译器、持久内存开发套件 (PMDK) 库
前提条件:熟悉 C++

 

简介

本文探讨了持久内存开发套件 (PMDK) 的一些基本构架模块,包括持久指针、交易和持久互斥体。本文还介绍了一个多线程示例应用,该应用可计算三角形和圆形的面积,以及圆柱形和球形的体积。每个线程对每个形状进行多次计算,以展示多线程 C++ 应用可如何读取并更新不同持久内存池中的数据。

该示例应用使用 PMDK 中对象导向型库 libpmemobj++ 的交易特性。通过使用交易和持久内存池,数据可免于意外中断,如写入时的电源或应用故障。交易具有原子性,这意味交易要么提交所有变化,要么不提交变化。如果交易未完全提交,libpmemobj 将撤销变化,以保持数据一致性。这有助于避免局部更新(亦称为残缺写入),应用很难检测并解决这一问题。有关 PMDK 交易的更多信息,请阅读持久内存编程的 C++ 交易。持久内存池为内存映射文件,包含持久对象。有关持久内存池的更多信息,请阅读C++ 中持久内存池简介。

前提条件

本文假设您对持久内存概念有基本的了解,且熟悉 PMDK 的特性。如若不然,请访问英特尔® 开发人员专区(英特尔® DZ)的 持久内存页面,了解必要的入门信息。

代码示例设计

下面的图 1 概述了代码示例。该应用使用了两个持久内存池:pool1pool2。每个池存储不同类别的对象,pool1 存储圆形和球形对象,pool2 包含三角形和圆锥形对象。该示例可运行多个线程,这些线程需争夺访问这些对象的专属权利,以使用当前数据计算形状面积和体积,或更改它们的数值。

…
struct params {
    pobj::pool<root> pop;
    double height, r, base;

    params(pobj::pool<root> pop,
    double height,
    double r, double base) : pop(pop), height(height), r(r), base(base)
    {}
};
…
/* Triangle class */
…
    void set_values(params* pdata)
    {
        pobj::transaction::exec_tx(pdata->pop, [&] {
            if (radius > DIVISOR) {
                radius = radius / DIVISOR;
                height = height / DIVISOR;
                cout << "\n Cone radius+height Reduce by " << DIVISOR << endl;
            }
            else {
                radius = radius + pdata->base;
                height = height + pdata->height;
            }
        }, pmutex);
    }
/* Triangle class */
…
/* access_pool function */
…
void access_pool(int thread_id, int num_itr, pobj::pool<root> pop1,
pobj::persistent_ptr<root> pool1, pobj::pool<root> pop2, 
pobj::persistent_ptr<root> pool2, double Rand_X)
{
    int remainder = thread_id % 2; 

    for (int i = 0; i < num_itr; i++) { // Start For-loop
        if (remainder == 0) { // Process EVEN threads
            print_safe(1, thread_id, i, pool1, pool2);	
            params vars = params(pop1, (Rand_X * 2.0) + 10.0,(Rand_X * 2.0) + 88.76,(Rand_X * 2.0) + 16.19);
            pool1->circle->set_values(&vars);
            pool1->sphere->set_values(&vars);
        }
        else { // Process ODD threads
            print_safe(2, thread_id, i, pool1, pool2);	
            params vars = params(pop2, (Rand_X * 6.0) + 32.0,(Rand_X * 6.0) + 43.76,(Rand_X * 6.0) + 51.19);
            pool2->triangle->set_values(&vars);
            pool2->cone->set_values(&vars);
        }
    } // End For-loop
} // End access_pool function…
/* access_pool function */
…
/* Main function*/
…
   Rand_X = dis(gen); //Generate random numbers
   threads[i] = thread(access_pool, i, num_itr, pop1, pool1, pop2, pool2, Rand_X);
…
/* Main function*/

“访问这两个持久内存池的线程”
图 1.概览:访问这两个持久内存池的线程

数据结构

如前所述,该程序可与两个持久内存池交互,每个池具有不同的对象。Pool1 存储圆形球形 对象,pool2 存储三角形圆锥形 对象。由于虚拟内存的性质,这些池在不同的程序调用中可映射至不同的地址。libpmemobj++ 库为我们提供了pool类的特别对象,以便与映射池连接。通过该对象(通常称为pop,用于持久对象指针),我们可获得该池根对象的有效指针。每个池具有一个根对象,用于访问池中创建的所有其他对象。在本示例中,两个池的根对象存储了每个池中创建的两个对象的两个指针。详见图 2。图 3 显示了所提供示例代码的数据结构。

“池和持久内存的概念”data-delta="2"
图 2.显示池和持久内存概念的可视化持久内存区域

“所提供示例代码的数据结构”data-delta="3"
图 3.所提供示例代码的数据结构

代码介绍

让我们看一下代码示例的结构。该程序首先创建或开启两个持久内存池,并初始化其中的持久对象。线程将使用这些数值计算圆形和三角形的面积,以及球形和圆锥形的体积。在线程计算完相应面积和体积后,对象的数值将进行更新,即乘以 1-100 的随机数字。更改这些数值(高度、基数和半径)会给该代码示例带来有趣改进。由于示例中的线程存在竞争关系,所以一些线程将计算相同面积和体积。这种竞争关系由设计所致,因为访问数据和计算面积或体积需使用不同的锁定区域,而非更新数据。然而,下面我们将发现,读写数据访问将进行同步,因此不会发生持久损坏。在图 4 中,您可看到示例执行的输出。

下面显示了池创建的语法。有关 libpmemobj++ 中持久内存库的更多信息,请参考文章“libpmemobj 的 C++ 绑定(第 4 部分)- 池处理包装程序”。在创建该池之前,我们需检查是否存在作为参数传递的池。如果它们不存在,使用 create 函数进行创建。create 函数需要四个参数:

(1) PERS_MEM_POOL1 – 内存池文件的位置

(2) LAYOUT – 池中对象布局的名称

(3) POOLSIZE –池大小

(4) S_IRUSR|S_IWUSR – 定义为对内存池文件的读写权限访问

下面是pool1示例。pool2 的代码具有相同语法:

pool1:
…
pers_mem_pool1 = pool<root>::create(PERS_MEM_POOL1, LAYOUT, POOLSIZE, S_IRUSR|S_IWUSR);
…

如果池存在,我们使用需要两个参数的 open 函数打开池:

  1. PERS_MEM_POOL1 – 内存池文件的位置
  2. LAYOUT – 池中对象布局的名称
…
pop1 = pobj::pool<root>::open(PERS_MEM_POOL1, LAYOUT1);
pool1 = pop1.get_root();
…

池打开后,两种池对象均会显示应用中的内存映射文件。如果池已创建,应用会调用 libpmemobj++ 函数 make_persistent(),分配新的持久对象。另一方面,如果打开了现有内存池,应用将开始正常访问数据。

调用make_persistent() 必须始终在交易中完成。在该示例中,我们调用main() 中的 make_persistent(),如下所示。更多信息请阅读libpmemobj 的 C++ 绑定(第 6 部分)- 交易libpmemobj 的 C++ 绑定(第 5 部分)- make_persistent at pmem.io。我们将新分配持久对象的地址分配至相应根对象中的持久指针变量。参见下面的代码段,了解更多详情:

…
pobj::transaction::exec_tx(pool1, [&] {
   pool1->circle = pobj::make_persistent<Circle>(&vars);
   pool1->sphere = pobj::make_persistent<Sphere>(&vars);
});
……
pobj::transaction::exec_tx(pool2, [&] {
    pool2->triangle = pobj::make_persistent<Triangle>(&vars);
    pool2->cone = pobj::make_persistent<Cone>(&vars);
});
……

在交易期间,pool1pool2 用于访问和修改每个相应池中的对象。结构 params 中的数值嵌入在交易执行语句中,被构造函数用于初始化持久内存对象。交易代码块将确保所有项目作为持久内存中的一个工作单元进行更新。这意味着在发生电源故障等中断情况时,未完成交易中的所有更改将被撤销。

在持久内存编程中,我们需要确保写入数据最终存储到内存设备中以保持持久性。如若不然,最近写入的数据可能在 CPU 高速缓存中仍不会刷新。在发生电源故障时,未刷新的数据将丢失。为防止这一情况,我们需要高速缓存中的数据。在下面的示例代码中,引入了交易语句。交易结束时,刷新将默默进行。

…
Circle(params* pdata)
{
  pobj::transaction::exec_tx(pdata->pop, [&] {
     radius = pdata->r; // 8 bytes only
  }, pmutex);
}
……
Triangle(params* pdata)
{
  pobj::transaction::exec_tx(pdata->pop, [&] {
     base = pdata->base;
     height = pdata->height;
  }, pmutex);
}
……
Cone(params* pdata)
{
   pobj::transaction::exec_tx(pdata->pop, [&] {
      radius = pdata->r;
      height = pdata->height;
   }, pmutex);
}
……
Sphere(params* pdata)
{
  pobj::transaction::exec_tx(pdata->pop, [&] {
     radius = pdata->r; // 8 bytes only
  }, pmutex);
}
……

多线程

下面,我们来看看线程和同步。前面,我们使用了持久互斥体锁定交易。该交易可帮助我们在发生电源故障和多线程写入同一对象时避免损坏数据。在本示例中,我们将使用特殊的持久互斥体(pmem::obj::mutex),而非标准互斥体(std::mutex)。这是因为我们可在持久内存中安全存储持久互斥体,使用标准互斥体会导致永久死锁。阅读下面的介绍文章,了解libpmemobj++ 同步基元 的更多信息。

本示例还将std::lock_guard 用于无需具备持久性、但需要同步的函数,如 print_area()print_volume() 函数。使用 std::lock_guard 可确保每个线程在代码块末端之前处于锁定状态。lock_guard 的语法在 print_safe() 函数中进行了展示:

…
void print_safe(int pool_id, int thread_id, int num_itr, pobj::persistent_ptr<root> pool1,
				pobj::persistent_ptr<root> pool2)
{
	std::lock_guard<std::mutex> lock(print_mutex); // thread safe
	if (pool_id == 1) {  //access pool1
		print_mesg(pool_id, thread_id, num_itr);
		pool1->circle->print_area();

		print_mesg(pool_id, thread_id, num_itr);
		pool1->sphere->print_vol();
	} else {   //access pool2
		print_mesg(pool_id, thread_id, num_itr);
		pool2->triangle->print_area();

		print_mesg(pool_id, thread_id, num_itr);
		pool2->cone->print_vol();
	}
}
 ……
}
……

管理线程

为管理线程,我们使用了std::thread()std::join() 函数的组合,以创建 num_threads,并将其加入两个简单的for回路,如下所示:

…
/* Threading */
for (int i=0; i < num_threads; i++) {
    Rand_X = dis(gen);  //Generate random numbers
    threads[i] = thread (access_pool, i, pers_mem1, pers_mem_pool1,
pers_mem2, pers_mem_pool2, Rand_X);
}
/* Join Threads */
for (int i = 0; i < num_threads; i++) {
	threads[i].join ();
}
……

在“线程化”for 回路中,我们生成了一个随机数字,以获取上文所说的有趣改进。thread() 函数将控制权及所有持久内存对象、线程数量和随时数字传送至 access_pool() 函数,以访问持久内存池。

access_pool() 函数通过访问池计算和打印面积与体积结果,然后在每次迭代中更改数值。每个线程将在整个周期num_itr(通过命令行输入)中运行。

编译和运行

使用Makefile 帮助编译和构建二进制文件。在当前的工作目录中,简单的“make”应对其进行编译:

$make

手动编译:

$g++ -c pPool.cpp -o pPool.o -I. -std=c++11 nodebug –lpmem –lpmemobj –lpthread

在构建二进制文件后,代码示例可使用以下语句运行:

$./pPool pool1 pool2 80 30

下面是结果输出示例:

“结果输出”data-delta="4"

“结果输出”data-delta="5"

图 4.结果界面显示了每个访问两个不同持久内存池的线程

在结果界面上,您可看到每个线程访问了一个持久内存池。此外,每个线程还可多次运行,具体取决于 num_itr数值。图 4 中的结果界面显示了池 id、线程数量、迭代次数、池中对象、面积/体积计算和结果。

总结

在本文中,我们创建和介绍了一个 C++ 示例应用,该应用展示了使用 PMDK 库 libpmemobj++ 的持久内存池、指针和交易特性。我们还介绍了使用交易语句保证数据原子性,以便在发生电源故障或应用崩溃时避免数据丢失或损坏。libpmemobj++ 库非常简单,易于集成到您的现有代码中。本文中的代码示例旨在帮助您构建和运行该程序。您可在 PMDK 示例存储库中查找更多持久内存编程示例,了解相关信息请登录我们的GitHub* 存储库

关于作者

Thai Le 目前担任英特尔公司核心视觉计算事业部的软件工程师,主要负责持久内存编程工作。

参考

有关编译器优化的更完整信息,请参阅优化通知