Published on

C++23 PMR로 allocator 지옥 탈출 - 메모리 풀 설계

Authors

서버나 게임, 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을 도입할 때는 다음 순서가 안전합니다.

  1. 핫패스에서 가장 많은 할당을 만드는 경로를 찾는다
  2. 그 경로의 컨테이너를 std::pmr::*로 바꾼다
  3. 리소스는 요청/작업 스코프에서 만들고 포인터로 주입한다
  4. 계측 리소스로 할당량을 확인한다
  5. 필요하면 업스트림을 pool로 교체해 재사용 최적화한다

이 과정을 거치면, allocator 템플릿 파라미터를 전파하느라 타입이 늘어나는 문제 없이도 점진적으로 개선할 수 있습니다.

정리

C++23에서 PMR을 이용한 메모리 풀 설계는 “커스텀 allocator를 타입에 박는 방식”에서 벗어나, 리소스 계층을 설계하고 수명 경계를 명확히 하는 방식으로 문제를 단순화합니다.

  • 요청/프레임 단위: monotonic_buffer_resource가 가장 단순하고 빠름
  • 재사용이 중요: unsynchronized_pool_resource 또는 synchronized_pool_resource
  • 실전 조합: monotonic의 업스트림을 pool로 두어 빠른 할당과 재사용을 동시에
  • 가장 중요한 함정: 리소스 수명(UB)과 스레드 안전성(데이터 레이스)

다음 단계로는, 계측 리소스를 붙여 워크로드별 할당 패턴을 수집하고, “어떤 객체가 얼마나 오래 살아남는지”를 기준으로 스코프 리소스와 장수 리소스를 분리해보면 효과가 큽니다.