이미지 로딩 중...
AI Generated
2025. 11. 14. · 6 Views
Rust로 만드는 나만의 OS 36 Linked List Allocator
운영체제에서 동적 메모리 할당을 구현하는 Linked List Allocator의 핵심 개념과 실전 구현을 다룹니다. Heap 관리, 메모리 블록 연결, 할당/해제 알고리즘을 실무 수준으로 학습합니다.
목차
- Linked List Allocator 개념
- GlobalAlloc 트레이트 구현
- 메모리 정렬과 align_up 함수
- 자유 블록 리스트 관리
- 할당자 초기화와 전역 등록
- 블록 분할 전략
- First-Fit vs Best-Fit vs Worst-Fit 전략
- 스레드 안전성과 Lock 구현
- OOM 핸들러와 에러 처리
- 성능 측정과 프로파일링
1. Linked List Allocator 개념
시작하며
여러분이 운영체제를 직접 만들다가 Box::new()나 Vec::push()를 호출했는데 패닉이 발생한 적 있나요? "no global allocator found"라는 에러 메시지를 보면서 막막했던 경험이 있을 겁니다.
이런 문제는 bare-metal 환경에서 OS를 개발할 때 반드시 마주치는 상황입니다. 일반적인 환경에서는 OS가 제공하는 메모리 할당자를 자동으로 사용하지만, 우리가 OS를 만드는 입장이라면 이 할당자 자체를 직접 구현해야 합니다.
바로 이럴 때 필요한 것이 Linked List Allocator입니다. 이것은 가장 기본적이면서도 효과적인 동적 메모리 할당 전략으로, 사용 가능한 메모리 블록들을 연결 리스트로 관리하여 할당과 해제를 처리합니다.
개요
간단히 말해서, Linked List Allocator는 힙 메모리를 자유 블록들의 연결 리스트로 관리하는 메모리 할당자입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, OS 커널에서는 표준 라이브러리의 할당자를 사용할 수 없기 때문입니다.
커널 모드에서는 모든 것을 직접 구현해야 하며, 동적 메모리 할당은 가장 기본적인 요구사항입니다. 예를 들어, 프로세스 테이블을 관리하거나 디바이스 드라이버의 버퍼를 할당하는 경우에 매우 유용합니다.
전통적인 방법과 비교하면, 기존에는 정적 배열로만 데이터를 관리했다면, 이제는 필요할 때마다 동적으로 메모리를 할당하고 해제할 수 있습니다. 이 개념의 핵심 특징은 첫째, 구현이 단순하여 OS 개발 초기 단계에 적합하고, 둘째, 메모리 단편화를 어느 정도 관리할 수 있으며, 셋째, 할당 해제 시 인접 블록 병합을 통해 효율을 높일 수 있다는 점입니다.
이러한 특징들이 중요한 이유는 제한된 메모리 환경에서 최대한 효율적으로 자원을 활용해야 하기 때문입니다.
코드 예제
// linked_list_allocator.rs
use alloc::alloc::{GlobalAlloc, Layout};
use core::ptr;
// 자유 메모리 블록을 나타내는 노드
struct ListNode {
size: usize,
next: Option<&'static mut ListNode>,
}
impl ListNode {
const fn new(size: usize) -> Self {
ListNode { size, next: None }
}
fn start_addr(&self) -> usize {
self as *const Self as usize
}
fn end_addr(&self) -> usize {
self.start_addr() + self.size
}
}
설명
이것이 하는 일: Linked List Allocator는 사용 가능한 메모리 영역들을 연결 리스트로 체인으로 연결하여, 메모리 할당 요청이 들어왔을 때 적절한 크기의 블록을 찾아 반환하고, 해제 시 다시 리스트에 추가하는 역할을 합니다. 첫 번째로, ListNode 구조체는 각 자유 메모리 블록을 표현합니다.
각 노드는 블록의 크기(size)와 다음 블록을 가리키는 포인터(next)를 포함합니다. 이렇게 하는 이유는 메모리 블록 자체를 메타데이터 저장소로 활용하여 추가 메모리 오버헤드를 최소화하기 위함입니다.
그 다음으로, start_addr()와 end_addr() 메서드가 실행되면서 블록의 시작과 끝 주소를 계산합니다. 내부에서는 self 포인터를 usize로 캐스팅하여 메모리 주소 계산을 수행합니다.
이는 나중에 메모리 블록을 병합하거나 정렬할 때 필수적인 정보입니다. 마지막으로, new() 생성자가 컴파일 타임에 평가 가능한 const fn으로 선언되어 정적 초기화를 가능하게 합니다.
최종적으로 이 구조는 런타임에 힙 메모리를 효율적으로 관리할 수 있는 기반을 만들어냅니다. 여러분이 이 코드를 사용하면 OS 커널에서 Box, Vec, String 같은 동적 할당이 필요한 모든 타입을 사용할 수 있게 됩니다.
실무에서의 이점으로는 첫째, 메모리를 효율적으로 재사용할 수 있고, 둘째, 다양한 크기의 할당 요청을 유연하게 처리할 수 있으며, 셋째, 메모리 누수를 방지하는 체계적인 관리가 가능합니다.
실전 팁
💡 ListNode의 크기는 최소 할당 단위가 되므로, 작은 할당에서도 최소 size_of::<ListNode>() 바이트가 필요합니다. 이를 고려해 최소 블록 크기를 설정하세요.
💡 자유 블록 리스트를 크기 순으로 정렬하면 first-fit 전략의 성능을 크게 개선할 수 있습니다. 하지만 정렬 오버헤드와 트레이드오프를 고려해야 합니다.
💡 메모리 주소는 항상 정렬(alignment) 요구사항을 만족해야 합니다. Layout 타입의 align() 메서드를 활용해 올바른 정렬을 보장하세요.
💡 디버깅 시 메모리 블록의 매직 넘버를 추가하여 손상된 블록을 조기에 발견할 수 있습니다. 개발 단계에서는 이런 검증 코드가 매우 유용합니다.
💡 멀티코어 환경에서는 반드시 뮤텍스나 스핀락으로 할당자를 보호해야 합니다. 동시 접근은 메모리 손상의 주요 원인입니다.
2. GlobalAlloc 트레이트 구현
시작하며
여러분이 Linked List 기반 할당자를 만들었는데, Rust 컴파일러가 이를 인식하지 못해서 여전히 Box를 사용할 수 없다면? 이는 Rust의 전역 할당자 시스템과 연결되지 않았기 때문입니다.
이런 문제는 Rust가 타입 시스템을 통해 할당자를 추상화하기 때문에 발생합니다. 단순히 할당 함수를 구현하는 것만으로는 부족하고, Rust가 정의한 표준 인터페이스를 구현해야 합니다.
바로 이럴 때 필요한 것이 GlobalAlloc 트레이트 구현입니다. 이를 통해 여러분의 커스텀 할당자를 Rust의 표준 메모리 할당 시스템과 완벽하게 통합할 수 있습니다.
개요
간단히 말해서, GlobalAlloc은 Rust의 전역 메모리 할당자가 반드시 구현해야 하는 표준 인터페이스입니다. 이 트레이트가 필요한 이유는 Rust 컴파일러와 표준 라이브러리가 일관된 방식으로 메모리를 할당하고 해제할 수 있도록 하기 위함입니다.
alloc 함수와 dealloc 함수만 구현하면, Box, Vec, String 등 모든 동적 할당 타입이 자동으로 여러분의 할당자를 사용하게 됩니다. 예를 들어, 커널에서 프로세스 큐를 Vec<Process>로 관리하거나, 파일 시스템 캐시를 HashMap으로 구현하는 경우에 필수적입니다.
전통적인 방법과 비교하면, C에서는 malloc/free 함수를 직접 호출하거나 함수 포인터를 전달했다면, Rust에서는 트레이트 시스템을 통해 컴파일 타임에 타입 안전성을 보장하면서도 유연하게 할당자를 교체할 수 있습니다. 이 개념의 핵심 특징은 첫째, unsafe 트레이트이므로 구현자가 메모리 안전성을 직접 보장해야 하고, 둘째, Layout 타입을 통해 크기와 정렬 정보를 함께 전달받으며, 셋째, 전역 단일 할당자만 존재할 수 있다는 점입니다.
이러한 특징들이 중요한 이유는 메모리 안전성과 성능이 직결되는 OS 커널 개발에서 예측 가능하고 안정적인 동작을 보장하기 때문입니다.
코드 예제
use alloc::alloc::{GlobalAlloc, Layout};
use core::ptr;
pub struct LinkedListAllocator {
head: spin::Mutex<ListNode>,
}
unsafe impl GlobalAlloc for LinkedListAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let (size, align) = (layout.size(), layout.align());
let mut list = self.head.lock();
// First-fit 전략으로 적절한 블록 찾기
if let Some((previous, current)) = list.find_region(size, align) {
let alloc_start = align_up(current.start_addr(), align);
let alloc_end = alloc_start + size;
// 남은 공간이 있으면 분할
let excess_size = current.end_addr() - alloc_end;
if excess_size > 0 {
current.size = alloc_start - current.start_addr();
let new_node = ListNode::new(excess_size);
current.next = Some(Box::leak(Box::new(new_node)));
}
previous.next = current.next.take();
alloc_start as *mut u8
} else {
ptr::null_mut()
}
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
let mut list = self.head.lock();
let node = ListNode::new(layout.size());
// 리스트에 다시 추가하고 인접 블록 병합
list.add_free_region(ptr as usize, node);
}
}
설명
이것이 하는 일: GlobalAlloc 구현은 Rust 컴파일러가 인식할 수 있는 표준 인터페이스를 제공하여, 모든 동적 할당이 우리가 만든 Linked List Allocator를 통해 처리되도록 합니다. 첫 번째로, alloc 메서드는 요청된 크기와 정렬 조건을 만족하는 메모리 블록을 찾습니다.
Layout 타입에서 크기와 정렬 정보를 추출한 후, find_region 메서드로 first-fit 전략을 사용해 첫 번째로 적합한 블록을 찾습니다. 이렇게 하는 이유는 단순하지만 효과적인 할당 전략으로, 대부분의 경우 충분한 성능을 제공하기 때문입니다.
그 다음으로, 찾은 블록이 요청된 크기보다 크면 블록 분할이 실행됩니다. 내부에서는 할당 후 남은 공간(excess_size)을 계산하고, 이것이 0보다 크면 새로운 ListNode를 생성하여 자유 블록 리스트에 다시 추가합니다.
이는 메모리 단편화를 최소화하고 작은 할당 요청도 효율적으로 처리하기 위함입니다. 마지막으로, dealloc 메서드가 해제된 메모리를 자유 블록 리스트에 추가하고 인접한 블록들을 병합합니다.
최종적으로 이 과정을 통해 메모리 단편화를 줄이고 재사용 가능한 큰 블록을 유지할 수 있습니다. 여러분이 이 코드를 사용하면 Rust의 모든 표준 컬렉션과 스마트 포인터를 bare-metal 환경에서도 사용할 수 있게 됩니다.
실무에서의 이점으로는 첫째, 타입 안전한 메모리 관리가 가능하고, 둘째, 표준 라이브러리의 모든 기능을 활용할 수 있으며, 셋째, 메모리 누수와 이중 해제를 컴파일 타임에 방지할 수 있습니다.
실전 팁
💡 alloc이 null_mut()을 반환하면 할당 실패이므로, 호출자는 이를 반드시 확인해야 합니다. Rust의 Box::new는 할당 실패 시 패닉을 발생시킵니다.
💡 스핀락(spin::Mutex)을 사용할 때는 인터럽트를 비활성화해야 데드락을 방지할 수 있습니다. 인터럽트 핸들러 내에서 할당이 발생하면 데드락 위험이 있습니다.
💡 Layout::from_size_align_unchecked는 unsafe하므로, 가능하면 Layout::from_size_align을 사용하여 유효성을 검증하세요.
💡 할당 성능을 추적하려면 할당/해제 횟수와 평균 탐색 깊이를 측정하는 통계 카운터를 추가하세요. 이는 성능 병목 지점을 파악하는 데 필수적입니다.
💡 메모리 부족 상황을 대비해 OOM(Out Of Memory) 핸들러를 구현하세요. 커널이 우아하게 실패하도록 하는 것이 중요합니다.
3. 메모리 정렬과 align_up 함수
시작하며
여러분이 메모리를 할당했는데 CPU가 정렬되지 않은 주소 접근으로 예외를 발생시킨 적 있나요? 특히 u64를 읽으려는데 주소가 8바이트로 정렬되지 않아서 크래시가 발생하는 상황 말입니다.
이런 문제는 하드웨어 수준의 제약 때문에 발생합니다. 대부분의 CPU 아키텍처는 특정 데이터 타입이 특정 경계에 정렬되어 있을 것을 요구하며, 이를 위반하면 성능 저하나 예외가 발생합니다.
x86_64에서는 일부 명령어(SSE, AVX)가 16바이트 또는 32바이트 정렬을 강제합니다. 바로 이럴 때 필요한 것이 메모리 정렬 함수들입니다.
특히 align_up 함수는 주어진 주소를 요구되는 정렬 경계로 올림하여 안전한 메모리 접근을 보장합니다.
개요
간단히 말해서, 메모리 정렬(alignment)은 데이터가 메모리의 특정 배수 주소에 위치하도록 하는 것이고, align_up은 주소를 다음 정렬 경계로 올림하는 함수입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, CPU는 정렬된 주소에서 데이터를 읽을 때 최적의 성능을 발휘하기 때문입니다.
정렬되지 않은 접근은 여러 번의 메모리 읽기가 필요하거나 하드웨어 예외를 발생시킬 수 있습니다. 예를 들어, DMA(Direct Memory Access) 버퍼를 할당하거나, SIMD 연산을 위한 벡터 데이터를 준비할 때 정확한 정렬이 필수적입니다.
전통적인 방법과 비교하면, 기존에는 단순히 (addr + align - 1) & !(align - 1) 같은 비트 연산을 직접 사용했다면, 이제는 명확한 함수로 추상화하여 가독성과 안전성을 높일 수 있습니다. 이 개념의 핵심 특징은 첫째, 정렬 값은 항상 2의 거듭제곱이어야 하고(1, 2, 4, 8, 16...), 둘째, 올림 연산이므로 이미 정렬된 주소는 변경되지 않으며, 셋째, 비트 연산을 사용하여 O(1) 시간에 계산할 수 있다는 점입니다.
이러한 특징들이 중요한 이유는 메모리 할당자가 빠르고 정확하게 동작하려면 정렬 계산이 효율적이어야 하기 때문입니다.
코드 예제
// 주소를 지정된 정렬 경계로 올림
fn align_up(addr: usize, align: usize) -> usize {
// align은 반드시 2의 거듭제곱이어야 함
debug_assert!(align.is_power_of_two());
// (addr + align - 1) & !(align - 1)과 동일
// 예: addr=10, align=8 -> (10 + 7) & !7 = 17 & 0xFFF8 = 16
let align_mask = align - 1;
if addr & align_mask == 0 {
addr // 이미 정렬됨
} else {
(addr | align_mask) + 1 // 다음 정렬 경계로 올림
}
}
// 주소를 정렬 경계로 내림
fn align_down(addr: usize, align: usize) -> usize {
debug_assert!(align.is_power_of_two());
addr & !(align - 1)
}
// 사용 예시
let addr = 0x1234;
let aligned = align_up(addr, 16); // 0x1240
let downaligned = align_down(addr, 16); // 0x1230
설명
이것이 하는 일: align_up 함수는 주어진 메모리 주소를 정렬 요구사항에 맞게 조정하여, CPU가 최적의 성능으로 데이터를 읽고 쓸 수 있도록 합니다. 첫 번째로, debug_assert!로 정렬 값이 2의 거듭제곱인지 검증합니다.
이렇게 하는 이유는 비트 연산 기반 알고리즘이 2의 거듭제곱에서만 올바르게 동작하기 때문입니다. 개발 빌드에서는 이 조건이 검증되지만, 릴리스 빌드에서는 성능을 위해 생략됩니다.
그 다음으로, align_mask를 계산하고 현재 주소가 이미 정렬되어 있는지 확인합니다. 내부에서는 addr & align_mask가 0인지 체크하는데, 이는 주소의 하위 비트들이 모두 0인지 확인하는 것입니다.
예를 들어 8바이트 정렬이면 하위 3비트가 모두 0이어야 합니다. 마지막으로, 정렬되지 않았다면 (addr | align_mask) + 1 연산으로 다음 정렬 경계를 계산합니다.
최종적으로 이 방식은 조건 분기 없이 순수 산술 연산으로 정렬을 달성하여 CPU 파이프라인에 친화적입니다. 여러분이 이 코드를 사용하면 모든 메모리 할당이 CPU 요구사항을 만족하게 되어 하드웨어 예외를 방지하고 최적의 성능을 얻을 수 있습니다.
실무에서의 이점으로는 첫째, SIMD 명령어를 안전하게 사용할 수 있고, 둘째, 캐시 라인 정렬로 false sharing을 방지할 수 있으며, 셋째, DMA 전송이 올바르게 동작하도록 보장할 수 있습니다.
실전 팁
💡 core::mem::align_of::<T>()를 사용하면 타입 T의 정렬 요구사항을 컴파일 타임에 얻을 수 있습니다. 이를 활용해 타입 안전한 할당을 구현하세요.
💡 캐시 라인 크기(보통 64바이트)로 정렬하면 멀티코어 환경에서 false sharing을 방지할 수 있습니다. 성능 크리티컬한 데이터 구조에 적용하세요.
💡 align_up과 align_down을 혼동하지 마세요. 할당 시작 주소는 align_up, 영역의 끝 계산은 align_down을 사용하는 것이 일반적입니다.
💡 정렬이 페이지 크기(4096바이트)를 초과하면 TLB 효율성도 고려해야 합니다. Huge page를 활용할 수 있는지 검토하세요.
💡 임베디드 시스템에서는 DMA 컨트롤러의 정렬 요구사항이 CPU와 다를 수 있습니다. 하드웨어 매뉴얼을 반드시 확인하세요.
4. 자유 블록 리스트 관리
시작하며
여러분이 메모리를 할당하고 해제하다 보니 작은 조각들만 남아서 큰 객체를 할당할 수 없게 된 적 있나요? 충분한 총 메모리가 있는데도 단편화 때문에 할당이 실패하는 상황 말입니다.
이런 문제는 메모리 단편화(fragmentation)라는 고전적인 운영체제 문제입니다. 할당과 해제가 반복되면서 메모리가 작은 조각들로 쪼개지고, 연속된 큰 공간을 찾기 어려워집니다.
특히 장시간 실행되는 서버나 임베디드 시스템에서 심각한 문제가 됩니다. 바로 이럴 때 필요한 것이 효율적인 자유 블록 리스트 관리입니다.
특히 인접한 자유 블록들을 병합(coalescing)하고, 적절한 정책으로 리스트를 정렬하는 것이 핵심입니다.
개요
간단히 말해서, 자유 블록 리스트 관리는 사용 가능한 메모리 블록들을 효율적으로 추적하고, 할당/해제 시 최적의 블록을 찾거나 병합하는 일련의 전략입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 단순히 해제된 블록을 리스트에 추가만 하면 단편화가 급격히 증가하기 때문입니다.
인접한 블록을 병합하지 않으면 1KB 블록 100개가 있어도 2KB 할당 요청을 처리하지 못하는 상황이 발생합니다. 예를 들어, 게임 엔진에서 프레임마다 임시 버퍼를 할당/해제하는 경우, 적절한 병합이 없으면 몇 분 안에 메모리가 고갈될 수 있습니다.
전통적인 방법과 비교하면, 기존에는 단순히 LIFO나 FIFO 방식으로 블록을 관리했다면, 이제는 주소 정렬 리스트와 인접 블록 병합을 통해 단편화를 적극적으로 관리할 수 있습니다. 이 개념의 핵심 특징은 첫째, 주소 순으로 정렬된 리스트를 유지하면 병합이 O(1)에 가능하고, 둘째, 블록 분할과 병합을 통해 유연성을 제공하며, 셋째, first-fit, best-fit, worst-fit 등 다양한 할당 전략을 적용할 수 있다는 점입니다.
이러한 특징들이 중요한 이유는 메모리 사용 패턴에 따라 최적의 전략을 선택할 수 있어야 하기 때문입니다.
코드 예제
impl ListNode {
// 자유 블록 리스트에 새로운 영역 추가 (주소 정렬 유지 + 병합)
fn add_free_region(&mut self, addr: usize, mut node: ListNode) {
// 리스트를 주소 순으로 순회
let mut current = self;
while let Some(ref mut next) = current.next {
if next.start_addr() > addr {
break; // 삽입 위치 발견
}
current = next;
}
let new_start = addr;
let new_end = addr + node.size;
// 다음 블록과 병합 가능한지 확인
if let Some(ref mut next) = current.next {
if new_end == next.start_addr() {
// 다음 블록과 병합
node.size += next.size;
node.next = next.next.take();
current.next = Some(Box::leak(Box::new(node)));
}
}
// 이전 블록과 병합 가능한지 확인
if current.end_addr() == new_start {
// 이전 블록에 병합
current.size += node.size;
if let Some(next) = node.next.take() {
current.next = Some(next);
}
} else {
// 병합 불가능, 중간에 삽입
node.next = current.next.take();
current.next = Some(Box::leak(Box::new(node)));
}
}
// 요구 조건에 맞는 블록 찾기 (first-fit 전략)
fn find_region(&mut self, size: usize, align: usize) -> Option<(&mut Self, &mut ListNode)> {
let mut current = self;
while let Some(ref mut next) = current.next {
let alloc_start = align_up(next.start_addr(), align);
let alloc_end = alloc_start.checked_add(size)?;
if alloc_end <= next.end_addr() {
// 적합한 블록 발견
return Some((current, next));
}
current = next;
}
None // 적합한 블록 없음
}
}
설명
이것이 하는 일: 자유 블록 리스트 관리는 메모리 블록들을 주소 순으로 정렬하여 유지하고, 해제 시 인접한 블록을 자동으로 병합하여 큰 연속 블록을 만들어냅니다. 첫 번째로, add_free_region 메서드는 새로 해제된 블록을 리스트에 삽입할 올바른 위치를 찾습니다.
주소 순으로 순회하면서 현재 블록보다 큰 주소를 가진 첫 번째 블록 앞에 삽입합니다. 이렇게 하는 이유는 주소 정렬 상태를 유지해야 인접 블록 병합을 효율적으로 수행할 수 있기 때문입니다.
그 다음으로, 삽입 위치를 찾은 후 다음 블록과 이전 블록이 인접한지 검사합니다. 내부에서는 new_end == next.start_addr() 같은 조건으로 메모리 주소가 연속되는지 확인하고, 연속되면 두 블록의 크기를 합쳐서 하나의 큰 블록으로 만듭니다.
이는 단편화를 즉시 해결하는 핵심 메커니즘입니다. 마지막으로, find_region 메서드가 할당 요청에 적합한 블록을 first-fit 전략으로 탐색합니다.
최종적으로 정렬 요구사항까지 고려하여(align_up) 첫 번째로 충분한 크기를 가진 블록을 반환합니다. 여러분이 이 코드를 사용하면 장시간 실행되는 시스템에서도 메모리 단편화를 최소화할 수 있습니다.
실무에서의 이점으로는 첫째, 연속된 큰 메모리 블록을 유지할 수 있고, 둘째, 할당 성공률이 크게 향상되며, 셋째, 메모리 사용 효율이 극대화됩니다.
실전 팁
💡 주소 정렬 리스트는 삽입이 O(n)이지만 병합이 O(1)입니다. 할당보다 해제가 빈번한 워크로드에 이상적입니다.
💡 크기 순 정렬 리스트를 병행하면 best-fit 전략을 O(1)에 구현할 수 있지만, 두 리스트를 동기화하는 오버헤드를 고려해야 합니다.
💡 최소 블록 크기를 설정하여 과도한 단편화를 방지하세요. 예를 들어 최소 64바이트로 설정하면 작은 할당도 효율적으로 관리됩니다.
💡 디버깅 시 리스트 무결성을 검증하는 함수를 작성하세요. 블록들이 중복되거나 정렬이 깨지는 것을 조기에 발견할 수 있습니다.
💡 통계 정보(총 자유 메모리, 최대 블록 크기, 블록 개수)를 추적하면 메모리 상태를 실시간으로 모니터링할 수 있습니다.
5. 할당자 초기화와 전역 등록
시작하며
여러분이 완벽한 Linked List Allocator를 구현했는데, 프로그램 시작 시 "allocator not initialized" 패닉이 발생한 적 있나요? 할당자는 만들었지만 Rust 런타임이 이를 인식하지 못하는 상황입니다.
이런 문제는 Rust의 전역 할당자 시스템이 명시적인 등록을 요구하기 때문에 발생합니다. 단순히 GlobalAlloc을 구현하는 것만으로는 부족하고, #[global_allocator] 속성으로 전역 할당자로 지정하고, 힙 메모리 영역을 초기화해야 합니다.
바로 이럴 때 필요한 것이 할당자 초기화와 전역 등록 절차입니다. 이를 통해 OS 부팅 과정에서 메모리 할당 시스템을 완전히 활성화할 수 있습니다.
개요
간단히 말해서, 할당자 초기화는 힙 메모리 영역의 시작 주소와 크기를 설정하는 것이고, 전역 등록은 #[global_allocator] 속성으로 Rust에게 사용할 할당자를 알려주는 것입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, bare-metal 환경에서는 힙 메모리 영역이 자동으로 준비되지 않기 때문입니다.
부트로더나 커널 초기화 코드에서 명시적으로 힙 영역을 설정하고, Rust 컴파일러에게 이 할당자를 사용하도록 지시해야 합니다. 예를 들어, 멀티부트 부트로더로부터 메모리 맵을 받아서 사용 가능한 물리 메모리를 힙으로 설정하는 과정이 필요합니다.
전통적인 방법과 비교하면, C에서는 링커 스크립트로 힙 영역을 정의하고 초기화 함수를 명시적으로 호출했다면, Rust에서는 타입 시스템과 속성을 활용해 컴파일 타임에 더 많은 검증을 수행할 수 있습니다. 이 개념의 핵심 특징은 첫째, #[global_allocator]는 단 하나만 존재할 수 있고, 둘째, 초기화는 어떤 동적 할당보다 먼저 실행되어야 하며, 셋째, 힙 영역은 반드시 유효한 물리/가상 메모리여야 한다는 점입니다.
이러한 특징들이 중요한 이유는 초기화 순서나 메모리 영역이 잘못되면 전체 시스템이 불안정해지기 때문입니다.
코드 예제
use linked_list_allocator::LockedHeap;
// 전역 할당자 선언 (컴파일러가 이를 사용함)
#[global_allocator]
static ALLOCATOR: LockedHeap = LockedHeap::empty();
// 힙 크기 상수 (예: 100KB)
pub const HEAP_START: usize = 0x_4444_4444_0000;
pub const HEAP_SIZE: usize = 100 * 1024; // 100 KiB
// 커널 초기화 시 호출되어야 하는 함수
pub fn init_heap() {
// 힙 영역을 할당자에 등록
unsafe {
ALLOCATOR.lock().init(HEAP_START, HEAP_SIZE);
}
}
// 메인 함수에서 초기화
pub fn kernel_main(boot_info: &'static BootInfo) {
// 먼저 힙을 초기화
init_heap();
// 이제 동적 할당을 사용할 수 있음
let heap_value = Box::new(41);
println!("heap_value at {:p}", heap_value);
// 벡터도 사용 가능
let mut vec = Vec::new();
for i in 0..500 {
vec.push(i);
}
println!("vec at {:p}, len = {}", vec.as_ptr(), vec.len());
}
설명
이것이 하는 일: 할당자 초기화와 전역 등록은 bare-metal 환경에서 Rust의 모든 동적 할당 기능을 사용 가능하게 만드는 필수 설정 과정입니다. 첫 번째로, #[global_allocator] 속성으로 ALLOCATOR를 전역 할당자로 선언합니다.
이렇게 하는 이유는 Rust 컴파일러가 모든 Box, Vec, String 등의 할당을 이 할당자를 통해 처리하도록 지시하기 위함입니다. LockedHeap::empty()는 아직 초기화되지 않은 빈 할당자를 생성합니다.
그 다음으로, init_heap 함수가 실행되면서 힙의 시작 주소와 크기를 설정합니다. 내부에서는 ALLOCATOR.lock().init()을 호출하여 지정된 메모리 영역을 하나의 큰 자유 블록으로 초기화합니다.
이는 첫 번째 할당 요청이 들어오기 전에 반드시 완료되어야 합니다. 마지막으로, kernel_main에서 init_heap()을 가장 먼저 호출한 후 동적 할당을 사용합니다.
최종적으로 이 순서를 지키면 모든 표준 컬렉션과 스마트 포인터가 정상적으로 작동합니다. 여러분이 이 코드를 사용하면 bare-metal 커널에서도 일반 Rust 프로그래밍과 동일하게 동적 할당을 사용할 수 있게 됩니다.
실무에서의 이점으로는 첫째, 표준 라이브러리의 모든 컬렉션을 활용할 수 있고, 둘째, 메모리 안전성을 보장받으며, 셋째, 코드의 표현력과 생산성이 크게 향상됩니다.
실전 팁
💡 init_heap을 두 번 호출하면 안 됩니다. 이미 사용 중인 할당자를 재초기화하면 메모리 손상이 발생합니다. Once 타입으로 보호하세요.
💡 힙 크기는 너무 작으면 빠르게 고갈되고, 너무 크면 페이지 테이블 오버헤드가 증가합니다. 프로파일링을 통해 적절한 크기를 찾으세요.
💡 HEAP_START 주소는 반드시 페이지 정렬되어야 하고, 실제 사용 가능한 메모리여야 합니다. 부트로더의 메모리 맵을 확인하세요.
💡 개발 단계에서는 할당 실패 시 상세한 로그를 출력하도록 alloc_error_handler를 구현하세요. 디버깅에 필수적입니다.
💡 멀티코어 시스템에서는 각 CPU가 로컬 힙을 가지도록 설계하면 락 경합을 줄일 수 있습니다. NUMA 아키텍처에서 특히 효과적입니다.
6. 블록 분할 전략
시작하며
여러분이 100KB 블록에서 1KB만 할당했는데, 나머지 99KB가 사라져버린 적 있나요? 내부 단편화(internal fragmentation)로 인해 메모리가 낭비되는 상황입니다.
이런 문제는 블록을 분할하지 않고 전체를 할당해버리면 발생합니다. 큰 블록이 있어도 작은 할당 하나로 전체가 소모되면, 남은 공간을 재사용할 수 없게 됩니다.
특히 크기가 다양한 할당 요청이 섞여 있는 실무 환경에서 심각한 메모리 낭비를 초래합니다. 바로 이럴 때 필요한 것이 블록 분할(splitting) 전략입니다.
적절한 크기로 블록을 나누어 할당하고, 남은 부분은 자유 리스트에 다시 추가하여 재사용 가능하게 만듭니다.
개요
간단히 말해서, 블록 분할은 큰 자유 블록에서 필요한 만큼만 할당하고, 남은 부분을 새로운 자유 블록으로 만드는 기법입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 메모리 할당 요청 크기가 매우 다양하기 때문입니다.
16바이트부터 수 메가바이트까지 다양한 크기의 할당이 섞여 있을 때, 분할 없이는 메모리 활용률이 급격히 떨어집니다. 예를 들어, 웹 서버에서 작은 요청 객체와 큰 응답 버퍼를 동시에 처리할 때, 블록 분할이 없으면 메모리가 금방 고갈됩니다.
전통적인 방법과 비교하면, 기존에는 고정 크기 블록만 제공하는 슬랩 할당자를 사용했다면, 이제는 가변 크기 블록을 동적으로 분할하여 유연성과 효율성을 모두 달성할 수 있습니다. 이 개념의 핵심 특징은 첫째, 남은 블록이 최소 크기 이상일 때만 분할하고, 둘째, 분할된 블록은 즉시 자유 리스트에 추가되며, 셋째, 정렬 요구사항도 함께 고려해야 한다는 점입니다.
이러한 특징들이 중요한 이유는 너무 작은 블록을 만들면 오히려 관리 오버헤드가 증가하기 때문입니다.
코드 예제
impl LinkedListAllocator {
unsafe fn alloc_from_region(region: &ListNode, size: usize, align: usize)
-> Result<*mut u8, ()>
{
let alloc_start = align_up(region.start_addr(), align);
let alloc_end = alloc_start.checked_add(size).ok_or(())?;
if alloc_end > region.end_addr() {
// 블록이 충분히 크지 않음
return Err(());
}
let excess_size = region.end_addr() - alloc_end;
// 최소 블록 크기 확인 (ListNode를 저장할 공간 필요)
const MIN_BLOCK_SIZE: usize = core::mem::size_of::<ListNode>();
if excess_size > 0 {
if excess_size >= MIN_BLOCK_SIZE {
// 남은 공간이 충분히 크면 분할
let excess_node = ListNode::new(excess_size);
let excess_addr = alloc_end;
// 남은 블록을 자유 리스트에 추가
let excess_ptr = excess_addr as *mut ListNode;
excess_ptr.write(excess_node);
// 원래 블록 크기 조정
let new_size = alloc_start - region.start_addr();
// (여기서 리스트 업데이트 로직)
} else {
// 남은 공간이 너무 작으면 그냥 할당에 포함
// (내부 단편화 발생하지만 관리 간소화)
}
}
Ok(alloc_start as *mut u8)
}
}
설명
이것이 하는 일: 블록 분할은 자유 블록이 할당 요청보다 클 때, 필요한 부분만 떼어내고 남은 부분을 새로운 자유 블록으로 만들어 메모리 낭비를 최소화합니다. 첫 번째로, alloc_from_region 함수는 정렬된 할당 시작 주소와 끝 주소를 계산합니다.
align_up으로 정렬 요구사항을 만족시키고, checked_add로 오버플로우를 안전하게 검사합니다. 이렇게 하는 이유는 잘못된 주소 계산이 메모리 손상으로 이어질 수 있기 때문입니다.
그 다음으로, excess_size를 계산하여 할당 후 남는 공간의 크기를 파악합니다. 내부에서는 이 값이 MIN_BLOCK_SIZE 이상인지 확인하는데, 이는 새로운 ListNode를 저장하기 위한 최소 공간이 필요하기 때문입니다.
너무 작은 조각은 관리할 수 없으므로 그냥 할당에 포함시킵니다. 마지막으로, 남은 공간이 충분히 크면 새로운 ListNode를 생성하고 자유 리스트에 추가합니다.
최종적으로 이 과정을 통해 메모리 활용률을 극대화하면서도 과도한 단편화를 방지할 수 있습니다. 여러분이 이 코드를 사용하면 다양한 크기의 할당 요청을 효율적으로 처리할 수 있습니다.
실무에서의 이점으로는 첫째, 메모리 낭비를 최소화하고, 둘째, 작은 할당과 큰 할당을 동시에 지원하며, 셋째, 장기 실행 시에도 안정적인 메모리 사용 패턴을 유지할 수 있습니다.
실전 팁
💡 MIN_BLOCK_SIZE를 적절히 설정하는 것이 중요합니다. 너무 작으면 리스트가 길어지고, 너무 크면 메모리 낭비가 증가합니다. 일반적으로 16-64바이트가 적절합니다.
💡 정렬 패딩도 excess_size 계산에 포함시켜야 합니다. 그렇지 않으면 정렬로 인한 공간 손실을 고려하지 못합니다.
💡 분할 횟수를 추적하면 할당 패턴을 분석할 수 있습니다. 과도한 분할은 단편화 신호일 수 있습니다.
💡 슬랩 할당자와 조합하면 작은 고정 크기 할당은 슬랩에서, 큰 가변 크기 할당은 linked list에서 처리하여 최적의 성능을 얻을 수 있습니다.
💡 분할 임계값을 동적으로 조정하는 adaptive 전략을 고려하세요. 메모리 압박 상황에서는 임계값을 낮춰 재사용을 늘릴 수 있습니다.
7. First-Fit vs Best-Fit vs Worst-Fit 전략
시작하며
여러분이 메모리 할당자를 만들었는데 성능이 기대에 미치지 못한 적 있나요? 할당 속도는 빠른데 단편화가 심하거나, 단편화는 적은데 할당이 너무 느린 상황 말입니다.
이런 문제는 어떤 자유 블록을 선택하느냐에 따라 성능과 효율성의 트레이드오프가 발생하기 때문입니다. 가장 먼저 찾은 블록을 사용할지, 가장 적합한 블록을 찾을지, 아니면 가장 큰 블록을 사용할지에 따라 메모리 사용 패턴이 완전히 달라집니다.
바로 이럴 때 필요한 것이 할당 전략(allocation strategy)에 대한 이해입니다. First-Fit, Best-Fit, Worst-Fit 각각의 장단점을 파악하고 워크로드에 맞는 전략을 선택해야 합니다.
개요
간단히 말해서, 할당 전략은 여러 개의 자유 블록 중에서 어떤 블록을 선택할지 결정하는 정책입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 워크로드 특성에 따라 최적의 전략이 다르기 때문입니다.
실시간 시스템에서는 예측 가능한 성능이 중요하고, 장기 실행 서버에서는 단편화 최소화가 우선순위입니다. 예를 들어, 게임 엔진의 프레임 할당자는 빠른 First-Fit이 적합하지만, 데이터베이스의 버퍼 풀은 단편화를 줄이는 Best-Fit이 유리합니다.
각 전략을 비교하면, First-Fit은 첫 번째로 적합한 블록을 선택하여 O(n) 시간에 빠르게 할당하고, Best-Fit은 가장 크기가 근접한 블록을 선택하여 메모리 낭비를 최소화하지만 전체 리스트를 탐색해야 하며, Worst-Fit은 가장 큰 블록을 선택하여 큰 블록을 유지하려 시도합니다. 이 개념의 핵심 특징은 첫째, 성능과 메모리 효율성 간의 트레이드오프가 존재하고, 둘째, 리스트 정렬 방식에 따라 구현 복잡도가 달라지며, 셋째, 워크로드 패턴에 따라 최적 전략이 다르다는 점입니다.
이러한 특징들이 중요한 이유는 단일 전략으로 모든 상황을 최적화할 수 없기 때문입니다.
코드 예제
impl ListNode {
// First-Fit: 첫 번째로 충분한 블록 선택 (빠름)
fn first_fit(&mut self, size: usize, align: usize) -> Option<&mut ListNode> {
let mut current = self;
while let Some(ref mut region) = current.next {
let alloc_start = align_up(region.start_addr(), align);
if alloc_start + size <= region.end_addr() {
return Some(region); // 첫 번째 적합 블록 발견
}
current = region;
}
None
}
// Best-Fit: 가장 크기가 근접한 블록 선택 (메모리 효율적)
fn best_fit(&mut self, size: usize, align: usize) -> Option<&mut ListNode> {
let mut current = self;
let mut best: Option<&mut ListNode> = None;
let mut best_excess = usize::MAX;
while let Some(ref mut region) = current.next {
let alloc_start = align_up(region.start_addr(), align);
if alloc_start + size <= region.end_addr() {
let excess = region.end_addr() - (alloc_start + size);
if excess < best_excess {
best_excess = excess;
best = Some(region);
if excess == 0 {
break; // 완벽한 매칭 발견
}
}
}
current = region;
}
best
}
// Worst-Fit: 가장 큰 블록 선택 (큰 블록 유지)
fn worst_fit(&mut self, size: usize, align: usize) -> Option<&mut ListNode> {
let mut current = self;
let mut worst: Option<&mut ListNode> = None;
let mut worst_size = 0;
while let Some(ref mut region) = current.next {
let alloc_start = align_up(region.start_addr(), align);
if alloc_start + size <= region.end_addr() && region.size > worst_size {
worst_size = region.size;
worst = Some(region);
}
current = region;
}
worst
}
}
설명
이것이 하는 일: 할당 전략은 메모리 할당 요청이 들어왔을 때 여러 자유 블록 중에서 어떤 블록을 선택할지 결정하는 알고리즘입니다. 첫 번째로, First-Fit 전략은 리스트를 순회하면서 첫 번째로 충분한 크기의 블록을 발견하면 즉시 반환합니다.
이렇게 하는 이유는 평균적으로 절반의 리스트만 탐색하면 되어 빠른 할당이 가능하기 때문입니다. 주소 정렬 리스트에서는 낮은 주소의 블록부터 사용하여 메모리 지역성도 향상됩니다.
그 다음으로, Best-Fit 전략은 전체 리스트를 탐색하면서 가장 적은 여유 공간(excess)을 가진 블록을 선택합니다. 내부에서는 best_excess를 추적하며 더 나은 매칭을 계속 찾고, 완벽한 매칭(excess == 0)을 발견하면 조기 종료합니다.
이는 작은 조각 생성을 최소화하여 단편화를 줄이는 전략입니다. 마지막으로, Worst-Fit 전략은 가장 큰 블록을 선택하여 할당 후에도 큰 블록이 남도록 합니다.
최종적으로 이 방식은 큰 할당 요청을 나중에 처리할 수 있도록 대비하지만, 실제로는 단편화를 증가시키는 경우가 많아 실무에서는 드물게 사용됩니다. 여러분이 워크로드 특성을 분석하여 적절한 전략을 선택하면 성능과 메모리 효율성을 최적화할 수 있습니다.
실무에서의 이점으로는 첫째, 실시간 시스템에서는 First-Fit으로 예측 가능한 성능을 얻고, 둘째, 메모리 제약 환경에서는 Best-Fit으로 활용률을 높이며, 셋째, 하이브리드 전략으로 양쪽의 장점을 결합할 수 있습니다.
실전 팁
💡 크기별 자유 리스트를 여러 개 유지하면 First-Fit의 속도와 Best-Fit의 효율성을 모두 얻을 수 있습니다. segregated fit 전략을 연구하세요.
💡 Best-Fit에서 리스트를 크기 순으로 정렬하면 O(log n)으로 개선할 수 있지만, 삽입 비용이 증가합니다. 벤치마킹으로 검증하세요.
💡 Next-Fit 전략도 고려하세요. 이전 할당 위치부터 탐색을 시작하여 First-Fit보다 더 균등한 분포를 얻을 수 있습니다.
💡 할당 크기에 따라 전략을 동적으로 변경하는 adaptive allocator를 구현할 수 있습니다. 작은 할당은 First-Fit, 큰 할당은 Best-Fit 방식입니다.
💡 통계 데이터를 수집하여 각 전략의 실제 성능을 측정하세요. 이론과 실제는 워크로드에 따라 크게 다를 수 있습니다.
8. 스레드 안전성과 Lock 구현
시작하며
여러분이 멀티코어 시스템에서 OS를 실행했는데 임의의 타이밍에 크래시가 발생한 적 있나요? 특히 여러 스레드가 동시에 메모리를 할당/해제할 때 자유 리스트가 손상되는 상황입니다.
이런 문제는 여러 CPU가 동시에 할당자의 내부 상태를 수정할 때 발생하는 경쟁 조건(race condition) 때문입니다. 하나의 CPU가 리스트를 순회하는 중에 다른 CPU가 노드를 제거하면, 댕글링 포인터나 메모리 손상이 발생합니다.
이는 디버깅하기 매우 어려운 비결정적 버그입니다. 바로 이럴 때 필요한 것이 스레드 안전성을 보장하는 Lock 구현입니다.
스핀락이나 뮤텍스로 할당자 접근을 직렬화하여 한 번에 하나의 스레드만 할당자를 사용하도록 보장합니다.
개요
간단히 말해서, 스레드 안전성은 여러 스레드가 동시에 접근해도 데이터 무결성이 유지되는 성질이고, Lock은 상호 배제(mutual exclusion)를 구현하는 동기화 메커니즘입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 현대 시스템은 대부분 멀티코어이며 병렬 처리가 기본이기 때문입니다.
OS 커널에서는 여러 CPU가 동시에 작동하고, 각각이 메모리 할당을 요청할 수 있습니다. 예를 들어, 네트워크 패킷 처리, 파일 시스템 I/O, 사용자 프로세스 관리가 동시에 일어나며 모두 메모리 할당이 필요합니다.
전통적인 방법과 비교하면, 단일 CPU 시스템에서는 인터럽트만 비활성화하면 충분했지만, 멀티코어에서는 반드시 원자적 연산과 메모리 배리어를 사용하는 Lock이 필요합니다. 이 개념의 핵심 특징은 첫째, 스핀락은 busy-waiting으로 대기하여 컨텍스트 스위칭 없이 빠르게 동작하고, 둘째, 인터럽트 비활성화와 함께 사용하여 데드락을 방지해야 하며, 셋째, Lock contention이 성능 병목이 될 수 있다는 점입니다.
이러한 특징들이 중요한 이유는 커널에서의 Lock 문제는 전체 시스템을 멈출 수 있기 때문입니다.
코드 예제
use spin::Mutex;
use core::alloc::{GlobalAlloc, Layout};
pub struct LockedHeap {
inner: Mutex<Heap>,
}
impl LockedHeap {
pub const fn empty() -> Self {
LockedHeap {
inner: Mutex::new(Heap::empty()),
}
}
pub unsafe fn init(&self, heap_start: usize, heap_size: usize) {
// Lock을 획득하여 독점 접근
self.inner.lock().init(heap_start, heap_size);
}
}
unsafe impl GlobalAlloc for LockedHeap {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// Lock을 획득하여 thread-safe 보장
let mut heap = self.inner.lock();
heap.allocate_first_fit(layout)
.ok()
.map_or(core::ptr::null_mut(), |allocation| allocation.as_ptr())
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
// Lock을 획득하여 thread-safe 보장
let mut heap = self.inner.lock();
heap.deallocate(core::ptr::NonNull::new_unchecked(ptr), layout);
}
// Lock은 scope 종료 시 자동으로 해제 (RAII)
}
// 인터럽트 비활성화와 함께 사용하는 안전한 버전
pub struct InterruptSafeAllocator {
inner: Mutex<Heap>,
}
impl InterruptSafeAllocator {
unsafe fn alloc_with_interrupt_disabled(&self, layout: Layout) -> *mut u8 {
// 1. 인터럽트 비활성화
let flags = x86_64::instructions::interrupts::are_enabled();
x86_64::instructions::interrupts::disable();
// 2. Lock 획득 및 할당
let result = self.inner.lock().allocate_first_fit(layout);
// 3. 인터럽트 복원
if flags {
x86_64::instructions::interrupts::enable();
}
result.ok().map_or(core::ptr::null_mut(), |a| a.as_ptr())
}
}
설명
이것이 하는 일: 스레드 안전성 구현은 Mutex를 사용하여 할당자의 내부 상태를 보호하고, 한 번에 하나의 스레드만 할당/해제 작업을 수행하도록 보장합니다. 첫 번째로, LockedHeap 구조체는 실제 힙 구현을 Mutex로 감싸서 보호합니다.
Mutex::new(Heap::empty())로 초기화하며, 이렇게 하는 이유는 Rust의 타입 시스템을 활용해 컴파일 타임에 동기화를 강제하기 때문입니다. Mutex가 없으면 컴파일조차 되지 않습니다.
그 다음으로, alloc과 dealloc 메서드에서 self.inner.lock()을 호출하여 Mutex를 획득합니다. 내부에서는 스핀락이 현재 Lock이 해제될 때까지 busy-waiting하며, Lock을 획득한 스레드만 힙에 접근할 수 있습니다.
Lock은 MutexGuard의 drop 시점(scope 종료)에 자동으로 해제되어 실수로 Lock을 해제하지 않는 버그를 방지합니다. 마지막으로, InterruptSafeAllocator는 인터럽트 비활성화까지 추가합니다.
최종적으로 인터럽트 핸들러 내에서 할당이 발생해도 데드락이 발생하지 않도록 보장합니다. 이는 커널 개발에서 필수적인 패턴입니다.
여러분이 이 코드를 사용하면 멀티코어 시스템에서도 안전하게 메모리 할당을 수행할 수 있습니다. 실무에서의 이점으로는 첫째, 데이터 레이스를 완전히 방지하고, 둘째, Rust의 소유권 시스템과 결합하여 컴파일 타임 안전성을 제공하며, 셋째, RAII 패턴으로 Lock 누수를 방지할 수 있습니다.
실전 팁
💡 스핀락은 짧은 critical section에 적합합니다. 긴 작업에는 sleep 기반 뮤텍스를 고려하되, 커널 컨텍스트에서는 sleep이 불가능할 수 있습니다.
💡 Lock을 잡은 상태에서 절대 다른 Lock을 획득하지 마세요. 데드락의 주요 원인입니다. Lock 순서를 명확히 정의하세요.
💡 lock() 대신 try_lock()을 사용하면 블로킹 없이 실패를 처리할 수 있습니다. 실시간 시스템에서 유용합니다.
💡 Lock contention을 측정하는 instrumentation을 추가하세요. 경합이 심하면 per-CPU allocator나 lock-free 알고리즘을 고려할 때입니다.
💡 #[thread_local] 속성으로 스레드별 할당자 캐시를 구현하면 Lock 획득 빈도를 크게 줄일 수 있습니다. tcmalloc의 핵심 아이디어입니다.
9. OOM 핸들러와 에러 처리
시작하며
여러분이 메모리가 부족한 상황에서 Box::new()를 호출했는데 시스템이 그냥 패닉으로 죽어버린 적 있나요? 에러 메시지도 제대로 출력되지 않고 디버깅 정보도 없이 크래시하는 상황입니다.
이런 문제는 Rust의 기본 메모리 할당 실패 처리가 즉시 abort하도록 설계되었기 때문입니다. 일반 애플리케이션에서는 OS가 프로세스를 종료하면 되지만, OS 커널 자체가 죽으면 전체 시스템이 멈춰버립니다.
특히 서버 환경에서는 우아한 복구(graceful degradation)가 필수적입니다. 바로 이럴 때 필요한 것이 커스텀 OOM(Out Of Memory) 핸들러입니다.
메모리 부족 상황을 감지하고, 적절한 로깅과 함께 복구를 시도하거나 최소한 유용한 디버깅 정보를 남기고 종료해야 합니다.
개요
간단히 말해서, OOM 핸들러는 메모리 할당이 실패했을 때 호출되는 커스텀 함수로, 시스템이 어떻게 반응할지 제어합니다. 왜 이것이 필요한지 실무 관점에서 설명하면, bare-metal 환경에서는 할당 실패에 대한 기본 처리가 없기 때문입니다.
사용자 공간 프로그램은 Option이나 Result로 실패를 처리할 수 있지만, 커널의 핵심 자료구조 할당이 실패하면 복구가 불가능할 수 있습니다. 예를 들어, 인터럽트 핸들러에서 버퍼 할당 실패는 하드웨어 응답 불가로 이어질 수 있습니다.
전통적인 방법과 비교하면, C에서는 malloc이 NULL을 반환하고 호출자가 직접 확인했지만, Rust에서는 alloc_error_handler 속성으로 전역 핸들러를 등록하여 일관된 에러 처리를 강제할 수 있습니다. 이 개념의 핵심 특징은 첫째, #[alloc_error_handler] 속성으로 전역 핸들러를 등록하고, 둘째, 핸들러는 반환하지 않으므로(! 타입) 반드시 패닉이나 무한 루프로 종료해야 하며, 셋째, 할당 실패 시점의 Layout 정보를 받아 디버깅에 활용할 수 있다는 점입니다.
이러한 특징들이 중요한 이유는 메모리 고갈은 치명적인 상황이므로 명확한 처리 정책이 필요하기 때문입니다.
코드 예제
#![feature(alloc_error_handler)]
use alloc::alloc::Layout;
use core::panic::PanicInfo;
// 메모리 할당 실패 시 호출되는 핸들러
#[alloc_error_handler]
fn alloc_error_handler(layout: Layout) -> ! {
// 1. 상세한 에러 정보 로깅
log::error!(
"Allocation error: failed to allocate {} bytes with alignment {}",
layout.size(),
layout.align()
);
// 2. 스택 트레이스 출력 (가능한 경우)
#[cfg(feature = "backtrace")]
print_stack_trace();
// 3. 메모리 통계 출력
print_memory_stats();
// 4. 시스템 상태 덤프 (디버깅용)
dump_allocator_state();
// 5. 패닉으로 시스템 중단 (안전한 종료)
panic!("Out of memory: cannot allocate {:?}", layout);
}
// 메모리 통계 출력
fn print_memory_stats() {
log::warn!("Memory statistics:");
log::warn!(" Total heap size: {} bytes", HEAP_SIZE);
log::warn!(" Used memory: {} bytes", get_used_memory());
log::warn!(" Free memory: {} bytes", get_free_memory());
log::warn!(" Largest free block: {} bytes", get_largest_free_block());
log::warn!(" Fragmentation: {:.2}%", calculate_fragmentation());
}
// 할당자 내부 상태 덤프
fn dump_allocator_state() {
log::debug!("Free block list:");
unsafe {
ALLOCATOR.lock().dump_free_list();
}
}
// 대안: 복구 시도 (일부 시스템에서)
#[alloc_error_handler]
fn alloc_error_handler_with_recovery(layout: Layout) -> ! {
log::error!("OOM: attempting recovery...");
// 캐시 정리
clear_caches();
// 낮은 우선순위 작업 종료
kill_low_priority_tasks();
// 메모리 압축 시도
if try_compact_memory() {
log::info!("Memory recovered, retrying allocation");
// 재시도는 불가능 (! 타입), 결국 패닉
}
panic!("OOM: recovery failed");
}
설명
이것이 하는 일: OOM 핸들러는 메모리 할당이 실패했을 때 시스템이 갑자기 죽지 않도록, 유용한 디버깅 정보를 수집하고 로깅한 후 제어된 방식으로 종료하거나 복구를 시도합니다. 첫 번째로, #[alloc_error_handler] 속성으로 전역 핸들러를 등록합니다.
Layout 매개변수로 실패한 할당의 크기와 정렬 정보를 받아, 어떤 종류의 할당이 실패했는지 파악할 수 있습니다. 이렇게 하는 이유는 디버깅 시 할당 패턴을 분석하여 메모리 누수나 과도한 할당을 찾기 위함입니다.
그 다음으로, print_memory_stats()와 dump_allocator_state()로 현재 메모리 상태를 상세히 출력합니다. 내부에서는 총 힙 크기, 사용 중인 메모리, 자유 블록 리스트, 단편화 정도 등을 로깅합니다.
이 정보는 포스트모템 분석에 매우 중요하며, 메모리 고갈의 근본 원인을 찾는 데 필수적입니다. 마지막으로, 일부 시스템에서는 캐시 정리나 낮은 우선순위 작업 종료 같은 복구를 시도할 수 있습니다.
최종적으로 복구가 불가능하면 panic!으로 시스템을 중단하되, 최대한 많은 정보를 남깁니다. 여러분이 이 코드를 사용하면 메모리 부족 상황에서도 원인을 파악하고 대응할 수 있습니다.
실무에서의 이점으로는 첫째, 디버깅 시간을 크게 단축하고, 둘째, 프로덕션 환경에서 장애 원인을 추적할 수 있으며, 셋째, 일부 경우 자동 복구로 가용성을 높일 수 있습니다.
실전 팁
💡 OOM 핸들러 내에서는 절대 할당을 수행하지 마세요. 로깅조차 스택 기반 버퍼를 사용해야 무한 재귀를 방지할 수 있습니다.
💡 메모리 통계를 미리 예약된 메모리에 주기적으로 업데이트하면 OOM 시점에 할당 없이 출력할 수 있습니다.
💡 시리얼 포트나 네트워크로 로그를 전송하는 것을 고려하세요. 화면 출력만으로는 커널 패닉 시 정보가 소실될 수 있습니다.
💡 단계적 복구 전략을 구현하세요. 먼저 캐시, 그 다음 비필수 서비스, 마지막으로 중요 서비스 순으로 메모리를 회수합니다.
💡 메모리 압박 경고 시스템을 구축하여 OOM 전에 미리 대응하세요. 여유 메모리가 임계값 이하로 떨어지면 미리 정리를 시작합니다.
10. 성능 측정과 프로파일링
시작하며
여러분이 할당자를 완성했는데 실제 성능이 얼마나 되는지 알 수 없어 답답했던 적 있나요? 할당 속도, 메모리 효율, 단편화 정도를 구체적인 수치로 파악하지 못하는 상황입니다.
이런 문제는 측정 없이는 최적화할 수 없다는 소프트웨어 개발의 기본 원칙에서 비롯됩니다. 직관이나 추측만으로는 실제 병목 지점을 찾을 수 없고, 최적화 효과도 검증할 수 없습니다.
특히 메모리 할당자는 전체 시스템 성능에 직접적인 영향을 미치므로 정확한 측정이 필수적입니다. 바로 이럴 때 필요한 것이 체계적인 성능 측정과 프로파일링입니다.
할당/해제 횟수, 평균 지연시간, 메모리 사용률, 단편화 지표 등을 추적하여 데이터 기반으로 최적화해야 합니다.
개요
간단히 말해서, 성능 측정은 할당자의 핵심 지표를 수치화하는 것이고, 프로파일링은 어디서 시간이 소비되는지 분석하는 것입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 할당자는 시스템 전체에서 수백만 번 호출되므로 작은 성능 차이도 큰 영향을 미치기 때문입니다.
할당 하나에 1마이크로초가 추가되면, 초당 100만 번 할당 시 1초의 오버헤드가 발생합니다. 예를 들어, 웹 서버에서 요청당 평균 100번의 할당이 발생한다면, 할당자 성능이 처리량을 직접적으로 결정합니다.
전통적인 방법과 비교하면, 기존에는 printf로 간헐적으로 로깅했다면, 이제는 카운터와 타이머를 내장하여 지속적으로 통계를 수집하고, 필요 시 상세한 리포트를 생성할 수 있습니다. 이 개념의 핵심 특징은 첫째, 원자적 카운터로 멀티스레드 환경에서도 정확히 측정하고, 둘째, 측정 오버헤드를 최소화하기 위해 조건부 컴파일을 사용하며, 셋째, 평균, 최대, 최소, 분포 등 다양한 통계를 제공한다는 점입니다.
이러한 특징들이 중요한 이유는 성능 회귀를 조기에 발견하고 최적화 효과를 검증하기 위함입니다.
코드 예제
use core::sync::atomic::{AtomicUsize, Ordering};
// 할당자 통계 구조체
pub struct AllocatorStats {
alloc_count: AtomicUsize,
dealloc_count: AtomicUsize,
total_allocated: AtomicUsize,
total_deallocated: AtomicUsize,
alloc_failures: AtomicUsize,
max_search_depth: AtomicUsize,
}
impl AllocatorStats {
pub const fn new() -> Self {
Self {
alloc_count: AtomicUsize::new(0),
dealloc_count: AtomicUsize::new(0),
total_allocated: AtomicUsize::new(0),
total_deallocated: AtomicUsize::new(0),
alloc_failures: AtomicUsize::new(0),
max_search_depth: AtomicUsize::new(0),
}
}
pub fn record_alloc(&self, size: usize, search_depth: usize) {
self.alloc_count.fetch_add(1, Ordering::Relaxed);
self.total_allocated.fetch_add(size, Ordering::Relaxed);
// 최대 탐색 깊이 업데이트 (compare-and-swap)
let mut current = self.max_search_depth.load(Ordering::Relaxed);
while search_depth > current {
match self.max_search_depth.compare_exchange_weak(
current, search_depth, Ordering::Relaxed, Ordering::Relaxed
) {
Ok(_) => break,
Err(x) => current = x,
}
}
}
pub fn record_dealloc(&self, size: usize) {
self.dealloc_count.fetch_add(1, Ordering::Relaxed);
self.total_deallocated.fetch_add(size, Ordering::Relaxed);
}
pub fn record_failure(&self) {
self.alloc_failures.fetch_add(1, Ordering::Relaxed);
}
pub fn report(&self) {
log::info!("=== Allocator Statistics ===");
log::info!("Allocations: {}", self.alloc_count.load(Ordering::Relaxed));
log::info!("Deallocations: {}", self.dealloc_count.load(Ordering::Relaxed));
log::info!("Total allocated: {} bytes", self.total_allocated.load(Ordering::Relaxed));
log::info!("Total deallocated: {} bytes", self.total_deallocated.load(Ordering::Relaxed));
log::info!("Active allocations: {}",
self.alloc_count.load(Ordering::Relaxed) - self.dealloc_count.load(Ordering::Relaxed));
log::info!("Leaked memory: {} bytes",
self.total_allocated.load(Ordering::Relaxed) - self.total_deallocated.load(Ordering::Relaxed));
log::info!("Failures: {}", self.alloc_failures.load(Ordering::Relaxed));
log::info!("Max search depth: {}", self.max_search_depth.load(Ordering::Relaxed));
}
}
// 조건부 컴파일로 프로덕션 오버헤드 제거
#[cfg(feature = "profile")]
static STATS: AllocatorStats = AllocatorStats::new();
// 할당 시 측정
#[cfg(feature = "profile")]
fn alloc_with_profiling(size: usize) {
let start = rdtsc(); // CPU 사이클 카운터
let (result, depth) = allocate_internal(size);
let duration = rdtsc() - start;
STATS.record_alloc(size, depth);
record_latency(duration);
result
}
설명
이것이 하는 일: 성능 측정 시스템은 할당자의 모든 주요 동작을 추적하여, 할당 패턴, 메모리 사용량, 실패율, 탐색 효율성 등을 정량화합니다. 첫 번째로, AllocatorStats 구조체는 원자적 카운터들로 멀티스레드 환경에서도 안전하게 통계를 수집합니다.
AtomicUsize와 Ordering::Relaxed를 사용하여 오버헤드를 최소화하면서도 대략적인 통계를 제공합니다. 이렇게 하는 이유는 정확한 순서보다는 전체적인 경향이 중요하기 때문입니다.
그 다음으로, record_alloc과 record_dealloc이 실행될 때마다 카운터를 업데이트합니다. 내부에서는 fetch_add로 원자적으로 값을 증가시키고, 최대 탐색 깊이는 compare_exchange_weak로 lock-free하게 업데이트합니다.
이는 측정 자체가 성능에 미치는 영향을 최소화하는 핵심 기법입니다. 마지막으로, report 메서드가 수집된 통계를 사람이 읽기 쉬운 형태로 출력합니다.
최종적으로 현재 활성 할당 개수, 누수된 메모리, 실패율 등을 계산하여 메모리 관리 상태를 한눈에 파악할 수 있습니다. 여러분이 이 코드를 사용하면 할당자의 실제 동작을 데이터로 확인하고, 최적화 방향을 결정할 수 있습니다.
실무에서의 이점으로는 첫째, 성능 회귀를 CI/CD에서 자동 감지하고, 둘째, 메모리 누수를 조기에 발견하며, 셋째, 워크로드별 최적 전략을 데이터 기반으로 선택할 수 있습니다.
실전 팁
💡 rdtsc 명령어로 CPU 사이클을 측정하면 나노초 단위 정밀도로 할당 지연을 추적할 수 있습니다. 히스토그램으로 분포를 시각화하세요.
💡 조건부 컴파일(#[cfg(feature = "profile")])로 프로덕션 빌드에서는 측정 코드를 완전히 제거할 수 있습니다. 제로 오버헤드를 달성하세요.
💡 per-CPU 통계를 유지하면 lock contention 없이 측정할 수 있고, 나중에 집계하면 됩니다. scalable한 프로파일링 기법입니다.
💡 메모리 사용량을 시계열로 기록하면 메모리 누수를 그래프로 쉽게 발견할 수 있습니다. Grafana 같은 도구와 통합하세요.
💡 벤치마크 스위트를 만들어 다양한 할당 패턴(작은 할당 많이, 큰 할당 적게, 혼합 등)을 테스트하세요. 실제 워크로드를 대표하는 벤치마크가 중요합니다.