LabHub

블로그

메모리 할당자 완벽 가이드 — ptmalloc, jemalloc, tcmalloc, mimalloc: 내부 구조, 단편화, 멀티스레드, NUMA, 보안 (2025)

들어가며 — 할당자는 보이지 않지만 어디에나 있다

malloc을 호출해본 모든 사람이 메모리 할당자를 사용해본 셈이다. 그러나 그 안에서 무슨 일이 일어나는지 아는 사람은 드물다. C 코드의 malloc(1024) 한 줄이 실제로는 수백 줄의 정교한 알고리즘을 거친다. 어떤 사이즈 클래스에 속하는지, 어떤 스레드의 캐시에 빈 공간이 있는지, arena를 잠가야 할지, 새 청크를 OS에서 받아야 할지 — 모든 결정이 마이크로초 단위로 일어난다.

이 결정들이 잘못되면 단편화로 인해 메모리 사용이 두 배가 되거나, 멀티스레드 락 경쟁으로 처리량이 절반으로 떨어지거나, 보안 취약점이 생긴다. Redis가 jemalloc을 쓰는 이유, Firefox가 mozjemalloc을 만든 이유, Chrome이 PartitionAlloc을 직접 만든 이유는 모두 이 결정들에서 나온다.

이 글은 메모리 할당자의 모든 것을 다룬다. 1987년 Doug Lea의 dlmalloc에서 시작해 ptmalloc, jemalloc, tcmalloc, mimalloc까지의 진화, 각 할당자의 내부 구조, 단편화의 본질, 멀티스레드 확장성의 어려움, NUMA, 보안, 그리고 실전 적용 사례까지. 1,300줄에 달하지만 모든 절은 독립적으로 읽을 수 있다.

이 글은 시리즈 두 글의 자매 작품이다:

이 글은 그 둘 사이 — 사용자 공간 할당자가 raw 바이트를 어떻게 관리하나를 다룬다. 셋이 모이면 메모리 계층의 풍경 전체가 그려진다.


1. 할당자가 푸는 문제

1.1 OS는 큰 단위로만 준다

OS는 메모리를 페이지(4KB) 단위로 준다. mmap 한 번에 최소 4KB. 그러나 사용자 코드는 malloc(8), malloc(24), malloc(123) 같은 작은 요청을 한다.

매번 mmap을 호출하면 두 가지 문제:

  1. 메모리 낭비 — 8바이트 요청에 4KB 페이지를 통째로 줌
  2. 시스템 콜 오버헤드 — 매 malloc이 syscall이면 매우 느림

해결: 할당자가 OS에서 큰 청크 (수십 KB-수 MB)를 받아두고, 그 안에서 작은 요청을 잘라서 분배.

1.2 free의 책임

free(ptr)을 호출하면 그 메모리가 다시 사용 가능해야 한다. 그러나 어떤 청크의 어느 부분이었는지 어떻게 알까? 그리고 인접 free 영역들과 합쳐서 큰 영역으로 만들 수 있을까?

이를 위해 할당자는 메타데이터를 유지한다. 청크별 헤더, free list, bitmap 등. 메타데이터가 너무 많으면 오버헤드, 너무 적으면 free가 어려워진다.

1.3 단편화 (Fragmentation)

할당과 해제를 반복하다 보면 단편화가 일어난다:

할당자의 핵심 도전 중 하나가 단편화 최소화.

1.4 멀티스레드

여러 스레드가 동시에 malloc을 호출하면 어떻게 될까? 단순한 글로벌 락은 락 경쟁으로 처리량이 폭락한다. 모던 할당자들은 thread-local cache, per-thread arena, lock-free free list 같은 기법으로 락 경쟁을 회피한다.

1.5 한 줄 요약

메모리 할당자는 "OS에서 받은 큰 메모리를 작게 잘라서 사용자 코드에 빠르고 효율적으로 분배하는" 시스템이다. 빠르고 (1us 이내), 효율적이고 (단편화 적음), 멀티스레드에 강해야 한다.


2. 역사 — dlmalloc에서 mimalloc까지

2.1 1987 — Doug Lea의 dlmalloc

Doug Lea가 작성한 첫 범용 malloc. 단순하고 효율적이고 portable했다. 거의 모든 후속 할당자의 기반이 되었다. ANSI C 표준 라이브러리에 들어갔고, 한 시대의 표준이었다.

핵심 아이디어:

2.2 1996 — Wolfram Gloger의 ptmalloc

dlmalloc은 단일 스레드용이었다. Wolfram Gloger가 멀티스레드 지원을 위해 fork. 핵심 추가: arena. 여러 개의 독립적 dlmalloc 인스턴스를 두고, 각 스레드가 자기 arena를 사용. 락 경쟁 완화.

ptmalloc은 glibc에 통합되어 Linux의 기본 malloc이 되었다. 30년이 지난 지금도 그렇다.

2.3 2005 — Jason Evans의 jemalloc

Jason Evans가 FreeBSD를 위해 작성. 처음부터 멀티스레드와 단편화 회피를 목표. Mozilla가 Firefox 3에 채택 (mozjemalloc), Facebook이 자기 인프라 전체에 채택. 대형 서버 워크로드의 사실상 표준이 되었다.

2.4 2007 — Google의 tcmalloc

Sanjay Ghemawat 등이 Google에서 작성. Thread-Caching Malloc의 줄임말. 핵심 아이디어: 매우 적극적인 thread-local cache. 스레드가 자기 캐시에서 작은 할당을 처리하면 글로벌 데이터 구조를 만지지 않음.

2.5 2019 — Microsoft Research의 mimalloc

Daan Leijen이 Microsoft Research에서 작성. 가장 새로운 메이저 할당자. Free list shardingheartbeat 메커니즘으로 단편화와 락 경쟁을 동시에 해결. 작고 (10K LOC 이하), 빠르고, 메모리 효율도 좋다.

2.6 그 외

각자 다른 트레이드오프를 만든다.

★ Insight ─────────────────────────────────────


3. 할당자의 기본 어휘

3.1 Chunk

할당자가 사용자에게 주는 메모리 단위. 보통 헤더 + 사용자 영역 + (옵션) 푸터로 구성. 헤더에는 크기, free 여부, 다른 메타데이터.

+--------+-----------------+---------+
| header | user data       | footer  |
+--------+-----------------+---------+
^                          ^
|                          |
metadata               next chunk

malloc(size)size 바이트의 user data를 가진 chunk를 반환한다. 실제 크기는 size + header + footer.

3.2 Free List

해제된 chunk들의 연결 리스트. free(ptr) 시 chunk를 적절한 free list에 추가. malloc(size) 시 free list에서 적절한 크기의 chunk를 꺼냄.

단순한 단일 free list는 검색이 O(N)이라 느리다. 그래서 사이즈별 분류를 한다.

3.3 Bin

같은 (또는 비슷한) 사이즈 chunk들의 free list. ptmalloc은 약 130개의 bin을 가진다 — fastbins, smallbins, largebins, unsorted bin.

bin 구조의 장점: 검색이 O(1)에 가까움. 사이즈를 인덱스로 변환하고 해당 bin에서 첫 chunk를 꺼냄.

3.4 Arena

독립적인 chunk 풀과 그에 딸린 메타데이터 + 락. 멀티스레드 할당자는 여러 arena를 둬서 락 경쟁을 회피한다.

ptmalloc의 arena 수 기본값: 8 * num_cpus (보통). jemalloc은 더 적극적인 정책.

3.5 Best-fit, First-fit, Next-fit

free list 탐색 전략:

거의 모든 모던 할당자가 segregated 변형을 쓴다.

3.6 Slab Allocation

같은 사이즈 객체를 위한 전용 풀. 페이지를 같은 사이즈로 잘라서 객체 슬롯들로 만든다. 할당/해제는 매우 빠르다 — 비트맵 또는 free list만 만짐.

Linux 메모리 글에서 본 SLUB이 같은 아이디어. 사용자 공간 할당자도 비슷한 기법을 쓴다.

3.7 Coalescing

인접 free chunk들을 합쳐서 더 큰 free chunk로 만드는 것. 단편화 회피의 핵심. 즉시(immediate) 또는 지연(deferred)될 수 있다.


4. ptmalloc — glibc의 malloc

ptmalloc은 30년 전 만들어졌고 30년 동안 점진적으로 진화했다. 가장 많은 코드가 돌고 있는 할당자이지만, 가장 단점이 많은 할당자이기도 하다.

4.1 Chunk 레이아웃

+----------------+
| prev_size      |  4 또는 8 bytes (이전 chunk가 free일 때만 의미 있음)
+----------------+
| size + flags   |  3 LSB는 플래그 (PREV_INUSE, IS_MMAPPED, NON_MAIN_ARENA)
+----------------+
| user data      |
| or             |
| fd, bk, ...    |  (free일 때 다음/이전 free chunk 포인터)
| ...            |
+----------------+

4.2 Arena

각 arena는 자기 락을 가진다. 같은 arena에 동시 접근하는 스레드들은 락을 경합한다.

기본 arena 수 한도: MALLOC_ARENA_MAX = 8 * num_cores. 환경 변수로 조정 가능.

MALLOC_ARENA_MAX=2 ./my-app   # arena 2개로 제한 (메모리 절약)

4.3 Bin 구조

ptmalloc의 핵심 자료구조. 약 130개의 bin:

4.4 Tcache (Linux glibc 2.26+)

매우 중요한 진화. 각 스레드별 캐시. 작은 chunk를 자기 캐시에 보관해서 arena 락 없이 처리.

// 의사 코드
void *malloc(size_t size) {
    int idx = size_to_tcache_idx(size);
    if (tcache[idx]) {
        void *ptr = tcache[idx];
        tcache[idx] = *(void **)ptr;  // 다음 슬롯 가리킴
        return ptr;
    }
    // tcache 미스 — arena로
    return arena_malloc(size);
}

기본 설정: 각 사이즈 클래스당 7개 chunk, 총 64 사이즈 클래스. 약 1KB 이하의 작은 할당이 거의 락 없이 처리됨.

4.5 mmap Threshold

큰 할당 (기본 128KB+)은 arena를 거치지 않고 직접 mmap. 이유:

문제: 매우 큰 할당이 자주 일어나면 mmap/munmap 비용이 큼. 동적으로 threshold가 조정된다 (M_MMAP_THRESHOLD 등).

4.6 ptmalloc의 한계

이 한계 때문에 큰 워크로드에서는 ptmalloc을 jemalloc 또는 tcmalloc으로 교체하는 것이 흔하다.

# LD_PRELOAD로 jemalloc 사용
LD_PRELOAD=/usr/lib/libjemalloc.so ./my-app

5. jemalloc

5.1 디자인 철학

5.2 사이즈 클래스

jemalloc은 사이즈를 매우 정밀하게 분류한다. 예시 (64비트 시스템):

small:  8, 16, 32, 48, 64, 80, 96, 112, 128, 160, 192, 224, 256, ...
large:  4 KiB, 5 KiB, ..., 14 KiB
huge:   16 KiB, 20 KiB, 24 KiB, ..., 4 GiB

각 사이즈 클래스는 자기 free list를 가진다. 1.25배씩 차이나는 정교한 분류 — internal fragmentation이 최소화됨.

5.3 Region, Run, Chunk

jemalloc의 메모리 계층:

[Chunk (4MB)]
  [Run for 16-byte regions]
    [region][region][region]...
  [Run for 32-byte regions]
    [region][region]...
  [Run for 64-byte regions]
    ...

5.4 Per-Thread Arena

각 스레드가 자기 arena에 핀된다. 작은 할당은 자기 arena의 thread cache (tcache)에서 처리. 미스 시 arena로 가서 락을 잡음.

arena 수는 동적으로 조정된다. 기본은 4 * num_cpus 정도. CPU 친화성을 활용.

5.5 Background Threads

jemalloc은 백그라운드 스레드를 띄워서 사용 안 하는 메모리를 OS에 돌려주는 작업을 한다. 이는 RSS를 낮게 유지하는 데 결정적이다.

metadata.background_thread:true

5.6 Profiling

jemalloc은 강력한 메모리 프로파일링을 내장한다.

MALLOC_CONF=prof:true,lg_prof_sample:19,prof_prefix:./jeprof ./my-app

이는 매 약 512KB 할당마다 호출 스택 샘플을 기록한다. 끝에 jeprof 도구로 분석하면 어떤 함수가 가장 많은 메모리를 할당했는지 알 수 있다.

Facebook은 자기 인프라 디버깅에 이 기능을 광범위하게 사용한다.

5.7 단편화 회피의 미세함

jemalloc의 단편화 회피는 여러 기법의 조합이다:

  1. 정밀한 사이즈 클래스 — internal fragmentation 최소화
  2. 사이즈 클래스 격리 — 다른 사이즈는 다른 run에. external fragmentation 격리.
  3. Best-fit 내 사이즈 클래스 — run 안에서는 best-fit
  4. Run 단위 회수 — 한 run의 모든 region이 free되면 run을 통째로 회수

이 조합 덕분에 jemalloc은 ptmalloc보다 30-50% 적은 메모리를 쓰는 경우가 흔하다.


6. tcmalloc

6.1 핵심 아이디어

이름 그대로 — Thread-Caching Malloc. 각 스레드가 자기 캐시를 가지고, 작은 할당의 거의 100%가 캐시에서 처리. 락은 거의 안 잡힘.

6.2 세 계층 구조

tcmalloc의 메모리는 세 계층:

  1. Thread Cache: 스레드별. 작은 객체 (256KB 이하)의 free list.
  2. Central Free List: 글로벌. 사이즈별 free list. 락 보호.
  3. Page Heap: 큰 단위 (페이지) 관리. mmap에서 받은 큰 영역.
[Thread 1 cache] -- [Central Free List] -- [Page Heap] -- [OS mmap]
[Thread 2 cache] -- 
[Thread 3 cache] -- 

작은 할당은 thread cache → 미스면 central → 미스면 page heap → OS.

6.3 사이즈 클래스

tcmalloc도 사이즈 클래스 기반. 약 88개 사이즈 클래스. jemalloc만큼 정밀하지는 않지만 충분히 효율적.

6.4 Thread Cache 동작

// 의사 코드
void *malloc(size_t size) {
    if (size > kMaxSize) {
        // 큰 할당 — page heap으로 직접
        return PageHeap::Alloc(size);
    }
    int cl = SizeClass(size);
    void *ptr = thread_cache.list[cl].pop();
    if (!ptr) {
        // cache 미스 — central에서 batch로 가져옴
        thread_cache.list[cl].refill_from_central(cl);
        ptr = thread_cache.list[cl].pop();
    }
    return ptr;
}

핵심 트릭: cache 미스 시 한 번에 여러 객체 (예: 32개)를 가져온다. 다음 32번의 할당은 다시 락 없이 처리됨.

6.5 Cache 사이즈 동적 조정

스레드가 많은 할당을 하면 cache가 커지고, 적게 할당하면 줄어든다. 동적 조정 알고리즘. 또한 MallocExtension::ReleaseFreeMemory()로 명시적으로 줄일 수 있다.

6.6 TCMalloc-NG (Next Generation)

Google의 새 tcmalloc. 옛 tcmalloc과 호환 인터페이스이지만 내부는 새로 작성. 핵심 변화:

GitHub의 google/tcmalloc 리포지토리가 이 새 버전이다. 옛 tcmalloc은 gperftools.

6.7 사용

LD_PRELOAD=/usr/lib/libtcmalloc.so ./my-app

또는 빌드 시 링크:

gcc app.c -o app -ltcmalloc

7. mimalloc

7.1 디자인 철학

Microsoft Research가 2019년 발표. "작고 빠른 할당자". 주요 목표:

7.2 Free List Sharding

mimalloc의 핵심 혁신. 전통적인 할당자는 한 사이즈 클래스에 한 free list를 둔다. mimalloc은 page 단위로 free list를 분리한다.

[Page A (16-byte slots)] → [free list A]
[Page B (16-byte slots)] → [free list B]
[Page C (16-byte slots)] → [free list C]

각 page에 자기 free list. 할당은 한 page의 free list에서. 그 page가 다 차면 다른 page로.

이 구조의 이점:

7.3 Heartbeat

Mimalloc은 주기적으로 "heartbeat"를 실행한다 — 사용 안 하는 page를 OS에 돌려주고, 통계를 갱신하고, 단편화를 정리한다. 이는 작은 백그라운드 작업이고 거의 비용이 없다.

7.4 Free Delayed

Free된 객체는 즉시 page의 free list로 안 간다. 일단 thread-local "delayed free" 리스트에 들어간다. 주기적으로 batch로 page에 반환.

이는 cache 친화성을 높이고 (free 작업이 batch), 멀티스레드 시 다른 스레드의 할당과 충돌을 줄인다.

7.5 Sizes

mimalloc의 기본 page 사이즈: 64KB (작은 객체용). 4MB (중간 객체용). 1GB (큰 객체용).

작은 객체는 64KB page에 잘게 잘려서 들어가고, 중간/큰 객체는 더 큰 page를 통째로 차지하거나 나눠 쓴다.

7.6 안전성

mimalloc은 다음 검증을 옵션으로 켤 수 있다:

이는 hardened allocator (다음 절)와 일부 겹친다.

7.7 사용

LD_PRELOAD=/usr/lib/libmimalloc.so ./my-app

또는:

gcc app.c -o app -lmimalloc

★ Insight ─────────────────────────────────────


8. 벤치마크 — 어느 것이 가장 빠른가

8.1 절대적 답은 없다

워크로드에 따라 답이 다르다. 일반적 경향:

8.2 대표적 벤치마크

mstress benchmark (mimalloc 팀이 공개):

AllocatorTime (s)RSS (GB)
ptmalloc18.24.5
jemalloc12.43.2
tcmalloc11.83.5
mimalloc10.93.1

(숫자는 대략적, 워크로드에 따라 다름. 출처: mimalloc 논문.)

Redis benchmark: Redis는 jemalloc을 디폴트로 쓴다. ptmalloc 대비 메모리가 약 40% 적게 든다고 알려져 있다. tcmalloc/mimalloc도 비슷한 결과.

8.3 latency tail

평균 시간보다 p99 latency가 더 중요한 경우:

Allocatormean (us)p99 (us)p99.9 (us)
ptmalloc0.85.228
jemalloc0.73.112
tcmalloc0.62.59
mimalloc0.62.38

(역시 대략적 수치)

8.4 어떻게 측정하나

# 실제 워크로드로 비교
for alloc in libc jemalloc tcmalloc mimalloc; do
    if [ "$alloc" = "libc" ]; then
        ./my-app
    else
        LD_PRELOAD=/usr/lib/lib${alloc}.so ./my-app
    fi
done

벤치마크 워크로드는 자기 워크로드를 흉내내야 한다. 일반 벤치마크의 결과는 참고만.


9. 단편화 — 진짜 깊이 있게

9.1 두 종류의 단편화

9.2 External 단편화 측정

fragmentation = 1 - (largest_free_block / total_free_memory)

이 값이 높으면 단편화 심함. 0.9 이상이면 큰 할당이 거의 안 됨.

9.3 Internal 단편화 측정

internal_frag = (allocated_size - requested_size) / allocated_size

전체 할당된 메모리 중 사용 안 되는 비율. 보통 5-15%.

9.4 사이즈 클래스가 단편화에 미치는 영향

사이즈 클래스가 정밀할수록 internal fragmentation은 줄지만, 클래스 수가 많아져 메타데이터가 커진다. 적당한 균형이 필요.

jemalloc은 약 200개의 클래스 (8KB 미만에서). tcmalloc은 약 88개. mimalloc은 약 70개. ptmalloc fastbins는 10개 + smallbins 62개.

9.5 단편화의 측정 도구

9.6 단편화 회피 전략

  1. 객체 풀: 같은 사이즈 객체를 미리 할당해두고 재사용.
  2. arena per workload: 짧은 수명 객체와 긴 수명 객체를 다른 arena에.
  3. Bulk free: 한 번에 많은 객체를 free (할당자가 효율적으로 처리).
  4. Right-sizing: 정확한 사이즈로 요청 (사이즈 클래스 경계를 의식).

10. 멀티스레드 어렵게 만드는 것

10.1 락 경쟁

전통적인 글로벌 락 할당자는 N개의 스레드가 동시에 malloc하면 1/N의 처리량밖에 안 나온다. 이를 회피하는 것이 모든 모던 할당자의 목표.

10.2 False Sharing

같은 cache line에 두 스레드의 데이터가 있으면, 하나가 쓸 때마다 다른 하나의 cache line이 invalidate된다. 이는 매우 느리다.

할당자는 cache line 단위로 padding해서 false sharing을 방지한다. 예: thread cache의 메타데이터를 64바이트 정렬.

10.3 NUMA 인지

NUMA 시스템에서는 CPU와 메모리의 거리가 중요하다. 할당된 페이지는 어느 노드에 있을까?

jemalloc은 NUMA를 인지한다. 옵션 (opt.thp:always, opt.metadata_thp:always)으로 NUMA 친화적 할당 가능.

tcmalloc-NG도 per-CPU cache로 NUMA 친화성을 자연스럽게 가진다.

10.4 Cross-Thread Free

스레드 A가 할당한 메모리를 스레드 B가 free할 수 있을까? 가능하지만 까다롭다.

mimalloc은 "deferred free" 메커니즘으로 처리. cross-thread free는 임시 큐에 들어가고 주기적으로 처리된다.

10.5 멀티스레드 벤치마크

스레드 수에 따른 처리량 (백만 op/s):

Allocator1 thread8 threads64 threads
ptmalloc123528 (락 경쟁)
jemalloc1165200
tcmalloc1270250
mimalloc1375280

ptmalloc은 8 스레드를 넘어서면 더 안 빨라진다 (오히려 느려짐). 다른 할당자들은 거의 선형 확장.


11. NUMA 인지 할당

11.1 왜 중요한가

Linux 메모리 글에서 봤듯, NUMA 시스템에서 원격 노드 액세스는 약 2배 느리다. 멀티 소켓 데이터베이스 서버에서는 결정적 차이.

11.2 First-Touch의 함정

Linux의 first-touch 정책은 "처음 접근한 CPU의 노드에 페이지 할당"이다. 이는 보통 좋다 — 그러나 할당자가 페이지를 미리 받아두면 깨진다.

예: 메인 스레드가 큰 arena를 할당. 나중에 worker 스레드들이 그 arena에서 chunk를 받아 씀. 그러면 모든 chunk가 메인 스레드의 노드에 있고, worker들은 원격 액세스.

11.3 해결책

11.4 jemalloc의 NUMA 옵션

MALLOC_CONF=arena_per_cpu:true ./my-app

이 옵션으로 각 CPU에 별도 arena가 만들어지고, 자동으로 first-touch가 옳게 동작.


12. ASAN과 메모리 디버깅

12.1 AddressSanitizer (ASAN)

LLVM/Clang에 들어 있는 메모리 에러 감지 도구. 컴파일 시 활성화:

clang -fsanitize=address -g app.c -o app

ASAN은 자기만의 할당자를 사용한다. 모든 할당된 영역 주변에 "redzone"을 둬서 buffer overflow를 즉시 감지. free된 영역은 한동안 quarantine에 보관해서 use-after-free를 감지.

오버헤드: 약 2배 (시간), 약 3배 (메모리). 그러나 개발 단계에서는 매우 가치 있음.

12.2 MemorySanitizer (MSAN)

초기화 안 된 메모리 읽기를 감지. ASAN과 다른 도구.

clang -fsanitize=memory -g app.c -o app

12.3 LeakSanitizer (LSAN)

메모리 누수 감지. ASAN의 일부로 자동 활성화.

12.4 Valgrind

ASAN보다 더 깊은 도구 (그러나 더 느림). 자기만의 가상 머신 위에서 프로그램을 실행. 모든 메모리 액세스를 검사.

valgrind --tool=memcheck ./app

valgrind는 컴파일 옵션 없이도 작동한다 (바이너리만 있어도). ASAN보다 10-50배 느리지만 더 자세하다.

12.5 Heaptrack

KDE 프로젝트의 메모리 프로파일러. 모든 할당을 기록하고 GUI로 분석.

heaptrack ./my-app
heaptrack_gui heaptrack.my-app.12345.gz

매우 직관적인 시각화 — flamegraph 형태로 어디서 메모리가 새는지 본다.


13. Hardened Allocators — 보안 우선

13.1 동기

메모리 할당자의 메타데이터는 매력적인 공격 표면이다. heap exploit의 상당 부분이 할당자 메타데이터 손상에서 시작한다 (예: house of force, fastbin attack).

Hardened allocator는 보안을 처음부터 설계 목표로 삼는다. 약간의 성능을 희생해서 공격을 어렵게 만든다.

13.2 보안 기법

13.3 대표 hardened allocators

13.4 비용

Hardened allocator는 보통 일반 할당자보다 10-30% 느리다. 메모리도 약간 더 쓴다. 그러나 보안이 중요한 환경 (브라우저, OS, 보안 critical 앱)에서는 가치 있다.

13.5 PartitionAlloc — Chrome의 선택

Chrome 브라우저는 자기만의 할당자 PartitionAlloc을 만들었다. 핵심:

Chrome은 이를 광범위하게 적용해서 memory safety bug 수를 크게 줄였다.


14. Slab과 Object Pool

14.1 객체 풀의 가치

같은 사이즈의 객체가 자주 만들어지고 해제되는 경우, 일반 할당자보다 객체 풀이 훨씬 빠르다.

typedef struct ObjectPool {
    void *free_list;
    size_t object_size;
} ObjectPool;

void *pool_alloc(ObjectPool *p) {
    if (!p->free_list) {
        // 새 batch 할당
        char *block = malloc(p->object_size * 64);
        for (int i = 0; i < 64; i++) {
            void **slot = (void **)(block + i * p->object_size);
            *slot = p->free_list;
            p->free_list = slot;
        }
    }
    void *obj = p->free_list;
    p->free_list = *(void **)obj;
    return obj;
}

void pool_free(ObjectPool *p, void *obj) {
    *(void **)obj = p->free_list;
    p->free_list = obj;
}

매 할당이 두세 줄의 인스트럭션. malloc/free의 50-100배 빠르다.

14.2 어디에 쓰이나

거의 모든 고성능 인프라가 어떤 형태의 객체 풀을 가진다.

14.3 Arena 할당

다른 패턴: 짧은 수명의 많은 객체를 한 arena에 할당하고, 끝에 통째로 free. 매 객체 free가 거의 무료.

typedef struct Arena {
    char *base;
    size_t pos;
    size_t cap;
} Arena;

void *arena_alloc(Arena *a, size_t size) {
    void *ptr = a->base + a->pos;
    a->pos += size;
    return ptr;
}

void arena_reset(Arena *a) {
    a->pos = 0;  // 모든 객체 무효, 메모리 재사용
}

컴파일러, 인터프리터, request-scoped 워크로드 (HTTP 핸들러)에 매우 적합.


15. Rust의 alloc API

15.1 글로벌 할당자

Rust는 #[global_allocator] 어트리뷰트로 글로벌 할당자를 지정할 수 있다.

use jemallocator::Jemalloc;

#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;

fn main() {
    let v = vec![1, 2, 3];
}

기본은 시스템 malloc (Linux에서는 ptmalloc). jemallocator, tcmalloc-rs, mimalloc 같은 crate로 교체 가능.

15.2 GlobalAlloc trait

unsafe trait GlobalAlloc {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8;
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
}

이 trait을 구현하면 자기만의 할당자를 만들 수 있다.

15.3 Allocator trait (nightly)

Allocator trait이 있는 nightly Rust에서는 컬렉션마다 다른 할당자를 쓸 수 있다.

let mut v: Vec<i32, MyAllocator> = Vec::new_in(MyAllocator);

이는 C++의 std::allocator와 비슷한 모델. 아직 stabilize 안 됨.

15.4 bumpalo — Rust용 arena

use bumpalo::Bump;

let arena = Bump::new();
let x = arena.alloc(42);
let y = arena.alloc("hello");
// arena가 drop될 때 모두 free

매우 빠른 짧은 수명 할당. 컴파일러 같은 워크로드에 자주 쓰임.


16. 사례 — Redis와 jemalloc

16.1 왜 Redis가 jemalloc인가

Redis는 키-값 데이터를 모두 메모리에 보관한다. 메모리 효율이 곧 비용이다.

Redis 팀은 처음에 ptmalloc을 썼지만, 운영 환경에서 RSS가 데이터 크기보다 훨씬 크다는 것을 발견했다 — 단편화 때문이다. jemalloc으로 바꾸자 RSS가 약 30% 줄었다.

이후 Redis는 jemalloc을 디폴트로 묶어서 배포한다 (make MALLOC=jemalloc). 옵션으로 libc malloc도 지원하지만, 운영 환경에서 그렇게 쓰는 곳은 거의 없다.

16.2 Redis 구성

Redis 빌드 시:

make MALLOC=jemalloc      # 기본
make MALLOC=tcmalloc      # 대안
make MALLOC=libc          # 시스템 malloc

운영 환경에서는 거의 항상 jemalloc.

16.3 메모리 통계

Redis는 jemalloc과 통합되어 메모리 통계를 노출한다:

INFO memory
used_memory:12345678
used_memory_rss:14567890
mem_fragmentation_ratio:1.18
mem_allocator:jemalloc-5.3.0

mem_fragmentation_ratio가 1.5 이상이면 단편화 심함. Redis 4.0부터 active defragmentation 기능이 있어서 자동으로 잠재적 fragmentation을 정리한다.


17. 사례 — Firefox와 mozjemalloc

17.1 역사

Firefox 3 시절, Mozilla는 메모리 사용량이 폭증하는 문제를 겪었다. 조사 결과 ptmalloc의 단편화가 주요 원인. jemalloc으로 교체하고 일부 Firefox 특화 수정을 가해 mozjemalloc을 만들었다.

이는 jemalloc 채택의 첫 메이저 사례였고, 이후 Facebook이 따랐다.

17.2 무엇이 다른가

mozjemalloc은 일반 jemalloc과 거의 같지만 일부 차이:

Firefox는 자기 할당자를 지속적으로 개선한다. 매 메이저 버전마다 메모리 사용량 개선이 release note에 등장.


18. 사례 — Chrome과 PartitionAlloc

18.1 보안 우선 설계

Chrome은 거대한 공격 표면이다 (모든 웹사이트가 잠재적 공격자). 메모리 안전성이 절대 우선.

PartitionAlloc은 이 요구에 답한다:

18.2 BackUp RefPtr (MiraclePtr)

각 raw pointer가 reference count로 추적된다. use-after-free 시 해당 메모리는 zero로 채워지고, 다음 액세스가 안전한 segfault로 이어진다 (exploit 불가).

이는 약간의 성능 비용이지만 (5-10%), Chrome의 위협 모델에서는 가치 있다.

18.3 결과

PartitionAlloc + BRP 적용 후 Chrome의 use-after-free 관련 보안 버그가 크게 감소했다. Google Project Zero의 통계에서 확인 가능.


19. 환경 변수와 튜닝

19.1 ptmalloc

MALLOC_ARENA_MAX=2 ./my-app                    # arena 수 제한
MALLOC_TRIM_THRESHOLD_=131072 ./my-app          # OS 반환 임계값
MALLOC_MMAP_THRESHOLD_=131072 ./my-app          # mmap 임계값
MALLOC_CHECK_=3 ./my-app                       # debug mode

19.2 jemalloc

# 통계 출력
MALLOC_CONF=stats_print:true ./my-app

# 프로파일링
MALLOC_CONF=prof:true,lg_prof_sample:19 ./my-app

# Background 스레드
MALLOC_CONF=background_thread:true ./my-app

# Decay time (메모리를 OS에 돌려주는 속도)
MALLOC_CONF=dirty_decay_ms:5000 ./my-app

19.3 tcmalloc

TCMALLOC_RELEASE_RATE=10 ./my-app             # 메모리 반환 속도
TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES=...     # thread cache 한도

19.4 mimalloc

MIMALLOC_VERBOSE=1 ./my-app                   # 통계 출력
MIMALLOC_LARGE_OS_PAGES=1 ./my-app             # huge page 사용
MIMALLOC_PURGE_DELAY=5000 ./my-app            # 메모리 반환 지연

20. 흔한 문제와 디버깅

20.1 "RSS가 예상보다 큼"

원인:

해결:

20.2 "lock contention 의심"

원인:

해결:

20.3 "단편화로 OOM"

원인:

해결:

20.4 "double free"

원인:

해결:

20.5 "use-after-free"

원인:

해결:


21. 미래 — 메모리 할당자의 다음 단계

21.1 NUMA 깊이

CXL, persistent memory 등 새 메모리 계층이 등장. 할당자가 어떤 메모리를 어떤 객체에 줄지 결정하는 것이 중요해진다.

21.2 보안 강화

브라우저, OS, 보안 critical 앱에서 hardened allocator의 채택이 늘어난다. PartitionAlloc 모델이 표준이 될 가능성.

21.3 GC와의 통합

일부 시스템은 GC와 malloc을 함께 쓴다. 두 시스템이 서로 알게 만드는 것이 새 도전.

21.4 ML 기반 사이즈 예측

객체의 수명을 예측해서 적절한 영역에 할당하는 ML 기반 할당자 연구가 있다. 아직 실험적.

21.5 Restartable Sequences (rseq)

Linux 4.18+ syscall. 사용자 공간의 atomic operation을 가능하게 한다. tcmalloc-NG가 활용. 다른 할당자도 채택할 가능성.


22. 결론 — 할당자는 끝나지 않는다

이 글을 다 읽었다면 다음 질문에 답할 수 있을 것이다:

할당자는 보이지 않지만 어디에나 있다. 잘 동작할 때는 신경 쓸 일이 없지만, 한 번 문제가 생기면 시스템 전체의 성능과 안정성을 좌우한다. 이 글이 그 보이지 않는 층을 조금 더 잘 보게 해줬기를 바란다.

이 글로 메모리 계층의 트릴로지가 완성되었다:

  1. Linux 메모리 관리 — 커널이 페이지를 어떻게 관리하나
  2. 가비지 컬렉션 — 런타임이 객체를 어떻게 관리하나
  3. 이 글 — 사용자 공간 할당자가 raw 바이트를 어떻게 관리하나

세 글이 모이면 "내 객체가 어디 사는지, 어떻게 거기 가는지, 언제 떠나는지"의 모든 단계에 답할 수 있다. mmap 한 syscall에서 시작해 jemalloc의 size class를 거쳐 GC의 minor cycle까지 — 이것이 모던 시스템 메모리의 풍경이다.

다음 글에서는 [Rust ownership과 borrow checker]를 다룰 예정이다. GC와 할당자 외에 메모리를 안전하게 다루는 또 다른 길이 있다.


부록 A — 참고 자료

부록 B — 자주 묻는 질문

Q: 어떤 할당자가 가장 좋나? A: 워크로드에 따라 다르다. 일반적으로:

Q: ptmalloc은 정말 안 좋나? A: "안 좋다"보다는 "오래되고 멀티스레드 확장성이 약하다." 단일 스레드, 작은 워크로드에는 충분.

Q: LD_PRELOAD로 할당자를 바꿔도 안전한가? A: 보통 안전. 그러나 일부 라이브러리는 자기 할당자를 가정하므로 호환성 이슈가 있을 수 있다. 항상 테스트 필요.

Q: Firefox와 Chrome은 왜 자기 할당자를 만들었나? A: 일반 할당자가 자기 워크로드에 최적이 아니어서. 또한 보안 요구 사항이 매우 높아서.

Q: jemalloc의 메모리 통계를 어떻게 보나? A: MALLOC_CONF=stats_print:true로 종료 시 출력. 또는 프로그램 안에서 mallctl("stats.allocated", ...)로 런타임 조회.

Q: 작은 객체를 자주 만드는데 빠르게 하려면? A: 객체 풀. 또는 arena 할당. malloc/free의 50-100배 빠르다.

Q: Rust에서도 할당자가 필요한가? A: 그렇다. Rust도 내부적으로 malloc-like 함수를 호출한다. #[global_allocator]로 교체 가능. jemallocator, mimalloc 등 crate가 있다.

Q: Valgrind와 ASAN 중 어느 것이 좋나? A: ASAN이 빠르고 (2배 정도), valgrind가 더 자세하다 (그러나 10-50배 느림). 개발 중에는 ASAN, 깊은 디버깅에는 valgrind.


부록 C — 미니 용어집


이 글로 메모리 트릴로지가 완성되었다. Linux 메모리 관리, 가비지 컬렉션, 그리고 이 글이 셋이 모이면 메모리 계층의 풍경이 그려진다 — 페이지에서 사이즈 클래스를 거쳐 GC 사이클까지. 다음 글에서는 Rust의 ownership과 borrow checker 같은 다른 길을 둘러볼 것이다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다