C++ 현대적 메모리 관리: 커스텀 알로케이터 제작과 std::pmr 가이드
이 글의 핵심
힙 할당 비용을 줄이기 위한 메모리 풀과 C++17 std::pmr(polymorphic memory resources)로 재사용 가능한 버퍼·풀을 만드는 방법. 문제 시나리오, 완전한 예제, 흔한 에러, 성능 벤치마크, 프로덕션 패턴까지 실전 코드로 다룹니다.
들어가며: malloc/new가 병목일 때
”할당/해제가 너무 자주 일어난다”
15번·32번에서 성능과 메모리를 다뤘다면, 할당 자체가 프로파일에서 상위를 차지할 때가 있습니다. 작은 객체를 많이 할당/해제하면 힙 단편화(작은 빈 공간이 흩어져 큰 연속 메모리 할당이 어려워지는 현상)와 할당자 오버헤드가 누적됩니다.
메모리 풀(미리 할당한 큰 블록에서 조각을 나눠 주어 할당 횟수를 줄이는 방식)은 미리 큰 블록을 한 번 할당하고, 그 안에서 작은 조각을 나눠 주어 할당 횟수를 줄입니다. C++17 std::pmr(polymorphic memory resources)는 std::memory_resource를 기반으로 컨테이너에 주입 가능한 할당자를 제공해, 같은 타입의 컨테이너를 서로 다른 풀에 연결할 수 있게 합니다.
이 글에서 다루는 것:
- 문제 시나리오: 프로파일에서 malloc이 상위를 차지할 때
- 메모리 풀 개념: 블록 할당 → 고정 크기/가변 크기 슬롯 제공
- std::memory_resource와 std::pmr::polymorphic_allocator
- 완전한 커스텀 allocator 예제: 로깅, 통계, 스레드별 풀
- 흔한 에러와 해결법: 수명 관리, 리소스 혼용
- 성능 벤치마크: 기본 할당 vs monotonic vs pool
- 프로덕션 패턴: 프레임 풀, 요청 스코프, 게임 엔티티
개념을 잡는 비유
메모리 풀·PMR은 창고 칸을 미리 나눠 두고 필요할 때만 꺼내 쓰는 방식에 가깝습니다. 할당·해제 패턴이 고정돼 있으면 전역 new보다 예측 가능하고 캐시 친화적일 수 있습니다.
목차
- 문제 시나리오: malloc이 병목일 때
- 메모리 풀 개념
- std::pmr 개요
- monotonic_buffer_resource와 pool
- 완전한 커스텀 memory_resource 예제
- 자주 발생하는 에러와 해결법
- 성능 벤치마크
- 프로덕션 패턴
- 정리
1. 문제 시나리오: malloc이 병목일 때
실제 겪는 상황
"프로파일러에서 malloc/free가 전체 실행 시간의 30%를 차지해요."
"작은 객체를 수만 개 할당하다 보니 힙 단편화로 OOM이 나요."
"게임 프레임마다 엔티티를 생성/삭제하는데 프레임 드랍이 심해요."
"HTTP 요청 처리 시마다 파싱 결과를 할당하는데, 동시 요청이 많으면 지연이 커요."
원인 후보
- 할당 횟수 과다: 작은 객체를 반복 할당/해제 → malloc 오버헤드 누적
- 힙 단편화: 작은 빈 공간이 흩어져 큰 연속 메모리 할당 실패
- 캐시 미스: 할당된 메모리가 물리적으로 흩어져 캐시 효율 저하
- 락 경합: 멀티스레드에서 전역 힙 락 경합
메모리 풀은 1~3번을 완화하고, 스레드별 풀은 4번을 완화합니다.
시나리오별 해결 방향
| 시나리오 | 특징 | 권장 리소스 |
|---|---|---|
| 게임 프레임 | 매 프레임 생성/해제, 수명이 짧음 | monotonic_buffer_resource + release() |
| HTTP 요청 | 요청 단위로 할당, 요청 끝에 해제 | monotonic_buffer_resource |
| 노드 기반 자료구조 | 고정 크기 노드 반복 할당/해제 | synchronized_pool_resource |
| 장기 객체 | 수명이 길고 크기가 다양함 | 기본 힙 또는 pool_resource |
Before/After: HTTP 파서 예시
Before (기본 할당): 매 요청마다 vector, map, string이 힙에서 반복 할당됩니다.
// ❌ 기본 할당 — malloc 호출 다수
void handleRequest(const std::string& raw) {
std::vector<std::string> path_segments;
std::map<std::string, std::string> headers;
std::string body;
parsePath(raw, path_segments);
parseHeaders(raw, headers);
parseBody(raw, body);
process(path_segments, headers, body);
}
After (pmr 적용): 요청 스코프 풀에서 한 번에 할당하고, 함수 종료 시 풀과 함께 해제됩니다.
// ✅ pmr — 할당 횟수·단편화 감소
void handleRequest(const std::string& raw) {
std::pmr::monotonic_buffer_resource request_pool;
std::pmr::vector<std::pmr::string> path_segments(&request_pool);
std::pmr::map<std::pmr::string, std::pmr::string, std::less<>>
headers(&request_pool);
std::pmr::string body(&request_pool);
parsePath(raw, path_segments);
parseHeaders(raw, headers);
parseBody(raw, body);
process(path_segments, headers, body);
} // request_pool 소멸 → 모든 할당 일괄 해제
2. 메모리 풀 개념
할당 횟수를 줄이기
- 풀은 큰 메모리 블록을 한 번(또는 소수 번) 할당한 뒤, 그 안에서 고정 크기 또는 가변 크기 슬롯을 나눠 줍니다. 해제 시 풀에 반환만 하고, 실제 free는 풀 소멸 시 한 번만 합니다.
- 고정 크기 풀: 같은 크기의 객체만 할당할 때(노드 풀, 이벤트 풀 등) 단순하고 단편화가 적습니다.
- 가변 크기: 여러 크기를 지원하려면 슬롯 관리가 복잡해지므로, monotonic_buffer_resource처럼 “앞에서만 잘라 쓰고 해제는 안 하는” 방식이 구현이 쉽고, 프레임/요청 단위로 리셋하는 용도에 맞습니다.
메모리 풀 동작 흐름
flowchart TB
subgraph Heap["전역 힙"]
H[한 번 큰 블록 할당]
end
subgraph Pool["메모리 풀"]
B[블록]
B --> S1[슬롯 1]
B --> S2[슬롯 2]
B --> S3[슬롯 3]
end
H --> B
S1 --> A1[객체 A]
S2 --> A2[객체 B]
S3 --> A3[객체 C]
monotonic vs pool 비교
flowchart LR
subgraph Mono["monotonic_buffer_resource"]
M1[할당1] --> M2[할당2] --> M3[할당3]
M3 -.->|release 시 전체 반환| M1
end
subgraph Pool["pool_resource"]
P1[슬롯] <--> P2[슬롯]
P2 <--> P3[슬롯]
end
3. std::pmr 개요
Polymorphic Allocator
- std::pmr::memory_resource는 순수 가상 인터페이스: allocate, deallocate, is_equal만 정의합니다. 구현체(pool_resource, monotonic_buffer_resource 등)를 런타임에 선택할 수 있습니다.
- std::pmr::polymorphic_allocator<T>는 이 memory_resource를 가리키는 할당자로, std::vector<T, std::pmr::polymorphic_allocator<T>>처럼 컨테이너에 넘기면 해당 리소스에서 할당합니다.
- std::pmr::vector는 std::vector<T, std::pmr::polymorphic_allocator<T>>의 별칭입니다. 생성자에서 memory_resource*를 받아 그 풀을 사용합니다.
monotonic_buffer_resource에 스택 버퍼 buffer의 주소와 크기를 넘기면, 그 버퍼 안에서만 순차적으로 메모리를 잘라 씁니다. std::pmr::vector<int> v(&pool)로 이 풀을 쓰는 벡터를 만들면 push_back 시 buffer 안에서 할당이 일어나고, 힙 malloc이 호출되지 않습니다. 버퍼가 부족하면 업스트림 리소스(기본은 전역 힙)에서 추가로 가져옵니다.
#include <memory_resource>
#include <vector>
int main() {
char buffer[1024];
std::pmr::monotonic_buffer_resource pool{std::data(buffer), std::size(buffer)};
std::pmr::vector<int> v(&pool);
v.push_back(1);
v.push_back(2);
// v의 할당은 buffer 안에서 이루어짐
}
pmr 컨테이너와 리소스 연결
#include <memory_resource>
#include <vector>
#include <string>
#include <map>
int main() {
std::pmr::monotonic_buffer_resource pool;
// 같은 풀을 사용하는 여러 컨테이너
std::pmr::vector<int> nums(&pool);
std::pmr::vector<std::pmr::string> names(&pool);
std::pmr::map<std::pmr::string, int, std::less<>> scores(&pool);
nums.push_back(42);
names.push_back("Alice");
scores["Bob"] = 100;
// 모두 pool에서 할당
}
4. monotonic_buffer_resource와 pool
앞에서만 잘라 쓰기
- std::pmr::monotonic_buffer_resource: 주어진 버퍼(또는 업스트림 리소스)에서 순차적으로 메모리를 잘라 씁니다. deallocate는 no-op이라, 개별 해제는 하지 않고 리셋으로 전체를 되돌립니다. 프레임 버퍼, 요청 스코프용으로 적합합니다.
- std::pmr::synchronized_pool_resource / unsynchronized_pool_resource: 고정 크기 블록 풀을 관리하며, 해제한 블록을 재사용합니다. pool_options로 블록 크기·최대 블록 수 등을 조정할 수 있습니다.
- 커스텀 memory_resource를 상속해 구현하면, 프로젝트별 정책(로깅, 통계, 스레드별 풀 등)을 넣을 수 있습니다.
frame_pool은 프레임마다 entities 등이 그 풀에서 할당받고, 프레임이 끝나면 release()로 풀을 비워 다음 프레임에서 같은 버퍼를 처음부터 다시 씁니다. deallocate는 호출해도 아무 일도 하지 않고, release() 한 번으로 “이 프레임에서 쓴 메모리 전부 반환”하는 패턴이라 게임·요청 단위 스코프에 맞습니다.
// 프레임 한 번에 사용 후 리셋
std::pmr::monotonic_buffer_resource frame_pool;
std::pmr::vector<Entity> entities(&frame_pool);
// ... 프레임 로직 ...
frame_pool.release(); // 다음 프레임에서 재사용
스택 버퍼 + monotonic (힙 할당 제로)
#include <memory_resource>
#include <vector>
#include <array>
void processRequest() {
// 스택에 64KB 버퍼 할당 — 힙 사용 없음
std::array<std::byte, 65536> stack_buffer;
std::pmr::monotonic_buffer_resource pool{
stack_buffer.data(), stack_buffer.size(),
std::pmr::new_delete_resource() // 오버플로 시 힙 사용
};
std::pmr::vector<int> ids(&pool);
std::pmr::vector<std::pmr::string> tokens(&pool);
// 요청 처리...
for (int i = 0; i < 1000; ++i) {
ids.push_back(i);
tokens.push_back("token");
}
// 함수 종료 시 stack_buffer와 pool 자동 해제
}
pool_options로 블록 크기 조정
#include <memory_resource>
int main() {
std::pmr::pool_options opts;
opts.max_blocks_per_chunk = 32; // 청크당 최대 블록 수
opts.largest_required_pool_block = 256; // 최대 블록 크기
std::pmr::synchronized_pool_resource pool{opts};
// 256바이트 이하 객체에 적합, 멀티스레드 안전
}
std::pmr 아키텍처 다이어그램
flowchart TB
subgraph Container["pmr 컨테이너"]
V["std pmr vector"]
M["std pmr map"]
end
subgraph Alloc["polymorphic_allocator"]
PA[memory_resource*]
end
subgraph Resources["memory_resource 구현체"]
MONO[monotonic_buffer_resource]
POOL[synchronized_pool_resource]
CUSTOM[커스텀 리소스]
end
subgraph Backend["백엔드"]
HEAP[전역 힙]
BUF[스택/정적 버퍼]
end
V --> PA
M --> PA
PA --> MONO
PA --> POOL
PA --> CUSTOM
MONO --> BUF
POOL --> HEAP
CUSTOM --> HEAP
5. 완전한 커스텀 memory_resource 예제
예제 1: 로깅 memory_resource
할당/해제를 로깅하는 디버깅용 리소스입니다.
#include <memory_resource>
#include <iostream>
#include <cstddef>
class logging_memory_resource : public std::pmr::memory_resource {
public:
explicit logging_memory_resource(std::pmr::memory_resource* upstream
= std::pmr::get_default_resource())
: upstream_(upstream) {}
private:
void* do_allocate(std::size_t bytes, std::size_t alignment) override {
void* p = upstream_->allocate(bytes, alignment);
std::cout << "[alloc] " << bytes << " bytes, align " << alignment
<< " -> " << p << "\n";
return p;
}
void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
std::cout << "[dealloc] " << bytes << " bytes @ " << p << "\n";
upstream_->deallocate(p, bytes, alignment);
}
[[nodiscard]] bool do_is_equal(
const std::pmr::memory_resource& other) const noexcept override {
return this == &other;
}
std::pmr::memory_resource* upstream_;
};
예제 2: 통계 수집 memory_resource
할당 횟수, 총 바이트, 피크 사용량을 추적합니다.
#include <memory_resource>
#include <atomic>
#include <cstddef>
class stats_memory_resource : public std::pmr::memory_resource {
public:
explicit stats_memory_resource(std::pmr::memory_resource* upstream
= std::pmr::get_default_resource())
: upstream_(upstream) {}
std::size_t allocation_count() const noexcept { return alloc_count_.load(); }
std::size_t total_allocated() const noexcept { return total_allocated_.load(); }
std::size_t peak_allocated() const noexcept { return peak_allocated_.load(); }
private:
void* do_allocate(std::size_t bytes, std::size_t alignment) override {
void* p = upstream_->allocate(bytes, alignment);
alloc_count_.fetch_add(1);
std::size_t prev = total_allocated_.fetch_add(bytes);
std::size_t current = prev + bytes;
for (std::size_t peak = peak_allocated_.load();
current > peak && !peak_allocated_.compare_exchange_weak(peak, current);
peak = peak_allocated_.load()) {}
return p;
}
void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
total_allocated_.fetch_sub(bytes);
upstream_->deallocate(p, bytes, alignment);
}
[[nodiscard]] bool do_is_equal(
const std::pmr::memory_resource& other) const noexcept override {
return this == &other;
}
std::pmr::memory_resource* upstream_;
std::atomic<std::size_t> alloc_count_{0};
std::atomic<std::size_t> total_allocated_{0};
std::atomic<std::size_t> peak_allocated_{0};
};
예제 3: 스레드별 monotonic 풀
스레드 로컬 풀로 락 경합을 제거합니다.
#include <memory_resource>
#include <vector>
#include <thread>
thread_local std::pmr::monotonic_buffer_resource* tls_pool = nullptr;
void initThreadPool() {
tls_pool = new std::pmr::monotonic_buffer_resource(
std::pmr::new_delete_resource());
}
void cleanupThreadPool() {
delete tls_pool;
tls_pool = nullptr;
}
std::pmr::memory_resource* getThreadPool() {
if (!tls_pool) initThreadPool();
return tls_pool;
}
void worker(int id) {
std::pmr::vector<int> local_data(getThreadPool());
for (int i = 0; i < 1000; ++i) {
local_data.push_back(i * id);
}
// 스레드 종료 시 tls_pool 해제 (실제로는 스레드 풀 라이프사이클에 맞춤)
}
예제 4: 고정 크기 블록 풀 (단순 구현)
같은 크기 객체만 할당하는 최소 구현입니다.
#include <memory_resource>
#include <vector>
#include <cstddef>
class fixed_block_pool : public std::pmr::memory_resource {
public:
explicit fixed_block_pool(std::size_t block_size, std::size_t block_count = 64)
: block_size_(block_size), blocks_(block_count) {
storage_.resize(block_size * block_count);
for (std::size_t i = 0; i < block_count; ++i) {
free_list_.push_back(storage_.data() + i * block_size);
}
}
private:
void* do_allocate(std::size_t bytes, std::size_t alignment) override {
if (bytes > block_size_) {
// 업스트림으로 위임 (또는 예외)
return nullptr;
}
if (free_list_.empty()) {
return nullptr; // 또는 확장
}
void* p = free_list_.back();
free_list_.pop_back();
return p;
}
void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
if (p >= storage_.data() && p < storage_.data() + storage_.size()) {
free_list_.push_back(static_cast<std::byte*>(p));
}
}
[[nodiscard]] bool do_is_equal(
const std::pmr::memory_resource& other) const noexcept override {
return this == &other;
}
std::size_t block_size_;
std::vector<std::byte> storage_;
std::vector<std::byte*> blocks_;
std::vector<std::byte*> free_list_;
};
6. 자주 발생하는 에러와 해결법
에러 1: 풀 수명보다 컨테이너가 오래 살 때 (Use-After-Free)
증상: 크래시, 정의되지 않은 동작, 힙 손상.
원인: memory_resource가 먼저 소멸되었는데, 그 리소스를 사용하는 컨테이너가 아직 살아 있을 때.
// ❌ 잘못된 코드
std::pmr::vector<int>* createVector() {
std::pmr::monotonic_buffer_resource pool;
return new std::pmr::vector<int>(&pool); // pool은 함수 종료 시 소멸!
}
// 반환된 vector는 이미 해제된 pool을 가리킴 → UB
해결법:
// ✅ 올바른 코드: 풀 수명을 컨테이너보다 길게
std::pmr::monotonic_buffer_resource* pool = new std::pmr::monotonic_buffer_resource;
std::pmr::vector<int>* vec = new std::pmr::vector<int>(pool);
// vec 소멸 전에 pool을 소멸하지 않도록 수명 관리
또는 풀과 컨테이너를 같은 스코프에 두기:
// ✅ 풀과 컨테이너 수명 일치
void process() {
std::pmr::monotonic_buffer_resource pool;
std::pmr::vector<int> vec(&pool);
// ... 사용 ...
} // vec, pool 순서대로 소멸
에러 2: monotonic에서 deallocate 기대
증상: 메모리가 해제되지 않고 계속 쌓임.
원인: monotonic_buffer_resource의 deallocate는 no-op입니다. 개별 해제를 하지 않습니다.
// ❌ 잘못된 기대
std::pmr::monotonic_buffer_resource pool;
std::pmr::vector<int> v(&pool);
v.push_back(1);
v.pop_back(); // 내부적으로 deallocate 호출되지만, pool은 해제하지 않음
// release() 호출 전까지 메모리는 풀에 남아 있음
해결법: monotonic은 release()로 전체 리셋하는 용도로만 사용. 개별 해제가 필요하면 synchronized_pool_resource 사용.
// ✅ monotonic: 스코프 끝에 release
{
std::pmr::monotonic_buffer_resource pool;
std::pmr::vector<int> v(&pool);
v.push_back(1);
v.pop_back();
pool.release(); // 이 시점에 풀 전체 리셋
}
에러 3: 서로 다른 리소스에서 할당한 컨테이너끼리 복사/이동
증상: 크래시 또는 메모리 손상.
원인: std::pmr::vector A가 리소스 R1에서 할당했는데, B(R2 사용)에 복사하면 A의 원소가 R2로 복사됩니다. 그 후 A가 소멸되면 R1에서 해제하고, B가 소멸되면 R2에서 해제하는데, B가 가진 데이터는 원래 R1에서 왔으므로 R2의 deallocate에 잘못된 포인터를 넘길 수 있습니다.
// ❌ 위험한 코드
std::pmr::monotonic_buffer_resource pool1, pool2;
std::pmr::vector<int> a(&pool1);
a.push_back(42);
std::pmr::vector<int> b(&pool2);
b = a; // a의 allocator가 b에 복사됨. b는 pool2를 쓰지만 데이터는 pool1에서 옴
// b 소멸 시 pool2.deallocate에 pool1의 포인터 전달 → UB
해결법: 같은 리소스를 쓰는 컨테이너끼리만 복사/이동하거나, 복사 시 대상이 자신의 리소스에서 새로 할당하도록 합니다. std::pmr::polymorphic_allocator는 복사 시 리소스 포인터를 복사하므로, 서로 다른 풀을 쓰는 컨테이너 간 대입은 주의해야 합니다.
// ✅ 같은 풀 사용
std::pmr::monotonic_buffer_resource pool;
std::pmr::vector<int> a(&pool);
std::pmr::vector<int> b(&pool);
a.push_back(42);
b = a; // 둘 다 pool 사용 → 안전
에러 4: 스택 버퍼 크기 부족
증상: monotonic이 업스트림(힙)에서 추가 할당. 스택 전용으로 쓰려 했는데 힙이 사용됨.
원인: 스택 버퍼가 작아서 monotonic_buffer_resource가 업스트림 리소스에서 추가 메모리를 가져옴.
// ❌ 버퍼가 너무 작을 수 있음
char buffer[256];
std::pmr::monotonic_buffer_resource pool{buffer, sizeof(buffer)};
std::pmr::vector<int> v(&pool);
for (int i = 0; i < 1000; ++i) v.push_back(i); // 256바이트 초과 → 힙 사용
해결법: 버퍼 크기를 넉넉히 잡거나, 업스트림을 nullptr로 두어 오버플로 시 예외를 던지게 할 수 있습니다(C++17에서 nullptr 업스트림 동작은 구현 정의). 또는 사용량을 프로파일링해 적절한 크기로 조정합니다.
// ✅ 넉넉한 버퍼 또는 업스트림 명시
std::array<std::byte, 65536> buffer;
std::pmr::monotonic_buffer_resource pool{
buffer.data(), buffer.size(),
std::pmr::new_delete_resource()
};
에러 5: pool_options 부적절한 설정
증상: synchronized_pool_resource에서 메모리 낭비 또는 할당 실패.
원인: largest_required_pool_block보다 큰 할당 요청, 또는 max_blocks_per_chunk가 너무 작아 청크를 너무 자주 할당.
해결법: 실제 사용하는 최대 블록 크기에 맞춰 pool_options를 설정하고, 블록 수를 프로파일링으로 조정합니다.
// ✅ 사용 패턴에 맞는 설정
std::pmr::pool_options opts;
opts.largest_required_pool_block = 64; // 주로 64바이트 이하 객체
opts.max_blocks_per_chunk = 128;
std::pmr::synchronized_pool_resource pool{opts};
에러 6: alignment 무시
증상: 특정 플랫폼에서 크래시 또는 SIGBUS.
원인: do_allocate에서 요청된 alignment를 지키지 않고 메모리 반환. SIMD 타입(__m256 등)이나 over-aligned 타입은 32, 64바이트 정렬이 필요할 수 있습니다.
// ❌ 잘못된 구현 (alignment 무시)
void* do_allocate(std::size_t bytes, std::size_t alignment) override {
return upstream_->allocate(bytes, 1); // alignment 무시!
}
해결법: 항상 요청된 alignment를 전달하고, 직접 구현 시 std::align 사용.
// ✅ alignment 준수
void* do_allocate(std::size_t bytes, std::size_t alignment) override {
return upstream_->allocate(bytes, alignment);
}
에러 7: 스레드 안전성 오해
증상: 멀티스레드에서 크래시 또는 데이터 손상.
원인: unsynchronized_pool_resource는 스레드 안전하지 않습니다. 여러 스레드가 같은 인스턴스를 공유하면 락 없이 동시 접근이 발생합니다.
// ❌ 위험: 여러 스레드가 같은 unsynchronized_pool 공유
std::pmr::unsynchronized_pool_resource pool;
std::thread t1([&] { std::pmr::vector<int> v(&pool); /* ... */ });
std::thread t2([&] { std::pmr::vector<int> v(&pool); /* ... */ });
해결법: 멀티스레드에서 공유할 풀은 synchronized_pool_resource 사용, 또는 스레드별 풀 사용.
// ✅ synchronized_pool_resource (멀티스레드 안전)
std::pmr::synchronized_pool_resource pool;
7. 성능 벤치마크
벤치마크 1: 기본 할당 vs monotonic vs pool
#include <chrono>
#include <memory_resource>
#include <vector>
#include <iostream>
void benchmarkAllocators() {
constexpr size_t N = 100000;
constexpr size_t elem_size = 32;
// 1. 기본 힙 할당
auto t1 = std::chrono::high_resolution_clock::now();
{
std::vector<int> v;
v.reserve(N);
for (size_t i = 0; i < N; ++i) v.push_back(static_cast<int>(i));
}
auto t2 = std::chrono::high_resolution_clock::now();
// 2. monotonic_buffer_resource (한 번 큰 버퍼)
std::vector<std::byte> buffer(N * elem_size * 2); // 넉넉히
std::pmr::monotonic_buffer_resource mono{
buffer.data(), buffer.size(),
std::pmr::new_delete_resource()
};
auto t3 = std::chrono::high_resolution_clock::now();
{
std::pmr::vector<int> v(&mono);
v.reserve(N);
for (size_t i = 0; i < N; ++i) v.push_back(static_cast<int>(i));
}
auto t4 = std::chrono::high_resolution_clock::now();
// 3. synchronized_pool_resource
std::pmr::synchronized_pool_resource pool;
auto t5 = std::chrono::high_resolution_clock::now();
{
std::pmr::vector<int> v(&pool);
v.reserve(N);
for (size_t i = 0; i < N; ++i) v.push_back(static_cast<int>(i));
}
auto t6 = std::chrono::high_resolution_clock::now();
using namespace std::chrono;
auto d_default = duration_cast<microseconds>(t2 - t1).count();
auto d_mono = duration_cast<microseconds>(t4 - t3).count();
auto d_pool = duration_cast<microseconds>(t6 - t5).count();
std::cout << "Default: " << d_default << " μs\n";
std::cout << "Monotonic: " << d_mono << " μs (" << (double)d_default/d_mono << "x)\n";
std::cout << "Pool: " << d_pool << " μs (" << (double)d_default/d_pool << "x)\n";
}
예상 결과 (환경에 따라 다름):
| 할당자 | 10만 push_back (μs) | 상대 속도 |
|---|---|---|
| 기본 vector | 2000~5000 | 1x |
| monotonic | 500~1500 | 2~4x |
| synchronized_pool | 800~2000 | 1.5~3x |
monotonic이 가장 빠른 경우가 많습니다. 할당이 순차적이고 해제가 없기 때문입니다.
벤치마크 2: 반복 할당/해제 (노드 풀 시나리오)
// 링크드 리스트 노드 10만 개 반복 할당/해제
struct Node {
int value;
Node* next;
};
void benchmarkNodePool() {
constexpr size_t iterations = 100000;
// 기본 힙
auto t1 = std::chrono::high_resolution_clock::now();
for (size_t i = 0; i < iterations; ++i) {
Node* head = nullptr;
for (size_t j = 0; j < 10; ++j) {
Node* n = new Node{static_cast<int>(j), head};
head = n;
}
while (head) {
Node* next = head->next;
delete head;
head = next;
}
}
auto t2 = std::chrono::high_resolution_clock::now();
// pool_resource
std::pmr::synchronized_pool_resource pool;
auto t3 = std::chrono::high_resolution_clock::now();
for (size_t i = 0; i < iterations; ++i) {
std::pmr::vector<Node> nodes(&pool);
for (size_t j = 0; j < 10; ++j) {
nodes.push_back(Node{static_cast<int>(j), nullptr});
}
// vector 소멸 시 pool에 반환
}
auto t4 = std::chrono::high_resolution_clock::now();
using namespace std::chrono;
std::cout << "Heap: " << duration_cast<microseconds>(t2-t1).count() << " μs\n";
std::cout << "Pool: " << duration_cast<microseconds>(t4-t3).count() << " μs\n";
}
예상: pool 사용 시 2~5배 정도 빠를 수 있습니다. (실제로 Node를 vector에 넣는 방식은 구조가 다르므로, 노드 기반 자료구조라면 polymorphic_allocator<Node>를 쓰는 커스텀 링크드 리스트로 측정하는 것이 더 정확합니다.)
벤치마크 요약 표
| 시나리오 | 기본 힙 | monotonic | pool_resource | 비고 |
|---|---|---|---|---|
| 순차 push_back (해제 없음) | 1x | 2~4x | 1.5~3x | monotonic 유리 |
| 반복 할당/해제 (고정 크기) | 1x | 부적합 | 2~5x | pool 유리 |
| 스레드 경합 많음 | 1x | 스레드별 사용 시 유리 | 동기화 오버헤드 | 스레드별 monotonic |
8. 프로덕션 패턴
패턴 1: 게임 프레임 풀
매 프레임 엔티티, 컴포넌트를 할당하고 프레임 끝에 일괄 해제합니다. 권장 패턴: 매 프레임 monotonic_buffer_resource를 같은 스택 버퍼로 새로 만들고, 프레임 끝에 스코프를 벗어나 자동 소멸되게 합니다.
void gameLoop() {
std::array<std::byte, 1024*1024> frame_buffer;
while (running) {
std::pmr::monotonic_buffer_resource frame_pool{
frame_buffer.data(), frame_buffer.size(),
std::pmr::new_delete_resource() // 오버플로 시 힙에서 추가
};
std::pmr::vector<Entity> entities(&frame_pool);
std::pmr::vector<Component> components(&frame_pool);
// ... 엔티티 생성, 프레임 로직 ...
} // frame_pool, entities 소멸 → 다음 프레임에서 같은 buffer 재사용
}
release() 사용 패턴: 같은 monotonic을 여러 프레임에 재사용하려면 release()를 호출합니다. 단, release() 후에는 이전에 할당한 모든 포인터가 무효화되므로, 해당 포인터를 쓰는 객체는 반드시 소멸되었어야 합니다.
void gameLoopWithRelease() {
std::array<std::byte, 1024*1024> frame_buffer;
std::pmr::monotonic_buffer_resource frame_pool{
frame_buffer.data(), frame_buffer.size(),
std::pmr::new_delete_resource()
};
while (running) {
frame_pool.release(); // 이전 프레임 메모리 리셋
std::pmr::vector<Entity> entities(&frame_pool);
// ... 프레임 로직 ...
} // 마지막 프레임 entities만 유효, 그 전엔 release()로 정리됨
}
패턴 2: HTTP 요청 스코프
요청 처리 함수 진입 시 풀 생성, 요청 처리 후 풀 소멸.
void handleRequest(const Request& req) {
std::pmr::monotonic_buffer_resource request_pool(
std::pmr::new_delete_resource());
std::pmr::vector<std::pmr::string> path_segments(&request_pool);
std::pmr::map<std::pmr::string, std::pmr::string, std::less<>>
headers(&request_pool);
parsePath(req.uri, path_segments);
parseHeaders(req.raw_headers, headers);
Response response = processRequest(path_segments, headers);
sendResponse(response);
} // request_pool 소멸 → 모든 할당 해제
패턴 3: 스레드 풀 워커별 풀
각 워커 스레드가 자신만의 풀을 가지면 락 경합이 줄어듭니다.
class Worker {
std::pmr::monotonic_buffer_resource worker_pool_;
std::pmr::vector<Task> local_queue_{&worker_pool_};
public:
Worker() : worker_pool_(std::pmr::new_delete_resource()) {}
void process(Task t) {
worker_pool_.release(); // 이전 작업 잔여 메모리 리셋
local_queue_.clear();
// t 처리 시 local_queue_ 등에서 할당
}
};
패턴 4: 계층적 리소스 (업스트림)
한 풀에서 부족하면 상위 풀에서 가져오는 구조입니다.
// 전역 풀 → 스레드 풀 → 프레임 풀
std::pmr::synchronized_pool_resource global_pool;
std::pmr::monotonic_buffer_resource thread_pool{&global_pool};
std::pmr::monotonic_buffer_resource frame_pool{&thread_pool};
std::pmr::vector<int> frame_data(&frame_pool);
// frame_pool 부족 → thread_pool → global_pool
패턴 5: 구현 체크리스트
- 풀 수명이 이를 사용하는 모든 컨테이너보다 길다
- monotonic 사용 시
release()호출 시점 명확히 (프레임/요청 끝) - 서로 다른 풀을 쓰는 pmr 컨테이너 간 복사/대입 금지
- 스택 버퍼 사용 시 크기 여유 있게
- pool_options는 실제 사용 패턴에 맞게
- 멀티스레드 시
synchronized_pool_resource또는 스레드별 풀
패턴 6: 점진적 도입 전략
기존 코드에 pmr를 도입할 때는 한 번에 바꾸지 말고, 병목이 확인된 경로부터 적용합니다.
// 1단계: 프로파일링으로 병목 확인
// perf, VTune, 또는 malloc 호출 횟수 로깅
// 2단계: 해당 함수/스코프에만 풀 도입
void processRequest(const Request& req) {
std::pmr::monotonic_buffer_resource pool;
std::pmr::vector<std::pmr::string> tokens(&pool);
// 기존 로직을 tokens 등 pmr 컨테이너로 점진적 교체
}
// 3단계: 성능 측정 후 확대 적용
패턴 7: 디버그 빌드에서 통계 리소스 사용
프로덕션에서는 기본 리소스를 쓰고, 디버그 빌드에서만 stats_memory_resource로 감싸 할당 횟수·피크를 확인합니다.
#ifdef NDEBUG
std::pmr::memory_resource* resource = std::pmr::get_default_resource();
#else
static stats_memory_resource stats{std::pmr::get_default_resource()};
std::pmr::memory_resource* resource = &stats;
#endif
std::pmr::vector<int> data(resource);
// ... 처리 ...
#ifndef NDEBUG
std::cout << "Allocations: " << stats.allocation_count()
<< ", Peak: " << stats.peak_allocated() << " bytes\n";
#endif
9. 정리
| 주제 | 요약 |
|---|---|
| 메모리 풀 | 큰 블록 한 번 할당 후 슬롯 나눠 쓰기 — 할당 횟수·단편화 감소 |
| std::pmr | memory_resource + polymorphic_allocator로 컨테이너에 풀 주입 |
| monotonic | 순차 할당·리셋만 — 프레임/요청 스코프에 적합 |
| pool_resource | 고정 크기 블록 재사용 — 일반 객체 풀에 적합 |
| 커스텀 리소스 | memory_resource 상속으로 로깅, 통계, 스레드별 풀 구현 |
핵심 원칙:
- 풀 수명 > 컨테이너 수명
- monotonic은
release()로만 해제 - 서로 다른 풀의 컨테이너 간 복사/대입 주의
- 프로파일링으로 실제 이득 확인 후 적용
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ std::pmr 완벽 가이드 | Polymorphic Memory Resources로 메모리 풀
- C++ 메모리 풀 완벽 가이드 | 객체 풀·슬랩·아레나·std::pmr 실전 [#32-2]
- C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가?
이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
이 글에서 다루는 키워드 (관련 검색어)
커스텀 할당자, pmr, 메모리 풀, std::memory_resource, polymorphic_allocator, monotonic_buffer_resource, synchronized_pool_resource 등으로 검색하시면 이 글이 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 프로파일러에서 malloc/free가 상위를 차지할 때, 게임 프레임·HTTP 요청처럼 짧은 수명의 객체를 많이 할당할 때, 노드 기반 자료구조에서 고정 크기 할당이 반복될 때 적용하면 됩니다. 위 문제 시나리오와 선택 가이드를 참고하세요.
Q. monotonic과 pool_resource 중 뭘 써야 하나요?
A. 프레임/요청처럼 “한 번에 할당하고 끝에 한 번에 해제”하는 패턴이면 monotonic. 개별 객체를 반복 할당/해제하는 패턴이면 pool_resource가 적합합니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference std::pmr와 P0339R6 문서를 참고하세요.
한 줄 요약: std::pmr·커스텀 알로케이터로 메모리 풀·단편화를 제어할 수 있습니다. 다음으로 SIMD·인트린직(#39-3)를 읽어보면 좋습니다.
이전 글: 고성능 C++ #39-1: 캐시·데이터 지향 설계
다음 글: [고성능 C++ #39-3] SIMD와 병렬화: std::execution과 인트린직(Intrinsics)을 이용한 연산 가속
관련 글
- C++ std::pmr 완벽 가이드 | Polymorphic Memory Resources로 메모리 풀
- C++ 캐시 효율적인 코드: 데이터 지향 설계 가이드
- C++ std::chrono 완벽 가이드 | duration·time_point·클럭·시간 측정 실전 활용
- C++ SIMD와 병렬화: std::execution과 인트린직 가이드
- C++ 메모리 관리 완벽 가이드 | 할당자·풀·아레나·프로덕션 패턴 [#55-5]