C의 전통적인 전역 할당자부터 Rust, Zig, Odin, C3, Hare의 설계를 비교하고, 현대 언어의 접근을 참고해 C에서 유연한 할당자 인터페이스(아레나 포함)를 직접 구현한다.
할당자(allocator)는 프로그램이 데이터 구조를 저장할 수 있도록 메모리(보통 힙)를 예약해 주는 도구다. 많은 C 프로그램은 표준 libc 할당자를 쓰거나, 잘해봐야 jemalloc이나 mimalloc 같은 다른 할당자로 바꿔 끼울 수 있게 해 둔다.
C와 달리 현대 시스템 언어들은 보통 할당자를 1급 시민으로 다룬다. 각 언어가 할당을 어떻게 처리하는지 살펴본 뒤, 그 접근을 따라 C에서 할당자를 하나 만들어 보자.
Rust • Zig • Odin • C3 • Hare • C • 마무리 생각
Rust는 여기서 살펴볼 언어들 중 비교적 오래된 편이며, 메모리 할당을 더 전통적인 방식으로 처리한다. 현재는 전역 할당자(global allocator)를 쓰지만, 기능 플래그 뒤에서 실험적인 Allocator API가 구현되어 있다(이슈 #32838). 여기서는 실험 API는 제쳐두고 안정(stable) API에 집중하겠다.
문서는 명확한 문장으로 시작한다:
주어진 프로그램에서 표준 라이브러리는
Box<T>와Vec<T>가 사용하는 하나의 “전역” 메모리 할당자를 가진다.
그리고 다소 모호한 문장이 뒤따른다:
현재 기본 전역 할당자는 지정되어 있지 않다(unspecified).
물론 이 말이 Rust 프로그램이 할당을 포기하고 중단한다는 뜻은 아니다. 실제로 Rust는 시스템 할당자를 전역 기본값으로 사용한다(다만 Rust 개발자들은 이를 문서로 “보장”하고 싶지 않아서 “unspecified”라고 적어 둔 것이다):
malloc;HeapAlloc;dlmalloc.전역 할당자 인터페이스는 std::alloc 모듈의 GlobalAlloc 트레이트로 정의된다. 구현자는 핵심 메서드 두 개(alloc, dealloc)를 제공해야 하며, 이를 바탕으로 두 메서드(alloc_zeroed, realloc)가 추가로 제공된다:
rustpub unsafe trait GlobalAlloc { // Allocates memory as described by the given `layout`. // Returns a pointer to newly-allocated memory, // or null to indicate allocation failure. unsafe fn alloc(&self, layout: Layout) -> *mut u8; // Deallocates the block of memory at the given `ptr` // pointer with the given `layout`. unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout); // Behaves like `alloc`, but also ensures that the contents // are set to zero before being returned. unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { // ... } // Shrinks or grows a block of memory to the given `new_size` in bytes. // The block is described by the given `ptr` pointer and `layout`. unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { // ... } }
Layout 구조체는 우리가 할당하려는 메모리 조각을 설명한다 — 바이트 단위 크기와 정렬(alignment)이다:
rustpub struct Layout { // private fields size: usize, align: Alignment, }
메모리 정렬
정렬은 어떤 데이터가 메모리에서 시작할 수 있는 위치를 제한한다. 데이터의 메모리 주소는 특정 값의 배수여야 하며, 이 값은 항상 2의 거듭제곱이다.
정렬은 데이터 타입에 따라 달라진다:
u8: 정렬 = 1. 어떤 주소에서든 시작 가능(0, 1, 2, 3...).i32: 정렬 = 4. 4로 나누어떨어지는 주소에서 시작해야 함(0, 4, 8, 12...).f64: 정렬 = 8. 8로 나누어떨어지는 주소에서 시작해야 함(0, 8, 16...).CPU는 “정렬된(aligned)” 메모리를 효율적으로 읽도록 설계되어 있다. 예를 들어, 주소 0x03에서 시작하는 4바이트 정수를 읽는다면(비정렬, unaligned) CPU는 메모리를 두 번 읽어야 한다 — 첫 바이트를 위해 한 번, 나머지 3바이트를 위해 한 번 — 그리고 이를 결합한다. 하지만 정수가 주소 0x04에서 시작한다면(정렬) CPU는 네 바이트를 한 번에 읽을 수 있다.
정렬된 메모리는 벡터화된 CPU 연산(SIMD)에도 필요하다. SIMD에서는 한 번의 명령으로 하나의 값이 아니라 값의 묶음을 처리한다.
컴파일러는 각 타입의 크기와 정렬을 알고 있으므로, Layout 생성자나 헬퍼 함수를 사용해 유효한 레이아웃을 만들 수 있다:
rustuse std::alloc::Layout; // 64-bit integer. let i64_layout = Layout::new::<i64>(); println!("{:?}", i64_layout); // Ten 32-bit integers. let array_layout = Layout::array::<i32>(10).unwrap(); println!("{:?}", array_layout); // Custom structure. struct Cat { name: String, is_grumpy: bool, } let cat_layout = Layout::new::<Cat>(); println!("{:?}", cat_layout); // Layout from a value. let fluffy = Cat { name: String::from("Fluffy"), is_grumpy: true, }; let fluffy_layout = Layout::for_value(&fluffy); println!("{:?}", fluffy_layout);
textLayout { size: 8, align: 8 (1 << 3) } Layout { size: 40, align: 4 (1 << 2) } Layout { size: 32, align: 8 (1 << 3) } Layout { size: 32, align: 8 (1 << 3) }
Cat이 32바이트나 차지하는 것에 놀라지 말자. Rust의String타입은 크기가 늘어날 수 있으므로 데이터 포인터, 길이, 용량(3 × 8 = 24바이트)을 저장한다. 여기에 불리언 1바이트와 패딩 7바이트(8바이트 정렬 때문에)가 더해져 총 32바이트가 된다.
System은 운영체제가 제공하는 기본 메모리 할당자다. 정확한 구현은 플랫폼에 따라 다르다. GlobalAlloc 트레이트를 구현하며 기본적으로 전역 할당자로 사용되지만, 문서에서 이를 보장하지는 않는다(“unspecified”를 기억하자). System을 전역 할당자로 명시적으로 설정하고 싶다면 #[global_allocator] 속성을 사용하면 된다:
rustuse std::alloc::System; #[global_allocator] static GLOBAL: System = System; fn main() { // ... }
다음 예시처럼 jemalloc 같은 커스텀 할당자를 전역으로 설정할 수도 있다:
rustuse jemallocator::Jemalloc; #[global_allocator] static GLOBAL: Jemalloc = Jemalloc; fn main() {}
전역 할당자를 직접 쓰려면 alloc과 dealloc 함수를 호출한다:
rustuse std::alloc::{alloc, dealloc, Layout}; unsafe { let layout = Layout::new::<u16>(); let ptr = alloc(layout); // no OOM check for now dealloc(ptr, layout); }
textok
실제로 사람들은 alloc이나 dealloc을 직접 쓰는 경우가 드물다. 대신 Box, String, Vec처럼 내부에서 할당을 처리해 주는 타입을 사용한다:
rustlet num = Box::new(42); // allocates println!("{:?}", num); let mut vec = Vec::new(); vec.push(1); // allocates vec.push(2); println!("{:?}", vec); // num and vec automatically deallocate // when they go out of scope.
text42 [1, 2]
System 할당자는 메모리를 할당할 수 없다고 해서 중단(abort)하지 않는다. 대신 null을 반환한다(이는 GlobalAlloc이 권장하는 동작과 정확히 일치한다):
rustuse std::alloc::{alloc, dealloc, handle_alloc_error, Layout}; unsafe { // Attempt to allocate a ton of memory. let layout = Layout::array::<u8>(usize::MAX / 2).unwrap(); let ptr = alloc(layout); if ptr.is_null() { println!("Out of memory!"); // Uncomment to abort. // handle_alloc_error(layout); } else { println!("Allocation succeeded."); dealloc(ptr, layout); } }
textOut of memory!
문서는 OOM(out-of-memory) 에러를 알리기 위해 handle_alloc_error 함수를 쓰라고 권한다. 이 함수는 즉시 프로세스를 중단시키거나, 바이너리가 표준 라이브러리에 링크되어 있지 않으면 패닉을 일으킨다.
저수준 alloc 함수와 달리, Box나 Vec 같은 타입들은 할당에 실패하면 handle_alloc_error를 호출하므로, 프로그램은 보통 메모리가 부족해지면 중단된다:
rustlet v: Vec<u8> = Vec::with_capacity(usize::MAX/2); println!("{}", v.len());
textmemory allocation of 9223372036854775807 bytes failed (exit status 139)
Allocator API • Memory allocation APIs
Zig에서 메모리 관리는 명시적(explicit)이다. 기본 전역 할당자는 없으며, 메모리를 할당해야 하는 모든 함수는 별도의 매개변수로 할당자를 받는다. 이 때문에 코드는 조금 더 장황해지지만, 프로그래머에게 최대한의 제어와 투명성을 제공하겠다는 Zig의 목표와 잘 맞는다.
Zig의 할당자는 std.mem.Allocator 구조체로, 불투명 self 포인터와 네 개의 메서드를 담은 메서드 테이블(vtable)을 가진다:
zigconst Allocator = @This(); ptr: *anyopaque, vtable: *const VTable, pub const VTable = struct { /// Return a pointer to `len` bytes with specified `alignment`, /// or return `null` indicating the allocation failed. alloc: *const fn (*anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8, /// Attempt to expand or shrink memory in place. resize: *const fn (*anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool, /// Attempt to expand or shrink memory, allowing relocation. remap: *const fn (*anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8, /// Free and invalidate a region of memory. free: *const fn (*anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void, };
Rust의 할당자 메서드가 raw 포인터와 크기를 인자로 받는 것과 달리, Zig의 할당자 메서드는 바이트 슬라이스([]u8) — 포인터와 길이를 합친 타입 — 를 받는다.
또 하나 흥미로운 차이는 선택적 ret_addr 매개변수인데, 이는 할당 호출 스택에서 첫 번째 반환 주소(return address)다. DebugAllocator 같은 일부 할당자는 이를 사용해 어떤 함수가 메모리를 요청했는지 추적한다. 이는 메모리 할당과 관련된 문제를 디버깅하는 데 도움이 된다.
Rust와 마찬가지로, 할당자 메서드는 에러를 반환하지 않는다. 대신 alloc과 remap은 실패하면 null을 반환한다.
Zig는 할당자 메서드를 직접 호출하는 대신 사용할 수 있는 타입 안전(type-safe) 래퍼도 제공한다:
zig// Allocate / deallocate a single object. pub fn create(a: Allocator, comptime T: type) Error!*T pub fn destroy(self: Allocator, ptr: anytype) void // Allocate / deallocate multiple objects. pub fn alloc(self: Allocator, comptime T: type, n: usize) Error![]T pub fn free(self: Allocator, memory: anytype) void
예시:
zigconst allocator = std.heap.page_allocator; // Create and destroy a single integer. const num = try allocator.create(i32); num.* = 42; allocator.destroy(num); // Allocate and free a slice of 10 bytes. const slice = try allocator.alloc(u8, 100); @memset(slice, 'A'); allocator.free(slice);
textok
할당자 메서드와 달리, 이러한 할당 함수들은 실패 시 에러를 반환한다.
어떤 함수나 메서드가 메모리를 할당한다면, 개발자가 할당자 인스턴스를 제공할 것을 기대한다:
zigconst allocator = std.heap.page_allocator; var list: std.ArrayList(u8) = .empty; defer list.deinit(allocator); try list.append(allocator, 'z'); try list.append(allocator, 'i'); try list.append(allocator, 'g');
textok
Zig 표준 라이브러리에는 std.heap 네임스페이스 아래 여러 내장 할당자가 있다.
page_allocator는 운영체제에 전체 페이지 단위 메모리를 요청하며, 각 할당은 syscall이다:
zigconst allocator = std.heap.page_allocator; const memory = try allocator.alloc(u8, 100); allocator.free(memory);
textok
FixedBufferAllocator는 고정 버퍼 내에서만 할당하며 힙 할당을 하지 않는다:
zigvar buffer: [1000]u8 = undefined; var fba: std.heap.FixedBufferAllocator = .init(&buffer); const allocator = fba.allocator(); const memory = try allocator.alloc(u8, 100); allocator.free(memory);
textok
ArenaAllocator는 자식 할당자를 감싸고, 여러 번 할당한 뒤 한 번만 free할 수 있게 한다:
zigvar arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const mem1 = try allocator.alloc(u8, 100); const mem2 = try allocator.alloc(u8, 100); allocator.free(mem1); // not needed allocator.free(mem2); // not needed
textok
arena.deinit() 호출이 모든 메모리를 해제한다. 개별 allocator.free() 호출은 no-op이다.
DebugAllocator(일명 GeneralPurposeAllocator)는 double-free, use-after-free를 방지할 수 있고 leak도 감지할 수 있는 안전한 할당자다:
zigvar gpa: std.heap.DebugAllocator(.{}) = .init; const allocator = gpa.allocator(); const memory = try allocator.alloc(u8, 100); allocator.free(memory); allocator.free(memory); // aborts
SmpAllocator는 멀티스레드 머신에서 최대 성능을 목표로 설계된 범용 스레드 안전 할당자다:
zigconst allocator = std.heap.smp_allocator; const memory = try allocator.alloc(u8, 100); allocator.free(memory);
textok
c_allocator는 libc 할당자를 감싼 래퍼다:
zigconst allocator = std.heap.c_allocator; // requires linking libc const memory = try allocator.alloc(u8, 100); allocator.free(memory);
Zig는 메모리를 할당할 수 없다고 해서 패닉이나 중단을 하지 않는다. 할당 실패는 단지 일반적인 에러이며, 개발자가 처리해야 한다:
zigconst allocator = std.heap.page_allocator; const n = std.math.maxInt(i64); const memory = allocator.alloc(u8, n) catch |err| { if (err == error.OutOfMemory) { print("Out of memory!\n", .{}); } return err; }; defer allocator.free(memory);
textOut of memory!
Allocators • std.mem.Allocator • std.heap
Odin은 명시적 할당자를 지원하지만 Zig처럼 그것만이 유일한 선택지는 아니다. Odin에서는 모든 스코프에 암묵적인 context 변수가 있으며, 여기 기본 할당자가 들어 있다:
odinContext :: struct { allocator: Allocator, temp_allocator: Allocator, // ... } // Returns the default `context` for each scope @(require_results) default_context :: proc "contextless" () -> Context { c: Context __init_context(&c) return c }
함수에 할당자를 전달하지 않으면 현재 컨텍스트에 설정된 할당자를 사용한다.
Odin의 할당자는 불투명 self 포인터와 단일 함수 포인터를 갖는 runtime.Allocator 구조체다:
odinAllocator_Mode :: enum byte { Alloc, Free, Resize, // ... } Allocator_Error :: enum byte { None = 0, Out_Of_Memory = 1, // ... } Allocator_Proc :: #type proc( allocator_data: rawptr, mode: Allocator_Mode, size, alignment: int, old_memory: rawptr, old_size: int, location: Source_Code_Location = #caller_location, ) -> ([]byte, Allocator_Error) Allocator :: struct { procedure: Allocator_Proc, data: rawptr, }
다른 언어들과 달리, Odin의 할당자는 모든 할당 작업을 하나의 프로시저로 처리한다. 실제 동작(할당, 리사이즈, 해제 등)은 mode 파라미터로 결정된다.
할당 프로시저는 할당된 메모리(.Alloc, .Resize에서)와 에러(성공 시 .None)를 반환한다.
Odin은 core:mem 패키지에, 특정 모드로 할당자 프로시저를 호출하는 저수준 래퍼 함수를 제공한다:
odinalloc :: proc( size: int, alignment: int = DEFAULT_ALIGNMENT, allocator := context.allocator, loc := #caller_location, ) -> (rawptr, runtime.Allocator_Error) free :: proc( ptr: rawptr, allocator := context.allocator, loc := #caller_location, ) -> runtime.Allocator_Error // and others
또한 저수준 인터페이스 대신 사용할 수 있는 타입 안전 내장(builtin)인 new/free(단일 객체), make/delete(여러 객체)도 있다:
odinnum := new(int) defer free(num) slice := make([]int, 100) defer delete(slice)
textok
기본적으로 모든 내장은 컨텍스트 할당자를 사용하지만, 선택적 파라미터로 커스텀 할당자를 넘길 수 있다:
odinptr := new(int, allocator=context.allocator) defer free(ptr, allocator=context.allocator) slice := make([]int, 10, allocator=context.allocator) defer delete(slice, allocator=context.allocator)
textok
특정 코드 블록에서 다른 할당자를 쓰고 싶다면 컨텍스트에서 재할당하면 된다:
odinalloc := custom_allocator() context.allocator = alloc // Uses the custom allocator. ptr := new(int) defer free(ptr)
Odin의 context에는 서로 다른 두 할당자가 있다:
context.allocator는 범용 할당용이다. 운영체제의 힙 할당자를 사용한다.context.temp_allocator는 짧은 수명의 할당을 위한 것이다. 스크래치 할당자(커지는 아레나의 일종)를 사용한다.odin// Temporary allocation (no manual free required). temp_mem, _ := mem.alloc(100, allocator=context.temp_allocator) // Persistent allocation (requires manual free). perm_mem, _ := mem.alloc(100, allocator=context.allocator) defer mem.free(perm_mem, context.allocator) // Clear the entire scratchpad at the end of the work cycle. free_all(context.temp_allocator)
textok
temp 할당자를 사용할 때는, 할당된 모든 메모리를 지우기 위해 free_all을 한 번 호출하기만 하면 된다.
Odin 표준 라이브러리에는 base:runtime와 core:mem 패키지에서 찾을 수 있는 여러 할당자가 포함되어 있다.
heap_allocator 프로시저는 범용 할당자를 반환한다:
odinallocator := runtime.heap_allocator() memory, err := mem.alloc(100, allocator=allocator) mem.free(memory, allocator=allocator)
textok
Arena는 단일 백킹 버퍼를 사용해 여러 번 할당하고 한 번만 해제할 수 있게 한다:
odinarena: mem.Arena buffer := make([]byte, 1024, runtime.heap_allocator()) mem.arena_init(&arena, buffer) defer mem.arena_free_all(&arena) allocator := mem.arena_allocator(&arena) m1, err1 := mem.alloc(100, allocator=allocator) m2, err2 := mem.alloc(100, allocator=allocator)
textok
Tracking_Allocator는 Zig의 DebugAllocator처럼 leak와 잘못된 메모리 접근을 감지한다:
odintrack: mem.Tracking_Allocator mem.tracking_allocator_init(&track, runtime.default_allocator()) defer mem.tracking_allocator_destroy(&track) allocator := mem.tracking_allocator(&track) memory, err := mem.alloc(100, allocator=allocator) free(memory, allocator=allocator) free(memory, allocator=allocator) // aborts
textTracking allocator error: Bad free of pointer 139851252672688 (exit status 132)
Stack이나 Buddy_Allocator 같은 다른 것들도 있다.
Zig처럼 Odin도 메모리를 할당할 수 없다고 해서 패닉이나 중단하지 않는다. 대신 두 번째 반환값으로 에러 코드를 돌려준다:
odindata, err := mem.alloc(1 << 62) if err != .None { fmt.println("Allocation failed:", err) return } defer mem.free(data)
textAllocation failed: Out_Of_Memory
Allocators • base:runtime • core:mem
Zig, Odin처럼 C3도 명시적 할당자를 지원한다. Odin처럼 C3도 기본 할당자를 두 개(힙과 temp) 제공한다.
C3에서 할당자는 core::mem::allocator::Allocator 인터페이스이며, 할당된 메모리를 0으로 초기화할지 여부를 추가 옵션으로 갖는다:
textenum AllocInitType { NO_ZERO, ZERO } interface Allocator { <* Acquire memory from the allocator, with the given alignment and initialization type. *> fn void*? acquire(usz size, AllocInitType init_type, usz alignment = 0); <* Resize acquired memory from the allocator, with the given new size and alignment. *> fn void*? resize(void* ptr, usz new_size, usz alignment = 0); <* Release memory acquired using `acquire` or `resize`. *> fn void release(void* ptr, bool aligned); }
Zig와 Odin과 달리 resize와 release는 (이전) 크기를 파라미터로 받지 않는다 — Odin처럼 직접 받지도 않고, Zig처럼 슬라이스를 통해 받지도 않는다. 이는 커스텀 할당자를 만들 때 조금 더 어렵게 만든다. 할당자는 메모리와 함께 크기도 추적해야 하기 때문이다. 반면 이 방식은 C 상호운용성을 더 쉽게 만든다(기본 C3 할당자를 사용할 때): C에서 할당한 데이터를 C3에서 해제할 때 C 코드에서 크기 파라미터를 넘길 필요가 없다.
Odin처럼, 할당자 메서드는 실패 시 에러를 반환한다.
C3는 core::mem::allocator 모듈에 할당자 메서드를 호출하는 저수준 래퍼 매크로를 제공한다:
textmacro void* malloc(Allocator allocator, usz size) macro void*? malloc_try(Allocator allocator, usz size) macro void* realloc(Allocator allocator, void* ptr, usz new_size) macro void*? realloc_try(Allocator allocator, void* ptr, usz new_size) macro void free(Allocator allocator, void* ptr) // and others
이들은 _try 접미사가 붙은 매크로는 에러를 반환하고, 나머지는 실패 시 중단(abort)한다.
예시:
text// `mem` is the global allocator instance. int* ptr = allocator::malloc(mem, int.sizeof); defer allocator::free(mem, ptr);
textok
또한 core::mem 모듈에는 전역 allocator::mem 할당자 인스턴스를 사용하는 유사한 이름의 함수/매크로가 있다:
text// Call the core::mem::allocator macros directly. fn void* malloc(usz size) fn void free(void* ptr) // Accept a type instead of a size. macro new($Type, #init = ...) macro alloc($Type) // Allocate multiple objects. macro new_array($Type, usz elements) macro alloc_array($Type, usz elements) // and others
예시:
text// `malloc` and `free` are builtins, // so they don't require the namespace. int* num = malloc(int.sizeof); defer free(num); // `new_array` requires the namespace. int[] slice = mem::new_array(int, 100); defer free(slice);
textok
함수나 메서드가 메모리를 할당한다면, 종종 개발자가 할당자 인스턴스를 제공할 것을 기대한다:
textList{int} list; list.init(mem); // use the heap allocator defer list.free(); list.push(11); list.push(22); list.push(33);
textok
C3는 스레드 로컬(thread-local) 할당자 인스턴스 두 개를 제공한다:
allocator::mem은 범용 할당용이다. 운영체제의 힙 할당자(보통 libc 래퍼)를 사용한다.allocator::tmem은 짧은 수명의 할당용이다. 아레나 할당자를 사용한다.core::mem 모듈에는 allocator::tmem 임시 할당자를 사용하는 함수/매크로가 있다:
text// Calls the core::mem::allocator macro directly. fn void* tmalloc(usz size, usz alignment = 0) // Accept a type instead of a size. macro tnew($Type, #init = ...) macro talloc($Type) // Allocate multiple objects. macro talloc_array($Type, usz elements)
@pool 매크로는 스코프를 벗어날 때 모든 임시 할당을 해제한다:
text@pool() { int* p1 = tmalloc(int.sizeof); int* p2 = tmalloc(int.sizeof); int* p3 = tmalloc(int.sizeof); // no manual free required }; // p1, p2, p3 are freed here
textok
List나 DString 같은 일부 타입은 초기화되지 않았으면 기본적으로 temp 할당자를 사용한다:
text@pool() { List{int} list; list.push(11); // implicitly initialize with the temp allocator list.push(22); DString str; str.appendf("Hello %s", "World"); // same };
textok
C3 표준 라이브러리에는 core::mem::allocator 모듈에 여러 내장 할당자가 있다.
LibcAllocator는 libc의 malloc/free를 감싼 래퍼다:
textLibcAllocator libc; char* memory = allocator::malloc(&libc, 100*char.sizeof); allocator::free(&libc, memory);
textok
ArenaAllocator는 단일 백킹 버퍼를 사용해 여러 번 할당하고 한 번만 해제할 수 있게 한다:
textchar[1024] buf; ArenaAllocator* arena = allocator::wrap(&buf); defer arena.clear(); char* m1 = allocator::malloc(arena, 100*char.sizeof); char* m2 = allocator::malloc(arena, 100*char.sizeof);
textok
TrackingAllocator는 leak와 잘못된 메모리 접근을 감지한다:
textTrackingAllocator track; track.init(mem); defer track.clear(); char* memory = allocator::malloc(&track, 100*char.sizeof); allocator::free(&track, memory); allocator::free(&track, memory); // aborts
textERROR: 'Attempt to release untracked pointer 0x55f5b0333330, this is likely a bug.'
BackedArenaAllocator나 OnStackAllocator 같은 다른 것들도 있다.
Zig, Odin처럼 C3도 할당 실패 시 에러를 반환할 수 있다:
textvoid*? data = allocator::malloc_try(mem, 1uLL << 62); if (catch err = data) { io::printfn("Allocation failed: %s", err); return; }; defer mem::free(data);
textAllocation failed: mem::OUT_OF_MEMORY
C3는 할당 실패 시 중단하도록 할 수도 있다:
textvoid* data = allocator::malloc(mem, 1uLL << 62); // void* data = malloc(1uLL << 62); // same thing defer free(data);
textERROR: 'Unexpected fault 'mem::OUT_OF_MEMORY' was unwrapped!'
core::mem 모듈의 함수/매크로들이 allocator::malloc_try 대신 allocator::malloc을 사용하므로, 실패 시 중단하는 방식이 선호되는 접근처럼 보인다.
Memory Handling • core::mem::alocator • core::mem
다른 언어들과 달리 Hare는 명시적 할당자를 지원하지 않는다. 표준 라이브러리에 여러 할당자 구현이 있지만, 런타임에서는 그중 하나만 사용된다.
Hare 컴파일러는 런타임이 malloc과 free 구현을 제공할 것을 기대한다:
harefn malloc(n: size) nullable *opaque; @symbol("rt.free") fn free_(_p: nullable *opaque) void;
프로그래머는 이를 직접 접근하지 않는 것이 의도다(물론 rt를 임포트해서 rt::malloc이나 rt::free를 호출하는 것도 가능하다). 대신 Hare는 이들을 사용해 더 높은 수준의 할당 헬퍼를 제공한다.
Hare는 내부적으로 전역 할당자를 사용하는 두 가지 고수준 할당 헬퍼 alloc과 free를 제공한다.
alloc은 개별 객체를 할당할 수 있다. 타입이 아니라 값을 받는다:
harelet n: *int = alloc(42)!; defer free(n); let s: *str = alloc("hello world")!; defer free(s); // coords is defined as struct { x: int, y: int } let p: *coords = alloc(coords{x=3, y=5})!; defer free(p);
textok
alloc은 두 번째 파라미터(아이템 개수)를 제공하면 슬라이스도 할당할 수 있다:
hare// Allocate a slice of 100 integers. let nums: []int = alloc([0...], 100)!; defer free(nums);
textok
free는 단일 객체 포인터(예: *int)와 슬라이스(예: []int) 모두에 대해 올바르게 동작한다.
Hare 표준 라이브러리에는 세 가지 내장 메모리 할당자가 있다:
malloc/free를 사용한다.실제로 사용할 할당자는 컴파일 타임에 선택된다.
다른 언어들처럼 Hare도 할당 실패 시 에러를 반환한다:
harematch (alloc([0...], 1 << 62)) { case let nums: []int => defer free(nums); case nomem => fmt::println("Out of memory")!; };
textOut of memory
!로 에러 시 중단할 수 있다:
harelet nums: []int = alloc([0...], 1 << 62)!; defer free(nums);
textAborted (core dumped) (exit status 134)
또는 ?로 에러를 전파할 수 있다:
harelet nums: []int = alloc([0...], 1 << 62)?; defer free(nums);
Dynamic memory allocation • malloc.ha
많은 C 프로그램은 표준 libc 할당자를 쓰거나, 많아야 매크로로 다른 할당자로 바꿔 끼울 수 있게 해 둔다:
c#define LIB_MALLOC malloc #define LIB_FREE free
또는 간단한 setter를 쓰기도 한다:
cstatic void *(*_lib_malloc)(size_t); static void (*_lib_free)(void*); void lib_set_allocator(void *(*malloc)(size_t), void (*free)(void*)) { _lib_malloc = malloc; _lib_free = free; }
이 방식은 libc 할당자를 jemalloc이나 mimalloc으로 교체하는 데는 쓸 수 있을지 모르지만, 유연성은 떨어진다. 예컨대 이런 API로 아레나 할당자를 구현하는 건 사실상 불가능에 가깝다.
이제 Zig, Odin, C3에서 현대적인 할당자 설계를 보았으니 — 비슷한 것을 C에서 만들어 보자. 결정해야 할 작은 선택들이 많고, 여기서는 내가 개인적으로 선호하는 방향으로 가겠다. 할당자를 설계하는 유일한 방법이라고 말하는 것이 아니라, 여러 방법 중 하나일 뿐이다.
우리 할당자는 실패 시 NULL 대신 에러를 반환해야 하므로, 에러 enum이 필요하다:
c// Allocation errors. typedef enum { Error_None = 0, Error_OutOfMemory, Error_SizeOverflow, } Error;
할당 함수는 (값 | 에러) 같은 태그드 유니온이나 (값, 에러) 튜플을 반환해야 한다. C에는 이것들이 내장되어 있지 않으니 커스텀 튜플 타입을 쓰자:
c// Allocation result. typedef struct { void* ptr; Error err; } AllocResult;
다음 단계는 할당자 인터페이스다. Odin처럼 단일 함수로 처리하는 방식은 구현을 불필요하게 복잡하게 만드는 것 같아서, Zig처럼 별도 메서드로 나누자:
c// Allocator interface. struct _Allocator { AllocResult (*alloc)(void* self, size_t size, size_t align); AllocResult (*realloc)(void* self, void* ptr, size_t oldSize, size_t newSize, size_t align); void (*free)(void* self, void* ptr, size_t size, size_t align); }; typedef struct { const struct _Allocator* m; void* self; } Allocator;
이 인터페이스 설계 접근은 별도 글에서 자세히 설명한다: Interfaces in C.
Zig는 raw 메모리 포인터 대신 바이트 슬라이스([]u8)를 사용한다. C에서도 자체 바이트 슬라이스 타입을 만들 수 있겠지만, 별 장점이 없어 보인다 — 타입 캐스팅만 늘어날 것이다. 그러니 조상들이 하던 대로 단순하게 void*를 쓰자.
이제 제네릭 Alloc과 Free 래퍼를 만들자:
c// Allocates an item of type T. // `AllocResult Alloc[T](Allocator a, T)` #define Alloc(a, T) \ ((a).m->alloc((a).self, sizeof(T), alignof(T))) // Frees an item allocated with Alloc. // Only accepts typed pointers, not void*. // `void Free[T](Allocator a, T* ptr)` #define Free(a, ptr) \ ((a).m->free((a).self, (ptr), sizeof(*(ptr)), alignof(typeof(*(ptr)))))
여기서는 단순화를 위해 typeof가 있다고 가정하겠다. 더 견고한 구현이라면 사용 가능 여부를 제대로 체크하거나, Free에 타입을 직접 전달해야 한다.
컬렉션을 위한 별도 헬퍼 쌍도 만들 수 있다:
c// Helper to prevent integer overflow during N-item allocation. static inline size_t calcSize(size_t size, size_t count) { if (count > 0 && size > SIZE_MAX / count) { return 0; } return size * count; } // Allocates n items of type T. // `AllocResult AllocN[T](Allocator a, T, size_t n)` #define AllocN(a, T, n) \ ((a).m->alloc((a).self, calcSize(sizeof(T), (n)), alignof(T))) // Frees n items allocated with AllocN. // Only accepts typed pointers, not void*. // `void FreeN[T](Allocator a, T* ptr, size_t n)` #define FreeN(a, ptr, n) \ ((a).m->free( \ (a).self, (ptr), \ calcSize(sizeof(*(ptr)), (n)), \ alignof(typeof(*(ptr)))))
__VA_ARGS__ 매크로 트릭을 써서 Alloc/Free가 단일 객체와 컬렉션 모두에서 동작하도록 만들 수도 있지만, 이 글에서는 그렇게 하지 말자 — 나는 과도한 매직 매크로를 피하는 편을 선호한다.
커스텀 할당자부터는 libc 래퍼로 시작하자. 대부분의 파라미터를 무시하므로 그다지 흥미롭진 않지만, 그래도:
c// The libc allocator wrapper. // Ignores alignment and treats zero-size allocations as errors. // Doesn't support reallocation to keep things simple. AllocResult Libc_Alloc(void* self, size_t size, size_t align) { (void)self; (void)align; if (size == 0) return (AllocResult){NULL, Error_SizeOverflow}; void* ptr = malloc(size); if (!ptr) return (AllocResult){NULL, Error_OutOfMemory}; return (AllocResult){ptr, Error_None}; } void Libc_Free(void* self, void* ptr, size_t size, size_t align) { (void)self; (void)size; (void)align; free(ptr); } Allocator LibcAllocator(void) { static const struct _Allocator mtab = { .alloc = Libc_Alloc, .free = Libc_Free, }; return (Allocator){.m = &mtab, .self = NULL}; }
사용 예시:
cint main(void) { Allocator allocator = LibcAllocator(); { // Allocate a single integer. AllocResult res = Alloc(allocator, int64_t); if (res.err != Error_None) { printf("Error: %d\n", res.err); return 1; } int64_t* x = res.ptr; *x = 42; Free(allocator, x); } { // Allocate an array of integers. size_t n = 100; AllocResult res = AllocN(allocator, int64_t, n); if (res.err != Error_None) { printf("Error: %d\n", res.err); return 1; } int64_t* arr = res.ptr; for (size_t i = 0; i < n; i++) { arr[i] = i + 1; } FreeN(allocator, arr, n); } }
textok
이제 self 필드를 사용해 고정 크기 버퍼를 백킹으로 하는 아레나 할당자를 구현해 보자:
c// A simple arena allocator. // Doesn't support reallocation. typedef struct { uint8_t* buf; size_t cap; size_t offset; } Arena; Arena NewArena(uint8_t* buf, size_t cap) { return (Arena){.buf = buf, .cap = cap, .offset = 0}; } static AllocResult Arena_Alloc(void* self, size_t size, size_t align) { Arena* arena = (Arena*)self; // 1. Calculate the alignment padding. if (size == 0) return (AllocResult){NULL, Error_SizeOverflow}; uintptr_t currentPtr = (uintptr_t)arena->buf + arena->offset; uintptr_t alignedPtr = (currentPtr + (align - 1)) & ~(align - 1); size_t newOffset = (alignedPtr - (uintptr_t)arena->buf) + size; // 2. Check for errors. if (newOffset < arena->offset) { return (AllocResult){NULL, Error_SizeOverflow}; } if (newOffset > arena->cap) { return (AllocResult){NULL, Error_OutOfMemory}; } // 3. Commit the allocation. arena->offset = newOffset; return (AllocResult){(void*)alignedPtr, Error_None}; } static void Arena_Free(void* self, void* ptr, size_t size, size_t align) { // Individual deallocations are no-ops. (void)self; (void)ptr; (void)size; (void)align; } static void Arena_Reset(Arena* arena) { arena->offset = 0; } Allocator Arena_Allocator(Arena* arena) { static const struct _Allocator mtab = { .alloc = Arena_Alloc, .free = Arena_Free, }; return (Allocator){.m = &mtab, .self = arena}; }
사용 예시:
cint main(void) { uint8_t buf[1024]; Arena arena = NewArena(buf, sizeof(buf)); Allocator allocator = Arena_Allocator(&arena); { // Allocate a single integer. AllocResult res = Alloc(allocator, int64_t); if (res.err != Error_None) { printf("Error: %d\n", res.err); return 1; } int64_t* x = res.ptr; *x = 42; // No need for Free. } { // Allocate an array of integers. size_t n = 100; AllocResult res = AllocN(allocator, int64_t, n); if (res.err != Error_None) { printf("Error: %d\n", res.err); return 1; } int64_t* arr = res.ptr; for (size_t i = 0; i < n; i++) { arr[i] = i + 1; } // No need for FreeN. } Arena_Reset(&arena); }
textok
좋다!
위 예시들처럼, 할당 메서드는 문제가 생기면 에러를 반환한다. Zig나 Odin만큼 편리하진 않지만, 그래도 꽤 직관적이다:
cint main(void) { Allocator allocator = LibcAllocator(); size_t n = SIZE_MAX; AllocResult res = AllocN(allocator, int64_t, n); if (res.err != Error_None) { printf("Allocation failed: %d\n", res.err); return 1; } FreeN(allocator, res.ptr, n); }
textAllocation failed: 2 (exit status 1)
아래는 우리가 살펴본 언어들의 할당 API를 비공식적으로 비교한 표다:
textSingle object Collection ┌──────────────────────────────────────────┐ Rust │ Box::new(42) vec![0; 100] │ │ │ Zig │ a.create(i32) a.alloc(i32, 100) │ │ │ Odin │ new(int) make([]int, 100) │ │ new(int, a) make([]int, 100, a) │ │ │ C3 │ mem::new(int) mem::new_array(int, 100) │ │ │ Hare │ alloc(42) alloc([0...], 100) │ │ │ C │ Alloc(a, int) AllocN(a, int, 100) │ └──────────────────────────────────────────┘
Zig에서는 항상 할당자를 지정해야 한다. Odin에서는 할당자 전달이 선택 사항이다. C3에서는 어떤 함수는 할당자를 요구하지만, 다른 함수는 전역 할당자를 사용한다. Hare에는 단 하나의 전역 할당자가 있다.
살펴본 것처럼, 현대 언어의 할당자에 마법 같은 것은 없다. C보다 훨씬 인체공학적(ergonomic)이고 안전하긴 하지만, 순수한 C에서도 같은 기법을 쓰지 못할 이유는 없다.
새 글을 놓치지 않으려면 ★구독하자.
2026년 2월 12일