C++의 new는 두 가지 일을 한 번에 합니다: 메모리 할당과 객체 초기화.
Rust는 이 둘을 완전히 분리했습니다.
그 이유, 장단점, 그리고 실제로 어떻게 쓰이는지 살펴봅니다.
이 구조의 핵심 문제는 할당자를 교체하기 어렵다는 겁니다.
전역 operator new를 오버로드하거나, 클래스마다 따로 정의해야 합니다.
표준 컨테이너는 std::allocator<T>라는 타입 파라미터로 이를 해결하지만 API가 복잡합니다.
분리의 이점: 초기화 코드는 그대로 두고 할당 방식만 교체할 수 있습니다.
#[global_allocator] 한 줄로 프로그램 전체의 Box, Vec, String이
다른 할당자를 쓰게 됩니다.
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은 "크기 N바이트, A바이트 경계에 정렬"이라는 정보입니다.
할당자는 크기만 알고 타입은 모릅니다 — 타입 정보는 호출자가 관리합니다.
이 덕분에 할당자 코드가 제네릭 없이 단순하게 유지됩니다.
아무것도 지정하지 않으면 std::alloc::System이 전역 할당자입니다.
플랫폼의 기본 malloc/free를 그대로 씁니다.
ptmalloc2는 멀티스레드 환경에서 락 경합이 심합니다.
스레드마다 arena를 따로 두지만 수가 제한돼 있어, 스레드가 많은 서버에서
malloc이 병목이 되는 경우가 실제로 많습니다.
코드 어디에도 안 건드리고 할당자만 바꿀 수 있습니다.
# 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 사용
}
✓ 설정 없음, 항상 있음
✓ 범용, 안정적
✗ 멀티스레드 락 경합
✗ 단편화 관리 보통
단일 스레드, 스크립트, CLI
✓ 스레드별 캐시, 락 거의 없음
✓ 단편화 적음 (size class)
✓ 프로파일링 내장
✗ 바이너리 크기 증가
서버, 데이터베이스 (TiKV, Firefox)
✓ 소형 할당 매우 빠름
✓ 멀티스레드 최적화
✓ Windows 친화적
✗ 대형 할당에서 jemalloc보다 느릴 수 있음
게임, GUI 앱, 단순 서버
✓ 할당이 포인터 덧셈 하나
✓ 개별 free 없음, 일괄 해제
✗ 개별 해제 불가
✗ 생명주기 같아야 함
파서, 게임 프레임, 요청 단위
서버가 몇 시간 돌면 메모리 사용량이 계속 증가하는 현상을 봤다면, 십중팔구 메모리 단편화 때문입니다.
alloc이 실패하거나
새 메모리를 OS에서 더 받아와야 합니다.
jemalloc은 size class로 이걸 완화합니다 — 비슷한 크기끼리 묶어서 재사용률을 높입니다.
특정 생명주기 동안 할당하고 한 번에 전부 해제해도 되는 경우
(HTTP 요청 처리, 파서, 게임 프레임)에 쓰는 패턴입니다.
free()를 개별로 호출하지 않고 범프(bump) 포인터만 되돌립니다.
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);
}
}
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() 호출 없음
임베디드, 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);
}
perf, heaptrack)로 malloc이 실제로 병목인지 확인하세요.
대부분은 그렇지 않습니다. 할당자 교체는 효과가 크지만 근거 없이 하면 시간 낭비입니다.