C++17常用新特性(十六)---PMR原理及使用攻略

3179次阅读  |  发布于2年以前

C++17的话题已经完成了十五期,今天将要给大家介绍下C++17中提出的一个重要的工具——PMR,全称为:polymorphic memory resource。翻译成中文就是:多态内存资源。PMR在C++ 17版本中还属于试验性质,正式使用是在C++20版本,因此如果想要尝鲜的话大家可以升级版本到C++20版本。

1 PMR初探

想要了解PMR是怎样管理内存的,需要先回忆一下C++17之前内存资源的管理。大家都知道长久以来,如果我们申请的资源要么是在栈上要么是在堆上,在栈上分配的是由编译器根据需要自动分配的,不需要手动对资源进行释放和清理。在堆上则是根据实际编程需要主动使用new操作符进行申请,使用完毕后在使用delete操作符进行手动释放和清理。在堆上申请资源虽然可以满足我们的要求但同时也伴随着副作用,如:

在C++17之前,我们使用的很多标准容器底层也是通过new和delete进行分配资源的。如我们熟悉的std::string。如果存储的字符串比较短如小于15字节长度的时候是默认在栈上存储。当字符串长度大于15时,string就会在堆上申请资源以满足动态扩展的功能。从C++17开始,标准委员会给我们提供了PMR,它可以使用一个事先定义好的连续空间,只要后续的所有操作能够在事先定义好的资源范围内使用就不会触发new和delete操作符的调用。PMR的操作主要被封装在std::pmr域名空间中。包含两个基础的类std::pmr::memory_resource和std::pmr::polymorphic_allocator。它们在std::pmr中的定义如下:

//std::pmr::memory_resource,抽象接口
class memory_resource;
//std::pmr::polymorphic_allocator,多态资源分配器
//C++17版本中的定义
template< class T >
class polymorphic_allocator;
//C++20版本中定义方式
template< class T = std::byte >
class polymorphic_allocator;

类模板 std::pmr::polymorphic_allocator 是一个分配器 (Allocator) ,展现出取决于其构造所用的 std::pmr::memory_resource 的不同的分配行为。因为 memory_resource 使用运行时多态管理分配器,以polymorphic_allocator 为其静态分配器类型的分配器可互操作,但能表现为如同它们拥有不同的分配器类型。polymorphic_allocator 的所有特化均满足分配器完整性要求。

2 PMR使用

先看下面这段代码,代码演示了PMR的使用方式:


int main()
{
   std::array<char, 10> memory;
   std::pmr::monotonic_buffer_resource pool{memory.data(),memory.size()};
   std::pmr::vector<std::string> container{&pool} ;
   for(int i=0;i<100;i++)
   {
       container.push_back("this is a test for use pmr and you know the length id over 15");
   }
   new_delete_sumary();
    return 0;
}

需要注意的是在pmr域名空间内部,定了一套和stl标准容器一模一样的容器,两者是完全兼容,如上面就使用了pmr内部定义的vector和标准stl下面的string。在代码一开始定义了一个数组,然后在这个数组的基础上定义了std::pmr::vector。后续向std::pmr::vector插入字符串时,如果字符串长度没有超过顺组的长度就不会触发new的调用,反之如果超过就会触发new的调用。第10行代码功能是统计在这个过程中发生的资源重分配的次数和总大小。上面代码运行后结果为:-

#new: 100 #del:0 #bytes:6200

从结果可知,代码运行完成发生了100次内存重分配,共6200个字节。这个结果也验证了前面我们所说的,下面将插入的字符串减小。使其长度小于10后,代码运行的结果为:-

#new: 0 #del:0 #bytes:0

从结果可知,当每次插入的字符长度小于数组大小时,就不会触发new的调用。这也说明,如果在栈上预先定义的内存大小能够满足代码生命周期的内存需要时就可以不触发内存的重新分配,从而提升代码运行效率。下面再对上面的代码进行修改,进行一次对比试验。首先将std::string修改成使用pmr下面的string。插入一个很大的字符串时会不会触发new的调用。


int main()
{
   std::array<char, 10> memory;
   std::pmr::monotonic_buffer_resource pool{memory.data(),memory.size()};
   std::pmr::vector<std::pmr::string> container{&pool} ;
   for(int i=0;i<100;i++)
   {
       container.push_back("this is a test for use pmr and you know the length id over 15");
   }
   new_delete_sumary();
    return 0;
}

运行结果为:

#new: 0 #del:0 #bytes:0

从结果可知,使用std::pmr::string后即使插入的字符串长度大于预先定义的数组长度也并没有触发new操作符的调用。这是因为:上面代码在使用了pmr域名空间下面定义的vector和 string,他们就可以同时使用pool上申请的资源。如果有超出的话在pmr下面还定义了synchronized_pool_resource类。它主要负责管理预先分配的内存资源。它的角色更像是一个内存池,可以从中获取需要的资源,如果内存池中的资源不足时会继续从它的上游分配器中获取更大的内存块以满足需要,每次获取的内存块大小是以几何级数递增的。与synchronized_pool_resource类相似的还有一个unsynchronized_pool_resource,它们的区别是前者为线程安全类,后者线程不安全。但是它们的实现都是继承了std::pmr::memory_resource。如代码所示:

class synchronized_pool_resource : public std::pmr::memory_resource;
class unsynchronized_pool_resource : public std::pmr::memory_resource

3 PMR如何避免产生内碎片

在PMR中,内存申请是单调增长的,只有在整个对象销毁时才会对申请的资源进行释放,因此在使用的过程中可以避免内存碎片的产生。为了更直观的验证上面的说法,这里需要从std::pmr::memory_resource中派生一个类打印出资源申请过程。


class track_resource : public std::pmr::memory_resource {
public:
    track_resource( std::pmr::memory_resource* up = std::pmr::get_default_resource() )
        : _upstream{ up } 
    { }

    void* do_allocate(size_t bytes, size_t alignment) override {
        std::cout << " do_allocate(): " << bytes << '\n';
        return _upstream->allocate(bytes, alignment);
    }
    void do_deallocate(void* ptr, size_t bytes, size_t alignment) override {
        std::cout << " do_deallocate(): " << bytes << '\n';
        _upstream->deallocate(ptr, bytes, alignment);
    }
    bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
        return this == &other;
    }

private:
    std::pmr::memory_resource* _upstream;
};

int main() { 
    char buffer[32] = {};
    track_resource tr; 
    std::pmr::monotonic_buffer_resource pool{
        std::data(buffer), std::size(buffer),
        &tr
    };
    std::pmr::vector<char> vec{&pool};  
    for(int i=0;i<32;++i)
    {
        vec.push_back(i+'A');
    }
    std::cout << buffer <<";vec大小:"<<vec.size()<<std::endl;
}

代码运行后结果如下:

do_allocate(): 64
AABABCDABCDEFGHABCDEFGHIJKLMNOP;vec大小:32
 do_deallocate(): 64

从上面的结果可以看出,vec每次进行扩展后之前分配的内存都没有进行释放。如果想在运行过程中vec不进行扩展可以调用reserve接口一次分配完毕,这样做可以节省内存。如下:

std::pmr::vector<char> vec{&pool}; 
    vec.reserve(32);
    for(int i=0;i<26;++i)
    {
        vec.push_back(i+'A');
    }
    std::cout << buffer <<";vec大小:"<<vec.size()<<std::endl;

修改完代码运行后,结果为:

ABCDEFGHIJKLMNOPQRSTUVWXYZ;vec大小:26

从运行结果可知,将vec预留足够的空间就就没有进行内存的重分配。

4 PMR内存耗尽时抛出异常

pmr可以从内存池中不断的申请资源,直到内存的耗尽。内存耗尽时monotonic_buffer_resource可以抛出一个异常。代码如下所示:

std::pmr::monotonic_buffer_resource pool{
        std::data(buffer), std::size(buffer),
        std::pmr::null_memory_resource()
    };

如上,当没有足够的内存进行分配时就会抛出一个异常。

5 标准容器和pmr域名下的容器大小

如下面代码所示:


int main() {
    std::cout << "标准容器大小:" <<sizeof(std::string)<<std::endl;
    std::cout<<"std::pmr::string大小:"<<sizeof(std::pmr::string)<<std::endl;
}

代码运行结果为:

标准容器大小:32
std::pmr::string大小:40

如上,pmr下的容器比标准容器要大,主要是因为多态分配器多了一个指向内存资源的指针,也正是因为这样,多态分配器也被称为是有状态的。标准容器里面并没有这样一个指针。

6 标准容器和pmr域名下的容器性能

这里以vector为例进行验证,测试代码如下:

void TestPmrVec(){
    char buffer[100] = {0};
    std::pmr::monotonic_buffer_resource pool{
        std::data(buffer), std::size(buffer)
    };
    std::pmr::vector<int> vec{&pool};
    PerfSum t;
     for(long i=0;i<1000000;i++){
         vec.push_back(i);
     }
     std::cout<<"End"<<std::endl;
 }

 void TestStdVec(){
     std::vector<int> vec ;
     PerfSum t;
     for(long i=0;i<1000000;i++){
         vec.push_back(i);
     }
     std::cout<<"End"<<std::endl;
 }

int main() {
    std::cout<<"标准容器插入1000000次:"<<std::endl;
    TestStdVec();
    std::cout<<"pmr容器插入1000000次:"<<std::endl;
    TestPmrVec();
}

运行结果如下:


标准容器插入1000000次:
End
统计性能结束,时间过去了149毫秒
pmr容器插入1000000次:
End
统计性能结束,时间过去了307毫秒

从运行结果看,pmr下vector的push_back比标准容器下要慢。这个结果实际上和试验结果是相违背的。理论上来说std::vector动态扩展是在堆上分配。std::pmr::vector在栈堆上,相比之下std::pmr::vector应该更快。但是试验结果反而变慢了,由此可见std::pmr在实际使用时并不像我们以为的那么乐观。自然慢的原因可能有很多,按照个人理解可能是:

7 总结

关于pmr,我们使用的还比较少,对于pmr底层的一些了解可能还存在不足。本文如有不足之处欢迎大家留言评论或者加微信好友讨论。

另外本文所有代码编译环境均在https://www.onlinegdb.com/编译通过,C++ 20版本。

8 参考

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8