이미지 로딩 중...

Rust로 만드는 나만의 OS 메모리 프레임 할당 - 슬라이드 1/9
A

AI Generated

2025. 11. 14. · 4 Views

Rust로 만드는 나만의 OS 메모리 프레임 할당

OS 개발의 핵심인 물리 메모리 관리를 Rust로 구현해봅니다. 메모리 프레임 할당자의 동작 원리부터 비트맵 기반 할당, 버디 시스템까지 실무 수준의 메모리 관리 기법을 다룹니다.


목차

  1. 메모리 프레임 할당자 기본 구조 - 물리 메모리를 효율적으로 관리하는 핵심 시스템
  2. 비트맵 기반 프레임 할당자 - 해제 가능한 효율적인 메모리 관리
  3. 버디 할당자 - 외부 파편화를 최소화하는 고급 메모리 관리
  4. 페이지 구조체와 메타데이터 관리 - 각 프레임의 상태를 추적하는 시스템
  5. Per-CPU 프레임 캐시 - 락 경합을 줄이는 고성능 할당
  6. 메모리 존(Zone) 시스템 - DMA와 고차원 메모리 관리
  7. 프레임 회수(Frame Reclaim) - 메모리 부족 시 재활용 전략
  8. OOM Killer - 메모리 고갈 시 최후의 안전장치

1. 메모리 프레임 할당자 기본 구조 - 물리 메모리를 효율적으로 관리하는 핵심 시스템

시작하며

여러분이 OS를 개발하면서 "어떻게 물리 메모리를 효율적으로 나눠줄까?"라는 고민을 해본 적 있나요? 애플리케이션이 메모리를 요청할 때마다 어느 영역을 줘야 할지, 이미 사용 중인 메모리는 어떻게 추적할지 막막했던 경험 말이죠.

이런 문제는 실제 OS 개발 현장에서 가장 먼저 마주치는 난관입니다. 잘못된 메모리 할당은 시스템 전체의 불안정성으로 이어지고, 메모리 누수나 이중 할당 같은 치명적인 버그를 야기합니다.

바로 이럴 때 필요한 것이 메모리 프레임 할당자입니다. 물리 메모리를 4KB 단위의 프레임으로 나누고, 각 프레임의 상태를 추적하며, 요청에 따라 효율적으로 할당하고 해제하는 시스템이죠.

개요

간단히 말해서, 메모리 프레임 할당자는 물리 메모리를 고정 크기 블록으로 관리하는 저수준 메모리 관리자입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, OS 커널은 페이지 테이블, 프로세스 스택, 디바이스 버퍼 등 수많은 곳에서 물리 메모리를 필요로 합니다.

메모리 할당자가 없다면 각 서브시스템이 메모리를 직접 관리해야 하고, 이는 곧 중복 할당과 메모리 파편화로 이어집니다. 예를 들어, 프로세스 생성 시 페이지 테이블을 위한 연속된 물리 메모리가 필요한데, 할당자 없이는 이를 효율적으로 찾을 방법이 없습니다.

전통적인 방법과의 비교를 하자면, 초기 OS들은 정적 메모리 파티션이나 간단한 비트맵을 사용했습니다. 기존에는 메모리를 미리 정해진 용도별로 나눴다면, 이제는 동적으로 필요한 만큼 할당하고 해제할 수 있습니다.

이 개념의 핵심 특징은 첫째, 고정 크기 프레임(보통 4KB) 단위로 관리하여 외부 파편화를 최소화하고, 둘째, 빠른 할당/해제를 위한 자료구조(비트맵, 스택, 리스트 등)를 사용하며, 셋째, 멀티코어 환경에서도 안전하게 동작하도록 동기화 메커니즘을 제공한다는 점입니다. 이러한 특징들이 중요한 이유는 OS의 모든 메모리 관련 작업이 이 할당자를 거치기 때문에, 성능과 안정성이 시스템 전체에 직접적인 영향을 미치기 때문입니다.

코드 예제

// 메모리 프레임 할당자의 기본 구조
use core::sync::atomic::{AtomicUsize, Ordering};

pub struct FrameAllocator {
    // 사용 가능한 메모리의 시작 주소
    memory_start: usize,
    // 사용 가능한 메모리의 끝 주소
    memory_end: usize,
    // 다음 할당할 프레임 번호
    next_frame: AtomicUsize,
    // 총 프레임 개수
    total_frames: usize,
}

impl FrameAllocator {
    // 새로운 할당자 생성
    pub fn new(start: usize, end: usize) -> Self {
        let total_frames = (end - start) / 4096;
        Self {
            memory_start: start,
            memory_end: end,
            next_frame: AtomicUsize::new(0),
            total_frames,
        }
    }

    // 프레임 한 개 할당
    pub fn allocate_frame(&self) -> Option<usize> {
        let frame = self.next_frame.fetch_add(1, Ordering::SeqCst);
        if frame < self.total_frames {
            Some(self.memory_start + frame * 4096)
        } else {
            None
        }
    }
}

설명

이것이 하는 일은 물리 메모리 전체를 추상화하여 프레임 단위로 할당하고 추적하는 시스템을 제공하는 것입니다. OS 커널의 다른 부분들이 "물리 메모리 4KB 주세요"라고 요청하면, 이 할당자가 사용 가능한 프레임을 찾아 반환합니다.

첫 번째로, FrameAllocator 구조체는 메모리 영역의 시작과 끝을 저장합니다. memory_startmemory_end는 부트로더나 BIOS/UEFI로부터 받은 사용 가능한 물리 메모리 범위를 나타냅니다.

왜 이렇게 하는지는, OS가 관리할 수 있는 메모리 영역이 시스템마다 다르고, 일부 영역은 하드웨어가 예약하고 있기 때문입니다. 예를 들어 0x0~0x100000 영역은 레거시 디바이스나 BIOS가 사용하므로 피해야 합니다.

그 다음으로, next_frame 필드가 실행되면서 다음에 할당할 프레임 번호를 추적합니다. 내부에서 어떤 일이 일어나는지 보면, AtomicUsize를 사용하여 여러 CPU 코어가 동시에 할당을 요청해도 레이스 컨디션 없이 안전하게 증가시킵니다.

fetch_add는 원자적으로 값을 읽고 증가시키는 연산으로, 락 없이도 동기화를 보장합니다. 마지막으로, allocate_frame 메서드가 실제 할당을 수행하여 최종적으로 물리 주소를 반환합니다.

프레임 번호를 4096(4KB)으로 곱하여 실제 물리 주소로 변환하고, 범위를 초과하면 None을 반환하여 메모리 고갈 상황을 알립니다. 여러분이 이 코드를 사용하면 간단하지만 동작하는 메모리 할당 시스템을 얻을 수 있습니다.

실무에서의 이점으로는 첫째, 원자적 연산으로 락 없이 멀티코어 지원이 가능하고, 둘째, 단순한 구조로 디버깅이 쉬우며, 셋째, 선형 할당 방식으로 할당 속도가 O(1)로 매우 빠르다는 점입니다. 물론 이 기본 구현은 해제를 지원하지 않아 실제 OS에서는 제한적입니다.

하지만 부팅 초기 단계에서 페이지 테이블이나 커널 힙을 초기화할 때는 충분히 유용합니다.

실전 팁

💡 AtomicUsize 사용 시 Ordering::SeqCst보다 Ordering::Relaxed가 충분한 경우가 많습니다. 프레임 할당은 순서가 중요하지 않으므로 Relaxed를 사용하면 성능이 향상됩니다. 단, 해제와 재할당이 있는 경우 순서 보장이 필요할 수 있습니다.

💡 메모리 정렬을 항상 확인하세요. memory_start가 4096으로 정렬되지 않으면 페이지 테이블에서 오류가 발생합니다. assert!(memory_start % 4096 == 0)로 초기화 시 검증하는 것이 좋습니다.

💡 할당 실패 시(None 반환) 패닉하지 말고 우아하게 처리하세요. 메모리 부족은 정상적인 상황일 수 있으므로, 캐시를 비우거나 스왑을 고려하는 등의 fallback 전략을 마련해야 합니다.

💡 디버깅을 위해 할당된 프레임 수를 추적하는 카운터를 추가하세요. allocated_frames: AtomicUsize를 두면 메모리 누수를 조기에 발견할 수 있습니다.

💡 UEFI/멀티부트에서 받은 메모리 맵을 꼭 확인하세요. 사용 가능한 영역이 여러 개로 나뉘어 있을 수 있으므로, 가장 큰 연속 영역을 선택하거나 여러 할당자를 사용하는 전략이 필요합니다.


2. 비트맵 기반 프레임 할당자 - 해제 가능한 효율적인 메모리 관리

시작하며

여러분이 앞의 선형 할당자를 사용하다가 "할당만 되고 해제는 안 되네?"라는 한계에 부딪힌 적 있나요? 프로세스가 종료되어도 메모리를 돌려받지 못하고, 결국 시스템이 재부팅해야만 하는 상황 말이죠.

이런 문제는 실제 OS에서 치명적입니다. 장시간 운영되는 서버 환경에서는 프로세스가 수시로 생성되고 종료되는데, 메모리를 재사용하지 못하면 금방 메모리가 고갈됩니다.

이는 시스템의 가용성을 크게 떨어뜨립니다. 바로 이럴 때 필요한 것이 비트맵 기반 할당자입니다.

각 프레임마다 1비트를 할당하여 사용 중인지 여부를 추적하고, 할당과 해제를 모두 지원하는 완전한 메모리 관리자입니다.

개요

간단히 말해서, 비트맵 할당자는 각 프레임의 상태를 1비트로 표현하여 메모리 오버헤드를 최소화하면서도 빠른 할당/해제를 제공하는 시스템입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실제 OS는 수천 개의 프로세스가 동시에 실행되며 끊임없이 메모리를 할당하고 해제합니다.

4GB 메모리를 관리한다면 약 100만 개의 프레임이 있는데, 각 프레임 상태를 추적하려면 효율적인 자료구조가 필수입니다. 예를 들어, 포인터로 연결 리스트를 만들면 8바이트씩 필요하지만, 비트맵은 1비트만 사용하므로 64배나 효율적입니다.

전통적인 방법과의 비교를 하자면, 초기 Unix는 프리 리스트를 사용했고, 일부 시스템은 스택 기반 할당자를 썼습니다. 기존에는 복잡한 자료구조로 프레임을 관리했다면, 이제는 간단한 비트 연산으로 빠르게 처리할 수 있습니다.

이 개념의 핵심 특징은 첫째, 메모리 오버헤드가 전체 메모리의 0.003%(32KB per 1GB)로 극도로 낮고, 둘째, 비트 연산을 활용하여 할당/해제가 O(n)이지만 실제로는 매우 빠르며, 셋째, 구현이 단순하여 버그 가능성이 낮다는 점입니다. 이러한 특징들이 중요한 이유는 메모리 관리자는 커널의 모든 부분에서 호출되므로, 안정성과 예측 가능한 성능이 무엇보다 중요하기 때문입니다.

코드 예제

// 비트맵 기반 프레임 할당자
use core::sync::Mutex;

pub struct BitmapAllocator {
    // 각 비트가 하나의 프레임을 나타냄 (0=free, 1=used)
    bitmap: Mutex<&'static mut [u64]>,
    memory_start: usize,
    total_frames: usize,
}

impl BitmapAllocator {
    // 프레임 한 개 할당
    pub fn allocate(&self) -> Option<usize> {
        let mut bitmap = self.bitmap.lock();
        // 비트맵에서 0인 비트(사용 가능한 프레임) 찾기
        for (i, chunk) in bitmap.iter_mut().enumerate() {
            if *chunk != u64::MAX { // 이 청크에 빈 프레임이 있음
                // trailing_ones: 오른쪽부터 연속된 1의 개수
                let bit = chunk.trailing_ones() as usize;
                *chunk |= 1 << bit; // 해당 비트를 1로 설정
                let frame = i * 64 + bit;
                return Some(self.memory_start + frame * 4096);
            }
        }
        None // 사용 가능한 프레임 없음
    }

    // 프레임 해제
    pub fn deallocate(&self, addr: usize) {
        let frame = (addr - self.memory_start) / 4096;
        let mut bitmap = self.bitmap.lock();
        let chunk_idx = frame / 64;
        let bit_idx = frame % 64;
        bitmap[chunk_idx] &= !(1 << bit_idx); // 해당 비트를 0으로 설정
    }
}

설명

이것이 하는 일은 비트맵이라는 간단한 자료구조로 수백만 개의 프레임 상태를 추적하고, 비트 연산으로 빠르게 할당/해제하는 것입니다. 각 프레임이 사용 중이면 1, 비어있으면 0으로 표시됩니다.

첫 번째로, bitmap 필드는 u64 배열로 구성되어 각 u64가 64개 프레임의 상태를 담당합니다. Mutex로 감싼 이유는 여러 CPU 코어가 동시에 접근할 때 데이터 레이스를 방지하기 위함입니다.

왜 이렇게 하는지는, 비트맵 자체는 단순 배열이지만 읽기-수정-쓰기 과정이 원자적이지 않으므로 명시적 락이 필요하기 때문입니다. 그 다음으로, allocate 메서드가 실행되면서 비트맵을 순회하며 빈 프레임을 찾습니다.

내부에서 어떤 일이 일어나는지 보면, chunk != u64::MAX 조건으로 모든 비트가 1인 청크(완전히 사용 중)를 빠르게 스킵합니다. 그 후 trailing_ones()로 오른쪽부터 연속된 1의 개수를 세어, 첫 번째 0 비트의 위치를 O(1)에 찾아냅니다.

이는 CPU의 하드웨어 명령어(BSF/CTZ)를 활용하므로 매우 빠릅니다. 마지막으로, deallocate 메서드가 물리 주소를 받아 해당 프레임을 해제합니다.

주소를 프레임 번호로 변환하고, 청크 인덱스와 비트 인덱스를 계산한 뒤, &= !(1 << bit_idx) 연산으로 해당 비트만 0으로 만듭니다. 비트 마스크 반전(!)을 사용하여 다른 비트는 보존하는 것이 핵심입니다.

여러분이 이 코드를 사용하면 완전한 기능을 갖춘 메모리 할당 시스템을 얻을 수 있습니다. 실무에서의 이점으로는 첫째, 해제가 가능하여 장시간 운영되는 시스템에 적합하고, 둘째, 메모리 오버헤드가 극히 낮아 대용량 메모리도 효율적으로 관리하며, 셋째, 비트 연산으로 성능이 우수하다는 점입니다.

단점은 최악의 경우 전체 비트맵을 스캔해야 하므로 O(n)이라는 점입니다. 하지만 실제로는 대부분의 경우 빠르게 빈 프레임을 찾으므로, 일반적인 워크로드에서는 문제가 되지 않습니다.

실전 팁

💡 trailing_ones 대신 trailing_zeros를 사용할 수도 있습니다. 비트 의미를 반전시키면(0=used, 1=free) 코드가 더 직관적일 수 있으니 팀 컨벤션에 맞게 선택하세요.

💡 큰 메모리를 관리할 때는 비트맵을 계층화하세요. L1 비트맵이 각 L2 비트맵 청크의 요약을 담으면, 빈 프레임 검색이 O(1)에 가까워집니다. 예를 들어 64KB 단위로 L2를 나누고, 각 L2가 빈 공간이 있는지 L1에 표시하는 방식입니다.

💡 Mutex 대신 스핀락을 고려하세요. 메모리 할당은 매우 짧은 크리티컬 섹션을 가지므로, 스핀락이 컨텍스트 스위치 오버헤드를 피해 더 빠를 수 있습니다. 단, 인터럽트 핸들러에서 사용한다면 인터럽트를 비활성화해야 데드락을 피할 수 있습니다.

💡 초기화 시 부트로더의 메모리 맵을 정확히 반영하세요. UEFI 메모리 맵의 EfiConventionalMemory 영역만 사용 가능으로 표시하고, 나머지는 예약된 것으로 마킹해야 합니다. 이를 놓치면 하드웨어 MMIO 영역을 덮어써서 시스템이 멈출 수 있습니다.

💡 더블 프리(이중 해제)를 감지하는 어서션을 추가하세요. deallocate 시 해당 비트가 이미 0이면 패닉하도록 하면, 메모리 관리 버그를 조기에 발견할 수 있습니다: assert!(bitmap[chunk_idx] & (1 << bit_idx) != 0).


3. 버디 할당자 - 외부 파편화를 최소화하는 고급 메모리 관리

시작하며

여러분이 커널 힙을 구현하면서 "4KB 프레임 여러 개를 연속으로 할당받고 싶은데, 비트맵으로는 어떻게 하지?"라는 고민을 해본 적 있나요? DMA 버퍼나 큰 구조체를 위해 연속된 물리 메모리가 필요한데, 프레임 하나씩 할당하면 흩어져 있을 수밖에 없는 상황 말이죠.

이런 문제는 실제 OS에서 빈번히 발생합니다. 페이지 테이블, 커널 스택, 디바이스 DMA 등은 연속된 물리 메모리를 요구하는데, 단순 비트맵 할당자는 이를 효율적으로 지원하지 못합니다.

연속된 프레임을 찾으려면 전체 비트맵을 스캔해야 하고, 파편화가 진행되면 큰 블록 할당이 실패할 수 있습니다. 바로 이럼 때 필요한 것이 버디 할당자입니다.

메모리를 2의 거듭제곱 크기로 나누고, 인접한 블록끼리 짝을 지어 관리하여 연속 메모리 할당과 병합을 효율적으로 처리하는 시스템입니다.

개요

간단히 말해서, 버디 시스템은 메모리를 재귀적으로 2등분하여 관리하고, 해제 시 인접한 빈 블록을 자동으로 병합하여 외부 파편화를 최소화하는 할당 알고리즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, Linux 커널의 메모리 관리자가 실제로 버디 시스템을 사용합니다.

드라이버가 16KB DMA 버퍼를 요청하면 연속된 4개 프레임을 할당해야 하는데, 버디 시스템은 이를 O(log n) 시간에 처리합니다. 또한 작은 블록들이 해제될 때 자동으로 병합되어 큰 블록으로 복원되므로, 장기간 운영해도 큰 블록을 할당할 수 있는 능력이 유지됩니다.

예를 들어, 4KB 블록 두 개가 인접하여 해제되면 자동으로 8KB 블록으로 병합되는 식입니다. 전통적인 방법과의 비교를 하자면, 단순 비트맵은 가변 크기 할당을 지원하지 못하고, 슬랩 할당자는 작은 객체에만 특화되어 있습니다.

기존에는 여러 할당자를 조합해야 했다면, 이제는 버디 시스템 하나로 다양한 크기를 효율적으로 처리할 수 있습니다. 이 개념의 핵심 특징은 첫째, 2의 거듭제곱 크기만 할당하여 정렬과 병합이 간단하고, 둘째, 프리 리스트를 크기별로 유지하여 빠른 할당이 가능하며, 셋째, 인접 블록 병합으로 외부 파편화를 적극적으로 방지한다는 점입니다.

이러한 특징들이 중요한 이유는 서버 워크로드처럼 다양한 크기의 메모리 요청이 섞여 있고, 장시간 운영되는 환경에서 메모리 파편화가 시스템 성능을 크게 저하시키기 때문입니다.

코드 예제

// 버디 할당자의 핵심 구조
const MAX_ORDER: usize = 11; // 2^11 = 2048 pages = 8MB

pub struct BuddyAllocator {
    // 각 order별 프리 리스트 (0 = 4KB, 1 = 8KB, ..., 11 = 8MB)
    free_lists: [Option<*mut FreeBlock>; MAX_ORDER],
    memory_start: usize,
}

struct FreeBlock {
    next: Option<*mut FreeBlock>,
}

impl BuddyAllocator {
    // order 크기의 블록 할당 (order 0 = 4KB, order 1 = 8KB, ...)
    pub fn allocate(&mut self, order: usize) -> Option<usize> {
        // 요청한 크기 이상의 블록 찾기
        for current_order in order..MAX_ORDER {
            if let Some(block) = self.free_lists[current_order].take() {
                // 큰 블록을 쪼개서 내려가기
                let mut addr = block as usize;
                unsafe {
                    self.free_lists[current_order] = (*block).next;
                }

                // 필요한 크기까지 블록을 반으로 나누기
                for split_order in (order..current_order).rev() {
                    let buddy_addr = addr + (1 << (split_order + 12)); // 12 = log2(4096)
                    self.free_lists[split_order] = Some(buddy_addr as *mut FreeBlock);
                }

                return Some(addr);
            }
        }
        None
    }
}

설명

이것이 하는 일은 메모리를 계층적으로 분할하고 병합하여, 다양한 크기의 연속 메모리를 효율적으로 할당하는 것입니다. 4KB부터 8MB까지 2의 거듭제곱 크기를 모두 지원합니다.

첫 번째로, free_lists 배열은 각 크기별로 사용 가능한 블록들의 연결 리스트를 유지합니다. order 0은 4KB 블록들의 리스트, order 1은 8KB 블록들의 리스트 식으로 구성됩니다.

왜 이렇게 하는지는, 특정 크기를 요청받았을 때 O(1)에 해당 크기의 프리 리스트를 찾을 수 있기 때문입니다. 예를 들어 16KB(order 2)를 요청하면 free_lists[2]를 바로 확인합니다.

그 다음으로, allocate 메서드가 실행되면서 요청한 크기 이상의 블록을 찾아 필요하면 쪼갭니다. 내부에서 어떤 일이 일어나는지 보면, order 2(16KB)를 요청했는데 없고 order 4(64KB)가 있다면, 64KB 블록을 가져와서 32KB 두 개로 쪼개고, 다시 16KB 네 개로 쪼개는 식입니다.

이 과정에서 쪼개진 절반은 해당 order의 프리 리스트에 반환되고, 나머지 절반은 다시 쪼개지거나 사용자에게 반환됩니다. 마지막으로, buddy_addr 계산이 핵심인데, 블록의 짝(buddy)은 블록 크기만큼 떨어진 주소에 위치합니다.

1 << (split_order + 12) 수식은 2^(order) * 4096을 의미하며, 이를 현재 주소에 더하면 버디 주소가 나옵니다. 이 수학적 규칙성 덕분에 포인터 탐색 없이 O(1)에 버디를 찾을 수 있습니다.

여러분이 이 코드를 사용하면 Linux 커널 수준의 메모리 관리 시스템을 얻을 수 있습니다. 실무에서의 이점으로는 첫째, 다양한 크기의 연속 메모리를 효율적으로 할당하고, 둘째, 자동 병합으로 장기 운영 시에도 큰 블록 할당이 가능하며, 셋째, 캐시 지역성이 좋아 성능이 우수하다는 점입니다.

단점은 내부 파편화가 발생한다는 것입니다. 9KB를 요청하면 16KB를 할당하므로 7KB가 낭비됩니다.

이를 보완하기 위해 실제 OS는 버디 시스템 위에 슬랩 할당자를 얹어 사용합니다.

실전 팁

💡 버디 주소 계산 시 XOR 트릭을 사용하세요. buddy_addr = addr ^ (1 << order_size)로 버디를 찾으면, 같은 공식으로 원래 주소도 찾을 수 있어 병합 시 편리합니다. 이는 버디가 항상 해당 비트만 반전된 위치에 있다는 수학적 성질을 이용한 것입니다.

💡 병합 시 무한 루프를 조심하세요. 버디가 실제로 비어있는지 확인하지 않고 병합하면 사용 중인 메모리를 손상시킬 수 있습니다. 비트맵이나 별도 상태 테이블로 각 블록의 할당 여부를 추적하는 것이 안전합니다.

💡 최대 order를 시스템 메모리 크기에 맞게 조정하세요. 4GB 시스템에서 order 20(4GB)까지 지원하면 프리 리스트 배열이 불필요하게 큽니다. 실제 물리 메모리 크기를 고려하여 MAX_ORDER를 설정하세요.

💡 페이지 컬러링을 고려하세요. 캐시 충돌을 피하기 위해 같은 캐시 라인에 매핑되는 페이지들을 구분하여 할당하면 성능이 향상됩니다. 프리 리스트를 컬러별로 나누는 방식이 일반적입니다.

💡 NUMA 시스템에서는 노드별로 버디 할당자를 분리하세요. 각 CPU가 가까운 메모리 노드에서 할당받도록 하면 메모리 접근 지연이 줄어듭니다. Linux의 __GFP_THISNODE 플래그가 이를 구현한 예시입니다.


4. 페이지 구조체와 메타데이터 관리 - 각 프레임의 상태를 추적하는 시스템

시작하며

여러분이 메모리를 할당하고 나서 "이 페이지를 몇 개의 프로세스가 공유하고 있지?", "이 페이지는 파일 캐시인가, 익명 메모리인가?"라는 정보가 필요했던 적 있나요? 단순히 할당/해제만으로는 부족하고, 각 페이지의 용도와 상태를 추적해야 하는 상황 말이죠.

이런 문제는 고급 메모리 관리 기능을 구현할 때 필수적입니다. Copy-on-Write, 메모리 매핑 파일, 페이지 스왑 등의 기능은 모두 각 페이지에 대한 추가 정보를 필요로 합니다.

이 정보가 없으면 어떤 페이지를 스왑 아웃할지, 어떻게 공유 메모리를 처리할지 결정할 수 없습니다. 바로 이럴 때 필요한 것이 페이지 구조체입니다.

각 물리 프레임마다 메타데이터를 저장하여 참조 카운트, 플래그, 소유자 등을 추적하는 시스템이죠.

개요

간단히 말해서, 페이지 구조체는 각 물리 프레임에 대응하는 메타데이터 객체로, 해당 프레임의 상태와 용도를 기록하는 관리 정보입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, Linux 커널의 struct page가 대표적인 예입니다.

각 4KB 프레임마다 약 64바이트의 메타데이터를 유지하여, 참조 카운트로 공유 메모리를 관리하고, 플래그로 더티/액티브 상태를 추적하며, LRU 리스트에 연결하여 페이지 회수 정책을 구현합니다. 예를 들어, fork()로 프로세스를 복사할 때 페이지를 즉시 복사하지 않고 참조 카운트만 증가시키는 Copy-on-Write 기법은 페이지 구조체 없이는 불가능합니다.

전통적인 방법과의 비교를 하자면, 초기 OS들은 페이지 테이블만으로 메모리를 관리했습니다. 기존에는 가상 주소에서 물리 주소로의 매핑 정보만 있었다면, 이제는 반대로 물리 프레임에서 어떤 가상 주소들이 매핑되어 있는지 역방향 매핑도 가능합니다.

이 개념의 핵심 특징은 첫째, 각 프레임의 상태를 중앙화하여 일관성 있는 관리가 가능하고, 둘째, 참조 카운팅으로 메모리 공유와 해제 타이밍을 정확히 제어하며, 셋째, 다양한 플래그로 페이지의 특성(더티, 락, 예약 등)을 표현한다는 점입니다. 이러한 특징들이 중요한 이유는 현대 OS의 메모리 관리가 단순 할당/해제를 넘어 페이지 캐싱, 스왑, COW 등 복잡한 최적화 기법을 사용하기 때문입니다.

코드 예제

// 페이지 구조체 정의
use core::sync::atomic::{AtomicU32, AtomicU16, Ordering};

#[repr(C)]
pub struct Page {
    // 원자적 참조 카운트 (얼마나 많은 곳에서 이 페이지를 사용 중인지)
    ref_count: AtomicU32,
    // 페이지 플래그 (dirty, locked, reserved 등)
    flags: AtomicU16,
    // 이 페이지가 속한 버디 시스템의 order
    order: u8,
    // 예약 필드 (정렬용)
    _reserved: u8,
}

// 페이지 플래그 정의
const PAGE_DIRTY: u16 = 1 << 0;      // 수정됨 (스왑 아웃 시 디스크에 기록 필요)
const PAGE_LOCKED: u16 = 1 << 1;     // 락 걸림 (I/O 진행 중)
const PAGE_RESERVED: u16 = 1 << 2;   // 예약됨 (커널 사용)

impl Page {
    // 페이지 참조 증가
    pub fn get(&self) -> u32 {
        self.ref_count.fetch_add(1, Ordering::AcqRel)
    }

    // 페이지 참조 감소, 0이 되면 해제 가능
    pub fn put(&self) -> u32 {
        let old = self.ref_count.fetch_sub(1, Ordering::AcqRel);
        if old == 1 {
            // 마지막 참조가 사라짐, 이제 이 페이지를 해제할 수 있음
        }
        old - 1
    }

    // 플래그 설정
    pub fn set_flag(&self, flag: u16) {
        self.flags.fetch_or(flag, Ordering::Relaxed);
    }
}

설명

이것이 하는 일은 물리 메모리의 각 프레임에 대한 제어 정보를 유지하여, 복잡한 메모리 관리 정책을 구현할 수 있게 하는 것입니다. 단순 할당자에서 완전한 메모리 관리 시스템으로 진화하는 핵심 요소입니다.

첫 번째로, ref_count 필드는 현재 몇 개의 참조가 이 페이지를 가리키는지 추적합니다. AtomicU32를 사용하는 이유는 여러 CPU 코어가 동시에 페이지를 참조하거나 해제할 수 있기 때문입니다.

왜 이렇게 하는지는, 예를 들어 부모와 자식 프로세스가 fork 후 같은 페이지를 공유하고, 둘 다 종료될 때까지 페이지를 유지해야 하기 때문입니다. 카운트가 0이 되는 순간이 실제 메모리 해제 시점입니다.

그 다음으로, flags 필드가 실행되면서 페이지의 다양한 상태를 비트 플래그로 표현합니다. 내부에서 어떤 일이 일어나는지 보면, PAGE_DIRTY 플래그가 설정된 페이지는 디스크와 내용이 다르므로 스왑 아웃 시 쓰기가 필요하고, PAGE_LOCKED는 I/O가 진행 중이므로 다른 스레드가 대기해야 함을 의미합니다.

fetch_or를 사용하여 원자적으로 플래그를 설정할 수 있습니다. 마지막으로, getput 메서드가 참조 카운트를 안전하게 증감시킵니다.

Ordering::AcqRel을 사용하는 이유는 참조 카운트 변경과 페이지 내용 접근 사이의 순서를 보장하기 위함입니다. put에서 카운트가 1에서 0으로 떨어지면, 이 시점에 페이지를 프리 리스트로 반환할 수 있습니다.

여러분이 이 코드를 사용하면 공유 메모리, COW, 메모리 매핑 파일 등 고급 기능의 기반을 얻을 수 있습니다. 실무에서의 이점으로는 첫째, 메모리 누수를 방지하고 정확한 해제 타이밍을 보장하며, 둘째, 페이지 상태를 중앙에서 관리하여 일관성을 유지하고, 셋째, 다양한 메모리 관리 정책을 유연하게 구현할 수 있다는 점입니다.

실제 구현 시에는 페이지 구조체 배열을 물리 메모리 초기에 할당하여, 물리 주소에서 페이지 구조체를 O(1)에 찾을 수 있도록 합니다: &PAGES[(paddr - MEM_START) / 4096].

실전 팁

💡 페이지 구조체 배열을 가상 메모리의 고정 위치에 매핑하세요. Linux는 mem_map을 커널 공간의 특정 주소에 매핑하여, 물리 주소 변환 없이 빠르게 접근합니다. pfn_to_page 매크로가 단순 배열 인덱싱으로 구현되는 이유입니다.

💡 참조 카운트가 0인 페이지에 접근하는 버그를 방지하려면 디버그 빌드에서 검증을 추가하세요. get 호출 시 카운트가 0이면 패닉하도록 하면, use-after-free를 조기에 발견할 수 있습니다.

💡 플래그 설정 시 너무 많은 플래그를 사용하지 마세요. Linux는 32개 정도의 플래그를 사용하지만, 초기 OS는 5~6개면 충분합니다. DIRTY, LOCKED, RESERVED, SLAB 정도로 시작하고 필요에 따라 추가하세요.

💡 페이지 구조체 크기를 캐시 라인 크기(64바이트)로 맞추는 것을 고려하세요. 그러면 각 페이지 메타데이터가 정확히 하나의 캐시 라인을 차지하여 false sharing을 방지하고 성능이 향상됩니다.

💡 메모리가 부족한 임베디드 시스템에서는 페이지 구조체 크기를 줄이세요. ref_count를 u16으로, flags를 u8로 줄이면 32바이트 정도로 압축할 수 있습니다. 1GB 메모리에서 약 8MB를 절약할 수 있습니다.


5. Per-CPU 프레임 캐시 - 락 경합을 줄이는 고성능 할당

시작하며

여러분이 멀티코어 시스템에서 메모리 할당자를 프로파일링했을 때 "왜 락 대기 시간이 이렇게 길지?"라는 문제를 발견한 적 있나요? 32개 코어가 동시에 메모리를 할당하려고 하는데, 전역 Mutex를 두고 경쟁하느라 병렬성이 무너지는 상황 말이죠.

이런 문제는 고성능 서버 환경에서 심각한 병목이 됩니다. 코어가 많을수록 락 경합이 증가하고, 결국 스케일링이 선형적이지 않게 됩니다.

네트워크 패킷 처리나 데이터베이스 서버처럼 모든 코어가 동시에 메모리를 할당하는 워크로드에서는 치명적입니다. 바로 이럴 때 필요한 것이 Per-CPU 캐시입니다.

각 CPU 코어가 자신만의 프레임 캐시를 가지고, 대부분의 할당을 락 없이 처리하여 확장성을 극대화하는 기법입니다.

개요

간단히 말해서, Per-CPU 캐시는 전역 할당자 앞에 코어별 빠른 경로를 두어, 락 경합 없이 대부분의 할당을 처리하는 캐싱 레이어입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, Linux의 슬랩 할당자와 페이지 프레임 할당자 모두 per-CPU 캐시를 사용합니다.

각 CPU가 자신의 캐시에서 먼저 할당을 시도하고, 캐시가 비었을 때만 전역 할당자에 접근하여 여러 프레임을 한 번에 가져옵니다. 이렇게 하면 대부분의 할당이 락 없이 진행되어 락 경합이 90% 이상 감소합니다.

예를 들어, 네트워크 패킷을 받을 때마다 버퍼를 할당하는데, per-CPU 캐시 덕분에 각 코어가 독립적으로 빠르게 할당할 수 있습니다. 전통적인 방법과의 비교를 하자면, 단순 전역 할당자는 모든 할당에 락을 요구하여 병렬성이 떨어집니다.

기존에는 여러 코어가 하나의 큐를 두고 경쟁했다면, 이제는 각자의 큐를 가지고 독립적으로 동작합니다. 이 개념의 핵심 특징은 첫째, 대부분의 할당이 현재 CPU의 로컬 캐시에서 처리되어 락이 불필요하고, 둘째, 캐시 미스 시에만 전역 할당자에 접근하여 배치로 리필하며, 셋째, CPU 친화성을 활용하여 캐시 지역성이 향상된다는 점입니다.

이러한 특징들이 중요한 이유는 현대 서버가 수십 개의 코어를 가지며, 락 경합이 성능의 주요 병목이 되기 때문입니다.

코드 예제

// Per-CPU 프레임 캐시 구조
use core::cell::UnsafeCell;

const CACHE_SIZE: usize = 16; // 각 CPU가 캐시하는 프레임 개수

pub struct PerCpuCache {
    // 캐시된 프레임들 (물리 주소)
    frames: [Option<usize>; CACHE_SIZE],
    // 현재 캐시된 개수
    count: usize,
}

// CPU별 캐시 배열 (각 CPU가 자신의 인덱스 접근)
static mut PER_CPU_CACHES: [UnsafeCell<PerCpuCache>; 64] =
    [const { UnsafeCell::new(PerCpuCache { frames: [None; CACHE_SIZE], count: 0 }) }; 64];

impl PerCpuCache {
    // 빠른 경로: 로컬 캐시에서 할당
    pub fn allocate_local() -> Option<usize> {
        let cpu_id = current_cpu_id();
        let cache = unsafe { &mut *PER_CPU_CACHES[cpu_id].get() };

        if cache.count > 0 {
            // 캐시에서 바로 반환 (락 불필요!)
            cache.count -= 1;
            return cache.frames[cache.count].take();
        }

        // 캐시 미스: 전역 할당자에서 배치로 리필
        Self::refill_cache(cache)
    }

    // 느린 경로: 전역 할당자에서 여러 프레임 가져오기
    fn refill_cache(cache: &mut PerCpuCache) -> Option<usize> {
        // 전역 할당자에서 CACHE_SIZE개 할당 (락 한 번만)
        for i in 0..CACHE_SIZE {
            if let Some(frame) = GLOBAL_ALLOCATOR.lock().allocate() {
                cache.frames[i] = Some(frame);
                cache.count += 1;
            } else {
                break;
            }
        }

        // 하나는 즉시 반환, 나머지는 캐시에 보관
        if cache.count > 0 {
            cache.count -= 1;
            cache.frames[cache.count].take()
        } else {
            None
        }
    }
}

설명

이것이 하는 일은 자주 사용되는 프레임들을 CPU별로 캐싱하여, 대부분의 할당을 단일 코어 내에서 완결시키는 것입니다. 마치 CPU의 L1 캐시처럼 동작합니다.

첫 번째로, PER_CPU_CACHES 배열은 각 CPU가 자신의 인덱스에 해당하는 캐시만 접근합니다. UnsafeCell로 감싼 이유는 정적 변수의 mutable 접근을 허용하기 위함인데, 각 CPU가 자신의 인덱스만 접근한다는 불변성을 프로그래머가 보장해야 합니다.

왜 이렇게 하는지는, 현재 실행 중인 CPU ID를 기반으로 접근하므로 다른 CPU와 겹칠 일이 없어 락이 불필요하기 때문입니다. 그 다음으로, allocate_local이 실행되면서 먼저 로컬 캐시를 확인합니다.

내부에서 어떤 일이 일어나는지 보면, count > 0이면 캐시에 프레임이 있으므로 배열에서 꺼내 즉시 반환합니다. 이 경로는 락이 전혀 없어 나노초 수준으로 빠릅니다.

실제 측정 결과 전역 락 기반 할당보다 10~100배 빠른 경우도 있습니다. 마지막으로, refill_cache가 캐시 미스 시 호출되어 전역 할당자에서 여러 프레임을 한 번에 가져옵니다.

한 번의 락 획득으로 16개를 가져와 캐시에 채우고, 하나는 즉시 반환하며 나머지는 다음 할당을 위해 보관합니다. 이렇게 배치 리필을 하면 락 획득 횟수가 1/16으로 줄어듭니다.

여러분이 이 코드를 사용하면 멀티코어 시스템에서 선형 스케일링에 가까운 성능을 얻을 수 있습니다. 실무에서의 이점으로는 첫째, 락 경합이 극적으로 감소하여 코어가 많아져도 성능이 유지되고, 둘째, 캐시 지역성이 향상되어 메모리 접근이 빨라지며, 셋째, 전역 할당자의 부담이 줄어 전체 시스템 효율이 증가한다는 점입니다.

주의할 점은 CPU 마이그레이션입니다. 태스크가 실행 중 다른 CPU로 이동하면, 기존 CPU의 캐시는 사용되지 않고 새 CPU의 캐시를 쓰게 되어 일시적으로 캐시 효율이 떨어집니다.

하지만 대부분의 OS는 CPU 친화성을 유지하려 하므로 실제로는 큰 문제가 되지 않습니다.

실전 팁

💡 current_cpu_id()를 효율적으로 구현하세요. x86-64에서는 GS 세그먼트 레지스터에 CPU ID를 저장하면 한 명령어로 읽을 수 있습니다: mov rax, gs:0. ARM은 TPIDR_EL1 레지스터를 사용합니다.

💡 캐시 크기를 워크로드에 맞게 튜닝하세요. 네트워크 서버처럼 할당이 빈번하면 3264개로 늘리고, 일반 애플리케이션은 816개면 충분합니다. 너무 크면 메모리 낭비가 되고, 너무 작으면 리필이 자주 발생합니다.

💡 해제도 per-CPU 캐시를 사용하세요. 해제된 프레임을 로컬 캐시에 먼저 넣고, 캐시가 가득 차면 전역 할당자에 반환하는 식으로 하면 해제도 빠릅니다. 단, 해제한 CPU와 할당한 CPU가 다를 수 있으므로 캐시 크기 제한이 중요합니다.

💡 프리엠션 비활성화를 고려하세요. allocate_local 실행 중 스케줄러가 태스크를 다른 CPU로 옮기면 문제가 생길 수 있습니다. 할당 중에는 preempt_disable()로 프리엠션을 막거나, 인터럽트를 비활성화하는 것이 안전합니다.

💡 NUMA 시스템에서는 CPU가 속한 NUMA 노드의 메모리를 우선 할당하세요. Per-CPU 캐시가 리필할 때 해당 CPU의 로컬 노드에서 가져오도록 하면, 원격 메모리 접근을 피해 지연 시간이 크게 줄어듭니다.


6. 메모리 존(Zone) 시스템 - DMA와 고차원 메모리 관리

시작하며

여러분이 디바이스 드라이버를 작성하면서 "이 DMA 컨트롤러는 하위 16MB만 접근할 수 있는데, 어떻게 메모리를 할당하지?"라는 제약을 마주친 적 있나요? 일반 메모리 할당자는 물리 주소를 신경 쓰지 않는데, 특정 영역의 메모리가 필요한 상황 말이죠.

이런 문제는 레거시 하드웨어나 특수한 아키텍처에서 빈번히 발생합니다. ISA 버스 DMA는 24비트 주소만 지원하고, 일부 32비트 PCI 디바이스는 4GB 이상을 접근 못 하며, ZONE_HIGHMEM처럼 커널이 직접 매핑할 수 없는 메모리도 있습니다.

바로 이럴 때 필요한 것이 메모리 존 시스템입니다. 물리 메모리를 여러 영역으로 나누고, 각 영역별로 할당자를 두어 요구사항에 맞는 메모리를 제공하는 계층적 관리 시스템입니다.

개요

간단히 말해서, 메모리 존은 물리 주소 범위별로 메모리를 분류하고, 각 용도에 맞는 영역에서 할당하도록 제약을 두는 메모리 관리 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, Linux 커널은 ZONE_DMA(016MB), ZONE_DMA32(04GB), ZONE_NORMAL(4GB~), ZONE_HIGHMEM(커널 직접 매핑 불가 영역) 등으로 나눕니다.

각 존은 독립적인 버디 할당자를 가지며, GFP 플래그로 어느 존에서 할당할지 지정합니다. 예를 들어, GFP_DMA 플래그를 주면 ISA 디바이스가 접근 가능한 하위 16MB 영역에서만 할당되어, 하드웨어 제약을 자동으로 만족시킵니다.

전통적인 방법과의 비교를 하자면, 초기 OS들은 물리 메모리를 균일하게 취급했습니다. 기존에는 드라이버가 직접 낮은 주소를 할당받으려 시도했다면, 이제는 할당자가 자동으로 적절한 영역을 선택합니다.

이 개념의 핵심 특징은 첫째, 하드웨어 제약을 추상화하여 드라이버 코드가 간결해지고, 둘째, fallback 메커니즘으로 DMA 메모리가 부족하면 일반 메모리로 대체할 수 있으며, 셋째, 각 존의 사용량을 모니터링하여 불균형을 관리한다는 점입니다. 이러한 특징들이 중요한 이유는 다양한 하드웨어와 메모리 구성을 지원하려면 유연한 메모리 관리가 필수이기 때문입니다.

코드 예제

// 메모리 존 시스템 구현
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Zone {
    DMA,      // 0 ~ 16MB (ISA 디바이스용)
    DMA32,    // 0 ~ 4GB (32비트 PCI 디바이스용)
    Normal,   // 4GB ~ (일반 용도)
}

pub struct ZoneAllocator {
    // 각 존마다 독립적인 버디 할당자
    zones: [BuddyAllocator; 3],
    // 존별 경계 주소
    zone_boundaries: [(usize, usize); 3],
}

impl ZoneAllocator {
    pub fn new(total_memory: usize) -> Self {
        let dma_end = (16 * 1024 * 1024).min(total_memory);
        let dma32_end = (4 * 1024 * 1024 * 1024).min(total_memory);

        Self {
            zones: [
                BuddyAllocator::new(0, dma_end),
                BuddyAllocator::new(dma_end, dma32_end),
                BuddyAllocator::new(dma32_end, total_memory),
            ],
            zone_boundaries: [
                (0, dma_end),
                (dma_end, dma32_end),
                (dma32_end, total_memory),
            ],
        }
    }

    // 특정 존에서 할당 (fallback 지원)
    pub fn allocate_zone(&mut self, order: usize, preferred: Zone) -> Option<usize> {
        // 먼저 선호하는 존에서 시도
        if let Some(addr) = self.zones[preferred as usize].allocate(order) {
            return Some(addr);
        }

        // 실패 시 상위 존으로 fallback (DMA -> DMA32 -> Normal)
        for zone_idx in (preferred as usize + 1)..3 {
            if let Some(addr) = self.zones[zone_idx].allocate(order) {
                return Some(addr);
            }
        }

        None
    }
}

설명

이것이 하는 일은 물리 주소 공간을 하드웨어 특성에 따라 분류하고, 요청에 맞는 영역에서 메모리를 할당하는 것입니다. 마치 도서관을 참고서/소설/잡지로 나누는 것과 같습니다.

첫 번째로, Zone 열거형은 세 가지 주요 영역을 정의합니다. DMA 존은 0~16MB로 레거시 ISA 디바이스가 24비트 주소 버스 제약 때문에 필요하고, DMA32는 32비트 PCI 디바이스를 위한 4GB 이하 영역이며, Normal은 나머지 모든 메모리입니다.

왜 이렇게 나누는지는, 하드웨어마다 접근 가능한 물리 주소 범위가 다르기 때문입니다. 그 다음으로, ZoneAllocator::new가 실행되면서 전체 메모리를 세 존으로 분할합니다.

내부에서 어떤 일이 일어나는지 보면, min()을 사용하여 시스템 메모리가 16MB보다 작으면 DMA 존만 존재하도록 합니다. 각 존의 경계를 zone_boundaries에 저장하여 나중에 주소가 어느 존에 속하는지 판단할 수 있습니다.

마지막으로, allocate_zone이 fallback 메커니즘을 구현합니다. 먼저 선호하는 존에서 할당을 시도하고, 실패하면 상위 존으로 올라갑니다.

예를 들어 DMA 존이 고갈되면 DMA32 존에서 할당하는데, DMA32도 DMA 제약(4GB 이하)을 만족하므로 안전합니다. 하지만 반대(Normal -> DMA)는 허용되지 않습니다.

여러분이 이 코드를 사용하면 다양한 하드웨어 요구사항을 자동으로 처리할 수 있습니다. 실무에서의 이점으로는 첫째, 드라이버가 물리 주소를 신경 쓰지 않고 존만 지정하면 되고, 둘째, 희귀한 DMA 메모리를 보호하여 필요한 곳에만 쓰이게 하며, 셋째, fallback으로 유연성을 제공한다는 점입니다.

실제 Linux 커널은 더 복잡한 존 시스템을 가지며, ZONE_MOVABLE(페이지 마이그레이션 가능), ZONE_DEVICE(NVDIMM 같은 특수 메모리) 등도 지원합니다. 또한 NUMA 시스템에서는 각 노드마다 존 세트가 있어 더욱 계층적입니다.

실전 팁

💡 DMA 존을 너무 작게 만들지 마세요. 16MB가 표준이지만, 레거시 디바이스가 없다면 DMA와 DMA32를 합쳐 4GB 존 하나로 단순화할 수 있습니다. 불필요한 분할은 파편화만 증가시킵니다.

💡 Fallback 방향을 항상 확인하세요. DMA -> Normal은 안전하지만, Normal -> DMA는 절대 안 됩니다. 할당 시 요청 플래그를 검증하여 잘못된 fallback을 방지하세요.

💡 존별 워터마크를 설정하세요. DMA 메모리가 일정 수준 이하로 떨어지면 일반 할당에서 DMA 존을 사용하지 못하게 막아, 정말 필요한 디바이스를 위해 예약하는 것이 좋습니다.

💡 64비트 시스템에서는 ZONE_DMA32가 불필요할 수 있습니다. 모든 디바이스가 64비트 DMA를 지원한다면 Normal 존만으로 충분하므로, 아키텍처별로 존 구성을 조정하세요.

💡 존 밸런싱을 구현하세요. 한 존이 고갈되고 다른 존은 비어있는 불균형 상태를 모니터링하여, 페이지 마이그레이션이나 할당 정책 조정으로 해결하는 것이 장기 안정성에 중요합니다.


7. 프레임 회수(Frame Reclaim) - 메모리 부족 시 재활용 전략

시작하며

여러분이 OS를 실행하다가 "물리 메모리가 다 찼는데 새 프로세스를 시작해야 한다면?"이라는 상황을 마주친 적 있나요? 할당자가 None을 반환하면 그냥 포기해야 할까, 아니면 사용 중인 메모리 중 일부를 회수할 방법이 있을까 하는 고민 말이죠.

이런 문제는 실제 OS에서 매우 중요한 정책 결정입니다. 메모리가 부족하다고 프로세스 생성을 거부하면 사용자 경험이 나빠지고, 무작정 프로세스를 죽이면 데이터 손실이 발생합니다.

스왑 공간을 사용하거나, 페이지 캐시를 비우거나, 덜 중요한 페이지를 희생시키는 등의 전략이 필요합니다. 바로 이럴 때 필요한 것이 프레임 회수 메커니즘입니다.

현재 사용 중인 페이지들을 평가하여, 디스크에 저장하거나 재생성 가능한 페이지를 골라내 메모리를 확보하는 시스템입니다.

개요

간단히 말해서, 프레임 회수는 물리 메모리가 부족할 때 덜 중요한 페이지를 찾아내어 해제하거나 스왑 아웃하여 새로운 할당을 위한 공간을 만드는 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 현대 OS는 물리 메모리보다 훨씬 많은 가상 메모리를 제공합니다.

프로세스들이 사용하는 가상 메모리 합이 물리 메모리를 초과하는 것이 일반적이므로, 동적으로 어떤 페이지를 물리 메모리에 유지할지 선택해야 합니다. 예를 들어, 파일 캐시는 언제든 디스크에서 다시 읽을 수 있으므로 메모리 압박 시 가장 먼저 회수 대상이 되고, 익명 페이지(프로세스 힙/스택)는 스왑 영역에 쓴 후 회수할 수 있습니다.

전통적인 방법과의 비교를 하자면, 초기 시스템들은 FIFO나 랜덤 교체를 사용했습니다. 기존에는 단순히 오래된 페이지를 쫓아냈다면, 이제는 LRU(Least Recently Used), Clock 알고리즘, 페이지 나이 등으로 실제 사용 패턴을 반영하여 지능적으로 선택합니다.

이 개념의 핵심 특징은 첫째, LRU 리스트로 최근 사용되지 않은 페이지를 추적하고, 둘째, Active/Inactive 리스트로 2단계 에이징을 구현하며, 셋째, 페이지 타입(파일 캐시, 익명, 커널)에 따라 다른 정책을 적용한다는 점입니다. 이러한 특징들이 중요한 이유는 잘못된 페이지를 회수하면 곧바로 다시 필요해져 스레싱(thrashing)이 발생하고, 시스템 성능이 급격히 저하되기 때문입니다.

코드 예제

// LRU 기반 페이지 회수 메커니즘
use alloc::collections::VecDeque;

pub struct PageReclaimer {
    // Active 페이지 리스트 (최근 사용됨)
    active_list: VecDeque<usize>,
    // Inactive 페이지 리스트 (오래 전 사용됨)
    inactive_list: VecDeque<usize>,
    // 페이지 테이블 (페이지 정보 접근용)
    page_table: &'static [Page],
}

impl PageReclaimer {
    // 메모리 부족 시 호출: 일정 개수의 페이지 회수
    pub fn reclaim_pages(&mut self, count: usize) -> usize {
        let mut reclaimed = 0;

        // 먼저 Inactive 리스트에서 회수 시도
        while reclaimed < count && !self.inactive_list.is_empty() {
            if let Some(page_addr) = self.inactive_list.pop_front() {
                let page = &self.page_table[page_addr / 4096];

                // 최근에 접근되었는지 확인 (하드웨어 Access 비트)
                if page.is_accessed() {
                    // 다시 활성화: Active 리스트로 이동
                    page.clear_accessed();
                    self.active_list.push_back(page_addr);
                } else if page.is_dirty() {
                    // 더티 페이지: 스왑 아웃 후 회수
                    swap_out_page(page_addr);
                    reclaimed += 1;
                } else {
                    // 클린 페이지: 즉시 회수 가능
                    free_page(page_addr);
                    reclaimed += 1;
                }
            }
        }

        // Inactive이 부족하면 Active에서 이동
        self.refill_inactive();

        reclaimed
    }

    // Active 페이지를 Inactive로 강등
    fn refill_inactive(&mut self) {
        let move_count = self.active_list.len() / 2;
        for _ in 0..move_count {
            if let Some(page_addr) = self.active_list.pop_front() {
                self.inactive_list.push_back(page_addr);
            }
        }
    }
}

설명

이것이 하는 일은 현재 메모리에 있는 페이지들을 사용 빈도에 따라 분류하고, 압박 상황에서 희생할 페이지를 선택하여 메모리를 확보하는 것입니다. 마치 캐시 교체 알고리즘을 물리 메모리에 적용한 것입니다.

첫 번째로, active_listinactive_list는 2단계 LRU를 구현합니다. 페이지가 처음 할당되면 Active 리스트에 들어가고, 일정 시간이 지나면 Inactive로 강등되며, 최종적으로 회수됩니다.

왜 이렇게 하는지는, 단순 LRU는 한 번만 쓰이는 페이지(스트리밍 읽기)가 자주 쓰이는 페이지를 쫓아내는 문제가 있기 때문입니다. 2단계로 나누면 일시적 접근과 반복 접근을 구분할 수 있습니다.

그 다음으로, reclaim_pages가 실행되면서 Inactive 리스트를 순회하며 회수 대상을 찾습니다. 내부에서 어떤 일이 일어나는지 보면, 먼저 하드웨어 Access 비트를 확인하여 최근 접근 여부를 판단합니다.

CPU가 페이지를 읽거나 쓸 때 자동으로 이 비트를 1로 설정하므로, OS는 이를 확인하여 "Inactive 리스트에 있지만 실제로는 자주 쓰인다"는 것을 알 수 있습니다. 그런 페이지는 Active로 되돌립니다.

마지막으로, 더티 비트 검사가 회수 비용을 결정합니다. 더티 페이지는 디스크와 내용이 다르므로 스왑 영역에 쓰기가 필요하지만, 클린 페이지(파일 캐시)는 그냥 버려도 나중에 디스크에서 다시 읽으면 되므로 즉시 회수 가능합니다.

따라서 클린 페이지를 우선 회수하여 I/O를 줄입니다. 여러분이 이 코드를 사용하면 메모리를 초과하는 워크로드도 실행할 수 있습니다.

실무에서의 이점으로는 첫째, 물리 메모리보다 많은 프로세스를 동시 실행하고, 둘째, 파일 캐시를 공격적으로 사용하여 디스크 I/O를 줄이며, 셋째, OOM(Out of Memory) 상황을 최대한 지연시킨다는 점입니다. 단점은 스레싱입니다.

워킹 셋이 물리 메모리보다 크면 끊임없이 페이지를 교체하게 되어 I/O에 시간을 다 쓰게 됩니다. 이 경우 OOM Killer가 프로세스를 강제 종료하여 시스템을 보호합니다.

실전 팁

💡 워터마크 시스템을 구현하세요. 메모리가 일정 수준(high watermark) 이하로 떨어지면 백그라운드 회수를 시작하고, 더 낮은 수준(low watermark)에서는 동기 회수를 수행하는 식으로 단계를 두면 급격한 성능 저하를 방지할 수 있습니다.

💡 페이지 타입별로 회수 비율을 조정하세요. 파일 캐시와 익명 페이지의 회수 비율을 swappiness 파라미터로 조절하면, 워크로드 특성에 맞게 최적화할 수 있습니다. 데이터베이스는 낮은 swappiness, 일반 데스크톱은 높은 swappiness가 유리합니다.

💡 Clock 알고리즘을 고려하세요. 연결 리스트 대신 원형 버퍼를 사용하면 리스트 조작 오버헤드가 줄고, Second-Chance 알고리즘으로 LRU에 가까운 성능을 낮은 비용으로 얻을 수 있습니다.

💡 Readahead 페이지는 별도 처리하세요. 예측 선읽기로 가져온 페이지가 실제로 쓰이지 않으면 즉시 회수 대상으로 표시하여, 유용한 페이지를 보호하세요.

💡 NUMA 인식 회수를 구현하세요. 각 NUMA 노드별로 독립적인 LRU 리스트를 유지하면, 로컬 메모리 부족 시 로컬 페이지만 회수하여 성능 저하를 최소화할 수 있습니다.


8. OOM Killer - 메모리 고갈 시 최후의 안전장치

시작하며

여러분이 시스템을 운영하다가 "페이지 회수도 안 되고, 스왑도 다 찼는데, 새 메모리 할당 요청이 들어온다면?"이라는 극한 상황을 마주친 적 있나요? 모든 메모리 확보 수단이 실패했을 때, 시스템이 완전히 멈추는 것을 막으려면 어떻게 해야 할까 하는 고민 말이죠.

이런 문제는 실제 프로덕션 환경에서 치명적입니다. 메모리 누수가 있는 프로세스나, 예상보다 많은 트래픽으로 메모리가 고갈되면 시스템 전체가 응답 불능 상태에 빠질 수 있습니다.

커널도 메모리 할당에 실패하면 패닉할 수 있어 위험합니다. 바로 이럴 때 필요한 것이 OOM(Out of Memory) Killer입니다.

더 이상 회수할 메모리가 없을 때, 프로세스들을 평가하여 가장 덜 중요한 것을 희생시켜 시스템을 보호하는 최후의 안전장치입니다.

개요

간단히 말해서, OOM Killer는 메모리 부족 상황에서 프로세스들에 점수를 매겨, 가장 높은 점수의 프로세스를 강제 종료하여 메모리를 확보하는 긴급 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 메모리 할당 실패는 커널 패닉으로 이어질 수 있어 시스템 전체가 다운됩니다.

차라리 하나의 프로세스를 희생하여 시스템을 살리는 것이 낫습니다. Linux OOM Killer는 각 프로세스의 메모리 사용량, 실행 시간, 우선순위 등을 고려하여 "가장 많은 메모리를 쓰면서 중요도는 낮은" 프로세스를 선택합니다.

예를 들어, 시스템 데몬(init, sshd)은 낮은 점수로 보호되고, 메모리를 많이 쓰는 사용자 애플리케이션(브라우저, 게임)이 주요 타겟이 됩니다. 전통적인 방법과의 비교를 하자면, 초기 Unix는 메모리 부족 시 그냥 할당을 거부하거나 패닉했습니다.

기존에는 시스템 전체가 멈췄다면, 이제는 일부 프로세스를 희생하여 가용성을 유지합니다. 이 개념의 핵심 특징은 첫째, 휴리스틱 점수로 희생자를 선택하여 중요한 프로세스를 보호하고, 둘째, 사용자가 oom_score_adj로 프로세스 우선순위를 조정할 수 있으며, 셋째, 시스템 안정성을 최우선으로 하여 패닉보다 프로세스 종료를 선택한다는 점입니다.

이러한 특징들이 중요한 이유는 서버는 항상 가용해야 하고, 일부 서비스 중단은 용인되더라도 전체 시스템 다운은 절대 안 되기 때문입니다.

코드 예제

// OOM Killer 구현
use alloc::vec::Vec;

pub struct Process {
    pid: usize,
    memory_usage: usize,    // 바이트 단위 메모리 사용량
    runtime: usize,         // 실행 시간 (초)
    oom_score_adj: i32,     // 사용자 조정 점수 (-1000 ~ 1000)
}

pub struct OomKiller;

impl OomKiller {
    // OOM 상황에서 희생자 선택 및 종료
    pub fn select_victim(processes: &[Process]) -> Option<usize> {
        let mut best_score = 0i64;
        let mut victim_pid = None;

        for proc in processes {
            // Init 프로세스는 절대 죽이지 않음
            if proc.pid == 1 {
                continue;
            }

            // OOM 점수 계산
            let score = Self::calculate_oom_score(proc);

            if score > best_score {
                best_score = score;
                victim_pid = Some(proc.pid);
            }
        }

        if let Some(pid) = victim_pid {
            println!("OOM: Killing process {} with score {}", pid, best_score);
            // 프로세스에 SIGKILL 전송
            kill_process(pid);
        }

        victim_pid
    }

    // OOM 점수 계산 (높을수록 희생 가능성 높음)
    fn calculate_oom_score(proc: &Process) -> i64 {
        // 메모리 사용량을 MB 단위로 (기본 점수)
        let mut score = (proc.memory_usage / (1024 * 1024)) as i64;

        // 실행 시간이 짧으면 점수 증가 (갓 시작한 프로세스 우선 희생)
        if proc.runtime < 300 { // 5분 미만
            score += 100;
        }

        // 사용자 조정 값 반영
        score += proc.oom_score_adj as i64 * 10;

        // 점수가 음수면 0으로 (보호됨)
        score.max(0)
    }
}

fn kill_process(pid: usize) {
    // 실제 구현: SIGKILL 시그널 전송
    // 프로세스 구조체 정리, 메모리 해제 등
}

설명

이것이 하는 일은 회복 불가능한 메모리 부족 상황에서, 시스템을 살리기 위해 일부 프로세스를 희생시키는 것입니다. 마치 배가 가라앉을 때 화물을 바다에 버리는 것과 같습니다.

첫 번째로, select_victim 함수는 모든 프로세스를 순회하며 점수를 계산합니다. PID 1(init)은 절대 죽이지 않는데, 이는 시스템의 루트 프로세스이므로 종료되면 커널 패닉이 발생하기 때문입니다.

왜 이렇게 하는지는, 일부 프로세스는 시스템 운영에 필수적이므로 보호해야 하기 때문입니다. 그 다음으로, calculate_oom_score가 실행되면서 여러 요소를 종합하여 점수를 산출합니다.

내부에서 어떤 일이 일어나는지 보면, 메모리 사용량이 주요 지표이지만, 실행 시간도 고려합니다. 오래 실행된 프로세스는 중요한 작업을 하고 있을 가능성이 높으므로 보호하고, 갓 시작한 프로세스는 재시작이 쉬우므로 희생 대상입니다.

마지막으로, oom_score_adj 값이 사용자 개입을 허용합니다. -1000으로 설정하면 절대 죽지 않고(OOM 보호), +1000으로 설정하면 최우선 희생 대상이 됩니다.

데이터베이스 같은 중요 서비스는 음수 값으로 보호하고, 테스트 프로세스는 양수 값으로 먼저 희생되도록 조정할 수 있습니다. 여러분이 이 코드를 사용하면 메모리 고갈로 인한 시스템 다운을 방지할 수 있습니다.

실무에서의 이점으로는 첫째, 전체 시스템 패닉을 막아 가용성을 유지하고, 둘째, 덜 중요한 프로세스를 우선 희생하여 피해를 최소화하며, 셋째, 관리자가 정책을 조정할 수 있다는 점입니다. 단점은 데이터 손실 가능성입니다.

저장하지 않은 작업은 사라지므로, 중요한 애플리케이션은 정기적으로 저장하고 OOM 보호를 설정해야 합니다. 또한 잘못된 희생자 선택도 문제인데, 점수 계산 휴리스틱이 완벽하지 않아 때로는 중요한 프로세스가 죽을 수 있습니다.

실전 팁

💡 중요한 서비스는 systemd의 OOMScoreAdjust 설정을 사용하세요. 데이터베이스나 웹 서버 유닛 파일에 OOMScoreAdjust=-1000을 추가하면 OOM Killer로부터 완전히 보호됩니다.

💡 메모리 cgroup limits를 설정하여 폭주하는 프로세스를 격리하세요. 컨테이너별로 메모리 상한을 두면, 한 컨테이너의 OOM이 전체 시스템에 영향을 주지 않습니다.

💡 OOM 이벤트를 로깅하고 모니터링하세요. /var/log/kern.log에서 "Out of memory: Kill process" 메시지를 찾아, 어떤 프로세스가 얼마나 자주 희생되는지 추적하면 메모리 튜닝에 유용합니다.

💡 Overcommit 정책을 조정하세요. /proc/sys/vm/overcommit_memory를 2로 설정하면 오버커밋을 금지하여 OOM을 원천 차단할 수 있지만, 메모리 효율이 떨어집니다. 워크로드 특성에 맞게 선택하세요.

💡 메모리 누수를 조기에 발견하세요. Valgrind나 AddressSanitizer로 개발 단계에서 누수를 잡으면, 프로덕션에서 OOM Killer가 발동할 일이 줄어듭니다. 또한 pstop으로 메모리 사용량을 주기적으로 모니터링하는 것이 중요합니다.


#Rust#MemoryManagement#FrameAllocator#OS개발#시스템프로그래밍

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.