Rust 할당자 시스템

C++의 new는 두 가지 일을 한 번에 합니다: 메모리 할당객체 초기화. Rust는 이 둘을 완전히 분리했습니다. 그 이유, 장단점, 그리고 실제로 어떻게 쓰이는지 살펴봅니다.

1. C++의 new가 하는 일

int* p = new int(42); 내부 순서: ① operator new(sizeof(int)) → malloc(4) 호출 → 주소 0x1234 반환 ② *p = 42 → 초기화 (생성자) ③ p = 0x1234 → 포인터 반환 delete p; ① p->~int() → 소멸자 (int는 trivial이라 아무것도 안 함) ② operator delete(p) → free(0x1234)

이 구조의 핵심 문제는 할당자를 교체하기 어렵다는 겁니다. 전역 operator new를 오버로드하거나, 클래스마다 따로 정의해야 합니다. 표준 컨테이너는 std::allocator<T>라는 타입 파라미터로 이를 해결하지만 API가 복잡합니다.

2. Rust가 분리한 이유

Box::new(42i32) 내부: ① Layout::new::<i32>() → 크기 4, 정렬 4인 레이아웃 생성 ② GlobalAlloc::alloc(layout) → 할당자에게 메모리 요청 (기본: malloc) ③ ptr.write(42) → 초기화 (placement new와 동일) ④ Box::from_raw(ptr) → 소유권을 Box로 묶어 반환 Box drop 시: ① ptr::drop_in_place(ptr) → T의 Drop 실행 ② GlobalAlloc::dealloc(ptr) → 메모리 반환

분리의 이점: 초기화 코드는 그대로 두고 할당 방식만 교체할 수 있습니다. #[global_allocator] 한 줄로 프로그램 전체의 Box, Vec, String이 다른 할당자를 쓰게 됩니다.

3. GlobalAlloc 트레이트 — 할당자의 계약

Rust의 모든 힙 할당은 이 트레이트를 거칩니다.

pub unsafe trait GlobalAlloc {
    // 필수 구현
    unsafe fn alloc(&self, layout: Layout) -> *mut u8;
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);

    // 기본 구현 있음 (오버라이드 가능)
    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
        // 기본: alloc 후 memset(0)
        let ptr = self.alloc(layout);
        if !ptr.is_null() {
            ptr::write_bytes(ptr, 0, layout.size());
        }
        ptr
    }

    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
        // 기본: 새로 alloc → memcpy → dealloc (비효율적)
        // 실제 할당자는 이걸 직접 구현해서 제자리 확장을 시도함
        let new_layout = Layout::from_size_align_unchecked(new_size, layout.align());
        let new_ptr = self.alloc(new_layout);
        if !new_ptr.is_null() {
            ptr::copy_nonoverlapping(ptr, new_ptr, layout.size().min(new_size));
            self.dealloc(ptr, layout);
        }
        new_ptr
    }
}
Layout이 뭔가요?
Layout은 "크기 N바이트, A바이트 경계에 정렬"이라는 정보입니다. 할당자는 크기만 알고 타입은 모릅니다 — 타입 정보는 호출자가 관리합니다. 이 덕분에 할당자 코드가 제네릭 없이 단순하게 유지됩니다.

4. 기본 할당자 — System

아무것도 지정하지 않으면 std::alloc::System이 전역 할당자입니다. 플랫폼의 기본 malloc/free를 그대로 씁니다.

System 할당자 구현 (단순화): Linux: libc::malloc / libc::free macOS: libSystem의 malloc (zone allocator 기반) Windows: HeapAlloc / HeapFree 즉 아무 설정 없어도 OS의 기본 메모리 관리자를 씀
glibc malloc의 한계
glibc의 ptmalloc2는 멀티스레드 환경에서 락 경합이 심합니다. 스레드마다 arena를 따로 두지만 수가 제한돼 있어, 스레드가 많은 서버에서 malloc이 병목이 되는 경우가 실제로 많습니다.

5. 할당자 교체 — #[global_allocator]

코드 어디에도 안 건드리고 할당자만 바꿀 수 있습니다.

# Cargo.toml
[dependencies]
tikv-jemallocator = "0.6"
// main.rs 맨 위에 한 줄
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;

// 이후 모든 Box, Vec, String, HashMap...이 jemalloc을 씀
// 코드는 한 줄도 안 바꿔도 됨
fn main() {
    let v: Vec = vec![1, 2, 3];  // 이미 jemalloc 사용
}
링커 관점에서 일어나는 일: Rust 런타임이 __rust_alloc() 심볼을 정의 ↓ #[global_allocator]가 이 심볼을 재정의 ↓ Box::new() → __rust_alloc() → jemalloc::malloc() (기존엔 libc::malloc()이었음) OS ABI 아래는 안 바꿈 — 그냥 링크 심볼 교체

6. 주요 할당자 비교

System (glibc ptmalloc2)

설정 없음, 항상 있음
범용, 안정적
멀티스레드 락 경합
단편화 관리 보통

단일 스레드, 스크립트, CLI

jemalloc (tikv-jemallocator)

스레드별 캐시, 락 거의 없음
단편화 적음 (size class)
프로파일링 내장
바이너리 크기 증가

서버, 데이터베이스 (TiKV, Firefox)

mimalloc (Microsoft)

소형 할당 매우 빠름
멀티스레드 최적화
Windows 친화적
대형 할당에서 jemalloc보다 느릴 수 있음

게임, GUI 앱, 단순 서버

Bump/Arena Allocator

할당이 포인터 덧셈 하나
개별 free 없음, 일괄 해제
개별 해제 불가
생명주기 같아야 함

파서, 게임 프레임, 요청 단위

7. 단편화 — 할당자를 바꾸는 진짜 이유

서버가 몇 시간 돌면 메모리 사용량이 계속 증가하는 현상을 봤다면, 십중팔구 메모리 단편화 때문입니다.

// 단편화 시뮬레이터 — 할당/해제 반복 시 메모리 상태
메모리 (100 유닛)
할당과 해제를 반복해보세요.
사용 중   해제됨(단편)   빈 공간
단편화가 발생하는 과정
할당 크기가 제각각이면 해제 후 남는 조각이 연속적이지 않습니다. 총 빈 공간은 충분해도 연속된 큰 블록을 찾지 못해 alloc이 실패하거나 새 메모리를 OS에서 더 받아와야 합니다. jemalloc은 size class로 이걸 완화합니다 — 비슷한 크기끼리 묶어서 재사용률을 높입니다.

8. Bump Allocator — 할당을 포인터 덧셈으로

특정 생명주기 동안 할당하고 한 번에 전부 해제해도 되는 경우 (HTTP 요청 처리, 파서, 게임 프레임)에 쓰는 패턴입니다. free()를 개별로 호출하지 않고 범프(bump) 포인터만 되돌립니다.

Bump Allocator 동작: 초기: ┌──────────────────────────────────────────────┐ │ (비어있는 블록, 4KB) │ └──────────────────────────────────────────────┘ ↑ bump (다음 할당 위치) alloc(16바이트): ┌─────────┬────────────────────────────────────┐ │ [AAAA16]│ │ └─────────┴────────────────────────────────────┘ ↑ bump += 16 alloc(32바이트): ┌─────────┬──────────────────┬─────────────────┐ │ [AAAA16]│ [BBBBBBB 32] │ │ └─────────┴──────────────────┴─────────────────┘ ↑ bump += 32 reset() — 전부 한 번에 해제: ┌──────────────────────────────────────────────┐ │ (다시 비어있음) │ └──────────────────────────────────────────────┘ ↑ bump = 시작 위치 개별 free() 호출 없음 — O(1) 일괄 해제
pub struct BumpAllocator {
    start: *mut u8,
    end:   *mut u8,
    bump:  Cell<*mut u8>,  // Cell: 내부 가변성 (할당자는 &self로 호출됨)
}

impl BumpAllocator {
    pub fn new(size: usize) -> Self {
        // 내부적으로 큰 블록 하나를 미리 할당
        let layout = Layout::from_size_align(size, 16).unwrap();
        let start  = unsafe { alloc(layout) };
        let end    = unsafe { start.add(size) };
        Self { start, end, bump: Cell::new(start) }
    }

    pub fn alloc_t<T>(&self) -> *mut T {
        let bump   = self.bump.get();
        // 정렬 맞추기
        let align  = mem::align_of::<T>();
        let offset = bump.align_offset(align);
        let ptr    = unsafe { bump.add(offset) };
        let next   = unsafe { ptr.add(mem::size_of::<T>()) };

        if next > self.end {
            panic!("BumpAllocator: OOM");
        }
        self.bump.set(next);
        ptr as *mut T
    }

    // 전부 한 번에 해제 — O(1)
    pub fn reset(&self) {
        self.bump.set(self.start);
    }
}
// Bump Allocator vs malloc 비교
malloc 방식 (할당/해제 개별)
Bump Allocator (포인터 전진만)
버튼을 눌러 비교해보세요.
사용 중   단편(해제됐지만 못 씀)   bump 사용 중   빈 공간

9. nightly: 컬렉션별 할당자 (Allocator 트레이트)

GlobalAlloc은 전역이라 모든 할당이 같은 할당자를 씁니다. nightly의 Allocator 트레이트를 쓰면 컬렉션마다 다른 할당자를 붙일 수 있습니다.

#![feature(allocator_api)]
use std::alloc::Allocator;

// Vec에 bump allocator 붙이기
let arena = BumpAllocator::new(1024 * 1024);  // 1MB

let mut names: Vec<&str, &BumpAllocator> = Vec::new_in(&arena);
let mut scores: Vec<u32, &BumpAllocator>  = Vec::new_in(&arena);

names.push("alice");
scores.push(100);

// HTTP 요청 끝 → arena 하나만 drop → 안의 Vec 전부 O(1) 해제
// free()를 수백 번 호출하는 대신 블록 하나 반환
drop(arena);

실제로 이 패턴을 쓰는 라이브러리:

// bumpalo — stable Rust에서 arena 쓰기
use bumpalo::Bump;

let arena = Bump::new();

// 아래 세 할당은 전부 같은 연속 블록에서 나옴
let x: &mut i32   = arena.alloc(42);
let s: &mut str    = arena.alloc_str("hello");
let v: &mut [u8]  = arena.alloc_slice_fill_copy(16, 0);

// arena drop → x, s, v 전부 한 번에 해제
// 각각 drop() 호출 없음

10. 커스텀 전역 할당자 직접 만들기

임베디드, OS 커널, WebAssembly처럼 malloc이 없는 환경에서 씁니다.

use std::alloc::{GlobalAlloc, Layout};
use std::sync::atomic::{AtomicUsize, Ordering};

// 간단한 통계 할당자 — malloc에 감싸서 카운트만 추적
struct TrackingAllocator {
    allocated: AtomicUsize,
    freed:     AtomicUsize,
}

unsafe impl GlobalAlloc for TrackingAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        self.allocated.fetch_add(layout.size(), Ordering::Relaxed);
        std::alloc::System.alloc(layout)  // 실제 할당은 System에 위임
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        self.freed.fetch_add(layout.size(), Ordering::Relaxed);
        std::alloc::System.dealloc(ptr, layout)
    }
}

#[global_allocator]
static ALLOC: TrackingAllocator = TrackingAllocator {
    allocated: AtomicUsize::new(0),
    freed:     AtomicUsize::new(0),
};

fn memory_stats() {
    let a = ALLOC.allocated.load(Ordering::Relaxed);
    let f = ALLOC.freed.load(Ordering::Relaxed);
    println!("할당: {} bytes, 해제: {} bytes, 현재 사용: {} bytes", a, f, a - f);
}

11. 언제 할당자를 신경 써야 하는가

신경 안 써도 되는 경우

  • CLI 툴, 스크립트
  • 단발성 프로그램
  • 할당 자체가 드문 경우
  • 이미 I/O가 병목인 경우

바꾸면 체감되는 경우

  • 요청당 수천 번 소형 할당하는 서버
  • 수십 스레드가 동시에 할당하는 경우
  • 몇 시간 뒤 메모리 사용량이 계속 늘 때
  • 파서/컴파일러 (임시 노드 대량 생성)
  • 게임 (프레임마다 동일 패턴 반복)
실전 판단 기준
먼저 프로파일러(perf, heaptrack)로 malloc이 실제로 병목인지 확인하세요. 대부분은 그렇지 않습니다. 할당자 교체는 효과가 크지만 근거 없이 하면 시간 낭비입니다.