C++11内存池设计模式?

39

我有一个程序,其中包含一个处理阶段,需要使用来自一个多态类型树的不同对象实例(全部在堆上分配),最终都派生自一个共同的基类。

由于这些实例可能循环地相互引用,并且没有明确的所有者,因此我想用 new 分配它们,用原始指针处理它们,并将它们保留在内存中以供该阶段使用(即使它们变得无引用),然后在使用这些实例的程序阶段之后,我想一次性将它们全部删除。

我考虑的结构如下:

struct B; // common base class

vector<unique_ptr<B>> memory_pool;

struct B
{
    B() { memory_pool.emplace_back(this); }

    virtual ~B() {}
};

struct D : B { ... }

int main()
{
    ...

    // phase begins
    D* p = new D(...);

    ...

    // phase ends
    memory_pool.clear();
    // all B instances are deleted, and pointers invalidated

    ...
}

除了小心确保所有的B实例都是通过new分配的,并且在内存池清空后没有人再使用指向它们的指针,这个实现有什么问题吗?

具体而言,我担心基类构造函数中使用this指针来构造一个std::unique_ptr,而此时派生类构造函数还没有完成。这会导致未定义行为吗?如果是,是否有解决方法?


为什么 B 需要了解内存池?从驱动程序代码(main)添加指针似乎是更好的选择。当然,内存池的生命周期可以被限制,这意味着当其超出范围时会自动释放。 - Jon
@Jon:你的意思是用memory_pool.emplace_back(new D(...))替换对new D(...)的调用吗?我想我可以编写一个模板函数,类似于make_shared的风格来构造对象,将参数转发给构造函数,并将其添加到内存池中。不过,我仍然想知道上面的代码是否存在未定义行为。 - Andrew Tomazos
请查看知名的自由开源软件解决方案,Boehm-Demers-Weiser保守型C/C++垃圾回收器 - user5560811
5个回答

17

如果您还没有熟悉Boost.Pool,请先了解一下。以下是来自 Boost 文档的介绍:

什么是 Pool?

Pool 分配是一种非常快速但使用受限的内存分配方案。有关 Pool 分配(也称为简单分离存储),请参见 concepts 概念和 Simple Segregated Storage

为什么我应该使用 Pool?

使用 Pool 可以更好地控制程序中的内存使用方式。例如,您可能遇到这样一种情况:希望在某个时刻分配一堆小的对象,然后在程序执行到某个点时不再需要它们。使用 Pool 接口,您可以选择运行它们的析构函数或者将它们舍弃;Pool 接口将确保没有系统内存泄漏。

何时应该使用 Pool?

当需要大量分配和释放小对象时通常使用 Pool。另一个常见的用法是上述情况,即许多对象可能从内存中删除。

一般来说,当需要更高效的方式进行不寻常的内存控制时,请使用 Pool。

我应该使用哪个 pool 分配器?

pool_allocator 是一种更通用的解决方案,旨在有效地处理对任意数量连续块的请求。

fast_pool_allocator 也是一种通用解决方案,但旨在有效地处理逐个块的请求;它适用于连续块,但不如 pool_allocator 那样好用。

如果您非常关注性能,请在处理诸如 std::list 这样的容器时使用 fast_pool_allocator,而在处理诸如 std::vector 这样的容器时使用 pool_allocator

内存管理是一个棘手的问题(线程、缓存、对齐、碎片等等)。对于严肃的生产代码来说,最好使用经过精心设计和优化的库,除非您的分析器显示存在瓶颈。


15
你的想法很好,已经有数百万的应用程序在使用。这种模式最为人所知的是“自动释放池”。它构成了Cocoa和Cocoa Touch Objective-C框架中“智能”内存管理的基础。尽管C++提供了很多其他替代方案,但我仍然认为这个想法有很大的优势。但是,在某些方面,我认为你的实现可能存在缺陷。
我能想到的第一个问题是线程安全。例如,当来自不同线程的相同基类对象被创建时会发生什么?一种解决方案可能是用互斥锁保护池访问。虽然我认为更好的方法是将该池作为线程特定对象。
第二个问题是在派生类构造函数抛出异常的情况下调用未定义的行为。你看,如果发生这种情况,派生对象将不会被构造,但是你的B的构造函数已经将指向this的指针推入了向量中。稍后,当清除向量时,它将尝试通过对象的虚表调用析构函数,而该对象要么不存在,要么实际上是一个不同的对象(因为new可以重用该地址)。
我不喜欢的第三件事是你只有一个全局池,即使它是线程特定的,这仍然不允许更精细的控制分配对象的范围。
考虑到以上情况,我会进行一些改进:
  1. 有一个池的堆栈,以实现更精细的范围控制。
  2. 将该池堆栈作为线程特定对象。
  3. 在发生故障(如派生类构造函数中的异常)时,请确保池不持有悬空指针。
以下是我的解决方案,只用了5分钟,勿喷:
#include <new>
#include <set>
#include <stack>
#include <cassert>
#include <memory>
#include <stdexcept>
#include <iostream>

#define thread_local __thread // Sorry, my compiler doesn't C++11 thread locals

struct AutoReleaseObject {
    AutoReleaseObject();
    virtual ~AutoReleaseObject();
};

class AutoReleasePool final {
  public:
    AutoReleasePool() {
        stack_.emplace(this);
    }

    ~AutoReleasePool() noexcept {
        std::set<AutoReleaseObject *> obj;
        obj.swap(objects_);
        for (auto *p : obj) {
            delete p;
        }
        stack_.pop();
    }

    static AutoReleasePool &instance() {
        assert(!stack_.empty());
        return *stack_.top();
    }

    void add(AutoReleaseObject *obj) {
        objects_.insert(obj);
    }

    void del(AutoReleaseObject *obj) {
        objects_.erase(obj);
    }

    AutoReleasePool(const AutoReleasePool &) = delete;
    AutoReleasePool &operator = (const AutoReleasePool &) = delete;

  private:
    // Hopefully, making this private won't allow users to create pool
    // not on stack that easily... But it won't make it impossible of course.
    void *operator new(size_t size) {
        return ::operator new(size);
    }

    std::set<AutoReleaseObject *> objects_;

    struct PrivateTraits {};

    AutoReleasePool(const PrivateTraits &) {
    }

    struct Stack final : std::stack<AutoReleasePool *> {
        Stack() {
            std::unique_ptr<AutoReleasePool> pool
                (new AutoReleasePool(PrivateTraits()));
            push(pool.get());
            pool.release();
        }

        ~Stack() {
            assert(!stack_.empty());
            delete stack_.top();
        }
    };

    static thread_local Stack stack_;
};

thread_local AutoReleasePool::Stack AutoReleasePool::stack_;

AutoReleaseObject::AutoReleaseObject()
{
    AutoReleasePool::instance().add(this);
}

AutoReleaseObject::~AutoReleaseObject()
{
    AutoReleasePool::instance().del(this);
}

// Some usage example...

struct MyObj : AutoReleaseObject {
    MyObj() {
        std::cout << "MyObj::MyObj(" << this << ")" << std::endl;
    }

    ~MyObj() override {
        std::cout << "MyObj::~MyObj(" << this << ")" << std::endl;
    }

    void bar() {
        std::cout << "MyObj::bar(" << this << ")" << std::endl;
    }
};

struct MyObjBad final : AutoReleaseObject {
    MyObjBad() {
        throw std::runtime_error("oops!");
    }

    ~MyObjBad() override {
    }
};

void bar()
{
    AutoReleasePool local_scope;
    for (int i = 0; i < 3; ++i) {
        auto o = new MyObj();
        o->bar();
    }
}

void foo()
{
    for (int i = 0; i < 2; ++i) {
        auto o = new MyObj();
        bar();
        o->bar();
    }
}

int main()
{
    std::cout << "main start..." << std::endl;
    foo();
    std::cout << "main end..." << std::endl;
}

4

小对象内存池

最近,我也需要一个几乎相同的东西(针对程序的一个阶段进行内存分配,可一次性全部清除)。但是,我的附加设计约束条件是所有对象都相当小。

我想出了以下“小对象内存池” - 也许对您有用:

#pragma once

#include "defs.h"
#include <cstdint>      // uintptr_t
#include <cstdlib>      // std::malloc, std::size_t
#include <type_traits>  // std::alignment_of
#include <utility>      // std::forward
#include <algorithm>    // std::max
#include <cassert>      // assert


// Small-object allocator that uses a memory pool.
// Objects constructed in this arena *must not* have delete called on them.
// Allows all memory in the arena to be freed at once (destructors will
// be called).
// Usage:
//     SmallObjectArena arena;
//     Foo* foo = arena::create<Foo>();
//     arena.free();        // Calls ~Foo
class SmallObjectArena
{
private:
    typedef void (*Dtor)(void*);

    struct Record
    {
        Dtor dtor;
        short endOfPrevRecordOffset;    // Bytes between end of previous record and beginning of this one
        short objectOffset;             // From the end of the previous record
    };

    struct Block
    {
        size_t size;
        char* rawBlock;
        Block* prevBlock;
        char* startOfNextRecord;
    };

    template<typename T> static void DtorWrapper(void* obj) { static_cast<T*>(obj)->~T(); }

public:
    explicit SmallObjectArena(std::size_t initialPoolSize = 8192)
        : currentBlock(nullptr)
    {
        assert(initialPoolSize >= sizeof(Block) + std::alignment_of<Block>::value);
        assert(initialPoolSize >= 128);

        createNewBlock(initialPoolSize);
    }

    ~SmallObjectArena()
    {
        this->free();
        std::free(currentBlock->rawBlock);
    }

    template<typename T>
    inline T* create()
    {
        return new (alloc<T>()) T();
    }

    template<typename T, typename A1>
    inline T* create(A1&& a1)
    {
        return new (alloc<T>()) T(std::forward<A1>(a1));
    }

    template<typename T, typename A1, typename A2>
    inline T* create(A1&& a1, A2&& a2)
    {
        return new (alloc<T>()) T(std::forward<A1>(a1), std::forward<A2>(a2));
    }

    template<typename T, typename A1, typename A2, typename A3>
    inline T* create(A1&& a1, A2&& a2, A3&& a3)
    {
        return new (alloc<T>()) T(std::forward<A1>(a1), std::forward<A2>(a2), std::forward<A3>(a3));
    }

    // Calls the destructors of all currently allocated objects
    // then frees all allocated memory. Destructors are called in
    // the reverse order that the objects were constructed in.
    void free()
    {
        // Destroy all objects in arena, and free all blocks except
        // for the initial block.
        do {
            char* endOfRecord = currentBlock->startOfNextRecord;
            while (endOfRecord != reinterpret_cast<char*>(currentBlock) + sizeof(Block)) {
                auto startOfRecord = endOfRecord - sizeof(Record);
                auto record = reinterpret_cast<Record*>(startOfRecord);
                endOfRecord = startOfRecord - record->endOfPrevRecordOffset;
                record->dtor(endOfRecord + record->objectOffset);
            }

            if (currentBlock->prevBlock != nullptr) {
                auto memToFree = currentBlock->rawBlock;
                currentBlock = currentBlock->prevBlock;
                std::free(memToFree);
            }
        } while (currentBlock->prevBlock != nullptr);
        currentBlock->startOfNextRecord = reinterpret_cast<char*>(currentBlock) + sizeof(Block);
    }

private:
    template<typename T>
    static inline char* alignFor(char* ptr)
    {
        const size_t alignment = std::alignment_of<T>::value;
        return ptr + (alignment - (reinterpret_cast<uintptr_t>(ptr) % alignment)) % alignment;
    }

    template<typename T>
    T* alloc()
    {
        char* objectLocation = alignFor<T>(currentBlock->startOfNextRecord);
        char* nextRecordStart = alignFor<Record>(objectLocation + sizeof(T));
        if (nextRecordStart + sizeof(Record) > currentBlock->rawBlock + currentBlock->size) {
            createNewBlock(2 * std::max(currentBlock->size, sizeof(T) + sizeof(Record) + sizeof(Block) + 128));
            objectLocation = alignFor<T>(currentBlock->startOfNextRecord);
            nextRecordStart = alignFor<Record>(objectLocation + sizeof(T));
        }
        auto record = reinterpret_cast<Record*>(nextRecordStart);
        record->dtor = &DtorWrapper<T>;
        assert(objectLocation - currentBlock->startOfNextRecord < 32768);
        record->objectOffset = static_cast<short>(objectLocation - currentBlock->startOfNextRecord);
        assert(nextRecordStart - currentBlock->startOfNextRecord < 32768);
        record->endOfPrevRecordOffset = static_cast<short>(nextRecordStart - currentBlock->startOfNextRecord);
        currentBlock->startOfNextRecord = nextRecordStart + sizeof(Record);

        return reinterpret_cast<T*>(objectLocation);
    }

    void createNewBlock(size_t newBlockSize)
    {
        auto raw = static_cast<char*>(std::malloc(newBlockSize));
        auto blockStart = alignFor<Block>(raw);
        auto newBlock = reinterpret_cast<Block*>(blockStart);
        newBlock->rawBlock = raw;
        newBlock->prevBlock = currentBlock;
        newBlock->startOfNextRecord = blockStart + sizeof(Block);
        newBlock->size = newBlockSize;
        currentBlock = newBlock;
    }

private:
    Block* currentBlock;
};

回答你的问题,只要对象完全构造完成之前没有人使用指针(指针值本身在此期间是安全的),那么你就没有调用未定义的行为。但是,这是一种相当侵入式的方法,因为对象本身需要了解内存池的情况。此外,如果你正在构造大量小对象,最好使用实际的内存池(如我的内存池)而不是为每个对象调用new,这样会更快。
无论使用何种类似池的方法,请注意,对象永远不应该手动delete,否则将导致重复释放!

4

我认为这是一个没有确定答案的有趣问题,但请让我将其分解为您实际提出的不同问题:

1)是否在子类初始化之前将基类指针插入向量会导致检索继承类(例如切片)时出现问题。

答案:不会,只要您确信被指向的类型是正确的,这个机制就不会引起这些问题,但请注意以下几点:

如果派生构造函数失败,则稍后可能会出现悬空指针的问题,因为即使在失败时,基于类的地址空间也会被释放到操作环境中,但向量仍将其地址视为基类类型。

请注意,尽管向量有点有用,但并不是最适合此操作的结构。即使使用该结构,这里也应该涉及一些控制反转,以允许向量对象控制对象的初始化,以便您知道成功/失败情况。

这些观点带来了隐含的第二个问题:

2)这是一个好的池化模式吗?

答案:并不是,原因如上所述,再加上其他问题(将向量推到其终点基本上会产生一个不必要的malloc,这将影响性能)。理想情况下,您应该使用池库或模板类,并且更好的是,将分配/去分配策略实现与池实现分离,其中已经暗示了低级解决方案,即从池初始化中分配足够的池内存,然后在池地址空间中使用void指针来使用这个空间(参见上面的Alex Zywicki的解决方案)。使用此模式,池销毁是安全的,因为池将是连续的内存,可以整体销毁,而不会通过失去对象的所有引用(通过存储管理器分配的地址为池的对象失去所有引用不会导致内存泄漏,但会使您留下脏块)引起悬空问题。

在C/C++早期(在STL大规模普及之前),这是一个广泛讨论的模式,可以在良好文献中找到许多实现和设计。例如:

Knuth(1973计算机编程的艺术:多卷本),有关池化的更完整列表,请参见:

http://www.ibm.com/developerworks/library/l-memory/

第三个隐含的问题似乎是:

3)这是使用池的有效方案吗?

答案:这是一个基于您自己的喜好做出的本地化设计决策,但说实话,您的实现(没有控制结构/聚合,可能存在子对象子集的循环共享)让我觉得您最好使用基本的包装器对象链表,每个对象都包含指向您的超类的指针,仅用于寻址目的。您的循环结构建立在此之上,您只需根据需要修改/增加/缩减列表以容纳所需的所有一级对象,完成后,您可以轻松地从链接列表中有效地销毁它们,时间复杂度为O(1)。

话虽如此,我个人建议此时(当您有使用池的情况时,因此您的思维方式正确)进行存储管理/池化类的构建,现在将其参数化/无类型,这将为您未来打下坚实的基础。


2
这听起来像是我听说过的线性分配器。我将解释我理解它如何工作的基础知识。
  1. 使用::operator new(size)分配一个内存块;
  2. 有一个void*,它是指向内存中下一个可用空间的指针;
  3. 您将拥有一个alloc(size_t size)函数,该函数将为您提供一个指向从步骤一中分配的内存块中的位置的指针,以便您可以构建放置新的数据;
  4. 放置new看起来像... int* i = new(location)int(); 其中location是指向从分配器分配的内存块的void*。
  5. 当您完成所有内存操作后,将调用Flush()函数,该函数将从池中释放内存或至少清除数据。

我最近编写了这样的代码,我将在这里发布我的代码,并尽我所能进行解释。

    #include <iostream>
    class LinearAllocator:public ObjectBase
    {
    public:
        LinearAllocator();
        LinearAllocator(Pool* pool,size_t size);
        ~LinearAllocator();
        void* Alloc(Size_t size);
        void Flush();
    private:
        void** m_pBlock;
        void* m_pHeadFree;
        void* m_pEnd;
    };

不要担心我从哪里继承而来。我一直在将这个分配器与内存池一起使用。但基本上,我是从内存池获取内存而不是从operator new获取。其内部工作原理基本相同。

以下是实现:

LinearAllocator::LinearAllocator():ObjectBase::ObjectBase()
{
    m_pBlock = nullptr;
    m_pHeadFree = nullptr;
    m_pEnd=nullptr;
}

LinearAllocator::LinearAllocator(Pool* pool,size_t size):ObjectBase::ObjectBase(pool)
{
    if (pool!=nullptr) {
        m_pBlock = ObjectBase::AllocFromPool(size);
        m_pHeadFree = * m_pBlock;
        m_pEnd = (void*)((unsigned char*)*m_pBlock+size);
    }
    else{
        m_pBlock = nullptr;
        m_pHeadFree = nullptr;
        m_pEnd=nullptr;
    }
}
LinearAllocator::~LinearAllocator()
{
    if (m_pBlock!=nullptr) {
        ObjectBase::FreeFromPool(m_pBlock);
    }
    m_pBlock = nullptr;
    m_pHeadFree = nullptr;
    m_pEnd=nullptr;
}
MemoryBlock* LinearAllocator::Alloc(size_t size)
{
    if (m_pBlock!=nullptr) {
        void* test = (void*)((unsigned char*)m_pEnd-size);
        if (m_pHeadFree<=test) {
            void* temp = m_pHeadFree;
            m_pHeadFree=(void*)((unsigned char*)m_pHeadFree+size);
            return temp;
        }else{
            return nullptr;
        }
    }else return nullptr;
}
void LinearAllocator::Flush()
{
    if (m_pBlock!=nullptr) {
        m_pHeadFree=m_pBlock;
        size_t size = (unsigned char*)m_pEnd-(unsigned char*)*m_pBlock;
        memset(*m_pBlock,0,size);
    }
}

这段代码完全可用,但由于我的继承和内存池使用,有几行需要更改。但我相信你可以找出需要更改的地方,如果你需要帮助修改代码,请告诉我。这段代码没有经过任何专业测试,不保证线程安全或其他高级功能。我只是想快速编写并与你分享,因为你似乎需要帮助。
我还有一个完全通用的内存池实现,如果你认为它可能有用,我可以解释它的工作原理。
再次,如果你需要任何帮助,请告诉我。祝你好运。

1
步骤3.5:如果要为不同类型的对象分配空间,您将担心您的alloc()函数是否正确处理对齐问题。 - Ron Burk

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接