C++ 메모리 풀 구현 비교: 객체 풀, 슬랩 할당자, 아레나, std::pmr
들어가며
대량의 같은 크기(또는 비슷한 크기) 객체를 반복해서 할당·해제하면 힙 단편화와 할당·해제 비용이 쌓입니다. 고정 크기 블록을 free list로 관리하는 풀 하나를 처음부터 만드는 과정과 풀을 쓸 때 흔히 저지르는 실수(풀보다 오래 사는 객체, 다른 풀에 반환, 이중 해제 등)는 C++ 할당 전략 비교: 커스텀 할당자, 메모리 풀, 아레나, std::pmr, jemalloc·tcmalloc에서 다룹니다. 이 글은 그다음 단계로, 고정 블록 풀 하나로는 부족할 때 고를 수 있는 네 가지 방식(타입 전용 객체 풀, 크기 클래스별 슬랩 할당자, 순차 할당·일괄 해제 아레나, C++17 std::pmr)을 나란히 구현하고 비교합니다. std::pmr로 메모리 할당 최적화하기를 먼저 읽어 두면 pmr 부분이 수월합니다.
malloc 병목, 단편화, 락 경합: 메모리 풀이 필요해지는 상황
프로파일러에서 malloc/free가 상위에 뜨는 경우, 장시간 실행 후 단편화로 할당이 실패하는 경우, 스레드가 늘수록 전역 힙 락 경합이 커지는 경우처럼 풀을 도입하게 되는 기본 상황과 고정 블록 풀로 푸는 방법은 C++ 할당 전략 비교: 커스텀 할당자, 메모리 풀, 아레나, std::pmr, jemalloc·tcmalloc에 정리했습니다. 아래는 고정 블록 풀 하나로는 해결되지 않아 다른 방식이 필요해지는 상황입니다.
시나리오 1: 할당 크기가 다양할 때
HTTP 파서처럼 헤더(작음), 바디 청크(중간), JSON(큼)이 섞여 할당되면 고정 블록 풀 하나로는 맞지 않습니다. 256바이트 블록 하나로 모든 요청을 받으면 32바이트 요청마다 224바이트가 버려집니다. 슬랩 할당자는 32, 64, 128, 256처럼 크기 클래스별로 풀을 두고, 요청 크기를 담을 수 있는 가장 작은 클래스에서 할당해 이 낭비를 줄입니다.
시나리오 2: 순차 할당만 하고 개별 해제가 없을 때
HTTP 요청 처리나 프레임 렌더링처럼 처리하는 동안 할당만 하고 끝에 한꺼번에 버리는 패턴이라면, 블록마다 free list를 관리하는 비용 자체가 필요 없습니다. 메모리 아레나는 커서만 앞으로 밀며 순차 할당하고, reset() 한 번으로 전체를 되돌립니다.
시나리오 3: 생성자·소멸자 자체가 무거울 때
객체 풀은 메모리를 얻는 비용을 줄여 주지만, acquire()에서 placement new로 생성자를 부르고 release()에서 소멸자를 부르는 구조라면 생성·소멸 비용은 그대로 남습니다. 객체가 내부에 std::vector나 std::string을 갖고 있으면 생성자 안에서 다시 힙 할당이 일어나므로 풀의 이득이 작아집니다. 이 경우는 객체를 소멸시키지 않고 살아 있는 채로 재사용하는 쪽이 맞습니다. 반환할 때 소멸자 대신 reset() 같은 멤버 함수로 상태만 초기화하고, 다음 acquire()에서 그 객체를 그대로 돌려주면 내부 버퍼의 capacity도 재사용됩니다. 아래 객체 풀은 “메모리 재사용”, 이 방식은 “객체 재사용”이라고 구분해 두면 어느 쪽이 필요한지 판단하기 쉽습니다.
시나리오 4: 스레드마다 사용량이 크게 다를 때
전역 힙 락 경합을 피하려고 스레드 로컬 풀을 적용했더니, 할당이 많은 스레드의 풀은 계속 커지고 한가한 스레드의 풀은 거의 비어 있는 채로 메모리를 붙잡고 있습니다. 스레드별 풀은 서로 메모리를 빌려줄 수 없기 때문입니다. 이때는 스레드 로컬 풀과 공유 상위 풀의 2단 구조를 씁니다. 평소에는 락 없는 로컬 풀에서 할당하고, 로컬 풀이 비면 락이 걸린 상위 풀에서 블록을 한 번에 여러 개(청크 단위) 가져옵니다. 로컬 풀이 일정량 이상 쌓이면 일부를 상위 풀로 돌려보냅니다. tcmalloc의 스레드 캐시와 중앙 free list가 이 구조입니다.
시나리오별 권장 패턴
| 시나리오 | 특징 | 권장 패턴 |
|---|---|---|
| malloc 병목 | 할당 횟수 과다 | 객체 풀, 고정 블록 풀 |
| 힙 단편화 | 장시간 실행 후 OOM | 풀 + 단일 큰 블록 |
| 스레드 경합 | 멀티스레드 확장성 | 스레드 로컬 풀 |
| 크기 다양 | 내부 단편화 우려 | 슬랩 할당자 |
| 순차 할당, 일괄 해제 | HTTP 요청, 프레임 | 메모리 아레나, std::pmr::monotonic_buffer_resource |
| 생성·소멸 비용이 큼 | 내부에 버퍼를 가진 객체 | 객체를 살려 두고 재사용하는 풀 |
| 스레드별 사용량 편차 | 워커마다 부하가 다름 | 스레드 로컬 풀 + 공유 상위 풀 |
객체 풀 (Object Pool)
핵심 아이디어
객체 풀은 특정 타입 T 전용 풀로, 메모리 할당뿐 아니라 객체 생성과 소멸까지 풀과 연동합니다. acquire()는 free list에서 꺼낸 빈 슬롯에 placement new로 객체를 만들어 돌려주고, release(obj)는 소멸자를 호출한 뒤 슬롯을 free list에 되돌립니다.
기본 객체 풀 구현
// 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()되지 않은 객체의 소멸자는 호출되지 않는다(아래 에러 5 참고).
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 바이트)별로 풀을 두고, 요청 크기를 담을 수 있는 가장 작은 클래스의 풀에서 할당합니다. 이름은 Solaris에서 시작되어 Linux 커널에도 쓰인 커널 객체 할당자(slab allocator)에서 왔습니다.
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에는 할당할 때와 같은 크기를 넘겨야 올바른 풀로 돌아갑니다. 크기를 잘못 넘기면 블록이 다른 크기 클래스의 free list에 섞여, 나중에 그 블록을 받은 쪽이 블록보다 큰 데이터를 쓰면서 이웃 블록을 덮어씁니다. 크기 없이 해제하고 싶다면 블록 앞에 크기 정보를 기록하거나, 청크 주소로 크기 클래스를 찾을 수 있게 청크를 클래스별로 정렬된 영역에 배치해야 합니다. get_class_index처럼 선형 탐색을 하는 부분은 클래스가 몇 개 안 되므로 문제가 되지 않지만, 클래스를 2의 거듭제곱으로 정했다면 비트 연산으로 바로 계산할 수도 있습니다.
메모리 아레나 (Memory Arena)
핵심 아이디어
메모리 아레나는 큰 버퍼를 한 번 확보한 뒤 커서를 앞으로만 밀며 순차 할당합니다. 개별 deallocate는 지원하지 않고, reset()으로 커서를 처음으로 되돌려 전체를 한꺼번에 해제합니다. 해제된 객체의 소멸자는 호출되지 않으므로, 소멸자가 하는 일이 있는 타입(파일 핸들, 힙을 쓰는 std::string 등)을 아레나에 올렸다면 reset() 전에 직접 소멸자를 불러야 합니다.
아레나 구현
// 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) {
// 오프셋이 아니라 실제 주소를 정렬한다 (아래 에러 6 참고)
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()는 상위 리소스에서 받은 메모리를 모두 돌려주는 쪽을 택하고 있습니다.
아레나를 함수 지역 고정 버퍼 위에 두는 변형을 흔히 “스택 할당자”라고 부릅니다. 이름의 “스택”은 메모리 위치보다 해제 규칙을 가리켜서, 맨 마지막에 할당한 블록이 해제될 때만 포인터를 되돌려 공간을 회수하고(LIFO) 나머지는 리셋 때까지 남겨 둡니다. 이 할당자를 표준 컨테이너에 꽂을 때 흔한 실수가 두 가지 있습니다. 첫째, template<class T, size_t N> class StackAllocator { alignas(T) char buf_[N * sizeof(T)]; ... };처럼 버퍼를 할당자 객체 안에 넣으면 컨테이너가 할당자를 값으로 복사·이동하는 순간 문제가 생깁니다. auto v2 = std::move(v);로 원소 포인터만 넘겨받은 v2는 v 안의 할당자 버퍼를 가리키다가 v가 소멸하면 댕글링이 되고, 복사본끼리 메모리를 공유하지 않으니 operator==도 의미 있게 정의할 수 없습니다. 그래서 뒤의 예제 4 SlabAllocatorAdapter처럼 버퍼(아레나)는 바깥에 두고 할당자는 포인터만 들게 하는 것이 정석이며(Howard Hinnant의 short_alloc이 대표적인 예), 표준의 std::pmr::polymorphic_allocator가 바로 이 구조입니다. 둘째, std::vector는 용량을 늘릴 때마다 새 버퍼를 할당하는데 bump 할당자는 옛 블록을 회수하지 못합니다. libstdc++처럼 용량을 1, 2, 4, 8, 16으로 두 배씩 늘리는 구현에서는 원소 10개를 넣는 데 버퍼를 31개 분량 소비합니다. bump 계열 할당자와 성장하는 컨테이너를 함께 쓸 때는 reserve()로 필요한 크기를 한 번에 잡는 것이 사실상 필수입니다.
크기가 런타임에 정해지는 임시 버퍼를 alloca나 VLA로 스택에 잡는 방법도 있지만, alloca는 비표준 확장이고 VLA는 C++ 표준이 아니며, 요청 크기가 남은 스택을 넘으면 bad_alloc 같은 알림 없이 세그멘테이션 폴트로 죽습니다(alloca 메모리는 함수가 반환할 때 풀리므로 루프 안에서 부르면 스택이 계속 자랍니다). 상한이 있으면 고정 크기 버퍼 위의 monotonic_buffer_resource를, 상한을 모르면 std::vector나 힙 폴백이 있는 아레나를 쓰는 편이 “버퍼가 모자라다”를 코드에서 처리할 기회라도 줍니다.
std::pmr 활용
C++17 표준 메모리 리소스
C++17의 std::pmr는 다형 메모리 리소스(polymorphic memory resource)를 제공합니다. std::pmr::vector, std::pmr::string, std::pmr::map 같은 컨테이너는 polymorphic_allocator를 쓰고, 이 할당자는 memory_resource* 포인터 하나만 들고 있다가 실제 할당을 그 리소스에 위임합니다. 표준이 제공하는 monotonic_buffer_resource(아레나), unsynchronized_pool_resource·synchronized_pool_resource(크기별 풀)를 쓰거나, memory_resource를 상속해 do_allocate, do_deallocate, do_is_equal을 구현한 리소스를 직접 꽂을 수 있습니다. 할당자 타입이 바뀌지 않으므로 리소스를 바꿔도 컨테이너 타입은 그대로입니다.
monotonic_buffer_resource (아레나)
// std::pmr::monotonic_buffer_resource 예제
// g++ -std=c++17 -O2 -o pmr_mono pmr_mono.cpp
#include <array>
#include <cstddef>
#include <functional>
#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::unsynchronized_pool_resource(단일 스레드)와 synchronized_pool_resource(스레드 안전)는 앞의 슬랩 할당자와 같은 아이디어로, 크기 클래스별 풀을 두고 상위 리소스에서 청크를 받아 나눠 줍니다. 조절할 수 있는 것은 std::pmr::pool_options의 max_blocks_per_chunk(청크당 최대 블록 수)와 largest_required_pool_block(풀로 처리할 최대 블록 크기, 이보다 크면 상위 리소스로 직접 위임) 정도이고, 크기 클래스 경계나 통계 수집, 디버그용 가드 바이트 같은 동작은 바꿀 수 없습니다. 그런 요구가 있을 때 직접 구현이 필요합니다. 여러 스레드가 같은 리소스를 쓰면 synchronized_pool_resource가 필요한데 락 비용이 생기므로, 스레드마다 unsynchronized_pool_resource를 따로 두는 구성도 검토해 볼 만합니다.
monotonic_buffer_resource의 deallocate는 아무것도 하지 않는다는 점도 기억해야 합니다. pmr::vector가 자라면서 재할당할 때마다 이전 버퍼가 회수되지 않고 쌓이므로, 요청이나 프레임처럼 짧은 스코프에서만 쓰고 스코프가 끝나면 리소스를 소멸시키거나 release()로 비웁니다. 크기를 알 수 있는 컨테이너는 reserve로 재할당 자체를 줄이고, 오래 사는 컨테이너에는 pool 계열 리소스를 씁니다.
커스텀 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 <utility>
#include <vector>
#include <iostream>
struct Entity {
int id; float x, y;
Entity() : id(0), x(0), y(0) {}
Entity(int id_, float x_, float y_) : id(id_), x(x_), y(y_) {}
};
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;
}
reset()은 남아 있는 객체의 소멸자를 부르지 않으므로, Entity처럼 소멸자가 하는 일이 없는(trivially destructible) 타입에만 써야 합니다. block_size를 생성자 인자로 받는 대신 sizeof(T)와 alignof(T)로 정하면 잘못된 크기를 넘기는 실수도 막을 수 있습니다.
예제 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 <algorithm>
#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, 잘못된 풀 반환, 정렬 무시 같은 실수
고정 블록 풀이라면 어디서나 생기는 실수(풀보다 오래 사는 객체, 다른 풀에 반환, 블록보다 큰 객체, 이중 해제)는 C++ 할당 전략 비교: 커스텀 할당자, 메모리 풀, 아레나, std::pmr, jemalloc·tcmalloc에 재현 코드와 함께 있습니다. 여기서는 슬랩·아레나·pmr·객체 풀 각각에서 생기는 문제만 다룹니다.
에러 1: 슬랩에서 잘못된 크기로 반환
deallocate(p, wrong_size)처럼 할당 때와 다른 크기를 넘기면 블록이 다른 크기 클래스의 풀로 들어갑니다. 64바이트 블록이 128바이트 풀에 섞이면, 다음에 그 블록을 128바이트로 받은 쪽이 이웃 블록을 덮어쓰고, 증상은 전혀 다른 곳에서 나타납니다. 호출자가 항상 같은 크기를 넘기도록 하거나, 블록에 크기를 기록해 두고 디버그 빌드에서 검사합니다.
에러 2: 아레나에서 개별 해제 기대
아레나는 reset()으로만 메모리를 되돌립니다. 오래 사는 객체 몇 개 때문에 reset()을 못 하면 아레나가 끝없이 자랍니다. 객체마다 수명이 다르다면 풀이나 슬랩이 맞고, 아레나에 개별 해제를 억지로 붙이면 결국 free list를 다시 만들게 됩니다.
에러 3: std::pmr 리소스 수명 문제
pmr 컨테이너는 메모리 리소스를 소유하지 않고 포인터만 들고 있으므로, 리소스가 먼저 소멸하면 컨테이너가 해제된 메모리를 계속 쓰게 됩니다.
// ❌ 잘못된 코드
std::pmr::vector<int>* createVector() {
std::pmr::monotonic_buffer_resource pool;
return new std::pmr::vector<int>(&pool); // pool은 함수 종료 시 소멸!
}
리소스는 그것을 쓰는 모든 컨테이너보다 오래 사는 곳에 둡니다. 둘을 한 클래스의 멤버로 묶는다면 리소스를 컨테이너보다 먼저 선언해야 나중에 소멸됩니다.
// ✅ 리소스를 컨테이너보다 먼저 선언 → 컨테이너가 먼저 소멸
struct RequestContext {
std::pmr::monotonic_buffer_resource arena;
std::pmr::vector<int> values{&arena};
};
에러 4: 스레드 안전성 오해
멀티스레드에서 간헐적으로 크래시가 난다면, 락 없는 전역 풀을 여러 스레드가 함께 쓰고 있는지 먼저 확인합니다. 앞의 FixedBlockPool은 free list 헤드를 아무 동기화 없이 읽고 쓰므로 두 스레드가 같은 블록을 받아 갈 수 있습니다.
// ❌ 잘못된 코드
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의 풀로 들어갑니다. 생산자·소비자 구조에서는 이렇게 메모리가 한쪽 풀에서 다른 쪽 풀로 계속 흘러가서, 생산자 풀은 매번 새 청크를 받고 소비자 풀은 끝없이 쌓입니다. 이런 구조라면 해제를 원래 스레드로 돌려보내거나(블록에 소유 풀 포인터를 기록), 시나리오 4의 공유 상위 풀로 넘기는 경로가 필요합니다. 또한 thread_local 풀은 스레드가 끝날 때 소멸하므로(청크를 해제하는 소멸자가 있다면), 그 스레드가 할당한 블록을 다른 스레드가 스레드 종료 뒤에도 쓰면 에러 3과 같은 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 설계
블록 크기는 그 풀이 받을 객체 크기의 최댓값에 맞춥니다. 크기가 들쭉날쭉하다면 가장 큰 크기로 블록을 잡아 작은 요청에서 낭비를 감수할지, 슬랩처럼 크기 클래스를 나눌지를 실제 할당 크기 분포를 보고 정합니다. 분포는 할당 지점에 카운터를 두거나 heaptrack 같은 도구로 확인할 수 있습니다.
정렬은 블록 크기와 청크 크기를 모두 정렬 단위의 배수로 맞춰야 보장됩니다. 청크 시작이 alignof(std::max_align_t)에 맞춰져 있어도 블록 크기가 그 배수가 아니면 두 번째 블록부터 정렬이 어긋납니다.
// 블록 크기를 max_align_t 정렬 단위의 배수로 올림
block_size_ = (block_size + alignof(std::max_align_t) - 1)
& ~(alignof(std::max_align_t) - 1);
블록보다 큰 요청이 들어올 수 있는 풀이라면 그런 요청은 전역 힙으로 넘기고, 해제할 때도 같은 기준(크기)으로 전역 힙에 돌려줘야 합니다. 할당과 해제의 판단 기준이 어긋나면 ::operator new로 받은 메모리가 풀의 free list에 들어가는 사고가 납니다.
void* allocate(std::size_t size) {
if (size > block_size_) return ::operator new(size); // fallback
return allocate();
}
void deallocate(void* p, std::size_t size) {
if (size > block_size_) { ::operator delete(p); return; }
deallocate(p);
}
객체 풀은 acquire()/release()를 사람이 짝지어 부르게 두지 말고, 에러 5의 PoolPtr처럼 커스텀 deleter를 가진 std::unique_ptr로 감싸 스코프가 끝나면 자동으로 반환되게 만듭니다. 소멸자만 가진 단순 래퍼 구조체를 직접 만들면 복사될 때 두 번 반환되는 문제가 생기므로, 복사를 막고 move만 허용하는 unique_ptr이 안전합니다.
마지막으로, 풀은 프로파일러로 할당이 병목이라는 것이 확인된 뒤에 도입하고, 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을 늘립니다. - 멀티스레드: 시나리오 4처럼 락 경합이 문제라면 단일 스레드 측정은 의미가 적습니다. 같은 루프를 스레드 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를 감싸 할당 횟수, 현재 사용 중인 블록 수, 최대 사용량을 세어 두면, 블록 크기와 청크 크기가 실제 부하에 맞는지 운영 중에 확인할 수 있습니다. 풀은 반환된 블록을 운영체제에 돌려주지 않으므로, 최대 사용량이 곧 그 프로세스가 계속 붙잡는 메모리라는 점을 함께 봐야 합니다.
패턴 3: 스레드 로컬 풀 + 상위 풀 폴백
시나리오 4에서 설명한 2단 구조입니다. 로컬 풀이 비면 std::mutex로 보호한 공유 풀에서 블록을 묶음으로 가져오고, 로컬 풀에 블록이 일정량 이상 쌓이면 일부를 공유 풀로 돌려보냅니다. 스레드별 사용량 편차가 클 때 락 경합을 줄이면서도 메모리가 한 스레드에 갇히지 않게 합니다.
같이 보면 좋은 글
이전 글: C++ I/O 병목 줄이기: cin·mmap·io_uring 성능 비교 다음 글: C++ 컨테이너 선택 가이드 | vector/list/deque/map/set 상황별 선택과 성능 최적화