7439 字
37 分钟
C++杂记 - 5(一):std::pmr 多态内存分配器
2023-03-07

std::pmr 多态内存分配器#

先回顾一下标准分配器#

这一节最好搭配侯捷的标准STL内存分配器来看,这样可以很好帮助理解一些动作的行为。

分配器的核心就是四个步骤: 分配空间(allocate),构造对象(construct),析构对象(destruct),解分配空间(deallocate)。

标准内存分配器就提供了这四个函数。多态内存分配器也提供了这四个函数。

QQ截图20230308160711

  • 在标准分配器当中,allocate和deallocate对应的全局的::operator new和::operator delete。construct和destruct对应的就是元素的构造和析构

为什么要有多态内存分配器#

在没有多态内存分配器的时候,我们面临两个问题。

第一种情况是内存分配器的传播问题。

我们有一个独特的例子:

QQ截图20230308162015

  • 我们有一个CustomString类,每一个对象接受一个allocator。我们现在有三个不同allocator的同类对象。注意此时对象类型均为CustomString。
  • 然后我们放入vector。在push_back x1和x2的时候没什么问题,使用了对应的分配器。但是如果我们在头部插入了x3,情况就不对了。
  • vector的头插逻辑是先在下一个可用位置分配一块内存,然后使用移动(拷贝)构造在新的位置构造出最后一个元素。然后再按照顺序把前面的元素搬移(拷贝)。在搬移(拷贝中),分配器是不会被拷贝(赋值)的。因为永远不该这么做,因为内存是已经被分配好了的,无法更改。
  • 所以这个时候有问题了。x1用了alloc2,x3用了alloc1。瞬间混乱了起来。

我们真正想要的是这样的东西:

QQ截图20230308162600

  • 所以在C++17之前,我们有一个std::scoped_allocator_adaptor。我们可以把自己的分配器使用它包装,它可以保证分配器的正确传播:

std::scoped_allocator_adaptor 类模板是可用于多层容器( map 的 list 的 set 的 vector 等等)的分配器。它以外层分配器类型 OuterAlloc 与零或更多个内层分配器类型 InnerAlloc... 实例化。直接以 scoped_allocator_adaptor 构造的容器用 OuterAlloc 分配其元素,但若元素自身是容器,则它使用首个内存分配器。该容器的元素,若其自身是容器,则使用第二个内存分配器,以此类推。若容器的层级多于内层分配器的数量,则最内层分配器为所有进一步内嵌的容器再度使用。

第二个问题是内存分配器固有的问题:

  • 针对默认内存分配器本身的性能有如下问题:

    • 内存分配、回收慢。

    • 内存分配可能有锁。

    • 内存对齐无法控制。

    • 分配位置无法控制。比如连续多次分配时,内存的定域性/局部性(locality)无法保证。

  • 针对使用的参数方面有如下问题(尤其针对std::allocator)

    • allocator是模板签名的一部分。不同allocator的容器,无法混用。

      • void func(const std::vector<int>&); // Default allocator
        //上面的vector签名是:std::vector<int, std::allocator<int>>
        std::vector<int, MyAlloc<int>> v(someAlloc); // 自定义分配器
        func(v); // ERROR: v is a different type than std::vector<int>
      • 二者签名不匹配。我们尽管可以使用模板,但是这会让后面所有的函数都是模板。

    • c++11以前,allocator无状态;c++11以后,可以有状态,然而allocator类型复杂难用。

    • allocator内存对齐无法控制,需要传入自定义allocator。

    • 以上三点、特别是第一点,造成STL无法成为软件接口(interface)的一部分。

    • 难以将局部特种内存(比如共享内存),内存池用于STL容器。

多态内存分配器,内存资源和容器的关系#

QQ截图20230308163419

memory_resource是获得的原始内存,我们拿到了原始内存后,通过polymorphic_allocator分配器来进行分配。最后容器需要使用分配器来给元素分配内存。说白了,分配器从内存资源中获得资源,然后进行内存的再分配。polymorphic_allocator分配器是一层memory_resource的包装。

  • 多态内存分配器作用域下的容器是pmr::。其实就是别名模板。所以我们以vector为例:
namespace pmr {
template< class T >
using vector = std::vector<T, std::pmr::polymorphic_allocator<T>>;
}

由于多态内存分配器把内存资源的获取和分配分离开来,所以可以保证同一个元素哪怕底层内层资源不同,但是分配器的类型可以相同。

这部分的细节我们后面再细说。

再次解释一下多态内存分配器为什么解决了签名问题#

**重申一下,内存资源不是类模板。而是使用了继承。所以这一部分是动态多态。所以说内存资源类型是运行时确定的。在编译期间,所有pmr::vector<T>类型的容器都是同一个类型。**分配器使用一个内存资源的指针在内部对内存资源进行管理。在上面的pmr::容器中,我们看到了多态内存分配器的模板参数是元素类型。然后我们容器已经构建完毕了。然后我们可以使用容器的构造函数传入分配器。这里用vector举例子:去查看文档 以第二个构造函数为例,就是接受一个分配器。

所以传入展开后会长这样:

std::vector<MyClass, std::pmr::polymorphic_allocator<MyClass> > vec = std::vector<MyClass, std::pmr::polymorphic_allocator<MyClass> >(allocator2);

我们可以看到所有的模板参数都是元素类型。分配器底层的内存资源并不影响分配器的类型。因为我们重申多态内存分配器把内存资源和分配分离开来了。所以针对同一元素类型的容器,可以使用不同类型的分配器。

QQ截图20230309004954

内存资源 memory_resources#

image-20230308163929518

  • 上图是一个抽象简化版的内存资源类。我们看到里面有allocate和deallocate。这两个是非虚函数。他们的分配动作是委托给私有虚成员函数do_allocate和do_deallocate进行的。

    • 这里是模板方法模式的NVI(non-virtual-interface)技术。
  • memory_resource是一个抽象基类。STL给我们提供了内置的5种内存资源和一些工具函数。

    • 我们如果要自己实现自己的内存资源,也需要继承自此类。

    • 类 std::pmr::memory_resource 是抽象接口,针对封装内存资源类的无界集。

  • 继承自memory_resource的类需要实现的函数主要是四个

    • do_allocate

      • 从自身内存中分配特定大小的内存。
        • 具体定义会随之变化。比如从何处分配。
    • do_deallocate

      • 从自身内存中解分配某一特定位置,特定大小的区块。
        • 具体定义会随之变化。比如解分配到何处,是否真的解分配。
    • is_equal

      • 比较两个内存资源是否相等。也就是从当前这个内存资源分配的内存是否可以通过另一个内存资源解分配。
    • release

      • 调用上游的deallocate来释放内存。将全部内存交还给上游内存分配器。
  • 内存资源是可以成为链条的。这一点我们下面讲。

  • 注意内存资源类不是类模板!!!

std::pmr::new_delete_resource#

这是最基础的,也是默认的内存资源。它是从哪儿获取的原始内存呢?答案是从全局的::operator new

返回指向使用全局 operator new 与 operator delete 分配内存的 memory_resource 的指针。

  • allocate
    • 因为这是默认的选项,所以非常自然地,内存的分配会使用::operator new
  • deallocate
    • 既然分配的都用默认的了,那么解分配也是会用到::operator delete

一般来说,上游分配器都是它。默认也是它。

std::pmr::monotonic_buffer_resource#

  • 这是一个比较特殊的资源。它特殊就特殊在我们可以使用栈内存做为内存资源。如使用char buffer[1024]来获取一段栈内存做为原始内存。它是一种单调递增的资源。它仅在销毁资源时释放分配的内存。它的意图是提供非常快速的内存分配,在内存用于分配少量对象,并于之后一次释放的情况下。
    • 因为它的原理仅仅是推指针。所以分配速度非常快。属于一块线性内存。
  • 构造函数
monotonic_buffer_resource(); //(1)
explicit monotonic_buffer_resource(std::pmr::memory_resource* upstream); //(2)
explicit monotonic_buffer_resource(std::size_t initial_size); //(3)
monotonic_buffer_resource(std::size_t initial_size, std::pmr::memory_resource* upstream); //(4)
monotonic_buffer_resource(void* buffer, std::size_t buffer_size); //(5)
monotonic_buffer_resource(void* buffer, std::size_t buffer_size, std::pmr::memory_resource* upstream);//(6)
monotonic_buffer_resource(const monotonic_buffer_resource&) = delete; //(7)

monotonic_buffer_resource 能以初始缓冲区构造,若无初始缓冲,或缓冲用尽,则从构造时提供的上游分配器分配缓冲区。缓冲区的大小以几何级数增长。

比较常用的有1,2,5,6

  • 第一个就是默认构造函数。默认构造函数会使用std::pmr::get_default_resource返回的memory_resource 当做上游分配器。

  • 第二个就是指定上游分配器。

  • 第五个是提供一块给定内存buffer,然后设置当前资源为buffer。buffer_size是下一块缓冲区的大小,然后使用std::pmr::get_default_resource返回的memory_resource 当做上游分配器。

    • 当给定的内存用尽后,使用上游分配器进行内存分配。
  • 第六个是提供一块给定内存buffer,然后设置当前资源为buffer。buffer_size是下一块缓冲区的大小,并且指定上游分配器。

    • 当给定的内存用尽后,使用上游分配器进行内存分配。
  • do_allocate

    • 若当前缓冲区有足够的未使用空间以适合一个拥有指定大小和对齐的块,则从当前缓冲区分配返回的块。

      否则,此函数通过调用 upstream_resource()->allocate(n, m) 分配新缓冲区,其中 n 不小于bytes 与下个缓冲区大小的较大者,且 m 不小于 alignment 。它设置新缓冲区为当前缓冲区,以实现定义的增长因子(不必是整数)增加下个缓冲区大小,然后从新分配的缓冲区分配返回块。

    • 插一句话,这个时候就是使用std::pmr::null_memory_resource的一个典型场景

  • do_deallocate

    • 此函数无效应。!!!啥也不干!!!因为我们都说了它是单调递增的!!!直到销毁资源为止(对象析构或调用release)
  • release

    • **通过上游 memory_resource 的 deallocate 函数,按需要释放所有分配的内存。**重设当前缓冲区及下个缓冲区大小为其构造时的初始值。

      内存被释放回上游资源,即使未对某些被分配块调用 deallocate 。

    • 释放内存。注意,这是调用上游deallocate解分配。解分配不代表调用内存中储存对象的析构函数。仅仅是把指针塞回给上游内存池。

      • 如果上游是null_memory_resource,那就相当于啥也没干。同时,一般来说使用这种方式分配内存的时候,初始缓冲区一般是在栈上。如果在堆上,记得要手动回收提供的初始内存。因为这些内存资源不会帮助我们回收初始内存资源。所谓的:重设当前缓冲区及下个缓冲区大小为其构造时的初始值 的含义仅仅是把指针推回去。
      • 内存被释放回上游资源,即使未对某些被分配块调用 deallocate 。这句话每一个内存资源的release都有。我们下面再说。

    源代码,来自

    void monotonic_buffer_resource::release()
    {
    const size_t header_size = sizeof(__monotonic_buffer_header);
    __original_.__used_ = 0;
    while (__original_.__next_ != nullptr) {
    __monotonic_buffer_header *header = __original_.__next_;
    __monotonic_buffer_header *next_header = header->__next_;
    size_t aligned_capacity = header->__capacity_ + header_size;
    __res_->deallocate(header->__start_, aligned_capacity, header->__alignment_); //调用上游分配器的deallocate
    __original_.__next_ = next_header;
    }
    }
    //除了那一行以外其他的就是回推指针。
  • 析构函数

    • 调用this->release() 来解分配此资源拥有的所有内存。
      • 所以说针对这几种内存资源,压根没必要release。因为已经应用了RAII特性。

测试

我们下面的测试是单纯的测试内存分配器。并没有结合容器。但是效果是一致的。

class MyClass {
public:
MyClass(int val = 0) : m_val(new int(val)) {
std::cout << "MyClass constructed with value: " << val << std::endl;
}
~MyClass() {
std::cout << "MyClass destroyed with value: " << *m_val << std::endl;
delete m_val;
}
private:
int* m_val;
};
int main() {
char buffer[1024]; //从栈获取原始内存
std::size_t buffer_size = sizeof(buffer);
cout << "original "<<&buffer << endl; //原始内存地址
{
std::pmr::monotonic_buffer_resource buffer_resource{buffer, buffer_size, std::pmr::null_memory_resource()};
//monotonic memory resource使用栈获取的资源做为初始资源,使用null_memory_resource当做上游分配器强调内存用尽后抛出bad_alloc而不是从默认分配器分配
std::pmr::polymorphic_allocator<MyClass> allocator{ &buffer_resource }; //初始化分配器
{
MyClass* t = allocator.allocate(1); //分配1个Myclass对象 8字节
cout << &t << endl; //获取指针地址
cout << "1st alloc place "<<&*t << endl; //获取当前分配地址
allocator.construct(t, 20); //在该地址构造元素
MyClass* tt = allocator.allocate(1); //再分配1个Myclass对象 8字节
cout << &tt << endl; //获取指针地址
cout << "2nd alloc place "<<&*tt << endl; //获取当前分配地址
allocator.construct(tt, 30); //在该地址构造元素
//------------如果不destroy会泄露。因为deallocate解分配并不会调用元素的析构函数---------
allocator.destroy(t); //析构元素
allocator.destroy(tt); //析构元素
//----------使用release重置内存,把首地址推回分配起始地址(原始内存首地址)
buffer_resource.release();
MyClass* ttt = allocator.allocate(1); //再次分配1个Myclass对象
cout << &ttt << endl;//获取指针地址
cout << "3rd alloc place "<<&*ttt << endl; //获取当前分配地址
allocator.construct(ttt, 40); //在该地址构造元素
allocator.destroy(ttt);
}
}
return 0;
}
original 0x7ffe4ff6eb90 起始地址
0x7ffe4ff6eb30
1st alloc place 0x7ffe4ff6eb90 起始地址和第一次分配地址一致
MyClass constructed with value: 20
0x7ffe4ff6eb38
2nd alloc place 0x7ffe4ff6eb98 第二次分配在第一次分配上+8
MyClass constructed with value: 30
MyClass destroyed with value: 20
MyClass destroyed with value: 30
0x7ffe4ff6eb40
3rd alloc place 0x7ffe4ff6eb90 release后指针推回至原生内存起始位置。
MyClass constructed with value: 40
MyClass destroyed with value: 40
  • 我们从上面的测试能看到

    • 如果不destroy会导致内存泄露。因为deallocate解分配并不会调用内存中元素的析构函数,仅仅是回推指针。
    • 如果不需要重复使用内存,则无需手动调用release。RAII特性会让buffer_resource内存资源在析构时自动调用release
    • 注意:我们是使用allocator进行内存分配,对象构造和对象摧毁。
  • 假设我们的初始内存是从堆中获取的,则必须要手动释放。因为分配器和内存资源并不保有底层原始内存

char* buffer = new char[1024]; //堆内存。
std::size_t buffer_size = 1024;
{
std::pmr::monotonic_buffer_resource buffer_resource{buffer, buffer_size, std::pmr::null_memory_resource()};
std::pmr::polymorphic_allocator<MyClass> allocator{ &buffer_resource };
{
//...
}
}
delete[] buffer; //回收内存

分析一下release过程。无论是析构还是手动调用release,都有两个步骤:第一步是调用上游的memory_resource 的 deallocate 函数。第二步是重设当前缓冲区及下个缓冲区大小为其构造时的初始值。当前我们上游是null_memory_resource,deallocate函数无效果。所以仅仅是把当前缓冲区及下个缓冲区大小为其构造时的初始值。(推回指针)

std::pmr::unsynchronized_pool_resource / std::pmr::synchronized_pool_resource#

这是一种池化的内存资源:

QQ截图20230308173021

具体应该不用赘述。侯捷老师的课讲标准分配器的时候就提到过大概的样子。

  • 这俩唯一的区别是一个线程安全,一个线程不安全。线程安全的性能贼差尽量别用。
  • 它占有被分配内存并在析构时释放它,即使 deallocate 未被对于某些被分配块调用。
    • 这个释放是指调用release。release中调用上游 memory_resource 的 deallocate 函数,释放所有此资源所占有的内存。
  • 它由供应不同块大小请求的池的汇集组成。每个池管理之后被分入一致大小的大块的汇集。
  • 对 do_allocate的调用被派发到供应适应请求大小的最小块的池。
  • 在池中耗尽内存,会导致该池从上游分配器分配额外的内存大块,以填满池。获取的大块大小以几何级数增加。
  • 请求超出最大的块大小的分配,由上游分配器直接供应。
  • 最大的块大小和大块大小的最大值可通过传递std::pmr::pool_options结构体给其构造函数调节。
  • 构造函数
unsynchronized_pool_resource(); //(1)
explicit unsynchronized_pool_resource(std::pmr::memory_resource* upstream); //(2)
explicit unsynchronized_pool_resource(const std::pmr::pool_options& opts); //(3)
unsynchronized_pool_resource(const std::pmr::pool_options& opts,std::pmr::memory_resource* upstream); //(4)
unsynchronized_pool_resource(const unsynchronized_pool_resource&) = delete; //(5)

有没有发现这一组构造函数和monotonic_buffer_resource有个非常明显的差别?也就是我们不再有像monotonic_buffer_resource第5和第6个构造函数那种提供初始原始内存的构造函数了。也就是说这两个池化内存资源必须从上游分配器获取资源。

如果依旧想要从栈分配,则可以先使用栈分配monotonic_buffer_resource,然后让它成为当前池化内存的上游分配器。

第一个构造函数就是使用默认的池化选项和默认的上游分配器。

第二,三,四个分别指定上游分配器,池化选项和二者皆指定。

  • do_allocate

    • 若为 bytes 大小的块所选的池不足以满足来自其内部数据结构的请求,则调用上游 memory_resources 上的 allocate() 以获得内存。

      若请求的大小大于最大的池所能处理者,则通过调用上游 memory_resources 上的 allocate() 分配内存。

  • do_deallocate

    • 将在 p 的内存返还到池。此操作是否或在何种场合下导致对上游 memory_resource 上的 deallocate() 调用是未指定的。

    • 在对池化资源使用do_deallocate的时候一定要仅返回内存至内存池,而不是调用上游分配器的deallocate直接回收。一定要格外注意。但是这一点平时普通人不需要过多操心

  • release

    • 需要时,通过调用上游 memory_resource 的 deallocate 函数,释放所有此资源所占有的内存。

      即使未对某些被分配块调用 deallocate ,资源也被返还给上游 memory_resource 。

    • 这就是release和deallocate的最大区别。deallocate是返还内存到内存池。而release是释放(返还)整个内存池全部的内存至上游分配器。因为是调用上游分配器的deallocate。

    • 不知道这玩意是只释放内存还是摧毁整个池。标准库没说

  • 析构函数

    • 通过调用 this->release() 释放此资源所拥有的所有内存。

      • 和其他的一样。

我们捋一下针对池化内存release的可能情况#

  • 第一种情况:
    • 如果sync或unsync的上游分配器是monotonic,sync或unsync调用release会使上游的monotonic调用deallocate。但是monotonic的deallocate不做任何事情。所以分配出去的内存无法被monotonic回收。只能单调递增直到monotonic的release被调用
  • 第二种情况:
    • 如果sync或unsync的上游是new_delete_resource,则sync/unsync调用release会使得上游的new_delete_resource调用其deallocate,我们说过它的deallocate是全局::operator delete。

在已经release的内存资源上再次调用allocate是否是UB?#

标准库没有明确说明。例子如下:

std::pmr::monotonic_buffer_resource buffer_resource{std::pmr::new_delete_resource()}; //最外层monotonic通过new_delete获取原始内存。
std::pmr::unsynchronized_pool_resource midlevel{&buffer_resource}; //第一层unsync池化
std::pmr::unsynchronized_pool_resource un_sync{&midlevel}; //第二层unsync 池化
std::pmr::polymorphic_allocator<MyClass> allocator{&un_sync};
{
MyClass* t = allocator.allocate(1);
cout << &t << endl;
cout << "1st alloc place "<<&*t << endl;
allocator.construct(t, 20);
MyClass* tt = allocator.allocate(1);
cout << &tt << endl;
cout << "2nd alloc place "<<&*tt << endl;
allocator.construct(tt, 30);
allocator.destroy(t);
allocator.destroy(tt);
un_sync.release(); //release内存资源。此行为会导致第一层unsync调用deallocate。回收已分配的内存到第一层unsync的内存池
MyClass* ttt = allocator.allocate(1); //第二层unsync再次allocate
cout << &ttt << endl;
cout << "3rd alloc place "<<&*ttt << endl;
allocator.construct(ttt, 40);
allocator.destroy(ttt);
}
0x7ffe69b80ed8
1st alloc place 0x559e13abbc00 //第一次分配
MyClass constructed with value: 20
0x7ffe69b80ee0
2nd alloc place 0x559e13abbc08 //第二次分配
MyClass constructed with value: 30
MyClass destroyed with value: 20
MyClass destroyed with value: 30
释放
0x7ffe69b80ee8
3rd alloc place 0x559e13abbc00 //第三次分配。地址同第一次。
MyClass constructed with value: 40
MyClass destroyed with value: 40

经过我的测试,似乎是会重建内存池。

已提问,目前无人回答

std::pmr::null_memory_resource#

  • 很多人很奇怪?为啥要弄个空的呢?答案很简单。我们提到了,内存资源可以成为链条。如果某一个分配资源不够了,我们不希望它去默认的上游分配器去获取内存,就可以把这个分配器做为那个分配器的上游分配器。

  • allocate

      • 其 allocate() 函数始终抛出 std::bad_alloc;
  • deallocate

    • 其 deallocate() 函数无效果;

分配器 allocator#

我们提到过。分配器是一种对内存资源的包装

QQ截图20230309000840

QQ截图20230309004214

  • 第二个转换构造函数非常有用。它的作用是我们可以直接把memory_resource传入任何需要polymorphic_allocator的地方。比如直接传入pmr::容器。

  • 构造函数

polymorphic_allocator() noexcept; //(1)
polymorphic_allocator( const polymorphic_allocator& other ) = default; //(2)
template< class U >
polymorphic_allocator( const polymorphic_allocator<U>& other ) noexcept; //(3) 关键的构造函数模板。
polymorphic_allocator( memory_resource* r); //(4) 转换构造

第一个:默认构造函数会默认构造一个以默认值(通常为new_delete_resource)为内存资源的分配器

第二个:拷贝构造

第三个:构造函数模板。下面的模板参数以及正确转换全都靠它

第四个:是上文提到的转换构造。

  • allocate

    • 用底层的 memory_resource 分配 n 个 T 类型对象的存储。

      等价于 return static_cast<T*>(resource()->allocate(n * sizeof(T), alignof(T))); 。

    • 由于我们提过,分配器是一种内存资源的wrapper。所以分配器调用的allocate就相当于对内存资源调用allocate。

    • 格外要注意的是,内存资源返回的内存是void*。而分配器返回的内存是对应类型的。

  • deallocate

    • 解分配 p 所指向的存储,它必须通过与 *resource() 比较等于的(相等的)std::pmr::memory_resource x 使用 x.allocate(n * sizeof(T), alignof(T)) 分配。

      等价于 this->resource()->deallocate(p, n * sizeof(T), alignof(T));

    • 还是调用了底层内存资源的deallocate

  • construct

    • 这玩意有一堆重载。主要是看第一个:

      • template < class U, class... Args >
        void construct( U* p, Args&&... args ); //(1)
      • 在 p 所指的,分配但未初始化的存储上,以提供的构造函数参数构造一个对象。若对象自身拥有使用分配器的类型,或它是 std::pair ,则传递 this->resource() 给被构造的对象。

      • 以使用分配器构造的手段在 p 所指示的未初始化内存位置,以 *this 为分配器创建给定类型 U 的对象。

    • 说白了就如果提供了分配器,就使用提供的分配器构造。

  • destroy

    • 销毁 p 所指向的对象,如同以调用 p->~U()

    • 所以我们强调了,dellocate解分配不调用内存位置上元素的析构函数。摧毁元素和回收内存是两个独立动作。

polymorphic_allocator 的模板参数#

有一些讲座说我们应该始终以std::byte为polymorphic_allocator 的模板参数。但是直觉来说,应该以元素类型为模板参数。

class MyClass {
public:
MyClass(int val = 0) : m_val(new int(val)) {
std::cout << "MyClass constructed with value: " << val << std::endl;
}
~MyClass() {
std::cout << "MyClass destroyed with value: " << *m_val << std::endl;
delete m_val;
}
private:
int* m_val;
};
int main() {
char buffer[1024];
std::size_t buffer_size = 1024;
{
std::pmr::monotonic_buffer_resource buffer_resource{buffer, buffer_size, std::pmr::null_memory_resource()};
//monotonic内存资源从已分配栈内存做为初始资源。并使用null做为上游资源
std::pmr::polymorphic_allocator<MyClass> allocator{ &buffer_resource }; //版本1,分配器参数为元素类型:MyClass
std::pmr::polymorphic_allocator<byte> allocator{ &buffer_resource }; //版本2,分配器参数为byte。
{
std::pmr::vector<MyClass> vec(allocator); //使用分配器初始化pmr vector
vec.reserve(1);//预留一个空间
vec.emplace_back(1);//原地构造一个对象
}
}
return 0;
}
MyClass constructed with value: 1
MyClass destroyed with value: 1
  • 注意关于monotonic内存资源和winkout的联系会在下一节讲。但是这里必须要提到一点,winkout和使用何种内存资源之间无任何联系。

两种分配器参数都可以正常工作。那么是什么原因呢?我们看一下这一行代码:

std::pmr::polymorphic_allocator<MyClass> allocator{ &buffer_resource };
std::pmr::vector<MyClass> vec(allocator); //此行展开

这一行代码展开后的样子是这样的:

std::vector<MyClass, std::pmr::polymorphic_allocator<MyClass>> vec = std::vector<MyClass, std::pmr::polymorphic_allocator<MyClass>>(allocator);

首先记住一点:allocator的模板参数一定会被推导为元素类型。因为别忘了上面提到的pmr是别名模板,元素类型是T,allocator的类型是根据T来的。

其次,我们发现如果分配器类型和元素类型一致,则顺利构造。左右两侧无区别。

但是如果我们换成byte呢?

std::pmr::polymorphic_allocator<byte> allocator{ &buffer_resource };
std::pmr::vector<MyClass> vec(allocator); //此行展开

这一行展开后是这样的:

std::vector<MyClass, std::pmr::polymorphic_allocator<MyClass>> vec = std::vector<MyClass, std::pmr::polymorphic_allocator<MyClass>>(std::pmr::polymorphic_allocator<MyClass>(allocator));

再次重申,allocator的模板参数一定会被推导为元素类型。同时,右侧括号内传入的分配器发生了隐式类型转换。调用的正是我们在分配器中提到的第三个构造函数。

所以理论上讲,此时行为都是一致的。而分配器本身的构造并不会造成性能的过多损失。但是有一点要注意,隐式类型转换后,传入的分配器是那个转换后的临时对象。所以现在vector内部的分配器和外面传入的allocator其实是两个独立对象了。一定要注意。最好还是使用和元素一样类型的分配器。

我的提问

分配器种类,分配器绑定方式和析构的组合#

目前为止,分配器绑定方式可以分为两种

  • 类型参数(模板参数)
  • 继承自抽象基类,比如:
    • QQ截图20230309225735

    • 注意这里不是继承自std::allocator。一般也不应该这样做。原因见这里

    • QQ截图20230310032548

目前为止,分配器内存来源可以分为这么几种

  • 默认全局分配器(std::allocator)

    • 绑定方式一定是类型参数
  • New_Delete_Allocator

    • 和默认全局分配器原理一致,但是绑定方式一定是继承自抽象基类
  • 多态内存资源

    • 如monotonic_buffer_resource,unsync/synchronized_pool_resource等

析构方式又可以分为两种:

  • 常规析构
  • wink-out

所以,一共我们可以得到这么多种搭配组合

QQ截图20230309225948

wink-out 和 常规析构#

首先注意,wink-out适用于任何内存资源。我们还记得容器分配构造的四个步骤:分配空间,构造对象,析构对象,解分配空间。

wink-out就是不析构,直接解分配。所以使用wink-out的时候一定要注意,如果内存区域的元素是含有额外资源的,则不可以winkout。会导致内存泄漏。

  • 我们看一下常规析构:

QQ截图20230309234908

一切都很正常。标准的四个步骤。

  • 我们再来看一下所谓的winked-out

QQ截图20230310023351

核心意思就是我们不再需要析构和解分配两个步骤

不需要析构非常好理解,因为如果某些对象没有保有其他资源,也就是析构函数是trivial的时候,调用析构是没有意义的。所以可以不调用这些析构。

不需要deallocate如何理解呢?一般情况下,通常只针对monotonic_buffer_resource使用winkout。因为它的deallocate是无作用。只有在析构的时候才会把资源交还给上游分配器。当然了,标准库提供的所有内存资源都符合RAII特性,在析构的时候都会把内存交还给上游分配器。所以这里不使用deallocate也是可以的。一定要记住,所有解分配deallocate都不会调用元素的析构函数。所以要注意内存泄漏问题,使用这个技巧之前一定要清楚自己在做什么。

我们来看个特殊例子,因为有时候容易错误理解:

int main() {
char buffer[1024];
std::size_t buffer_size = 1024;
{
std::pmr::monotonic_buffer_resource buffer_resource{buffer, buffer_size, std::pmr::null_memory_resource()};
//外层monotonic原始内存
std::pmr::polymorphic_allocator<MyClass> allocator{ &buffer_resource }; //分配器
{
std::pmr::vector<MyClass> t(allocator); //分配器传入pmr vector
t.reserve(1); //预留1个位置
t.emplace_back(20); //原地构造元素
}
}
return 0;
}
MyClass constructed with value: 20
MyClass destroyed with value: 20

奇怪?明明我们没有手动对内存资源进行操作,怎么会正确析构呢?因为这是pmr::vector帮助我们做的。因为它毕竟还是vector。还是会在vector被销毁的时候帮助我们对元素进行析构。

std::vector<T,Allocator>::~vector:调用元素的析构函数,然后解分配所用的存储。

自定义分配器的相等比较#

参考来自这里

[operator ==(a1, a2)] returns true only if the storage allocated by the allocator a1 can be deallocated through a2. Establishes reflexive, symmetric, and transitive relationship. Does not throw exceptions. operator ==(a1, a2)仅当分配器 a1 分配的存储可以通过分配器 a2 释放时才返回 true。建立了自反、对称和传递关系。不抛出异常。

一般来说,如果两个分配器相等,那么在移动一个容器至另一个容器的时候可以直接交换指针,在第二个容器重用第一个容器的内存。如果分配器不相等,则必须要在第二个容器中重新分配内存,然后复制每一个元素,然后释放第一个容器。

针对如何实现内存资源,和如何实现使用多态内存分配器的容器,可以看看下面参考资料的内容

参考资料#

CppCon 2017针对如何实现内存资源和自己的使用分配器的容器

CppCon 2019针对如何实现带有多态内存分配器的类和容器类

CppCon 2017理解并测试分配器

游戏引擎新感觉

CSDN的pmr

分配器,内存资源,容器的关联

polymorphic allocator的模板参数

如何测试wink-out

memory_source的LLVM源代码 和 LLVM最终源代码

C++杂记 - 5(一):std::pmr 多态内存分配器
https://sterben-01.github.io/posts/杂记5/
作者
Sterben_01
发布于
2023-03-07
许可协议
CC BY-NC-SA 4.0