- Published on
C++23 pmr 메모리 폭주? monotonic_buffer_resource 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/배치 코드에서 std::pmr를 도입하면 할당 오버헤드가 줄고 성능이 좋아지는 경우가 많습니다. 그런데 운영에서 종종 “pmr로 바꿨더니 메모리가 폭주한다”는 증상이 나옵니다. 특히 std::pmr::monotonic_buffer_resource(이하 MBR)는 구조적으로 한 번 확보한 메모리를 개별 객체 해제 시점에 OS로 돌려주지 않는 특성이 있어, 사용 패턴이 맞지 않으면 RSS가 계속 증가하는 것처럼 보입니다.
이 글은 C++23 관점에서 MBR의 동작을 정확히 짚고, 폭주처럼 보이는 상황을 재현한 뒤, 버퍼 사이징, 업스트림 선택, 릴리즈 전략을 통해 운영 안정성을 확보하는 튜닝 방법을 정리합니다.
운영 튜닝 접근 방식은 DB에서 누적/회수가 안 맞아 부풀어 오르는 현상을 다루는 것과 유사합니다. 예를 들어 PostgreSQL 테이블이 VACUUM 누락으로 비대해지는 패턴과 문제 구조가 닮아 있습니다. 필요하면 함께 읽어볼 만합니다: VACUUM 안 해서 폭증한 PostgreSQL 테이블 복구
pmr과 monotonic_buffer_resource의 핵심 동작
왜 “폭주”처럼 보이나
MBR은 이름 그대로 단조 증가(monotonic) 방식으로 메모리를 제공합니다.
- 작은 블록 할당 요청이 들어오면 내부 버퍼에서 “포인터를 앞으로 밀어” 제공
- 내부 버퍼가 부족하면 업스트림 리소스에서 새 덩어리(chunk) 를 더 확보
deallocate는 호출되더라도 실제로는 대부분 아무 일도 하지 않음(논리적 해제)- 전체 메모리 반환은 보통
release()또는 리소스 파괴 시점에만 발생
즉, 컨테이너를 clear() 하거나 스코프를 빠져나가며 객체가 파괴되어도, MBR이 잡아둔 chunk들은 그대로 남아 다음 할당에 재사용됩니다. 이게 서비스형 프로세스에서는 RSS가 내려가지 않는 현상으로 관측됩니다.
“메모리 누수”와의 차이
- 누수: 참조를 잃어 영원히 접근 불가
- MBR: 접근은 가능하고 재사용도 되지만, OS 관점에서 반환이 늦거나 없음
운영에서 문제는 “누수냐 아니냐”보다 피크 이후에도 메모리 점유가 유지되어 OOM으로 이어지는가입니다.
폭주 패턴 3가지: 어떤 코드가 위험한가
1) 긴 수명의 MBR을 전역/싱글톤으로 사용
요청 단위로 잠깐 쓰고 버릴 메모리를 전역 MBR에 계속 쌓으면, 워크로드의 피크가 곧 프로세스의 상시 메모리로 굳어집니다.
- 위험 신호: “트래픽 피크 이후 RSS가 내려오지 않는다”
- 원인:
release()가 호출되지 않음
2) 가변 크기 데이터가 큰 편차로 들어오는 경우
예: JSON 파싱, 로그/이벤트 배치, 그래프 탐색 등에서 특정 입력만 매우 커짐.
- MBR은 큰 요청을 처리하려고 큰 chunk를 추가 확보
- 이후 평시에는 그 chunk가 놀아도 RSS는 유지
3) 컨테이너가 내부적으로 큰 버퍼를 잡는 경우
std::pmr::vector나 std::pmr::string은 성장 과정에서 더 큰 버퍼를 재할당할 수 있습니다. MBR을 쓰면 재할당 과정에서 확보된 과거 버퍼가 개별 해제로 돌아가지 않으니, 피크에서 크게 커진 뒤 다시 줄어도 메모리는 남습니다.
재현 코드: “clear 했는데 메모리가 안 줄어든다”
아래 예제는 MBR을 길게 유지하면서 큰 문자열을 반복 생성/파기하는 상황을 단순 재현합니다.
#include <memory_resource>
#include <string>
#include <vector>
#include <iostream>
int main() {
std::pmr::monotonic_buffer_resource mbr;
// 같은 mbr을 계속 재사용
std::pmr::vector<std::pmr::string> v{&mbr};
for (int round = 0; round < 200; ++round) {
v.clear();
// 특정 라운드에서만 매우 큰 할당이 발생한다고 가정
const std::size_t n = (round == 50) ? 200000 : 2000;
for (std::size_t i = 0; i < n; ++i) {
// 큰 문자열을 만들어 벡터에 넣기
v.emplace_back(1000, 'x', &mbr);
}
if (round % 10 == 0) {
std::cout << "round=" << round << " size=" << v.size() << "\n";
}
}
// 여기서 프로그램이 끝나기 전까지는 mbr이 잡은 메모리가 유지될 수 있음
}
이 코드는 논리적으로는 v.clear()로 요소를 비우지만, MBR이 잡은 chunk는 유지됩니다. 운영에서 이런 패턴이 “pmr 메모리 폭주”로 보고됩니다.
튜닝 1: 수명 설계를 바꿔라(가장 효과 큼)
MBR은 “짧은 생명주기에서 대량 할당”에 최적입니다. 따라서 가장 먼저 볼 것은 리소스의 스코프입니다.
요청/작업 단위로 MBR을 만들고 끝에서 release()
#include <memory_resource>
#include <string>
#include <vector>
struct RequestContext {
std::pmr::monotonic_buffer_resource mbr;
// 이 컨텍스트에서만 쓰는 pmr 컨테이너들
std::pmr::vector<std::pmr::string> tokens{&mbr};
void reset() {
tokens.clear();
// 컨테이너 clear만으로는 chunk가 줄지 않으므로
mbr.release();
}
};
- 장점: 피크 메모리가 요청 단위로 회수
- 단점:
release()비용(다만 보통 요청 끝에서 한 번이면 충분)
“풀을 재사용”하되, 주기적으로 회수
요청당 생성/파괴가 부담이면, 스레드 로컬로 두고 N회마다 release() 하는 방식도 실무에서 흔합니다.
thread_local std::pmr::monotonic_buffer_resource tls_mbr;
thread_local int tls_counter = 0;
void handle_request() {
std::pmr::vector<int> tmp{&tls_mbr};
// ... 작업 ...
if (++tls_counter % 1000 == 0) {
tls_mbr.release();
}
}
이때 중요한 것은 “메모리 상한”을 감으로 두지 말고, 관측치(RSS, 작업 크기 분포) 기반으로 주기를 정하는 것입니다.
튜닝 2: 초기 버퍼를 주고, chunk 성장을 통제하라
MBR은 생성 시 초기 버퍼를 제공할 수 있습니다. 작은 할당이 많고 대부분 일정 크기 이하라면, 초기 버퍼를 주는 것만으로 업스트림 chunk 추가를 크게 줄일 수 있습니다.
#include <array>
#include <memory_resource>
int main() {
std::array<std::byte, 1 * 1024 * 1024> buf; // 1MiB
std::pmr::monotonic_buffer_resource mbr(
buf.data(),
buf.size(),
std::pmr::get_default_resource()
);
// 이 mbr을 사용하는 pmr 컨테이너들...
}
실무 튜닝 포인트:
- 초기 버퍼는 “평균”이 아니라 P95~P99 작업 크기에 맞추는 편이 안전
- 너무 크게 잡으면 상시 메모리 증가(특히 스레드 로컬일 때)
- 너무 작으면 결국 업스트림 chunk가 계속 붙어 효과가 감소
튜닝 3: 업스트림 리소스를 바꿔 메모리 반환/분산을 개선
MBR은 내부 chunk를 “어디서” 가져올지 업스트림에 위임합니다. 기본은 new_delete_resource() 계열인데, 운영 환경에서는 다음을 고려할 수 있습니다.
unsynchronized_pool_resource를 업스트림으로 쓰는 조합
- 장점: 다양한 크기 할당이 섞일 때 일반
new보다 단편화/오버헤드를 줄일 수 있음 - 단점: 풀 리소스 자체도 캐시를 유지하므로, 반환 정책을 함께 설계해야 함
#include <memory_resource>
int main() {
std::pmr::unsynchronized_pool_resource pool;
std::pmr::monotonic_buffer_resource mbr(&pool);
// ... 사용 ...
// 필요 시
mbr.release();
pool.release();
}
중요: mbr.release()는 MBR이 잡은 chunk를 업스트림에 돌려줄 뿐이고, 업스트림이 풀이라면 그 풀도 메모리를 잡고 있을 수 있습니다. “RSS를 내려야 하는 요구”가 있다면 pool.release() 타이밍도 함께 설계해야 합니다.
튜닝 4: 큰 할당은 MBR에서 분리하라(하이브리드 전략)
MBR은 작은/중간 크기 다량 할당에 강합니다. 반대로 “가끔 등장하는 매우 큰 할당”이 문제를 만듭니다.
전략:
- 작은 객체/임시 문자열/노드: MBR
- 큰 버퍼(예: 수백 KiB 이상): 별도 경로로
std::pmr::new_delete_resource()또는 직접std::vector기본 할당
예시로, 큰 문자열은 pmr이 아닌 일반 std::string으로 두거나, 큰 버퍼는 별도 할당기로 빼는 식입니다.
#include <memory_resource>
#include <string>
#include <vector>
std::string make_big_payload(std::size_t n) {
// 큰 데이터는 일반 힙에 두어, 수명 종료 시 반환되도록
return std::string(n, 'p');
}
void work(std::pmr::memory_resource* mr) {
std::pmr::vector<std::pmr::string> small{mr};
small.emplace_back("tiny", mr);
auto big = make_big_payload(2 * 1024 * 1024);
(void)big;
}
임계값은 워크로드에 따라 다르지만, “피크 입력”이 문제라면 이 하이브리드가 체감 효과가 큽니다.
튜닝 5: 컨테이너 사용 습관을 바꿔 피크를 줄여라
MBR이 폭주하는 게 아니라, 컨테이너가 불필요한 재할당/성장을 유발하는 경우도 많습니다.
vector.reserve()로 재할당 횟수 줄이기string누적 시reserve()또는append패턴 점검- 불필요한 임시 컨테이너 생성 제거
예:
std::pmr::vector<int> v{mr};
v.reserve(100000); // 대략적인 상한을 알면 효과적
for (int i = 0; i < 100000; ++i) v.push_back(i);
여기서 포인트는 “MBR이니까 신경 안 써도 된다”가 아니라, MBR은 되돌림이 약하니 피크 자체를 낮추는 습관이 더 중요하다는 점입니다.
관측과 진단: 무엇을 보면 원인을 좁힐 수 있나
1) RSS만 보지 말고, 할당 패턴을 본다
- 라운드/요청별로 처리량, 입력 크기 분포(P50/P95/P99)
- 요청당 임시 객체 수, 문자열 총 길이
release()호출 빈도
2) 메모리 리소스 계측 래퍼를 붙여라
업스트림 memory_resource를 감싸서 총 할당량, 최대 동시 사용량을 로깅하면 “피크가 언제 생겼는지”가 바로 드러납니다.
#include <memory_resource>
#include <atomic>
#include <cstddef>
class tracking_resource : public std::pmr::memory_resource {
public:
explicit tracking_resource(std::pmr::memory_resource* upstream)
: upstream_(upstream) {}
std::size_t bytes_allocated() const { return allocated_.load(); }
private:
void* do_allocate(std::size_t bytes, std::size_t alignment) override {
allocated_.fetch_add(bytes);
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> allocated_{0};
};
주의: 위 예제는 “총 할당량”만 추적합니다. 실제로는 현재 사용량, 피크 사용량, 요청 단위 리셋 시점 등을 함께 기록하는 게 좋습니다.
운영에서 이런 계측은 로그 폭주처럼 “관측 지표가 한쪽만 커지는” 문제를 잡을 때도 통하는 방식입니다. 참고: Linux journalctl 로그 폭주로 디스크 100% 찰 때
실전 체크리스트: MBR 튜닝 순서
- MBR 수명 확인: 전역/싱글톤이면 먼저 의심
release()전략: 요청/배치/프레임 단위로 호출할 수 있는 구조인지- 초기 버퍼 제공: P95 기준으로 잡고 업스트림 chunk 증가를 최소화
- 업스트림 선택: 기본
newvs 풀 리소스, 그리고 풀의release()정책 - 큰 할당 분리: 희귀한 대형 버퍼는 MBR 밖으로
- 컨테이너 피크 줄이기:
reserve()와 임시 객체 제거 - 계측: “언제, 무엇 때문에” 피크가 생겼는지 수치로 확인
결론: MBR은 빠르지만, 회수 정책을 직접 설계해야 한다
std::pmr::monotonic_buffer_resource는 임시 메모리 패턴에 매우 강력하지만, “자동으로 깔끔하게 줄어드는 메모리”를 기대하면 운영에서 곤란해집니다. 폭주처럼 보이는 대부분의 사례는
- 리소스 수명(너무 김)
- 피크 입력(희귀한 대형 할당)
- 반환 타이밍 부재(
release()없음)
의 조합으로 설명됩니다.
MBR을 도입할 때는 성능만 보지 말고, 회수 지점과 상한(피크)을 함께 설계하세요. 그게 C++23 pmr을 운영 친화적으로 만드는 핵심입니다.