- Published on
C++23 PMR로 allocator 지옥 탈출 - 메모리 풀 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 게임, HFT 같은 저지연 C++ 코드에서 성능 문제를 파고들다 보면 결국 메모리 할당으로 돌아오는 경우가 많습니다. new/delete 호출이 많거나, std::vector/std::string이 여기저기 흩어진 힙에서 조각화(fragmentation)를 만들거나, allocator를 타입마다 다르게 주입하다가 코드가 급격히 복잡해지는 상황이 대표적입니다. 흔히 말하는 “allocator 지옥”이죠.
C++17부터 도입된 PMR(Polymorphic Memory Resource)과 C++23의 표준 라이브러리 개선을 활용하면, 할당 정책을 타입이 아니라 “리소스”로 분리해서 코드 복잡도를 크게 낮추고, 메모리 풀을 안전하게 설계할 수 있습니다. 이 글에서는 std::pmr의 핵심 개념, 모노토닉 풀로 빠르게 할당하는 패턴, 풀 리소스(unsynchronized_pool_resource)로 재사용을 극대화하는 패턴, 그리고 실전에서 자주 터지는 함정(수명/스레드/업스트림)을 정리합니다.
성능 최적화가 결국 장애 대응과 맞닿는 경우도 많습니다. 예를 들어 지연이 튀면 재시작/타임아웃/스케줄링 이슈로 이어질 수 있는데, 그런 관점의 운영 트러블슈팅은 systemd 서비스가 계속 재시작될 때 원인 추적법 같은 글과 함께 보면 흐름이 잘 이어집니다.
allocator 지옥의 전형적인 증상
다음 중 2개 이상 해당되면 PMR을 고려할 타이밍입니다.
- 컨테이너마다 allocator 템플릿 인자를 달기 시작했는데 타입이 폭발함
- 요청 단위로 생성되는 객체가 많고, 요청이 끝나면 한 번에 메모리를 버리고 싶음
- 작은 객체가 많아
malloc/free호출 오버헤드가 눈에 띔 - 힙 조각화로 RSS가 늘고, 성능이 시간에 따라 악화됨
- 성능을 위해 커스텀 풀을 만들었는데 예외/수명/스레드에서 버그가 남
PMR은 “컨테이너가 어떤 allocator 타입을 쓰는가”를 “컨테이너가 어떤 memory_resource를 바라보는가”로 바꿉니다. 즉, 타입 시스템에 allocator를 박아 넣지 않고 런타임에 리소스를 교체할 수 있습니다.
PMR 핵심 개념 3가지
1) std::pmr::memory_resource
PMR의 추상 인터페이스입니다. do_allocate/do_deallocate/do_is_equal을 구현해 “어떻게 할당할지”를 정의합니다.
- 장점: 리소스는 포인터로 전달되므로 타입 폭발이 없음
- 단점: 가상 호출 비용이 있지만, 대개 할당 비용에 비하면 작거나 상쇄됨
2) std::pmr::polymorphic_allocator
memory_resource를 감싸서 표준 allocator 인터페이스로 변환합니다.
3) PMR 컨테이너
std::pmr::vector, std::pmr::string, std::pmr::unordered_map 같은 별칭들이 제공됩니다. 이들은 내부적으로 polymorphic_allocator를 사용합니다.
핵심은 한 번 memory_resource*만 잘 넘기면, 그 리소스 아래에서 컨테이너들이 일관된 정책으로 할당한다는 점입니다.
가장 많이 쓰는 빌딩 블록: 모노토닉 + 버퍼
요청 단위(프레임 단위)로 객체를 만들고, 끝나면 전부 버리는 워크로드에서 std::pmr::monotonic_buffer_resource는 거의 치트키입니다.
- 할당: 포인터를 앞으로만 이동(매우 빠름)
- 해제: 개별 해제 없음(리소스 파괴 또는
release()로 한 번에) - 단점: 중간에 해제해도 메모리 회수 불가
예제: 요청 스코프 아레나(arena)
아래 코드는 요청 처리 동안 생성되는 문자열/벡터가 모두 같은 아레나에서 할당되도록 만듭니다.
#include <array>
#include <cstddef>
#include <iostream>
#include <memory_resource>
#include <string>
#include <vector>
struct RequestArena {
// 스택 버퍼를 1차로 쓰고, 부족하면 업스트림(기본 new/delete)로 확장
std::array<std::byte, 64 * 1024> buffer;
std::pmr::monotonic_buffer_resource mbr;
RequestArena()
: mbr(buffer.data(), buffer.size(), std::pmr::new_delete_resource()) {}
std::pmr::memory_resource* resource() { return &mbr; }
void reset() { mbr.release(); }
};
static void handle_request(RequestArena& arena) {
std::pmr::vector<std::pmr::string> logs{arena.resource()};
for (int i = 0; i < 1000; ++i) {
std::pmr::string s{arena.resource()};
s = "event:" + std::to_string(i);
logs.push_back(std::move(s));
}
// 요청 끝나면 logs가 파괴되지만, 메모리는 아레나에 남아있음
}
int main() {
RequestArena arena;
for (int r = 0; r < 100; ++r) {
handle_request(arena);
arena.reset(); // 한 번에 회수
}
}
이 패턴의 효과는 다음과 같습니다.
- 요청 동안
malloc호출이 크게 줄어듦(스택 버퍼에서 대부분 처리) - 요청 종료 시
release()한 번으로 회수 - 컨테이너마다 allocator 템플릿을 달 필요 없이
resource()만 넘김
함정: monotonic_buffer_resource는 “진짜 해제”가 아님
- 컨테이너가
clear()를 해도 메모리는 돌아오지 않습니다. - 장수 객체(캐시, 전역 맵)에 이 리소스를 쓰면 메모리가 계속 누적됩니다.
따라서 수명이 명확한 스코프(요청/프레임/배치) 에만 쓰는 게 안전합니다.
재사용이 필요한 경우: 풀 리소스 조합
“요청이 끝나도 메모리를 재사용하고 싶다”, “작은 객체를 자주 만들고 지운다”라면 풀 기반 리소스가 맞습니다.
std::pmr::unsynchronized_pool_resource: 단일 스레드 또는 외부 동기화 전제std::pmr::synchronized_pool_resource: 내부 동기화 제공(대신 비용 증가)
예제: 풀 리소스를 업스트림으로 두고, 요청은 모노토닉
실전에서 자주 쓰는 구성이 monotonic의 업스트림을 pool로 두는 것입니다.
- 요청 중에는 초고속 bump allocation
- 요청이 끝나고
release()하면, 큰 블록이 풀로 돌아가 재사용됨 - 시스템 힙으로의 왕복을 줄임
#include <memory_resource>
#include <string>
#include <vector>
struct MemorySystem {
std::pmr::unsynchronized_pool_resource pool;
// pool은 기본적으로 new/delete를 업스트림으로 사용
MemorySystem() : pool(std::pmr::new_delete_resource()) {}
};
struct RequestScope {
std::pmr::monotonic_buffer_resource arena;
explicit RequestScope(std::pmr::memory_resource* upstream)
: arena(upstream) {}
std::pmr::memory_resource* rsrc() { return &arena; }
void reset() { arena.release(); }
};
static void work(std::pmr::memory_resource* r) {
std::pmr::vector<std::pmr::string> v{r};
for (int i = 0; i < 10000; ++i) {
v.emplace_back("payload");
}
}
int main() {
MemorySystem ms;
for (int i = 0; i < 100; ++i) {
RequestScope req{&ms.pool};
work(req.rsrc());
req.reset();
}
}
이 조합은 “요청 단위로 정리되지만, OS 힙과의 상호작용은 최소화”하는 데 유리합니다.
메모리 풀 설계 체크리스트
1) 수명 모델부터 고정하기
풀 설계의 80%는 수명입니다.
- 스코프 기반(요청/프레임):
monotonic_buffer_resource+release() - 자주 생성/파괴되는 작은 객체:
unsynchronized_pool_resource - 스레드 간 공유: 가능하면 스레드 로컬 풀, 불가하면
synchronized_pool_resource
수명 모델이 애매하면, 풀은 곧 “누수처럼 보이는” 메모리 증가를 만들 수 있습니다.
2) 업스트림을 명시적으로 선택하기
PMR 리소스는 계층을 이룹니다.
- 최하단 업스트림: 보통
std::pmr::new_delete_resource() - 상위 리소스: 풀/모노토닉/로그용 계측 리소스 등
업스트림을 잘못 두면:
- 풀에서 확보한 블록이 결국 시스템 힙 조각화를 악화
- 혹은 너무 큰 블록을 잡아 RSS가 튐
3) 스레드 모델을 과소평가하지 말기
unsynchronized_pool_resource는 이름 그대로 동기화가 없습니다.- 여러 스레드가 같은 리소스를 만지면 데이터 레이스입니다.
권장 패턴은 다음 중 하나입니다.
- 스레드마다
thread_local풀을 둔다 - 공유해야 하면
synchronized_pool_resource로 바꾼다 - 또는 상위 레벨에서 락을 잡고 단일 스레드처럼 사용한다
4) PMR을 “경계”에서만 주입하라
코드 전역에 memory_resource*를 전파하면 또 다른 지옥이 됩니다. 대신 다음처럼 경계를 만들면 좋습니다.
- 요청 핸들러 시작점에서 리소스를 만들고
- 도메인 로직에는
std::pmr::*컨테이너만 전달 - 장수 객체는 기본 allocator(또는 별도 장수 풀)로 분리
에러 처리도 경계에서 묶으면 깔끔합니다. 예외를 안 쓰는 코드라면 C++23 std::expected로 예외 없는 에러처리와 RAII에서 소개한 방식처럼, “리소스 스코프 + 결과 타입” 조합이 특히 잘 맞습니다.
계측 가능한 커스텀 memory_resource 만들기
PMR의 강점 중 하나는 “할당을 가로채서 관찰”하기 쉽다는 점입니다. 운영 환경에서 메모리 사용량을 추적하거나, 특정 요청이 비정상적으로 큰 할당을 하는지 확인할 때 유용합니다.
아래는 바이트 단위로 총 할당량을 누적하는 간단한 계측 리소스입니다.
#include <atomic>
#include <cstddef>
#include <memory_resource>
class CountingResource final : public std::pmr::memory_resource {
public:
explicit CountingResource(std::pmr::memory_resource* upstream)
: upstream_(upstream) {}
std::size_t bytes_allocated() const {
return bytes_allocated_.load(std::memory_order_relaxed);
}
private:
void* do_allocate(std::size_t bytes, std::size_t alignment) override {
bytes_allocated_.fetch_add(bytes, std::memory_order_relaxed);
return upstream_->allocate(bytes, alignment);
}
void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
upstream_->deallocate(p, bytes, alignment);
}
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> bytes_allocated_{0};
};
이 리소스를 pool의 업스트림으로 두거나, 반대로 monotonic의 업스트림으로 두면 “요청당 할당 총량” 같은 지표를 쉽게 얻을 수 있습니다. 지표 기반 튜닝은 DB나 캐시에서도 동일하게 중요합니다. CPU가 치솟는 원인을 핫키/슬로우로그로 좁혀가는 접근은 MySQL CPU 100%? Redis 핫키·슬로우로그 튜닝과도 결이 같습니다.
실전에서 자주 터지는 문제들
PMR 문자열/컨테이너를 “일반 컨테이너”에 섞어 넣기
std::pmr::string은 내부 할당자가 PMR입니다. 이를 std::string과 섞거나, PMR 컨테이너를 기본 컨테이너에 넣을 때 복사/이동 경로에서 예상치 못한 할당이 생길 수 있습니다.
- 경계에서 타입을 변환할 때는 비용을 의식적으로 지불
- 내부에서는 PMR 타입으로 통일
리소스 수명보다 오래 사는 객체가 리소스를 참조
std::pmr 컨테이너는 memory_resource*를 들고 있습니다. 리소스가 먼저 파괴되면 UB입니다.
- 요청 스코프 리소스로 만든 컨테이너를 전역/캐시에 저장하지 않기
- 객체 그래프가 복잡하면 “리소스 소유자”를 명확히 하기
release()의 의미를 오해
monotonic_buffer_resource::release()는 “이 리소스가 잡고 있던 모든 메모리를 업스트림에 반납하고 초기화”합니다. 즉, 그 리소스에서 할당된 객체가 아직 살아있으면 즉시 UB로 이어집니다.
release()호출 시점에 해당 리소스 기반 객체가 모두 파괴되었는지 보장 필요
마이그레이션 전략: 한 번에 갈아엎지 말기
기존 코드에 PMR을 도입할 때는 다음 순서가 안전합니다.
- 핫패스에서 가장 많은 할당을 만드는 경로를 찾는다
- 그 경로의 컨테이너를
std::pmr::*로 바꾼다 - 리소스는 요청/작업 스코프에서 만들고 포인터로 주입한다
- 계측 리소스로 할당량을 확인한다
- 필요하면 업스트림을
pool로 교체해 재사용 최적화한다
이 과정을 거치면, allocator 템플릿 파라미터를 전파하느라 타입이 늘어나는 문제 없이도 점진적으로 개선할 수 있습니다.
정리
C++23에서 PMR을 이용한 메모리 풀 설계는 “커스텀 allocator를 타입에 박는 방식”에서 벗어나, 리소스 계층을 설계하고 수명 경계를 명확히 하는 방식으로 문제를 단순화합니다.
- 요청/프레임 단위:
monotonic_buffer_resource가 가장 단순하고 빠름 - 재사용이 중요:
unsynchronized_pool_resource또는synchronized_pool_resource - 실전 조합:
monotonic의 업스트림을pool로 두어 빠른 할당과 재사용을 동시에 - 가장 중요한 함정: 리소스 수명(UB)과 스레드 안전성(데이터 레이스)
다음 단계로는, 계측 리소스를 붙여 워크로드별 할당 패턴을 수집하고, “어떤 객체가 얼마나 오래 살아남는지”를 기준으로 스코프 리소스와 장수 리소스를 분리해보면 효과가 큽니다.