Skip to content
Published on

메모리 할당자 완전 가이드 2025: malloc, jemalloc, tcmalloc, mimalloc — Fragmentation, Arena, Thread Cache 심층 분석

Authors

들어가며: malloc() 한 줄 뒤의 세계

당신이 당연하게 쓰는 함수

int *p = malloc(sizeof(int) * 1000);

이 한 줄이 내부적으로 무엇을 하는지 아는가? 대부분의 답은 "메모리 할당하지"지만, 실제로는 훨씬 복잡하다:

  1. 어떤 크기인지 분류한다 (tiny? small? large? huge?).
  2. 스레드별 캐시를 먼저 확인한다.
  3. 캐시에 없으면 arena에서 가져온다.
  4. Arena도 부족하면 mmap 또는 sbrk 로 OS에 요청한다.
  5. 메타데이터를 갱신하고, 할당된 포인터를 반환한다.

이 각 단계가 수백 줄의 코드와 십 년의 최적화를 담고 있다. 그리고 이 내부 구조가 당신의 애플리케이션 성능을 극적으로 좌우한다.

왜 중요한가?

  • Redis: jemalloc으로 25% 메모리 절약, 더 나은 fragmentation 관리.
  • Firefox: jemalloc으로 메모리 사용량 대폭 감소.
  • Go 런타임: 자체 메모리 할당자 (TCMalloc 아이디어 차용).
  • Rust jemallocator: 멀티스레드 서버에서 기본 malloc 대비 수 배 빠름.
  • MySQL: jemalloc/tcmalloc으로 fragmentation 문제 해결.

"할당자가 뭐 그렇게 중요해?"라고 생각했다면, 이 글을 읽고 나면 생각이 바뀔 것이다.


1. 메모리 할당자의 근본 문제

무엇을 해결하려 하는가?

메모리 할당자의 요구사항:

  1. 빠른 할당/해제: O(1) 이상적.
  2. 낮은 fragmentation: 메모리 낭비 최소화.
  3. 스레드 안전: 멀티스레드 환경 지원.
  4. 확장성: 코어 수에 따라 선형 성능.
  5. 적은 메타데이터: 관리 오버헤드 최소.

이 다섯 가지를 동시에 만족시키는 건 어렵다. 할당자 설계는 트레이드오프의 연속이다.

Fragmentation: 숨은 적

메모리가 충분해 보여도 할당이 실패할 수 있다. 그것이 fragmentation 때문이다.

External Fragmentation (외부 파편화)

메모리 상태:
[사용][빈 8KB][사용][빈 8KB][사용][빈 8KB]

16KB 요청 → 실패! (연속된 공간 없음)

전체 16KB가 남아 있어도 연속된 영역이 없어서 큰 요청이 실패한다.

Internal Fragmentation (내부 파편화)

할당자는 종종 요청보다 큰 블록을 준다. 예를 들어 17바이트 요청에 24바이트 블록을 주면 7바이트 낭비.

요청: 17바이트
할당: 24바이트 (size class에 맞춤)
낭비: 7바이트 (internal fragmentation)

내부 파편화는 할당자의 size class 설계에 달려 있다.

간단한 예시: Buddy System

가장 고전적인 할당자인 Buddy System:

  1. 메모리를 2의 거듭제곱 크기 블록으로 분할.
  2. 요청 시 가장 가까운 큰 블록을 찾음.
  3. 블록이 너무 크면 절반으로 분할 (buddy 생성).
  4. 해제 시 buddy와 병합해 큰 블록으로.
2MB 블록 요청
┌──── 4MB ────┐
│             │
└─────────────┘
      ↓ 분할
┌─ 2MB ─┬─ 2MB ─┐
│ 할당   │ buddy │
└───────┴───────┘

장점: 빠른 분할/병합, 구현 단순. 단점: 내부 파편화 심함 (2의 거듭제곱 반올림).

Linux 커널의 페이지 할당자가 지금도 Buddy System을 쓴다.


2. glibc ptmalloc (ptmalloc2)

기본 malloc의 정체

Linux에서 C로 작성한 프로그램이 기본으로 쓰는 malloc은 glibc의 ptmalloc2다. Doug Lea의 dlmalloc에서 파생되어 멀티스레드 지원이 추가된 버전.

Chunk 기반 구조

ptmalloc은 모든 메모리를 chunk 단위로 관리한다. 각 chunk는:

┌──────────────┐
│ prev_size     (이전 chunk 크기, 이전이 free일 때만 유효)
├──────────────┤
│ size | flags  (현재 chunk 크기 + 3비트 플래그)
├──────────────┤
│              │
│  user data   │
│              │
└──────────────┘

플래그:

  • PREV_INUSE: 이전 chunk가 사용 중.
  • IS_MMAPPED: mmap으로 할당됨.
  • NON_MAIN_ARENA: non-main arena에 속함.

Bin: 크기별 분류

해제된 chunk들은 bin(일종의 free list)에 들어간다:

  • Fast bins: 16 ~ 80바이트 크기 (각 8바이트 단위). LIFO. 빠르지만 병합 안 함.
  • Unsorted bin: 최근 free된 chunk가 임시로 대기.
  • Small bins: 16 ~ 1024바이트. 각 크기별 정렬된 목록.
  • Large bins: 1024바이트 이상. 크기 내림차순 정렬.

Arena: 스레드 경합 완화

멀티스레드에서 모든 스레드가 하나의 힙을 공유하면 락 경합이 심하다. ptmalloc은 arena로 해결한다:

  • Main arena: 메인 스레드용 (sbrk 사용).
  • Thread arena: 필요에 따라 생성 (mmap으로 128MB 단위).
  • 스레드는 최근 사용한 arena를 선호.
  • Arena당 mutex 하나.

코어 수가 늘면 arena도 늘어나서 경합 감소. 기본 arena 수는 코어 수 × 8.

sbrk vs mmap

  • sbrk: 힙을 연장. 빠르지만 힙 끝에서만 가능.
  • mmap: 별도 메모리 영역 할당. 큰 할당(기본 128KB 이상) 시 사용.

mmap으로 할당된 것은 해제 시 바로 OS에 반환된다. sbrk 기반은 힙이 조각나면 반환이 어렵다.

Coalescing (병합)

chunk를 해제할 때 인접한 free chunk와 병합해 큰 chunk를 만든다. 이것이 외부 파편화를 줄이는 핵심.

[free 8KB][free 8KB][used]
[free 16KB][used]

단, fast bin의 chunk는 병합 안 한다 (성능을 위해). 그래서 장시간 실행 시 fast bin에 작은 조각이 쌓일 수 있다.

ptmalloc의 문제점

ptmalloc은 훌륭하지만 현대적 워크로드에 한계가 있다:

  1. Fragmentation: 특히 장시간 실행 서버에서 점점 심해짐.
  2. 락 경합: arena 많아도 병목 생김.
  3. sbrk 기반: 반환이 어려움.
  4. 확장성: 많은 코어(64+)에서 성능 저하.

이 한계를 해결하기 위해 수많은 대체 할당자가 나왔다. 그 중 가장 유명한 셋: jemalloc, tcmalloc, mimalloc.


3. jemalloc: Jason Evans의 걸작

역사

Jason Evans가 2005년 FreeBSD를 위해 만들었다. 이후 Firefox, Facebook, Redis 등이 채택하며 유명해졌다. 2013년부터 Facebook이 메인 개발을 이어가고 있다.

핵심 설계 원칙

  1. Low fragmentation: 크기 클래스 세분화.
  2. Scalable concurrency: per-CPU arena, thread cache.
  3. Low memory overhead: 메타데이터 집중 관리.
  4. Profiling: 강력한 디버깅과 통계.

Size Class 체계

jemalloc은 크기를 매우 세밀하게 나눈다:

  • Small: 8, 16, 32, ..., 14KB (약 40개 클래스).
  • Large: 16KB ~ 4MB (huge page 경계).
  • Huge: 4MB 이상.

각 size class마다 별도의 free list. 요청 크기를 가장 가까운 클래스로 반올림.

내부 파편화: 평균 ~10% (ptmalloc의 ~15%보다 낮음).

Arena와 Bin

jemalloc의 arena는 ptmalloc과 유사하지만 더 세분화:

Arena
├── small_runs[size_class_0]: runs of small chunks
├── small_runs[size_class_1]
├── ...
├── large_runs: large allocations
└── huge_runs: huge allocations

Run: 연속된 페이지들의 집합. 한 run은 한 size class의 여러 slot을 포함.

Thread Cache (tcache)

jemalloc은 스레드마다 thread cache를 유지한다:

thread_local tcache_t cache = {
    slow_bins[0]: [addr, addr, addr, ...],  // 8바이트 size class
    slow_bins[1]: [addr, addr, addr, ...],  // 16바이트
    ...
};
  • 할당: tcache에서 먼저 꺼냄 (락 없음).
  • 해제: tcache에 넣음 (락 없음).
  • tcache가 너무 차면 arena로 반환.

효과: 일반 할당/해제의 99%가 락 없이 처리된다.

Huge Pages 활용

jemalloc은 Transparent Huge Pages (THP)를 적극 활용한다:

  • 2MB 페이지로 TLB miss 감소.
  • metadata_thp 옵션으로 메타데이터도 huge page에 배치.

프로파일링

jemalloc의 강점 중 하나는 내장 프로파일러다:

MALLOC_CONF="prof:true,prof_prefix:jeprof.out" ./my_app
jeprof --show_bytes --pdf ./my_app jeprof.out.*.heap > heap.pdf

힙 프로파일로 메모리 누수할당 hot path를 찾을 수 있다. Facebook의 내부 프로파일링 도구와 깊이 통합되어 있다.

Facebook의 활용

Facebook은 jemalloc을 수백만 대의 서버에서 쓴다. 그들의 경험:

  • ptmalloc 대비 ~30% 메모리 절약.
  • 멀티스레드 워크로드에서 2~10배 빠름.
  • 장시간 실행 서버의 fragmentation 크게 감소.

Redis의 jemalloc 채택

Redis는 glibc malloc의 fragmentation 문제로 고생했다. Salvatore Sanfilippo가 jemalloc으로 교체한 후:

  • 메모리 사용 25% 감소.
  • 장시간 실행 후에도 안정적.
  • MEMORY MALLOC-STATS 명령어로 내부 상태 조회 가능.

Redis는 기본적으로 jemalloc을 번들로 포함한다.


4. tcmalloc: Google의 속도광

역사

TCMalloc (Thread-Caching Malloc) 은 Google에서 개발됐다. 2005년부터 gperftools에 포함되었고, 2020년 별도 프로젝트로 분리됐다.

철학

"멀티스레드 환경에서 극한의 속도를 추구한다."

jemalloc이 메모리 효율과 속도의 균형이라면, tcmalloc은 속도를 위해 약간의 메모리를 희생한다.

계층 구조

tcmalloc은 세 레벨로 구성된다:

  1. Thread cache: 스레드당 작은 캐시. 락 없음. 가장 빠름.
  2. Central cache: 모든 스레드가 공유. 락 필요.
  3. Page heap: OS와 직접 상호작용. 큰 할당.
요청 흐름:
Thread Cache  (miss)Central Cache  (miss)Page Heap  (miss)OS

Thread Cache 세부

각 스레드는 size class별 free list를 유지한다:

  • 기본 크기: 스레드당 ~2MB.
  • 각 size class에 수십~수백 개 free slot.
  • 할당/해제는 이 리스트에서 pop/push.

TCMalloc의 핵심 최적화: thread cache hit이 압도적으로 많다. 일반적인 코드에서 99.9% 이상의 할당이 thread cache에서 처리된다.

리플래닝 (Refilling)

thread cache가 비면 central cache에서 batch로 가져온다:

Thread cache 비어있음
Central cache에서 32개 slot 가져옴
Thread cache에 추가
→ 다음 31번 할당은 다시 락 없이

이는 락 획득 횟수를 극도로 줄인다.

Span 기반 관리

tcmalloc은 span이라는 단위로 페이지를 관리한다:

  • Span: 연속된 페이지들의 집합 (예: 16페이지).
  • 각 span은 단일 size class의 slot들을 담거나, 하나의 큰 할당을 담는다.
  • Page heap은 span을 radix tree로 관리.

Per-CPU Mode (최신 tcmalloc)

2020년 이후 tcmalloc은 per-CPU 모드를 기본으로 한다:

  • 스레드가 아닌 CPU마다 캐시.
  • restartable sequences (rseq) 시스템콜 사용.
  • 스레드가 다른 CPU로 이동해도 올바르게 동작.

장점: 수천 스레드가 있어도 캐시 오버헤드는 CPU 수에 비례.

Google의 사용

Google은 모든 내부 C++ 서비스에 tcmalloc을 쓴다. 그들의 벤치마크:

  • 5~15% 더 빠른 할당 (ptmalloc 대비).
  • 수십 코어에서 선형 확장.
  • 낮은 꼬리 레이턴시 (p99, p999).

gperftools vs google/tcmalloc

혼동 주의: 두 가지 버전이 있다.

  • gperftools tcmalloc: 오래된 구현. 여전히 많이 쓰이지만 구식.
  • google/tcmalloc: 2020년 이후 신버전. Per-CPU, 새 기능 많음.

신규 프로젝트에는 google/tcmalloc을 추천.


5. mimalloc: Microsoft의 도전장

역사

mimalloc (mi = Microsoft) 은 Daan Leijen이 Microsoft Research에서 2019년 발표했다. 논문 "Mimalloc: Free List Sharding in Action"에서 독특한 접근을 소개.

핵심 아이디어: Free List Sharding

mimalloc의 혁신은 페이지별 free list다:

  • 각 페이지는 자기 페이지 내의 free slot 목록을 유지.
  • 스레드가 할당 시 "현재 작업 중인 페이지"에서만 할당.
  • 해제 시 해당 페이지의 목록에 추가.

결과: 작업 지역성(locality) 이 매우 좋다. 같은 페이지에서 계속 할당/해제하므로 캐시 친화적.

Heartbeat과 Deferred Free

다른 스레드가 페이지의 slot을 해제하면 (cross-thread free) 그건 즉시 처리하지 않고 별도 리스트에 쌓아둔다. 주기적으로 "heartbeat" 시점에 메인 리스트로 merge.

이 지연이 atomic 연산을 줄여 성능을 높인다.

작은 코드베이스

mimalloc의 장점 중 하나는 단순함이다:

  • ~10,000줄의 C 코드 (tcmalloc, jemalloc은 수만 줄).
  • 이식 쉬움.
  • 쉽게 커스터마이즈 가능.

성능

벤치마크 결과 (논문):

  • Redis 벤치마크: mimalloc > tcmalloc > jemalloc > glibc.
  • 평균 메모리 사용은 jemalloc과 비슷.
  • 일부 워크로드에서 가장 빠름.

주의: 벤치마크는 워크로드마다 다르다. 프로젝트별로 테스트하자.

Microsoft의 사용

  • Microsoft Azure: 일부 서비스.
  • .NET 런타임: 실험적 GC 통합.
  • Rust, Swift 생태계: 선택 가능한 할당자.

언어 통합

// Rust
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
# Python: libmimalloc.so을 LD_PRELOAD
LD_PRELOAD=/path/to/libmimalloc.so python app.py

6. 성능 비교: 누가 최고인가?

벤치마크 주의 사항

"어떤 할당자가 가장 빠른가?"에 유일한 답은 없다. 워크로드에 따라 승자가 다르다:

  • 할당/해제 짝이 빠르게 반복: tcmalloc, mimalloc 유리.
  • 장시간 실행, fragmentation 중요: jemalloc 유리.
  • 큰 할당 위주: glibc도 나쁘지 않음.
  • 수천 스레드: per-CPU tcmalloc.

일반적인 경향

대략적인 순위 (평균 워크로드):

순위속도메모리확장성
1tcmallocjemalloctcmalloc
2mimallocmimallocjemalloc
3jemalloctcmallocmimalloc
4glibcglibcglibc

단, 이는 평균일 뿐이고 특정 워크로드에선 순위가 바뀐다.

벤치마크하는 법

# 실제 워크로드를 여러 할당자로 실행
LD_PRELOAD=/usr/lib/libjemalloc.so ./my_app
LD_PRELOAD=/usr/lib/libtcmalloc.so ./my_app
LD_PRELOAD=/usr/lib/libmimalloc.so ./my_app

# 측정:
# - 처리량 (req/s)
# - 메모리 사용 (RSS, VSZ)
# - Fragmentation 비율
# - 꼬리 레이턴시 (p99, p99.9)

반드시 프로덕션과 유사한 워크로드로 테스트해야 한다. 합성 벤치마크는 오해를 불러올 수 있다.


7. Fragmentation 심층 분석

Fragmentation을 측정하는 법

// jemalloc
size_t allocated, mapped;
size_t sz = sizeof(size_t);
mallctl("stats.allocated", &allocated, &sz, NULL, 0);
mallctl("stats.mapped", &mapped, &sz, NULL, 0);

double fragmentation = 1.0 - (double)allocated / mapped;
  • allocated: 현재 사용 중인 바이트.
  • mapped: OS로부터 받은 바이트.
  • 비율이 낮을수록 좋음 (fragmentation 적음).

Redis의 INFO memory 명령어는 이 값을 자동으로 보여준다:

mem_fragmentation_ratio:1.05  # 5% 오버헤드 (양호)
mem_fragmentation_ratio:1.30  # 30% 오버헤드 (문제)
mem_fragmentation_ratio:0.9   # 0.9? 스왑된  (심각)

Fragmentation을 일으키는 패턴

  1. 다양한 크기 혼재: 15바이트, 73바이트, 500바이트 등 섞어 쓰면 hole 많음.
  2. 오래된 장기 할당: 큰 객체 주변에 작은 객체가 붙어 반환 불가.
  3. 메모리 릭 근처: 누수된 객체가 병합을 방해.
  4. 장시간 실행: 시간이 지날수록 악화.

Fragmentation 감소 기법

  1. Arena 분리: 다른 size class끼리 섞지 않기.
  2. Slab allocator: 같은 크기만 담는 풀.
  3. Object pool: 자주 쓰는 객체 재사용.
  4. Compaction: GC 언어에선 가능, C/C++에선 어려움.
  5. Huge pages: 큰 단위로 예약해 파편화 억제.

Redis의 Active Defragmentation

Redis는 activedefrag yes 옵션으로 실시간 defragmentation 을 제공한다:

  1. fragmentation 비율이 높아지면 활성화.
  2. 백그라운드로 객체를 새 위치로 복사.
  3. 원래 위치 해제.
  4. 점진적으로 fragmentation 감소.

jemalloc의 xallocx와 긴밀히 협력한다. Redis 특유의 워크로드에 맞춤.


8. 실전 튜닝: 환경 변수와 옵션

ptmalloc 튜닝

# Arena 수 제한
MALLOC_ARENA_MAX=2 ./my_app

# Mmap 임계값 조정 (기본 128KB)
MALLOC_MMAP_THRESHOLD_=262144 ./my_app

# Per-thread 캐시 끄기
MALLOC_PERTURB_=0 ./my_app

MALLOC_ARENA_MAX=2 는 유명한 트릭이다. 특히 컨테이너 환경에서 arena가 너무 많이 생성되어 메모리 낭비가 발생하는 경우 줄이면 RSS가 크게 감소한다.

jemalloc 튜닝

# 상세 통계
MALLOC_CONF="stats_print:true" ./my_app

# 프로파일링
MALLOC_CONF="prof:true,prof_prefix:heap" ./my_app

# Dirty page 반환 정책
MALLOC_CONF="dirty_decay_ms:5000,muzzy_decay_ms:5000"

# 더 공격적인 메모리 반환
MALLOC_CONF="dirty_decay_ms:0"

# Huge page 활성화
MALLOC_CONF="thp:always"

# Arena 수
MALLOC_CONF="narenas:4"

tcmalloc 튜닝

# Aggressive decommit (메모리 반환)
TCMALLOC_AGGRESSIVE_DECOMMIT=1

# Sample 비율 (프로파일링)
TCMALLOC_SAMPLE_PARAMETER=524288

mimalloc 튜닝

# 환경 변수
MIMALLOC_VERBOSE=1       # 통계 출력
MIMALLOC_SHOW_STATS=1    # 종료 시 통계
MIMALLOC_PAGE_RESET=1    # 사용 후 페이지 재설정
MIMALLOC_LARGE_OS_PAGES=1  # Huge pages

9. 커스텀 할당자 만들기

언제 필요한가?

  • Object pool: 같은 크기 객체 대량 재사용.
  • Arena allocator: 짧은 생명주기, 한 번에 모두 해제.
  • Bump allocator: 매우 빠른 할당, 개별 해제 없음.
  • Slab allocator: 특정 타입만 효율적으로.

Bump Allocator: 가장 단순한 예

typedef struct {
    char *buffer;
    size_t size;
    size_t offset;
} BumpAllocator;

void* bump_alloc(BumpAllocator *a, size_t n) {
    if (a->offset + n > a->size) return NULL;
    void *ptr = a->buffer + a->offset;
    a->offset += n;
    return ptr;
}

void bump_reset(BumpAllocator *a) {
    a->offset = 0;  // 모두 해제
}
  • 할당: O(1), 락 없음.
  • 해제: 개별 불가. 전체 reset만.
  • 용도: 프레임별 할당(게임), 요청별 할당(웹 서버).

Arena Allocator

typedef struct Arena {
    char *current;
    size_t remaining;
    struct Arena *next;
} Arena;

void* arena_alloc(Arena **head, size_t n) {
    if ((*head)->remaining < n) {
        // 새 블록 할당
        Arena *new_arena = malloc(sizeof(Arena) + BLOCK_SIZE);
        new_arena->next = *head;
        *head = new_arena;
        (*head)->current = (char*)(new_arena + 1);
        (*head)->remaining = BLOCK_SIZE;
    }

    void *ptr = (*head)->current;
    (*head)->current += n;
    (*head)->remaining -= n;
    return ptr;
}

void arena_free_all(Arena *head) {
    while (head) {
        Arena *next = head->next;
        free(head);
        head = next;
    }
}

장점: 초고속 할당, 개별 해제 오버헤드 없음. 단점: 개별 해제 불가, 메모리 사용 예측 필요.

언어별 커스텀 할당자

Rust:

use bumpalo::Bump;
let arena = Bump::new();
let x = arena.alloc(42u64);
// arena가 drop되면 모든 객체 해제

Zig: 명시적 allocator 파라미터가 언어 철학의 일부.

const allocator = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer allocator.deinit();

10. 언어 런타임의 할당자

Go: 자체 할당자

Go는 glibc malloc을 쓰지 않는다. 자체 할당자를 탑재한다:

  • mcache: 스레드(실제로는 P)별 캐시.
  • mcentral: size class별 중앙 캐시.
  • mheap: 전역 힙.
  • 구조가 tcmalloc과 매우 유사 (사실 영감 받음).

GC와 긴밀히 통합되어 있어 완전히 독립적.

Java JVM: TLAB

Java는 GC가 메모리 관리를 담당하지만, 할당 자체도 빠르다:

  • TLAB (Thread-Local Allocation Buffer): 스레드별 연속 메모리.
  • Bump pointer 방식으로 O(1) 할당.
  • TLAB이 차면 새 것을 Eden에서 받음.
  • 가비지 컬렉션이 free를 담당.

Python: 복잡한 구조

Python은:

  • Pymalloc: 작은 객체용 (bump 스타일).
  • glibc malloc: 큰 객체용.
  • Reference counting: 해제 시점 결정.

GIL 때문에 스레드 경합은 문제 안 되지만, 작은 객체 할당이 매우 많아 pymalloc이 중요.

V8 (JavaScript): Generational GC

  • New space: bump allocator, 짧은 생명주기.
  • Old space: mark-compact, 긴 생명주기.
  • Large object space: mmap 기반.

11. 디버깅 도구

Valgrind

메모리 에러 탐지의 고전:

valgrind --leak-check=full --show-leak-kinds=all ./my_app
  • 메모리 누수.
  • 이미 해제된 메모리 접근.
  • 초기화되지 않은 값 사용.

단점: 매우 느림 (10~50배).

AddressSanitizer (ASan)

컴파일 타임에 삽입되는 빠른 sanitizer:

gcc -fsanitize=address -g -o my_app my_app.c
./my_app  # 메모리 에러 자동 감지
  • Valgrind의 10배 빠름 (2~3배 오버헤드).
  • 거의 모든 유형의 메모리 에러 탐지.
  • 프로덕션에선 부적절 (오버헤드).

Heap Profiling

# jemalloc
MALLOC_CONF="prof:true" ./my_app
jeprof --text ./my_app heap.prof

# tcmalloc
HEAPPROFILE=/tmp/heap ./my_app
pprof --text ./my_app /tmp/heap.*.heap

# mimalloc
MIMALLOC_SHOW_STATS=1 ./my_app

/proc/[pid]/maps, smaps

cat /proc/[pid]/smaps | grep -E 'Size|Rss'

실제 메모리 매핑 상황을 볼 수 있다. Arena가 어디 있는지, mmap이 얼마나 많은지 등.


12. 실전 사례: 문제와 해결

사례 1: Python 다중 프로세스의 메모리 폭발

증상: Python 웹 서버(gunicorn, uwsgi)가 시간이 지나며 RSS가 급증.

원인: glibc ptmalloc이 per-thread arena를 너무 많이 생성. 각 arena는 128MB까지 점유 가능. 컨테이너 환경에서 OOM 킬.

해결:

MALLOC_ARENA_MAX=2 gunicorn app:app

RSS가 수 GB에서 수백 MB로 감소하는 경우가 흔하다.

사례 2: Redis의 Fragmentation

증상: Redis의 mem_fragmentation_ratio가 1.5 이상.

원인: 다양한 크기의 키/값이 섞여서 hole 많음.

해결:

  1. jemalloc으로 교체 (Redis 4.0+ 기본).
  2. activedefrag yes 활성화.
  3. 필요시 DEBUG RELOAD 또는 재시작.

사례 3: MySQL의 성능 저하

증상: MySQL이 많은 동시 연결에서 느려짐.

원인: glibc malloc의 arena 경합.

해결:

LD_PRELOAD=/usr/lib/libjemalloc.so.2 mysqld
# 또는
LD_PRELOAD=/usr/lib/libtcmalloc.so mysqld

MySQL 문서가 이를 권장한다. 특히 InnoDB 워크로드에 효과적.

사례 4: Go 프로그램의 메모리 반환

증상: Go 프로그램이 메모리를 OS로 잘 반환하지 않음.

원인: Go 런타임이 지연 반환 정책 사용.

해결:

debug.FreeOSMemory()  // 명시적 반환 요청

Go 1.16+부터는 기본 정책이 개선되어 이 트릭이 덜 필요하다.

사례 5: Electron의 메모리 사용

증상: Electron 앱이 메모리를 과도하게 사용.

원인: V8 + Chromium의 여러 프로세스가 각각 힙을 유지.

해결:

  • --js-flags="--max-old-space-size=256" 으로 V8 힙 제한.
  • 프로세스 수 최소화.
  • Native 모듈에 jemalloc 링크.

13. 미래: 할당자 연구의 최전선

Large Page 최적화

64KB, 2MB, 1GB 페이지를 더 적극적으로 활용. TLB miss 감소. ARM Graviton, Intel Sapphire Rapids에서 효과 큼.

Persistent Memory 할당자

Intel Optane (단종되었지만) 같은 PMEM용 할당자. 재시작 후에도 유효한 포인터 관리.

NUMA-aware Allocation

코어별이 아닌 NUMA 노드별 할당. 원격 메모리 접근 회피.

Learned Allocators

머신러닝으로 size class와 정책을 자동 조정. 연구 중인 주제.

Rust의 Global Allocator 생태계

Rust는 global allocator를 쉽게 교체할 수 있어, 수많은 할당자 실험이 가능:

  • jemallocator
  • mimallocator
  • tcmalloc-rs
  • wee_alloc (WebAssembly용 작은 allocator)
  • bumpalo (arena)
  • talc (실시간용)

퀴즈로 복습하기

Q1. glibc ptmalloc에서 MALLOC_ARENA_MAX=2가 효과적인 상황은?

A. 멀티스레드 환경인데 메모리 사용이 우선인 경우다. ptmalloc은 기본적으로 "코어 수 × 8"개의 arena를 생성할 수 있고, 각 arena는 독립적인 힙을 유지한다. 많은 스레드가 있을 때 arena가 폭증하면 실제 사용하지 않는 메모리도 할당자가 붙잡고 있어 RSS가 급증한다. 특히 컨테이너 환경(제한된 메모리)에서 OOM의 원인이 된다. Python gunicorn/uwsgi, Java 등에서 흔히 RSS를 수 배 줄이는 효과가 있다. 대가는 arena 경합으로 인한 약간의 성능 저하지만, 메모리 제약이 성능보다 중요할 때 유효한 트레이드오프다.

Q2. Thread cache가 할당자 성능에 왜 그렇게 중요한가?

A. 일반적인 워크로드에서 할당/해제의 99% 이상이 thread cache에서 처리되기 때문이다. Thread cache는 스레드 로컬 저장소라 락이 필요 없다. 반면 전역 힙은 mutex 경합이 발생하며, 수십 코어 환경에서 확장성 병목이 된다. tcmalloc, jemalloc, mimalloc 모두 thread cache가 핵심 설계 요소다. 없다면 매 malloc/free가 락을 잡아야 하고, 현대 고동시성 애플리케이션의 성능은 1/10 이하로 떨어질 것이다. Cache 크기는 보통 스레드당 수 MB 수준으로 제한되며, 너무 크면 메모리 낭비, 너무 작으면 refill 오버헤드가 생긴다.

Q3. Internal과 External fragmentation의 차이와 해결법은?

A.

  • Internal Fragmentation: 할당자가 요청보다 큰 블록을 주어서 생기는 낭비. 예: 17바이트 요청에 24바이트 할당 → 7바이트 낭비. 해결: size class를 세분화 (jemalloc의 40개 클래스 vs buddy의 2의 거듭제곱).

  • External Fragmentation: 전체 free 메모리는 충분하지만 연속된 공간이 없어 큰 요청이 실패하는 현상. 예: 10KB 여유가 있어도 1KB씩 조각나 있으면 2KB 요청 실패. 해결: chunk 병합 (coalescing), compaction (GC 언어), 또는 크기별 분리 풀 (같은 size class만 모음).

실전에선 둘 다 일어나며, 할당자 선택과 워크로드 패턴이 큰 영향을 준다. jemalloc의 "low fragmentation" 명성은 external fragmentation을 잘 관리한다는 뜻이다.

Q4. tcmalloc의 per-CPU 모드와 per-thread 모드의 차이는?

A. Per-thread 모드는 각 스레드가 자신의 캐시를 가진다. 문제: 스레드 수가 수천이 되면 캐시 메모리 오버헤드가 폭증한다 (예: 1000 스레드 × 2MB = 2GB). 또한 사용되지 않는 스레드의 캐시는 낭비다.

Per-CPU 모드CPU 코어마다 캐시를 두고, 스레드가 "현재 실행 중인 CPU"의 캐시에 접근한다. rseq (restartable sequences) 시스템콜 덕분에 스레드가 preempt되어 다른 CPU로 이동해도 안전하게 처리된다. 장점: (1) 캐시 수가 CPU 수에 비례 (보통 수십 개), (2) 핫 데이터가 CPU 로컬 캐시에 남아 L1/L2 활용도 높음, (3) 수천 스레드 워크로드에서 메모리 효율. 2020년 이후 google/tcmalloc은 이 모드를 기본으로 한다.

Q5. Redis가 jemalloc을 번들하는 이유와 mem_fragmentation_ratio의 의미는?

A. Redis는 다양한 크기의 key/value를 대량으로 다룬다. glibc ptmalloc은 이런 패턴에 fragmentation이 쌓이는 문제가 있었다. 장시간 실행 후 mem_fragmentation_ratio가 1.5~2.0까지 올라가는 경우가 흔했다. Redis 팀은 jemalloc으로 교체 후 25% 메모리 절약을 경험했고, 이제 기본 번들이다.

mem_fragmentation_ratio = RSS / allocated:

  • 1.0~1.1: 이상적. 오버헤드 거의 없음.
  • 1.1~1.5: 정상. 약간의 overhead.
  • 1.5 이상: 문제. fragmentation 심각. activedefrag 활성화 또는 재시작 고려.
  • 1.0 미만: 위험! 메모리가 스왑에 나가 있거나 RSS가 부정확. 즉시 조사.

이 비율을 모니터링하는 것이 Redis 운영의 기본이다.


마치며: 당신이 쓰는 모든 줄의 뒤에는

핵심 정리

  1. malloc은 복잡하다: 크기 분류, arena, thread cache, fragmentation 관리.
  2. glibc는 기본이지만 최적은 아니다: 멀티스레드 고부하 환경에서 대안 필요.
  3. jemalloc: 메모리 효율 + 프로파일링. Redis, Facebook 표준.
  4. tcmalloc: 속도의 정점. Google의 선택.
  5. mimalloc: 단순함과 locality의 아름다움. 최신 대안.
  6. Thread cache: 확장성의 비밀.
  7. Fragmentation: 측정하고 관리해야.
  8. 커스텀 할당자: 특정 패턴엔 직접 만드는 것이 답.

언제 할당자를 바꿀 것인가?

  • RSS가 예상보다 크다 → MALLOC_ARENA_MAX 또는 jemalloc.
  • 할당 경합이 보인다 (perf로) → tcmalloc 또는 mimalloc.
  • 장시간 fragmentation 문제 → jemalloc + active defrag.
  • 수천 스레드 환경 → per-CPU tcmalloc.
  • 짧은 생명주기 객체가 많다 → arena/bump allocator.

마지막 교훈

C/C++를 쓰지 않아도 이 지식은 유효하다. 당신의 Python, Go, Rust, Java 프로그램도 결국 어떤 할당자 위에서 돌아간다. 메모리 사용량이 이상하게 높을 때, 성능이 멀티코어에서 선형 확장 안 될 때, fragmentation이 의심될 때 — 그때 이 지식이 빛을 발한다.

당신이 평생 쓸 malloc() 한 줄 뒤에는 수십 년의 시스템 엔지니어링이 서 있다. 그것을 안다는 건 당신이 더 좋은 엔지니어가 되는 길이다.


참고 자료