C++ 메모리 풀 구현 비교: 객체 풀, 슬랩 할당자, 아레나, std::pmr

들어가며: “malloc이 병목이에요”

왜 메모리 풀인가

대량의 같은 크기(또는 비슷한 크기) 객체를 반복해서 할당·해제하면 힙 단편화와 할당·해제 비용이 쌓입니다. 고정 크기 블록을 free list로 관리하는 풀 하나를 처음부터 만드는 과정과 풀을 쓸 때 흔히 저지르는 실수(풀보다 오래 사는 객체, 다른 풀에 반환, 이중 해제 등)는 #48-3 커스텀 메모리 풀 제작기에서 다룹니다. 이 글은 그다음 단계로, 고정 블록 풀 하나로는 부족할 때 고를 수 있는 네 가지 방식을 나란히 구현하고 비교합니다.

이 글에서 다루는 것:

  • 객체 풀: 타입 전용 풀, 생성자·소멸자와 연동
  • 슬랩 할당자: 크기 클래스별 풀, 내부 단편화 최소화
  • 메모리 아레나: 순차 할당, 일괄 해제
  • std::pmr: C++17 표준 메모리 리소스, STL 통합
  • 네 방식 각각에서만 생기는 실수와 운영 패턴 선수 지식: #39-2 std::pmr·커스텀 할당자를 알면 좋습니다.

malloc 병목, 단편화, 락 경합: 메모리 풀이 필요해지는 상황

프로파일러에서 malloc/free가 상위에 뜨는 경우, 장시간 실행 후 단편화로 할당이 실패하는 경우, 스레드가 늘수록 전역 힙 락 경합이 커지는 경우처럼 풀을 도입하게 되는 기본 상황과 고정 블록 풀로 푸는 방법은 #48-3에 정리했습니다. 아래는 고정 블록 풀 하나로는 해결되지 않아 다른 방식이 필요해지는 상황입니다.

시나리오 1: 할당 크기가 다양할 때

"32바이트, 64바이트, 128바이트, 256바이트가 섞여서 할당돼요."
"고정 블록 풀 하나로 하면 256바이트 풀에 32바이트 요청 시 낭비가 심해요."

상황: HTTP 파서에서 헤더(작음), 바디 청크(중간), 큰 JSON(큼) 등 크기가 다양합니다. 해결 포인트: 슬랩 할당자는 32, 64, 128, 256 등 크기 클래스별로 풀을 두어, 요청 크기에 맞는 최소 풀에서 할당합니다.

시나리오 2: 순차 할당만 하고 개별 해제가 없을 때

"요청 처리 중에만 할당하며, 요청 끝에 한 번에 다 해제해요."
"개별 해제가 필요 없어서, 풀의 free list 관리 오버헤드가 아깝습니다."

상황: HTTP 요청 처리, 프레임 렌더링처럼 “할당만 하고 끝에 일괄 해제”하는 패턴입니다. 해결 포인트: 메모리 아레나는 커서만 앞으로 이동시키며 순차 할당하며, reset() 한 번으로 전체를 되돌립니다.

시나리오 3: 생성자·소멸자 자체가 무거울 때

"풀로 바꿨는데도 Bullet 생성이 여전히 느려요."
"생성자에서 버퍼를 잡고 소멸자에서 푸는데, 그게 매번 반복돼요."

상황: 객체 풀은 메모리를 얻는 비용을 줄여 주지만, acquire()에서 placement new로 생성자를 부르고 release()에서 소멸자를 부르는 구조라면 생성·소멸 비용은 그대로 남습니다. 객체가 내부에 std::vector나 std::string을 갖고 있으면 생성자 안에서 다시 힙 할당이 일어나므로 풀의 이득이 작아집니다. 해결 포인트: 이 경우는 객체를 소멸시키지 않고 살아 있는 채로 재사용하는 쪽이 맞습니다. 반환할 때 소멸자 대신 reset() 같은 멤버 함수로 상태만 초기화하고, 다음 acquire()에서 그 객체를 그대로 돌려주면 내부 버퍼의 capacity도 재사용됩니다. 아래 객체 풀은 “메모리 재사용”, 이 방식은 “객체 재사용”이라고 구분해 두면 어느 쪽이 필요한지 판단하기 쉽습니다.

시나리오 4: 스레드마다 사용량이 크게 다를 때

"워커 스레드마다 할당 패턴이 달라요. 어떤 스레드는 많이, 어떤 스레드는 거의 안 써요."
"전역 풀 하나로 하면 락 경합이 있고, 스레드별 풀은 메모리 낭비가 걱정돼요."

상황: 전역 힙 락 경합을 피하려고 스레드 로컬 풀을 적용했더니, 할당이 많은 스레드의 풀은 계속 커지고 한가한 스레드의 풀은 거의 비어 있는 채로 메모리를 붙잡고 있습니다. 스레드별 풀은 서로 메모리를 빌려줄 수 없기 때문입니다. 해결 포인트: 스레드 로컬 풀 + 공유 상위 풀의 2단 구조를 씁니다. 평소에는 락 없는 로컬 풀에서 할당하고, 로컬 풀이 비면 락이 걸린 상위 풀에서 블록을 한 번에 여러 개(청크 단위) 가져옵니다. 로컬 풀이 일정량 이상 쌓이면 일부를 상위 풀로 돌려보냅니다. tcmalloc의 스레드 캐시와 중앙 free list가 이 구조입니다.

시나리오별 권장 패턴

시나리오특징권장 패턴
malloc 병목할당 횟수 과다객체 풀, 고정 블록 풀
힙 단편화장시간 실행 후 OOM풀 + 단일 큰 블록
스레드 경합멀티스레드 확장성스레드 로컬 풀
크기 다양내부 단편화 우려슬랩 할당자
순차 할당, 일괄 해제HTTP 요청, 프레임메모리 아레나, std::pmr::monotonic_buffer_resource
생성·소멸 비용이 큼내부에 버퍼를 가진 객체객체를 살려 두고 재사용하는 풀
스레드별 사용량 편차워커마다 부하가 다름스레드 로컬 풀 + 공유 상위 풀

개념을 잡는 비유

메모리 풀·PMR은 창고 칸을 미리 나눠 두고 필요할 때만 꺼내 쓰는 방식에 가깝습니다. 할당·해제 패턴이 고정돼 있으면 전역 new보다 예측 가능하고 캐시 친화적일 수 있습니다.


객체 풀 (Object Pool)

핵심 아이디어

객체 풀은 특정 타입 T 전용으로, 메모리 할당뿐 아니라 객체 생성/소멸을 풀과 연동합니다. allocate 대신 acquire()로 “풀에서 꺼낸 메모리에 placement new로 객체 생성”, deallocate 대신 release(obj)로 “소멸자 호출 후 풀에 반환”합니다.

flowchart TB
    subgraph Pool["객체 풀 (Bullet)"]
        F1[빈 슬롯 1]
        F2[빈 슬롯 2]
        F3[빈 슬롯 3]
    end
    subgraph Acquire["acquire()"]
        A1[빈 슬롯 선택]
        A2["placement new Bullet()"]
        A3[객체 반환]
    end
    subgraph Release["release(obj)"]
        R1["소멸자 ~Bullet() 호출"]
        R2[슬롯을 free list에 반환]
    end
    F1 --> A1 --> A2 --> A3
    A3 --> R1 --> R2 --> F1

기본 객체 풀 구현

// object_pool_basic.hpp
// g++ -std=c++17 -O2 -o object_pool object_pool_basic.cpp
#include <cstddef>
#include <new>
#include <utility>
#include <vector>
#include <iostream>
template <typename T>
class ObjectPool {
public:
    ObjectPool() = default;
    ~ObjectPool() {
        // 청크 단위로 받은 메모리는 청크 단위로 돌려준다.
        // 아직 release()되지 않은 객체의 소멸자는 호출되지 않는다(아래 에러 9 참고).
        for (void* c : chunks_) ::operator delete(c, std::align_val_t{block_align_});
    }
    ObjectPool(const ObjectPool&) = delete;
    ObjectPool& operator=(const ObjectPool&) = delete;
    template <typename... Args>
    T* acquire(Args&&... args) {
        void* mem = allocate_raw();
        return new (mem) T(std::forward<Args>(args)...);
    }
    void release(T* obj) {
        if (!obj) return;
        obj->~T();
        deallocate_raw(obj);
    }
private:
    union Node {
        Node* next;
    };
    // 블록은 T와 free list 노드를 모두 담을 수 있어야 한다: 크기·정렬 모두 큰 쪽을 따른다
    static constexpr std::size_t block_align_ =
        (alignof(T) > alignof(Node)) ? alignof(T) : alignof(Node);
    static constexpr std::size_t raw_size_ =
        (sizeof(T) > sizeof(Node)) ? sizeof(T) : sizeof(Node);
    static constexpr std::size_t block_size_ =
        (raw_size_ + block_align_ - 1) / block_align_ * block_align_;
    static constexpr std::size_t blocks_per_chunk_ = 64;
    void* allocate_raw() {
        if (!head_) expand();
        Node* p = head_;
        head_ = head_->next;
        return p;
    }
    void deallocate_raw(void* p) {
        Node* node = static_cast<Node*>(p);
        node->next = head_;
        head_ = node;
    }
    void expand() {
        // 정렬 지정 operator new: alignas(64) 같은 over-aligned 타입도 지원 (C++17)
        char* chunk = static_cast<char*>(
            ::operator new(block_size_ * blocks_per_chunk_, std::align_val_t{block_align_}));
        chunks_.push_back(chunk);
        for (std::size_t i = 0; i < blocks_per_chunk_; ++i) {
            Node* node = reinterpret_cast<Node*>(chunk + i * block_size_);
            node->next = head_;
            head_ = node;
        }
    }
    Node* head_ = nullptr;
    std::vector<void*> chunks_;   // 해제용으로 청크 시작 주소를 기억
};
struct Bullet {
    float x, y, vx, vy;
    Bullet() : x(0), y(0), vx(0), vy(0) {}
    Bullet(float x_, float y_, float vx_, float vy_)
        : x(x_), y(y_), vx(vx_), vy(vy_) {}
};
int main() {
    ObjectPool<Bullet> pool;
    Bullet* b1 = pool.acquire(100.0f, 200.0f, 5.0f, 0.0f);
    Bullet* b2 = pool.acquire();
    pool.release(b1);
    pool.release(b2);
    Bullet* b3 = pool.acquire(0, 0, 1, 1);
    std::cout << "객체 풀 테스트 완료: " << b3->x << "," << b3->y << "\n";
    pool.release(b3);
    return 0;
}

설명: Node union으로 free list의 next 포인터를 비어 있는 블록 내부에 저장합니다. 블록이 사용 중일 때는 그 자리에 T가 있고, 반환되면 같은 자리를 next 포인터로 재활용하므로 블록마다 따로 메타데이터가 필요 없습니다.

블록 크기와 정렬은 코드에서 가장 틀리기 쉬운 부분이라 이유를 적어 둡니다.

  • 크기: sizeof(T)가 포인터보다 작은 타입(예: char 몇 개짜리 구조체)이면 반환된 블록에 next 포인터를 쓸 때 옆 블록을 덮어씁니다. 그래서 sizeof(T)와 sizeof(Node) 중 큰 값을 씁니다.
  • 정렬: 블록 시작 주소는 청크 시작 + i * block_size_이므로, 청크 시작이 block_align_에 맞춰져 있고 block_size_가 block_align_의 배수여야 모든 블록이 정렬됩니다. 일반 ::operator new(size)는 alignof(std::max_align_t)(보통 16)까지만 보장하므로, alignas(64)로 캐시 라인에 맞춘 타입을 담으면 정렬이 깨집니다. 위 코드는 C++17의 std::align_val_t 오버로드로 이를 해결합니다.
  • 해제: 흔히 보이는 실수는 소멸자에서 free list를 따라가며 블록마다 ::operator delete를 부르는 것입니다. 블록은 청크 내부의 주소라 개별적으로 delete하면 정의되지 않은 동작이고, 사용 중인 블록은 free list에 없어서 청크 전체를 돌려주지도 못합니다. 청크 시작 주소를 따로 보관했다가 청크 단위로 해제해야 합니다.

main에서 b3는 방금 반환된 b2의 슬롯을 그대로 받습니다. free list가 LIFO(마지막에 반환된 블록을 먼저 꺼냄)이기 때문인데, 방금 쓴 메모리는 CPU 캐시에 남아 있을 가능성이 높아서 이 순서가 캐시 측면에서도 유리합니다. 반대로 해제한 포인터를 실수로 계속 쓰는 버그가 있으면, 다음 acquire()가 바로 그 메모리를 다른 객체에 넘겨주므로 증상이 엉뚱한 곳에서 나타납니다. 디버그 빌드에서는 release() 때 next 포인터 자리를 뺀 나머지 바이트를 특정 패턴(예: 0xDD)으로 채워 두면 이런 버그를 찾기 쉬워집니다.


슬랩 할당자 (Slab Allocator)

핵심 아이디어

슬랩 할당자는 여러 크기 클래스(예: 32, 64, 128, 256, 512 바이트)별로 풀을 두며, 요청 크기에 맞는 최소 풀에서 할당합니다. Linux 커널의 슬랩 할당자에서 이름을 따왔습니다.

flowchart LR
    subgraph Request[할당 요청]
        R1[48바이트]
        R2[200바이트]
    end
    subgraph Slabs[슬랩]
        S32["32B 풀"]
        S64["64B 풀"]
        S128["128B 풀"]
        S256["256B 풀"]
    end
    R1 --> S64
    R2 --> S256

슬랩 할당자 구현

// slab_allocator.hpp
// g++ -std=c++17 -O2 -o slab slab_allocator.cpp
#include <algorithm>
#include <cstddef>
#include <new>
#include <array>
#include <vector>
#include <iostream>
class FixedBlockPool {
public:
    explicit FixedBlockPool(std::size_t block_size, std::size_t blocks_per_chunk = 64)
        : block_size_(std::max(block_size, sizeof(void*)))
        , blocks_per_chunk_(blocks_per_chunk)
        , head_(nullptr) {}
    ~FixedBlockPool() {
        for (char* c : chunks_) ::operator delete(c);   // 청크 단위로 해제
    }
    FixedBlockPool(const FixedBlockPool&) = delete;
    FixedBlockPool& operator=(const FixedBlockPool&) = delete;
    void* allocate() {
        if (!head_) expand();
        if (!head_) return nullptr;
        void* p = head_;
        head_ = *static_cast<void**>(head_);
        return p;
    }
    void deallocate(void* p) {
        if (!p) return;
        *static_cast<void**>(p) = head_;
        head_ = p;
    }
private:
    void expand() {
        std::size_t chunk_size = block_size_ * blocks_per_chunk_;
        std::size_t align = alignof(std::max_align_t);
        chunk_size = (chunk_size + align - 1) & ~(align - 1);
        char* chunk = static_cast<char*>(::operator new(chunk_size));
        chunks_.push_back(chunk);
        for (std::size_t i = 0; i < blocks_per_chunk_; ++i) {
            void* block = chunk + i * block_size_;
            *static_cast<void**>(block) = head_;
            head_ = block;
        }
    }
    std::size_t block_size_;
    std::size_t blocks_per_chunk_;
    void* head_;
    std::vector<char*> chunks_;
};
class SlabAllocator {
public:
    static constexpr std::size_t NUM_CLASSES = 7;
    static constexpr std::size_t SIZE_CLASSES[NUM_CLASSES] =
        {32, 64, 128, 256, 512, 1024, 2048};
    void* allocate(std::size_t size) {
        std::size_t sc = get_size_class(size);
        if (sc == 0) return ::operator new(size);
        return pools_[get_class_index(sc)].allocate();
    }
    void deallocate(void* p, std::size_t size) {
        if (!p) return;
        std::size_t sc = get_size_class(size);
        if (sc == 0) {
            ::operator delete(p);
            return;
        }
        pools_[get_class_index(sc)].deallocate(p);
    }
private:
    static std::size_t get_size_class(std::size_t size) {
        for (std::size_t i = 0; i < NUM_CLASSES; ++i) {
            if (size <= SIZE_CLASSES[i]) return SIZE_CLASSES[i];
        }
        return 0;
    }
    static std::size_t get_class_index(std::size_t sc) {
        for (std::size_t i = 0; i < NUM_CLASSES; ++i) {
            if (SIZE_CLASSES[i] == sc) return i;
        }
        return 0;
    }
    std::array<FixedBlockPool, NUM_CLASSES> pools_{
        FixedBlockPool{32, 64},
        FixedBlockPool{64, 64},
        FixedBlockPool{128, 64},
        FixedBlockPool{256, 64},
        FixedBlockPool{512, 32},
        FixedBlockPool{1024, 16},
        FixedBlockPool{2048, 8},
    };
};
int main() {
    SlabAllocator slab;
    void* p1 = slab.allocate(48);
    void* p2 = slab.allocate(200);
    slab.deallocate(p1, 48);
    slab.deallocate(p2, 200);
    std::cout << "슬랩 할당자 테스트 완료\n";
    return 0;
}

주의: deallocate에 size를 넘겨야 올바른 풀에 반환됩니다. size 없이 쓰려면 블록에 크기 메타데이터를 저장하는 방식이 필요합니다.


메모리 아레나 (Memory Arena)

핵심 아이디어

메모리 아레나는 큰 버퍼를 한 번 할당하며, 커서를 앞으로만 이동시키며 순차 할당합니다. 개별 deallocate는 지원하지 않으며, reset()으로 커서를 처음으로 되돌려 “전체 해제”합니다.

flowchart LR
    subgraph Arena[메모리 아레나]
        direction LR
        A1["할당1 | 할당2 | 할당3 | ..."]
        C["커서 →"]
    end
    subgraph Reset["reset()"]
        R["커서 = 0"]
    end
    A1 --> C
    C --> R

아레나 구현

// memory_arena.hpp
// g++ -std=c++17 -O2 -o arena memory_arena.cpp
#include <cstddef>
#include <new>
#include <vector>
#include <iostream>
class MemoryArena {
public:
    explicit MemoryArena(std::size_t initial_size = 65536)
        : capacity_(initial_size)
        , offset_(0) {
        buffer_ = static_cast<char*>(::operator new(capacity_));
    }
    ~MemoryArena() {
        ::operator delete(buffer_);
    }
    MemoryArena(const MemoryArena&) = delete;
    MemoryArena& operator=(const MemoryArena&) = delete;
    void* allocate(std::size_t size, std::size_t alignment = alignof(std::max_align_t)) {
        std::size_t aligned_offset = (offset_ + alignment - 1) & ~(alignment - 1);
        if (aligned_offset + size > capacity_) {
            return nullptr;  // 또는 expand
        }
        void* p = buffer_ + aligned_offset;
        offset_ = aligned_offset + size;
        return p;
    }
    void reset() {
        offset_ = 0;
    }
    std::size_t used() const { return offset_; }
    std::size_t capacity() const { return capacity_; }
private:
    char* buffer_;
    std::size_t capacity_;
    std::size_t offset_;
};
int main() {
    MemoryArena arena(4096);
    void* p1 = arena.allocate(32);
    void* p2 = arena.allocate(64);
    std::cout << "사용량: " << arena.used() << " / " << arena.capacity() << "\n";
    arena.reset();
    std::cout << "리셋 후: " << arena.used() << "\n";
    return 0;
}

확장 가능 아레나 (청크 체인)

고정 크기 아레나는 용량을 넘으면 nullptr을 돌려주므로, 요청 하나가 얼마나 쓸지 미리 알기 어려우면 버퍼가 부족할 때 새 청크를 받아 이어 붙이는 방식이 편합니다.

// expandable_arena.hpp
#include <cstddef>
#include <cstdint>
#include <new>
#include <vector>
class ExpandableArena {
public:
    explicit ExpandableArena(std::size_t chunk_size = 65536) : chunk_size_(chunk_size) {}
    ~ExpandableArena() { for (auto& c : chunks_) ::operator delete(c.data); }
    ExpandableArena(const ExpandableArena&) = delete;
    ExpandableArena& operator=(const ExpandableArena&) = delete;
    void* allocate(std::size_t size, std::size_t alignment = alignof(std::max_align_t)) {
        if (!chunks_.empty()) {
            if (void* p = bump(chunks_[current_], size, alignment)) return p;
            // 이전 reset() 이후 남아 있는 다음 청크가 있으면 재사용
            while (current_ + 1 < chunks_.size()) {
                ++current_;
                chunks_[current_].used = 0;
                if (void* p = bump(chunks_[current_], size, alignment)) return p;
            }
        }
        // 청크보다 큰 요청도 담을 수 있게 새 청크 크기를 정한다
        std::size_t cap = (size + alignment > chunk_size_) ? size + alignment : chunk_size_;
        chunks_.push_back({static_cast<char*>(::operator new(cap)), cap, 0});
        current_ = chunks_.size() - 1;
        return bump(chunks_[current_], size, alignment);
    }
    void reset() {                 // 청크는 돌려주지 않고 처음부터 다시 쓴다
        for (auto& c : chunks_) c.used = 0;
        current_ = 0;
    }
private:
    struct Chunk { char* data; std::size_t capacity; std::size_t used; };
    static void* bump(Chunk& c, std::size_t size, std::size_t alignment) {
        // 오프셋이 아니라 실제 주소를 정렬한다 (아래 에러 10 참고)
        auto base = reinterpret_cast<std::uintptr_t>(c.data);
        auto addr = (base + c.used + alignment - 1) & ~(std::uintptr_t)(alignment - 1);
        if (addr + size > base + c.capacity) return nullptr;
        c.used = static_cast<std::size_t>(addr + size - base);
        return reinterpret_cast<void*>(addr);
    }
    std::size_t chunk_size_;
    std::size_t current_ = 0;
    std::vector<Chunk> chunks_;
};

reset()에서 청크를 해제하지 않는 것은, 다음 요청도 비슷한 양을 쓸 가능성이 높아서 매번 operator new를 다시 부르지 않기 위해서입니다. 대신 한 번 크게 치솟은 요청이 있으면 그만큼의 청크를 계속 붙잡고 있게 되므로, 장시간 실행되는 서버라면 “첫 청크만 남기고 나머지는 해제”하는 정책을 따로 두는 편이 좋습니다. std::pmr::monotonic_buffer_resource의 release()는 상위 리소스에서 받은 메모리를 모두 돌려주는 쪽을 택하고 있습니다.


std::pmr 활용

C++17 표준 메모리 리소스

C++17 std::pmr는 다형 메모리 리소스를 제공합니다. memory_resource를 상속해 do_allocate, do_deallocate를 구현하면 STL 컨테이너에 주입할 수 있습니다.

flowchart TB
    subgraph Containers[컨테이너]
        V["std pmr vector"]
        M["std pmr map"]
        S["std pmr string"]
    end
    subgraph Allocator[polymorphic_allocator]
        PA[memory_resource*]
    end
    subgraph Resources[memory_resource 구현체]
        MONO[monotonic_buffer_resource]
        POOL[pool_resource]
        CUSTOM[커스텀]
    end
    V --> PA
    M --> PA
    S --> PA
    PA --> MONO
    PA --> POOL
    PA --> CUSTOM

monotonic_buffer_resource (아레나)

// std::pmr::monotonic_buffer_resource 예제
// g++ -std=c++17 -O2 -o pmr_mono pmr_mono.cpp
#include <memory_resource>
#include <vector>
#include <string>
#include <map>
#include <iostream>
void handleRequest() {
    std::array<std::byte, 65536> stack_buffer;
    std::pmr::monotonic_buffer_resource arena{
        stack_buffer.data(), stack_buffer.size(),
        std::pmr::new_delete_resource()
    };
    std::pmr::vector<std::pmr::string> path_segments(&arena);
    std::pmr::map<std::pmr::string, std::pmr::string, std::less<>>
        headers(&arena);
    std::pmr::string body(&arena);
    path_segments.push_back("api");
    path_segments.push_back("v1");
    headers["Content-Type"] = "application/json";
    body = "{\"key\":\"value\"}";
    std::cout << "경로: " << path_segments[0] << "/" << path_segments[1] << "\n";
    std::cout << "Content-Type: " << headers["Content-Type"] << "\n";
}

pool_resource (고정 블록 풀)

std::pmr::pool_options로 블록 크기·청크당 블록 수를 설정하며, synchronized_pool_resource(스레드 안전) 또는 unsynchronized_pool_resource(단일 스레드)를 사용합니다. 멀티스레드 환경에서는 synchronized_pool_resource를 선택합니다.

커스텀 memory_resource 예제

// custom_memory_resource.hpp
#include <memory_resource>
#include <cstdio>
class LoggingMemoryResource : public std::pmr::memory_resource {
public:
    explicit LoggingMemoryResource(std::pmr::memory_resource* upstream
        = std::pmr::get_default_resource())
        : upstream_(upstream) {}
    void* do_allocate(std::size_t bytes, std::size_t alignment) override {
        void* p = upstream_->allocate(bytes, alignment);
        std::printf("allocate(%zu, %zu) -> %p\n", bytes, alignment, p);
        return p;
    }
    void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
        std::printf("deallocate(%p, %zu, %zu)\n", p, bytes, alignment);
        upstream_->deallocate(p, bytes, alignment);
    }
    bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
        return this == &other;
    }
private:
    std::pmr::memory_resource* upstream_;
};

게임 프레임 풀, 요청 스코프 아레나, 스레드 로컬 풀 예제

예제 1: 게임 프레임 풀 (객체 풀 + reset)

// game_frame_pool.cpp - g++ -std=c++17 -O2 -o game_pool game_frame_pool.cpp
#include <cstddef>
#include <new>
#include <vector>
#include <iostream>
struct Entity { int id; float x, y; Entity() : id(0), x(0), y(0) {} };
template <typename T>
class FrameObjectPool {
public:
    explicit FrameObjectPool(std::size_t block_size, std::size_t capacity = 256)
        : block_size_(block_size), capacity_(capacity), count_(0) {
        storage_ = static_cast<char*>(::operator new(block_size_ * capacity_));
        for (std::size_t i = 0; i < capacity_; ++i)
            free_list_.push_back(storage_ + i * block_size_);
    }
    ~FrameObjectPool() { ::operator delete(storage_); }
    template <typename... Args>
    T* acquire(Args&&... args) {
        if (free_list_.empty()) return nullptr;
        void* mem = free_list_.back();
        free_list_.pop_back();
        ++count_;
        return new (mem) T(std::forward<Args>(args)...);
    }
    void release(T* obj) {
        if (!obj) return;
        obj->~T();
        free_list_.push_back(static_cast<void*>(obj));
        --count_;
    }
    void reset() {
        free_list_.clear();
        for (std::size_t i = 0; i < capacity_; ++i)
            free_list_.push_back(storage_ + i * block_size_);
        count_ = 0;
    }
private:
    std::size_t block_size_, capacity_, count_;
    char* storage_;
    std::vector<void*> free_list_;
};
int main() {
    FrameObjectPool<Entity> pool(sizeof(Entity), 128);
    auto* e = pool.acquire(1, 100.0f, 200.0f);
    pool.release(e);
    pool.reset();
    std::cout << "프레임 풀 테스트 완료\n";
    return 0;
}

예제 2: HTTP 요청 스코프 (std::pmr 아레나)

// http_request_arena.cpp - g++ -std=c++17 -O2 -o http_arena http_request_arena.cpp
#include <memory_resource>
#include <vector>
#include <string>
#include <map>
#include <iostream>
void handleHttpRequest(const std::string&) {
    std::pmr::monotonic_buffer_resource arena(std::pmr::new_delete_resource());
    std::pmr::vector<std::pmr::string> path(&arena);
    std::pmr::map<std::pmr::string, std::pmr::string, std::less<>> headers(&arena);
    path.push_back("api");
    path.push_back("v1");
    headers["Content-Type"] = "application/json";
    std::cout << "요청 처리 완료\n";
}
int main() { handleHttpRequest(""); return 0; }

예제 3: 스레드 로컬 풀

// thread_local_pool.cpp
#include <cstddef>
#include <new>
#include <thread>
#include <vector>
#include <iostream>
class FixedBlockPool {
public:
    explicit FixedBlockPool(std::size_t block_size, std::size_t blocks_per_chunk = 64)
        : block_size_(std::max(block_size, sizeof(void*)))
        , blocks_per_chunk_(blocks_per_chunk)
        , head_(nullptr) {}
    void* allocate() {
        if (!head_) expand();
        void* p = head_;
        head_ = *static_cast<void**>(head_);
        return p;
    }
    void deallocate(void* p) {
        if (!p) return;
        *static_cast<void**>(p) = head_;
        head_ = p;
    }
private:
    void expand() {
        std::size_t chunk_size = block_size_ * blocks_per_chunk_;
        std::size_t align = alignof(std::max_align_t);
        chunk_size = (chunk_size + align - 1) & ~(align - 1);
        char* chunk = static_cast<char*>(::operator new(chunk_size));
        for (std::size_t i = 0; i < blocks_per_chunk_; ++i) {
            void* block = chunk + i * block_size_;
            *static_cast<void**>(block) = head_;
            head_ = block;
        }
    }
    std::size_t block_size_;
    std::size_t blocks_per_chunk_;
    void* head_;
};
struct Entity {
    int id;
    float x, y;
};
int main() {
    std::vector<std::thread> threads;
    for (int t = 0; t < 4; ++t) {
        threads.emplace_back([t]() {
            thread_local FixedBlockPool pool(256, 64);
            for (int i = 0; i < 1000; ++i) {
                Entity* e = static_cast<Entity*>(pool.allocate());
                e->id = t * 1000 + i;
                e->x = e->y = 0.0f;
                pool.deallocate(e);
            }
        });
    }
    for (auto& th : threads) th.join();
    std::cout << "스레드 로컬 풀 테스트 완료\n";
    return 0;
}

예제 4: 슬랩 할당자를 STL 컨테이너에 연결

앞에서 만든 SlabAllocator를 std::vector나 std::list에 쓰려면 표준 할당자 요구사항(Allocator named requirement)을 맞추는 얇은 어댑터가 필요합니다. std::allocator_traits가 나머지(construct, destroy, rebind 등)를 기본값으로 채워 주므로, 직접 써야 하는 것은 많지 않습니다.

// slab_allocator_adapter.hpp — 앞에서 만든 SlabAllocator를 사용
#include <cstddef>
#include <list>
#include <vector>
template <typename T>
class SlabAllocatorAdapter {
public:
    using value_type = T;
    explicit SlabAllocatorAdapter(SlabAllocator* slab) noexcept : slab_(slab) {}
    // rebind용 변환 생성자: std::list<T>는 내부 노드 타입으로 할당자를 바꿔 쓴다
    template <typename U>
    SlabAllocatorAdapter(const SlabAllocatorAdapter<U>& other) noexcept : slab_(other.slab_) {}
    T* allocate(std::size_t n) {
        return static_cast<T*>(slab_->allocate(n * sizeof(T)));
    }
    void deallocate(T* p, std::size_t n) noexcept {
        slab_->deallocate(p, n * sizeof(T));   // 할당 때와 같은 크기 → 같은 크기 클래스
    }
    template <typename U>
    bool operator==(const SlabAllocatorAdapter<U>& o) const noexcept { return slab_ == o.slab_; }
    template <typename U>
    bool operator!=(const SlabAllocatorAdapter<U>& o) const noexcept { return slab_ != o.slab_; }
    SlabAllocator* slab_;   // 같은 슬랩을 가리키면 서로 해제 가능
};
int main() {
    SlabAllocator slab;   // 컨테이너보다 먼저 선언 → 나중에 소멸
    std::list<int, SlabAllocatorAdapter<int>> lst{SlabAllocatorAdapter<int>(&slab)};
    for (int i = 0; i < 100; ++i) lst.push_back(i);   // 노드 하나당 작은 할당 → 슬랩이 유리
    std::vector<int, SlabAllocatorAdapter<int>> v{SlabAllocatorAdapter<int>(&slab)};
    v.reserve(16);                                     // 64바이트 → 64B 크기 클래스
}

이 어댑터가 잘 맞는 곳은 std::list, std::map처럼 노드 하나씩 작은 할당을 반복하는 컨테이너입니다. 노드 크기가 일정해서 늘 같은 크기 클래스에 들어갑니다. std::vector는 커질 때마다 요청 크기가 두 배씩 바뀌어 여러 크기 클래스를 옮겨 다니다가 2048바이트를 넘으면 전역 힙으로 넘어가므로, 슬랩의 이득이 거의 없습니다.

deallocate에 크기를 넘겨야 올바른 풀로 돌아가는 슬랩의 약점이 여기서는 문제가 되지 않습니다. 표준 할당자 인터페이스는 원래 deallocate(p, n)에 할당 때와 같은 n을 넘기도록 규정하고 있기 때문입니다. 한편 슬랩의 블록은 ::operator new로 받은 청크 안에 차례로 놓이므로 alignof(std::max_align_t)(보통 16) 정렬까지만 보장됩니다. 따라서 alignof(T)가 그보다 큰 타입(예: alignas(64) 구조체)에는 이 어댑터를 쓰면 안 됩니다. 필요하면 allocate에서 alignof(T) > alignof(std::max_align_t)를 static_assert로 막아 둡니다.


use-after-free, 잘못된 풀 반환, 정렬 무시 같은 실수

고정 블록 풀이라면 어디서나 생기는 실수(풀보다 오래 사는 객체, 다른 풀에 반환, 블록보다 큰 객체, 이중 해제)는 #48-3의 풀 사용 실수에 재현 코드와 함께 있습니다. 여기서는 슬랩·아레나·pmr·객체 풀 각각에서 생기는 문제만 다룹니다.

에러 1: 슬랩에서 잘못된 크기로 반환

원인: deallocate(p, wrong_size)로 다른 크기 클래스 풀에 반환. 해결: 할당 시 크기 저장 또는 호출자가 정확히 전달.

에러 2: 아레나에서 개별 해제 기대

원인: 아레나는 reset()으로만 해제합니다. 해결: 개별 해제가 필요하면 풀 또는 슬랩 사용.

에러 3: std::pmr 리소스 수명 문제

증상: 컨테이너가 리소스보다 오래 살 때 use-after-free.

// ❌ 잘못된 코드
std::pmr::vector<int>* createVector() {
    std::pmr::monotonic_buffer_resource pool;
    return new std::pmr::vector<int>(&pool);  // pool은 함수 종료 시 소멸!
}

해결법:

// ✅ 리소스 수명을 컨테이너보다 길게
std::pmr::monotonic_buffer_resource* pool =
    new std::pmr::monotonic_buffer_resource;
std::pmr::vector<int>* vec = new std::pmr::vector<int>(pool);

에러 4: 스레드 안전성 오해

증상: 멀티스레드에서 간헐적 크래시. 원인: 전역 풀을 여러 스레드가 락 없이 사용함.

// ❌ 잘못된 코드
FixedBlockPool global_pool(256);
void worker() {
    void* p = global_pool.allocate();  // data race!
    global_pool.deallocate(p);
}

해결법:

// ✅ 스레드 로컬 풀
void worker() {
    thread_local FixedBlockPool pool(256);
    void* p = pool.allocate();
    pool.deallocate(p);
}

스레드 로컬 풀에는 함정이 하나 더 있습니다. 스레드 A에서 할당한 블록을 큐로 넘겨 스레드 B가 해제하면, 블록이 B의 풀로 들어갑니다. 생산자·소비자 구조에서는 이렇게 메모리가 한쪽 풀에서 다른 쪽 풀로 계속 흘러가서, 생산자 풀은 매번 새 청크를 받고 소비자 풀은 끝없이 쌓입니다. 이런 구조라면 해제를 원래 스레드로 돌려보내거나(블록에 소유 풀 포인터를 기록), 시나리오 7의 공유 상위 풀로 넘기는 경로가 필요합니다. 또한 thread_local 풀은 스레드가 끝날 때 소멸하므로, 그 스레드가 할당한 블록을 다른 스레드가 스레드 종료 뒤에도 쓰면 에러 1과 같은 use-after-free가 됩니다.

에러 5: 객체 풀에서 release() 없이 풀이 소멸

증상: 에러 메시지 없이 파일이 닫히지 않거나, 락·소켓이 풀리지 않고 남아 있음. 원인: 앞의 ObjectPool 소멸자는 청크 메모리만 돌려줍니다. release()되지 않은 객체가 남아 있으면 그 객체의 소멸자는 한 번도 호출되지 않습니다. 메모리는 청크와 함께 회수되지만, 객체가 들고 있던 메모리 밖의 자원(파일 핸들, 락, 소켓)은 그대로 새어 나갑니다.

// ❌ 잘못된 코드
{
    ObjectPool<FileHandle> pool;
    FileHandle* f = pool.acquire("test.txt");
    // ... 조기 return 이나 예외로 release() 를 건너뜀
}   // pool 소멸: 청크는 해제되지만 f->~FileHandle() 은 호출되지 않음

해결법: 반환을 사람이 기억하게 두지 말고 std::unique_ptr의 커스텀 deleter로 묶습니다.

// ✅ 스코프를 벗어나면 자동으로 pool.release() 호출
template <typename T>
struct PoolDeleter {
    ObjectPool<T>* pool;
    void operator()(T* p) const noexcept { pool->release(p); }
};
template <typename T>
using PoolPtr = std::unique_ptr<T, PoolDeleter<T>>;
template <typename T, typename... Args>
PoolPtr<T> make_pooled(ObjectPool<T>& pool, Args&&... args) {
    return PoolPtr<T>(pool.acquire(std::forward<Args>(args)...), PoolDeleter<T>{&pool});
}
// 사용
ObjectPool<FileHandle> pool;           // 풀을 먼저 선언 → 나중에 소멸
{
    auto f = make_pooled(pool, "test.txt");
}                                      // 여기서 release() → ~FileHandle()

unique_ptr은 move만 가능하므로 소유권이 한 곳에만 있다는 것도 타입으로 드러납니다. 풀이 먼저 소멸하는 순서 문제는 여전히 남으므로, 풀을 쓰는 객체들보다 풀을 먼저 선언하거나 더 긴 수명을 가진 곳에 두어야 합니다. 디버그 빌드에서는 풀 소멸자에서 “아직 반환되지 않은 객체 수”를 세어 0이 아니면 assert하게 해 두면 누락을 일찍 발견할 수 있습니다.

에러 6: 아레나에서 정렬 무시

증상: __m256 같은 SIMD 타입이나 alignas(32) 구조체를 아레나에 올리면 x86에서 정렬을 요구하는 명령(vmovaps 등)이 일반 보호 예외로 죽고, ARM 일부 환경에서는 SIGBUS가 납니다. 정렬 요구가 느슨한 타입만 쓸 때는 멀쩡해서 늦게 발견됩니다. 원인: 커서를 요청 크기만큼만 밀어서 다음 할당 주소가 정렬되지 않았거나, 정렬을 버퍼 시작 기준 오프셋으로만 계산한 경우입니다.

// ❌ 정렬을 전혀 안 함
void* allocate(std::size_t size) {
    void* p = buffer_ + offset_;
    offset_ += size;
    return p;
}
// ❌ 오프셋만 정렬: buffer_ 자체가 16바이트 정렬이면 alignment=32 요청에 실패할 수 있음
std::size_t aligned = (offset_ + alignment - 1) & ~(alignment - 1);

앞에서 만든 MemoryArena도 두 번째 방식입니다. ::operator new가 돌려준 buffer_는 alignof(std::max_align_t)(보통 16)까지만 정렬이 보장되므로, 오프셋을 32의 배수로 맞춰도 실제 주소는 32의 배수가 아닐 수 있습니다. alignof(std::max_align_t) 이하의 정렬만 쓴다면 문제가 없지만, SIMD용으로 쓸 거라면 주소 기준으로 계산해야 합니다.

해결법: 실제 주소를 정렬하거나, 표준 std::align을 씁니다.

// ✅ std::align: 남은 공간 안에서 정렬된 주소를 찾아 주고, 공간이 부족하면 nullptr
#include <memory>
void* allocate(std::size_t size, std::size_t alignment = alignof(std::max_align_t)) {
    void* p = buffer_ + offset_;
    std::size_t space = capacity_ - offset_;
    if (!std::align(alignment, size, p, space)) return nullptr;
    offset_ = static_cast<std::size_t>(static_cast<char*>(p) - buffer_) + size;
    return p;
}

비트 마스크 방식(& ~(alignment - 1))은 alignment가 2의 거듭제곱일 때만 맞습니다. alignof가 돌려주는 값은 항상 2의 거듭제곱이지만, 호출자가 임의 값을 넘길 수 있는 API라면 assert((alignment & (alignment - 1)) == 0)로 확인해 두는 것이 좋습니다.


풀 수명·블록 크기·fallback·RAII 반환 원칙

풀 수명 > 객체 수명

풀에서 할당한 객체는 풀이 소멸되기 전에 반드시 해제하거나 사용을 마처리해야 합니다.

블록 크기 선택

  • 실제 할당하는 객체 크기의 최대값에 맞춥니다.
  • sizeof(Entity)가 128이면 블록 128 또는 256으로 설정.
  • 프로파일링으로 할당 크기 분포를 확인한 뒤, 90% 이상이 커버되는 크기를 선택합니다.

정렬 보장

// ✅ 정렬 보장
block_size_ = (block_size + alignof(std::max_align_t) - 1)
              & ~(alignof(std::max_align_t) - 1);
std::size_t chunk_size = block_size_ * blocks_per_chunk_;
chunk_size = (chunk_size + alignof(std::max_align_t) - 1)
             & ~(alignof(std::max_align_t) - 1);

Fallback 처리

블록 크기 초과 요청 시 전역 힙으로 위임합니다.

void* allocate(std::size_t size) {
    if (size > block_size_) {
        return ::operator new(size);
    }
    return allocate();
}

객체 풀에서 release() 반드시 호출

release()를 호출하지 않고 풀이 소멸되면, 풀에 남아 있는 객체의 소멸자가 호출되지 않습니다. 리소스 누수(파일 핸들, 락 등)가 발생할 수 있습니다.

RAII 래퍼 사용

acquire()/release()를 직접 짝지어 부르지 말고, 앞의 에러 5에서 본 PoolPtr(커스텀 deleter를 가진 std::unique_ptr)처럼 스코프가 끝나면 자동으로 반환되게 만듭니다. 소멸자만 가진 단순 구조체로 만들면 복사될 때 두 번 반환되는 문제가 생기므로, 복사를 막고 move만 허용하는 unique_ptr을 쓰는 편이 안전합니다.

프로파일링 후 적용

메모리 풀은 프로파일러로 병목이 확인된 경우에 적용합니다. 막연히 적용하면 복잡도만 늘어납니다. 측정 방법은 바로 다음 절에서 다룹니다.

std::pmr 우선 고려

C++17 이상이라면 std::pmr를 먼저 고려합니다. 표준 구현이 대부분의 요구를 충족합니다.


풀이 정말 빠른지 측정하는 법

풀이 빠른지 느린지는 할당 패턴, 객체 크기, 스레드 수, 그리고 비교 대상인 기본 할당자(glibc malloc, jemalloc, mimalloc, Windows 힙)에 따라 크게 달라집니다. 다른 곳에서 본 배수를 기대하기보다 자기 워크로드와 비슷한 패턴으로 직접 재는 것이 정확합니다. 아래는 그 뼈대입니다.

// bench_pools.cpp — 앞에서 만든 ObjectPool, SlabAllocator 헤더를 함께 include
// g++ -std=c++17 -O2 -o bench bench_pools.cpp
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <random>
#include <vector>
struct SmallObject {
    int id = 0;
    float x = 0, y = 0, z = 0;
};
constexpr std::size_t N = 500000;
template <typename Alloc, typename Free>
void run(const char* name, Alloc alloc, Free free_fn, bool shuffle) {
    std::vector<SmallObject*> ptrs;
    ptrs.reserve(N);
    std::mt19937 rng(42);
    std::uint64_t checksum = 0;
    auto start = std::chrono::steady_clock::now();
    for (std::size_t i = 0; i < N; ++i) {
        SmallObject* p = alloc();
        p->id = static_cast<int>(i);           // 메모리를 실제로 건드림
        ptrs.push_back(p);
    }
    if (shuffle) std::shuffle(ptrs.begin(), ptrs.end(), rng);   // 해제 순서를 섞음
    for (auto* p : ptrs) { checksum += p->id; free_fn(p); }
    auto end = std::chrono::steady_clock::now();
    auto ns = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count();
    std::cout << name << (shuffle ? " (shuffled)" : " (LIFO)    ")
              << ": " << static_cast<double>(ns) / N << " ns/op, checksum " << checksum << "\n";
}
int main() {
    ObjectPool<SmallObject> pool;
    SlabAllocator slab;
    for (bool shuffle : {false, true}) {
        run("new/delete", [] { return new SmallObject(); },
            [](SmallObject* p) { delete p; }, shuffle);
        run("ObjectPool", [&] { return pool.acquire(); },
            [&](SmallObject* p) { pool.release(p); }, shuffle);
        run("Slab      ",
            [&] { return new (slab.allocate(sizeof(SmallObject))) SmallObject(); },
            [&](SmallObject* p) { p->~SmallObject(); slab.deallocate(p, sizeof(SmallObject)); },
            shuffle);
    }
}

이 코드에서 신경 쓴 부분과, 직접 잴 때 확인할 점은 다음과 같습니다.

  • 최적화로 사라지지 않게: C++14부터 컴파일러는 짝이 맞는 new/delete를 통째로 없앨 수 있습니다. 할당한 메모리에 값을 쓰고, 해제 전에 읽어 checksum으로 출력하는 것은 이 때문입니다.
  • 해제 순서: 할당한 순서의 역순(LIFO)으로 해제하면 기본 할당자도 스레드 캐시(glibc tcache 등) 덕분에 매우 빠르게 동작해 풀과의 차이가 작게 나옵니다. 실제 서비스처럼 순서가 섞이면 결과가 달라지므로 두 경우를 모두 잽니다.
  • 첫 실행 효과: 첫 번째 루프에는 페이지 폴트와 풀의 청크 확보 비용이 들어가서, 두 번째 루프보다 느린 것이 정상입니다. 같은 측정을 여러 번 반복하고 첫 회는 워밍업으로 버립니다.
  • 시계: steady_clock을 쓰고, 연산 하나당 나노초로 환산해 N에 상관없이 비교되게 합니다. 전체 시간이 밀리초 단위로 너무 짧으면 N을 늘립니다.
  • 멀티스레드: 시나리오 3처럼 락 경합이 문제라면 단일 스레드 측정은 의미가 적습니다. 같은 루프를 스레드 1, 2, 4, 8개로 돌려 스레드 수에 따라 처리량이 어떻게 변하는지를 봐야 합니다.
  • 비교 대상: 풀을 만들기 전에 LD_PRELOAD로 jemalloc이나 mimalloc을 끼워 같은 측정을 해 보면, 코드를 바꾸지 않고 얻을 수 있는 개선 폭을 먼저 확인할 수 있습니다.

아레나는 개별 해제가 없으므로 같은 틀로 비교하기 어렵습니다. “요청 하나 처리”처럼 할당 묶음 단위를 정하고, 기본 할당자로 묶음 전체를 할당·해제하는 시간과 아레나로 할당한 뒤 reset() 하는 시간을 비교합니다.

void bench_arena_round(MemoryArena& arena) {
    for (int round = 0; round < 10; ++round) {
        arena.reset();                                   // 요청 하나가 끝난 것으로 간주
        for (std::size_t i = 0; i < 1000; ++i) arena.allocate(32);
    }
}

속도만 보지 말고 메모리 사용량도 함께 기록해야 합니다. 풀은 반환된 블록을 운영체제에 돌려주지 않으므로 피크 사용량이 그대로 유지되고, 슬랩은 크기 클래스 반올림으로 요청보다 큰 블록을 씁니다. Linux에서는 /usr/bin/time -v의 Maximum resident set size로 피크 메모리를 간단히 비교할 수 있습니다.


크기별 풀, 풀 통계, 스레드 로컬 풀 + 상위 풀 폴백

게임 프레임 풀과 HTTP 요청 스코프 아레나는 앞의 예제 1·2에서 전체 코드를 봤으므로, 여기서는 그 외의 운영 패턴만 정리합니다.

패턴 1: 크기별 풀 (Size-Class Pool)

class SizeClassPool {
public:
    void* allocate(std::size_t size) {
        if (size <= 64) return pool64_.allocate();
        if (size <= 128) return pool128_.allocate();
        if (size <= 256) return pool256_.allocate();
        if (size <= 512) return pool512_.allocate();
        return ::operator new(size);
    }
    void deallocate(void* p, std::size_t size) {
        if (size <= 64) return pool64_.deallocate(p);
        if (size <= 128) return pool128_.deallocate(p);
        if (size <= 256) return pool256_.deallocate(p);
        if (size <= 512) return pool512_.deallocate(p);
        ::operator delete(p);
    }
private:
    FixedBlockPool pool64_{64, 64};
    FixedBlockPool pool128_{128, 64};
    FixedBlockPool pool256_{256, 64};
    FixedBlockPool pool512_{512, 32};
};

패턴 2: 풀 통계 및 모니터링

allocate/deallocate를 래핑해 alloc_count_, current_used_, peak_used_를 추적합니다. 프로덕션에서 메모리 사용량 추이를 모니터링할 때 유용합니다.

패턴 3: 스레드 로컬 풀 + 상위 풀 폴백

로컬 풀에서 할당 실패 시 std::lock_guard로 락을 걸고 전역 풀에서 할당합니다. 스레드별 피크 사용량이 다를 때 메모리 효율과 확장성을 동시에 확보합니다.

도입 전 확인할 것

- [ ] 블록 크기를 실제 할당 크기 분포에 맞게 설정
- [ ] 멀티스레드 시 스레드 로컬 풀 또는 락 사용
- [ ] 풀 수명이 할당 객체 수명보다 길도록 보장
- [ ] fallback: 블록 크기 초과 시 전역 힙 처리
- [ ] 디버그 빌드에서 use-after-free 검사 (선택)
- [ ] 프로덕션에서 풀 사용량 모니터링 (선택)
- [ ] std::pmr 우선 고려, 필요 시 커스텀 구현

같이 보면 좋은 글

자주 묻는 질문 (FAQ)

Q. std::pmr 컨테이너가 메모리 리소스보다 오래 살면 어떻게 되나요?

A. pmr 컨테이너는 메모리 리소스를 소유하지 않고 포인터만 들고 있습니다. 함수 안의 지역 monotonic_buffer_resource로 만든 컨테이너를 반환하는 식으로 리소스가 먼저 소멸하면, 컨테이너는 해제된 메모리를 계속 사용해 use-after-free가 납니다. 리소스는 그것을 쓰는 모든 컨테이너보다 오래 사는 곳에 두고, 클래스 멤버라면 리소스를 컨테이너보다 먼저 선언해 나중에 소멸되게 합니다.

Q. 슬랩 할당자와 아레나 중 무엇을 골라야 하나요?

A. 기준은 해제 방식입니다. 객체마다 수명이 달라 개별 해제가 필요하면 크기 클래스별 free list를 두는 슬랩 할당자가 맞고, HTTP 요청이나 게임 프레임처럼 수명이 한꺼번에 끝나면 커서만 밀고 마지막에 통째로 버리는 아레나가 더 단순하고 빠릅니다. 아레나에 개별 해제를 억지로 붙이면 결국 free list를 다시 만들게 되므로, 그런 요구가 생기면 슬랩으로 바꾸는 편이 낫습니다.

Q. std::pmr::unsynchronized_pool_resource와 직접 만든 슬랩 할당자는 무엇이 다른가요?

A. pool_resource도 크기 클래스별 풀을 두는 같은 아이디어라 대부분의 경우 직접 만들 필요가 없습니다. 조절할 수 있는 것은 pool_options의 최대 블록 크기와 청크당 블록 수 정도이고, 크기 클래스 경계나 통계 수집, 디버그용 가드 바이트 같은 동작은 바꿀 수 없습니다. 그런 요구가 있거나 C++17 이전 환경이라면 직접 구현이 필요합니다. 여러 스레드가 같은 리소스를 쓰면 synchronized_pool_resource가 필요한데, 이 경우 락 비용이 생기므로 스레드마다 unsynchronized_pool_resource를 두는 구성도 검토해 볼 만합니다.

Q. monotonic_buffer_resource를 썼더니 메모리가 계속 늘어납니다.

A. monotonic_buffer_resource의 deallocate는 아무것도 하지 않습니다. 그래서 pmr::vector가 자라면서 재할당할 때마다 이전 버퍼가 회수되지 않고 쌓입니다. 요청이나 프레임처럼 짧은 스코프에서만 쓰고 스코프가 끝나면 리소스를 소멸시키거나 release()로 비우며, 크기를 알 수 있는 컨테이너는 reserve로 재할당 자체를 줄입니다. 오래 사는 컨테이너에는 pool 계열 리소스가 맞습니다. 객체 풀·슬랩·아레나·std::pmr로 메모리 할당 비용과 단편화를 줄일 수 있습니다. 이전 글: C++ I/O 병목 줄이기: cin·mmap·io_uring 성능 비교 다음 글: [C++ 코테 압축 #32-3] C++ STL 치트시트