Skip to content

✍️ 필사 모드: Linux 메모리 관리 완벽 가이드 — 페이지 테이블, 페이지 폴트, Buddy/SLUB, Page Cache, THP, NUMA, cgroups (2025)

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 메모리는 가장 복잡한 커널 서브시스템이다

Linux 커널을 처음 들여다본 사람이 가장 압도되는 부분은 거의 항상 메모리 관리다. 스케줄러는 함수 몇 개로 핵심 아이디어를 잡을 수 있고, 파일시스템은 인터페이스가 잘 정의되어 있다. 그러나 메모리 관리는 — 가상 메모리, 페이지 테이블, 물리 페이지 할당, 슬랩 할당자, 페이지 캐시, 스왑, NUMA, cgroups, 메모리 압박, OOM, THP — 모든 것이 모든 것에 영향을 준다. 한 곳을 건드리면 다른 곳이 무너진다.

이 글은 "Linux 내부 구조 시리즈"의 네 번째이자 마지막 글이다. 이전 세 글:

이 글까지 다 읽으면 Linux 커널이 사용자 프로세스를 위해 해주는 일의 거의 전부를 손에 잡을 수 있게 된다. 부팅이 끝난 후, 스케줄러가 CPU를 주고, 메모리 관리가 주소 공간을 챙기고, I/O 경로가 디스크/네트워크와 이야기한다. 이 네 가지가 모이면 "내 프로세스가 살아가는 환경"의 풍경 전체가 보인다.

이 글은 1,400줄에 달하지만, 모든 절은 독립적으로 읽을 수 있다. 페이지 테이블만 궁금하면 2장, 슬랩 할당자만 궁금하면 5장으로 바로 가도 된다.


1. 가상 메모리 — 왜 필요한가

1.1 두 개의 거짓말

운영체제는 모든 사용자 프로세스에게 두 개의 큰 거짓말을 한다:

  1. "너는 CPU 전체를 가지고 있어" — 실제로는 스케줄러가 시간을 잘게 쪼개 분배한다.
  2. "너는 자기 자신만의 전체 메모리 주소 공간을 가지고 있어" — 실제로는 가상 메모리 시스템이 물리 메모리를 잘게 쪼개 분배한다.

이 두 거짓말 덕분에 사용자 코드는 다른 프로세스의 존재를 신경 쓰지 않고 자기 일만 하면 된다. 가상 메모리는 두 번째 거짓말의 구현이다.

1.2 가상 주소와 물리 주소

가상 주소는 프로세스가 보는 주소다. x86_64에서는 64비트지만 실제로는 48비트 (또는 LA57 모드에서 57비트)만 사용한다. 즉 256TB (또는 128PB)의 가상 주소 공간.

물리 주소는 실제 RAM의 위치다. 32GB 시스템이라면 35비트로 충분하다.

가상 주소를 물리 주소로 매핑하는 것이 페이지 테이블의 역할이다.

1.3 페이지 단위

매핑은 4KB 단위로 이루어진다. 이를 **페이지(page)**라 한다. 4KB는 임의의 선택이 아니라, x86 하드웨어가 강제한 단위다 (다른 아키텍처에서는 다르다 — ARM은 4/16/64KB 모두 지원).

x86_64는 추가로 2MB와 1GB의 큰 페이지(huge page)도 지원한다. 이는 페이지 테이블 크기를 줄이고 TLB 미스를 감소시킨다.

1.4 한 줄 요약

가상 메모리는 "주소 공간을 페이지 단위로 나눠서, 각 페이지를 물리 메모리의 어딘가로 매핑하는" 시스템이다. 매핑은 페이지 테이블에 저장되고, CPU의 MMU가 매번 매핑을 따라간다.


2. x86_64 페이지 테이블 — 4단계의 위계

2.1 가상 주소의 비트 분해

x86_64의 48비트 가상 주소는 다음과 같이 분해된다:

+---+---+---+---+---+
| PML4 | PDPT | PD | PT | offset |
| 9  | 9  | 9 | 9 | 12 |
+---+---+---+---+---+
   bit47       bit11      bit0
  • PML4 인덱스: 최상위 9비트 (47:39). 512개의 PML4 엔트리 중 하나를 선택.
  • PDPT 인덱스: 다음 9비트 (38:30). PML4 엔트리가 가리키는 PDPT의 한 엔트리를 선택.
  • PD 인덱스: 그 다음 9비트 (29:21). PDPT 엔트리가 가리키는 PD의 한 엔트리를 선택.
  • PT 인덱스: 그 다음 9비트 (20:12). PD 엔트리가 가리키는 PT의 한 엔트리를 선택.
  • 오프셋: 마지막 12비트 (11:0). 페이지 안의 바이트 위치.

각 단계의 테이블은 정확히 4KB (= 512 entries × 8 bytes/entry). 4KB는 페이지 한 장의 크기이므로, 테이블 자체도 페이지 한 장이면 충분하다 — 우아하다.

2.2 페이지 테이블 워크

CPU가 가상 주소 0x7fff12345678을 물리 주소로 변환하는 과정:

  1. CR3 레지스터에서 PML4의 물리 주소를 읽음.
  2. 비트 47-39를 잘라 PML4 인덱스로 사용. 해당 PML4 엔트리를 읽음.
  3. 그 엔트리의 NX, RW, US 비트를 검사. 권한 OK면 PDPT의 물리 주소를 얻음.
  4. 비트 38-30을 잘라 PDPT 인덱스로 사용. 같은 식으로 PD의 물리 주소를 얻음.
  5. 비트 29-21을 잘라 PD 인덱스로 사용. PT의 물리 주소를 얻음.
  6. 비트 20-12을 잘라 PT 인덱스로 사용. 최종 페이지의 물리 주소를 얻음.
  7. 오프셋(비트 11-0)을 더해 최종 물리 주소 계산.

총 4번의 메모리 접근. 만약 매번 이 작업을 한다면 메모리 접근이 5배 느려진다 (오리지널 1번 + 워크 4번). 이를 막는 것이 TLB.

2.3 TLB — Translation Lookaside Buffer

TLB는 최근 사용한 가상→물리 매핑을 캐시하는 작은 하드웨어 캐시다. 보통:

  • L1 TLB: 64-128 엔트리, 1사이클 액세스
  • L2 TLB: 1024-2048 엔트리, 7사이클 액세스

TLB 히트 시 페이지 테이블 워크가 생략되고, 가상 주소가 즉시 물리 주소로 변환된다. TLB 미스가 큰 워크로드 (메모리 인접성이 나쁜 워크로드)는 페이지 테이블 워크 비용을 크게 받는다.

2.4 ASID와 PCID

문맥 스위치 시 페이지 테이블이 바뀌면 TLB를 비워야 한다 (다른 프로세스의 매핑은 무효). 이 비우기는 비용이 크다.

PCID(Process Context ID)는 TLB 엔트리에 12비트의 컨텍스트 ID를 태그한다. 다른 PCID의 엔트리는 자동으로 무시되므로, 컨텍스트 스위치 시 TLB를 통째로 비울 필요가 없다. Intel은 Sandy Bridge부터 PCID를 지원, Linux는 4.14에서 활성화.

2.5 5단계 페이지 테이블 (LA57)

ICE Lake 이후 Intel CPU는 5단계 페이지 테이블을 지원한다. PML5라는 새 단계가 추가되어 가상 주소 공간이 256TB → 128PB로 늘어났다. 이는 거대 메모리 서버 (수백 TB 이상의 메모리를 가진 시스템)를 위한 것이다.

대부분의 일반 시스템은 여전히 4단계로 충분하다. Linux는 부팅 시 자동으로 4단계 또는 5단계 모드를 선택한다.

★ Insight ─────────────────────────────────────

  • 왜 4단계로 잘게 나눴나: 단순한 1단계 페이지 테이블은 가상 주소 공간이 클수록 비효율적이다. 48비트 공간을 4KB 페이지로 1단계 매핑하면 페이지 테이블만 512GB가 필요하다. 다단계 트리는 사용된 영역만 메모리를 차지한다 (sparse mapping).
  • 각 단계가 정확히 9비트인 이유: 9비트 → 512 엔트리 → 512 × 8 bytes = 4096 바이트 = 한 페이지. 페이지 테이블 자체가 페이지에 정확히 들어맞도록 설계된 우아한 결과.
  • PCID는 게임 체인저: 컨텍스트 스위치마다 TLB flush가 약 5%의 처리량 손실을 일으킬 수 있다. PCID는 이를 거의 0으로 줄인다. 다만 TLB의 유효 용량이 줄어드는 사이드 이펙트가 있다 (여러 컨텍스트가 공유). ─────────────────────────────────────────────────

3. 프로세스의 메모리 모델 — mm_structvm_area_struct

3.1 한 프로세스, 한 mm_struct

각 프로세스는 자기 가상 메모리 정보를 mm_struct라는 구조체에 담는다. 핵심 필드:

struct mm_struct {
    struct {
        struct maple_tree mm_mt;     /* VMA 컬렉션 (Linux 6.1+) */
        unsigned long mmap_base;
        unsigned long task_size;
        pgd_t *pgd;                   /* PML4 (또는 PML5)의 가상 주소 */
        atomic_t mm_users;
        atomic_t mm_count;
        unsigned long total_vm;       /* 총 가상 페이지 수 */
        unsigned long pinned_vm;
        unsigned long data_vm, exec_vm, stack_vm;
        unsigned long start_code, end_code;
        unsigned long start_data, end_data;
        unsigned long start_brk, brk;
        unsigned long start_stack;
        unsigned long arg_start, arg_end, env_start, env_end;
        /* ... */
    };
};
  • pgd: 이 프로세스의 PML4 (페이지 테이블 루트) 가상 주소. CR3는 이의 물리 주소.
  • total_vm: 전체 가상 페이지 수.
  • mm_mt: 모든 VMA(virtual memory area)를 담는 컬렉션. 6.1 이전에는 레드블랙 트리, 그 이후 maple tree로 교체.

3.2 VMA — vm_area_struct

가상 주소 공간의 한 연속 영역을 표현한다. 예를 들어 한 mmap된 파일, 한 스택, 한 힙은 각자 하나의 VMA가 된다.

struct vm_area_struct {
    unsigned long vm_start;
    unsigned long vm_end;
    struct mm_struct *vm_mm;
    pgprot_t vm_page_prot;
    unsigned long vm_flags;          /* VM_READ, VM_WRITE, VM_EXEC, VM_SHARED, ... */
    struct file *vm_file;             /* 파일 매핑이면 가리킴 */
    unsigned long vm_pgoff;
    const struct vm_operations_struct *vm_ops;
    /* ... */
};
  • vm_start/vm_end: 이 VMA가 차지하는 가상 주소 범위.
  • vm_flags: 권한과 속성 (VM_READ, VM_WRITE, VM_EXEC, VM_SHARED, VM_GROWSDOWN 등).
  • vm_file: 파일 매핑이면 해당 파일.
  • vm_pgoff: 파일 안에서의 페이지 오프셋.

3.3 /proc/PID/maps의 정체

cat /proc/$$/maps로 보는 출력은 VMA 리스트의 시각화다:

55a1c0c00000-55a1c0c2c000 r--p 00000000 fd:00 1234567 /usr/bin/bash
55a1c0c2c000-55a1c0d10000 r-xp 0002c000 fd:00 1234567 /usr/bin/bash
55a1c0d10000-55a1c0d4c000 r--p 00110000 fd:00 1234567 /usr/bin/bash
55a1c0d4c000-55a1c0d50000 r--p 0014b000 fd:00 1234567 /usr/bin/bash
55a1c0d50000-55a1c0d59000 rw-p 0014f000 fd:00 1234567 /usr/bin/bash
...
7ffd5e3f2000-7ffd5e413000 rw-p 00000000 00:00 0       [stack]
7ffd5e489000-7ffd5e48c000 r--p 00000000 00:00 0       [vvar]
7ffd5e48c000-7ffd5e490000 r-xp 00000000 00:00 0       [vdso]

각 줄이 한 VMA. 첫 컬럼은 vm_start-vm_end, 두 번째는 권한 + 공유/사적, 세 번째는 파일 오프셋, 네 번째는 디바이스, 다섯 번째는 inode, 여섯 번째는 파일 경로 (또는 특수 영역 이름).

3.4 page table은 lazy

mmap이 새 VMA를 만들 때 페이지 테이블이 즉시 채워지지 않는다. 페이지 테이블 엔트리는 모두 비어 있고 (또는 PROT_NONE), 처음 접근할 때 페이지 폴트가 일어나서 비로소 채워진다. 이를 demand paging이라 한다.

mmap이 매우 빠른 이유다 — 100GB 파일을 mmap해도 즉시 끝난다. 실제 매핑은 사용자가 접근할 때마다 점진적으로 일어난다.


4. 페이지 폴트 — 가장 자주 일어나는 트랩

4.1 두 종류의 폴트

페이지 폴트(page fault)는 CPU가 매핑되지 않은 가상 주소에 접근하거나 권한이 부족할 때 발생하는 트랩이다. 두 종류로 나뉜다:

  • Minor fault: 물리 페이지는 이미 메모리에 있지만 페이지 테이블 엔트리가 없는 경우. 예: COW(copy-on-write) 직후 첫 쓰기, 새 매핑의 첫 접근.
  • Major fault: 페이지가 디스크에 있어서 I/O가 필요한 경우. 예: 스왑된 페이지에 다시 접근, 파일 매핑의 첫 접근.

Major fault는 디스크 대기 시간 (보통 수 밀리초)을 동반한다. 워크로드의 major fault rate가 높으면 응답성이 크게 떨어진다.

4.2 do_page_fault → handle_mm_fault

x86에서 페이지 폴트는 인터럽트 14번. 진입점은 do_page_fault(어셈블리)이고, 곧 C 함수 handle_mm_fault로 디스패치한다.

// 매우 단순화한 의사 코드
vm_fault_t handle_mm_fault(struct vm_area_struct *vma, unsigned long addr,
                           unsigned int flags) {
    if (!vma) return VM_FAULT_SIGSEGV;
    if (!(vma->vm_flags & required_flag(flags)))
        return VM_FAULT_SIGSEGV;

    pgd = pgd_offset(vma->vm_mm, addr);
    p4d = p4d_alloc(vma->vm_mm, pgd, addr);
    pud = pud_alloc(vma->vm_mm, p4d, addr);
    pmd = pmd_alloc(vma->vm_mm, pud, addr);
    pte = pte_alloc(vma->vm_mm, pmd, addr);

    return handle_pte_fault(vma, addr, pte, flags);
}

handle_pte_fault는 PTE의 상태에 따라 다음으로 디스패치한다:

  • PTE가 비어있고 익명 매핑이면 → do_anonymous_page
  • PTE가 비어있고 파일 매핑이면 → do_faultdo_read_fault
  • PTE가 swap entry면 → do_swap_page
  • PTE는 있지만 쓰기 권한이 없고 COW 가능이면 → do_wp_page

각 분기가 수십 줄의 진짜 코드다.

4.3 COW — Copy on Write

fork()는 자식 프로세스의 메모리를 부모와 공유한다. 어떻게 가능할까? 모든 페이지를 복사한다면 매우 느릴 것이다.

COW의 마법:

  1. fork는 자식에게 새 mm_struct를 만들고, 모든 VMA를 복제한다.
  2. 모든 페이지 테이블 엔트리를 부모와 공유하되, 읽기 전용으로 만든다.
  3. 부모/자식 누구든 쓰기를 시도하면 페이지 폴트.
  4. do_wp_page가 페이지를 복사하고 새 엔트리로 교체.

이 메커니즘 덕분에 fork 후 즉시 exec을 하는 흔한 패턴이 거의 무료가 된다 — 어차피 실제로 쓴 페이지는 거의 없다.

4.4 Demand Paging — 처음 접근할 때 매핑

새 매핑이나 새로 할당한 익명 페이지는 do_anonymous_page에서 처리된다:

static vm_fault_t do_anonymous_page(struct vm_fault *vmf) {
    struct vm_area_struct *vma = vmf->vma;
    struct page *page;

    if (!(vmf->flags & FAULT_FLAG_WRITE) && !mm_forbids_zeropage(vma->vm_mm)) {
        // 읽기 폴트는 zero page로 매핑 (공유)
        return setup_zero_page(vmf);
    }

    // 쓰기 폴트는 진짜 페이지 할당
    page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
    if (!page) return VM_FAULT_OOM;

    set_pte_at(vma->vm_mm, vmf->address, vmf->pte, mk_pte(page, vma->vm_page_prot));
    return 0;
}

핵심 트릭: 첫 읽기는 모두 같은 zero page(전역 0으로 채워진 페이지)에 매핑된다. 첫 쓰기에서야 진짜 페이지가 할당된다. 이 때문에 0으로 채워진 큰 버퍼를 mmap해도 RSS는 거의 0이다.

4.5 do_swap_page — 스왑에서 다시 가져오기

PTE가 swap entry를 가리키고 있으면, 이 함수가 디스크에서 페이지를 다시 읽어온다:

static vm_fault_t do_swap_page(struct vm_fault *vmf) {
    swp_entry_t entry = pte_to_swp_entry(vmf->orig_pte);
    struct page *page = lookup_swap_cache(entry, vma, addr);

    if (!page) {
        // 스왑 캐시 미스 — 디스크에서 읽기
        page = swapin_readahead(entry, GFP_HIGHUSER_MOVABLE, vma, addr);
        if (!page) return VM_FAULT_OOM;
    }

    // PTE 갱신
    pte = mk_pte(page, vma->vm_page_prot);
    set_pte_at(vma->vm_mm, vmf->address, vmf->pte, pte);
    swap_free(entry);
    return 0;
}

이게 major fault다 — 디스크 read가 들어가므로 보통 수 ms가 걸린다.


5. Buddy Allocator — 물리 페이지 할당의 토대

5.1 문제

커널은 자주 "연속된 N 페이지가 필요해"라고 요청한다. 단순한 free list로 관리하면 단편화(fragmentation)가 빠르게 진행된다. Buddy allocator는 이를 해결한다.

5.2 구조

물리 메모리를 차수(order)별 free list로 관리한다:

  • order 0: 1 페이지 (4KB)
  • order 1: 2 페이지 (8KB)
  • order 2: 4 페이지 (16KB)
  • ...
  • order 10: 1024 페이지 (4MB)
  • order 11: 2048 페이지 (8MB) — MAX_ORDER

5.3 할당 알고리즘

alloc_pages(GFP_KERNEL, order)로 특정 차수의 페이지를 할당:

  1. 요청한 차수의 free list에서 페이지를 찾는다.
  2. 비어있으면 한 단계 위 차수에서 찾는다.
  3. 한 단계 위에서 찾으면 둘로 쪼갠다 — 절반은 반환, 절반은 한 단계 아래 free list에 넣는다.
  4. 아래로 계속 쪼개면서 요청한 차수까지 도달.

5.4 해제 알고리즘 — Buddy 합치기

페이지를 해제할 때 그 "buddy"가 같은 차수에서 free 상태면 합쳐서 한 단계 위로 올린다. 가능하면 계속 합친다.

Buddy의 정의: 같은 차수의 두 인접 블록 중, 시작 주소가 짝수 차수의 비트만 다른 쌍.

order 2 (4 pages):
    [A B C D] [E F G H]
    ↑ buddy

A의 buddy는 E. 둘 다 free면 합쳐서 order 3으로.

5.5 Migration Types와 단편화 회피

같은 차수의 free list가 여러 개로 나뉜다 (migration type별):

  • MIGRATE_UNMOVABLE: 커널 자료구조 같이 옮길 수 없는 페이지
  • MIGRATE_MOVABLE: 사용자 공간 페이지 같이 옮길 수 있는 페이지
  • MIGRATE_RECLAIMABLE: 캐시 같이 회수할 수 있는 페이지
  • MIGRATE_HIGHATOMIC: 인터럽트 컨텍스트의 긴급 할당용

같은 타입끼리 클러스터링하면 단편화가 줄어든다. 큰 연속 영역이 필요할 때 movable 페이지를 옮겨서 공간을 만들 수 있다 (compaction).

5.6 Zones

물리 메모리는 zone으로 나뉜다:

  • ZONE_DMA: 16MB 미만 (구식 ISA 디바이스용)
  • ZONE_DMA32: 4GB 미만 (32비트 DMA용)
  • ZONE_NORMAL: 일반 메모리
  • ZONE_HIGHMEM: 32비트 시스템에서 커널이 직접 매핑할 수 없는 고주소 (64비트는 없음)
  • ZONE_MOVABLE: 핫플러그용 zone

각 zone마다 별도의 buddy allocator. 할당 요청은 적합한 zone에서 시도하고, 실패하면 더 낮은 zone으로 폴백.

5.7 워터마크 — kswapd가 일어나는 시점

각 zone마다 세 워터마크:

  • low: 이 아래로 떨어지면 kswapd가 깨어나 백그라운드 reclaim 시작.
  • min: 이 아래로 떨어지면 사용자 공간 할당이 차단되고 direct reclaim이 시작.
  • high: kswapd가 reclaim을 멈추는 목표 수치.

/proc/zoneinfo에서 볼 수 있다:

Node 0, zone   Normal
  pages free     1234567
        min      890
        low      1234
        high     1578
        ...

5.8 GFP 플래그 — 어떤 식으로 할당할까

alloc_pages는 GFP(Get Free Pages) 플래그를 받는다. 핵심 플래그:

  • GFP_KERNEL: 일반 커널 컨텍스트. 잠들 수 있고, IO 가능.
  • GFP_ATOMIC: 인터럽트 컨텍스트. 잠들면 안 됨. 메모리 부족 시 실패할 수 있음.
  • GFP_USER: 사용자 공간용. 보통 GFP_KERNEL과 비슷하지만 cgroups 회계에 들어감.
  • GFP_NOWAIT: 잠들지 않음. 회수도 안 함. 매우 가벼움.
  • GFP_NOFS: 파일시스템 코드를 호출하지 않음 (FS 안에서 호출 시 재귀 회피).
  • GFP_NOIO: I/O를 시작하지 않음 (블록 레이어 안에서 호출 시).
  • GFP_DMA: ZONE_DMA에서만 할당.
  • GFP_HIGHUSER_MOVABLE: 사용자 공간 + ZONE_MOVABLE 우선.

플래그 조합이 매우 미묘하다. 잘못 쓰면 데드락 (FS 안에서 GFP_KERNEL을 쓰면 reclaim이 다시 FS를 호출할 수 있음).


6. SLUB Allocator — 작은 객체 할당

6.1 문제

Buddy allocator의 최소 단위는 4KB. 그러나 커널은 16바이트 struct file, 256바이트 struct dentry 같은 작은 객체를 자주 만든다. 매번 4KB를 잡는 것은 낭비.

해결책: slab allocator. 4KB 페이지를 잘라 작은 객체용 풀로 사용한다.

6.2 SLAB → SLUB → SLOB의 역사

Linux 슬랩 할당자는 세 번 다시 쓰였다:

  • SLAB: 1996년 Solaris에서 차용. 정교하지만 메타데이터 오버헤드가 컸다.
  • SLUB: 2007년 Christoph Lameter가 작성. 단순하고 메타데이터를 페이지에 임베드. 현재 기본.
  • SLOB: 임베디드용 초경량. 단편화 회피보다 코드 크기 최소화 우선.

Linux 6.5부터 SLAB은 deprecated, 6.10에 완전히 제거되었다. 이제 SLUB만 남았다 (SLOB은 임베디드 전용으로 남음).

6.3 SLUB 디자인

각 객체 크기 클래스마다 kmem_cache가 있다. kmem_cache_create()로 만든다:

struct kmem_cache *my_cache = kmem_cache_create(
    "my_struct",
    sizeof(struct my_struct),
    __alignof__(struct my_struct),
    SLAB_HWCACHE_ALIGN,
    NULL  /* constructor */
);

struct my_struct *obj = kmem_cache_alloc(my_cache, GFP_KERNEL);
/* ... use obj ... */
kmem_cache_free(my_cache, obj);

내부 구조:

  • 각 cache는 여러 slab (1개 이상의 페이지 그룹)을 가진다.
  • 각 slab은 객체로 채워져 있다.
  • 비어있는 객체 슬롯은 free list로 연결됨.
  • per-CPU cache가 있어서 락 없이 빠른 할당.

6.4 kmalloc

일반 커널 코드는 kmem_cache를 직접 만들지 않고 kmalloc(size, gfp)을 쓴다:

char *buf = kmalloc(123, GFP_KERNEL);
/* ... */
kfree(buf);

kmalloc은 미리 만들어둔 사이즈 클래스 (8, 16, 32, 64, ..., 8192 바이트) 중 적합한 것을 고른다. 8192 바이트보다 크면 buddy allocator로 직접.

6.5 객체 캐싱의 부가 효과

자주 쓰는 객체 (task_struct, inode, dentry)는 전용 cache가 있다. 객체가 free되어도 즉시 메모리에 반환되지 않고 cache에 남는다. 다음 할당 시 같은 슬롯이 재사용된다 — L1/L2 캐시 효율 + constructor 비용 절약.

/proc/slabinfo에서 모든 slab 통계를 볼 수 있다:

# slabinfo - version: 2.1
# name           <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab>
inode_cache         12345    23456    640    25    4
dentry             56789    78901    192    21    1
task_struct        234      256     6144    5     8
file_cache         1234     1500    320    25    2
...

7. vmalloc — 비연속 가상 메모리

7.1 언제 쓰는가

kmalloc은 물리적으로 연속된 메모리를 보장한다. 하지만 큰 할당 (예: 100MB)에서는 연속된 큰 영역을 찾기 어렵다. vmalloc은 물리적으로는 단편화된 페이지를 가상적으로 연속되어 보이게 매핑한다.

void *buf = vmalloc(100 * 1024 * 1024);  /* 100MB */
/* ... */
vfree(buf);

내부적으로 alloc_pages(GFP_KERNEL, 0)을 25,600번 호출하고, 각각을 가상 주소 공간에 매핑한다.

7.2 트레이드오프

  • 장점: 큰 할당이 가능. 단편화에 강함.
  • 단점: 페이지 테이블 오버헤드. TLB 미스가 더 자주 발생. DMA에 직접 사용 불가 (물리적으로 비연속).

규칙: 작거나 자주 호출되는 할당 → kmalloc, 큰 할당 또는 가끔 호출 → vmalloc.


8. Page Cache — 파일 데이터의 캐시

8.1 모든 파일 I/O의 경유지

read(fd, buf, size)가 호출되면, 커널은 항상 page cache를 거친다:

  1. 파일의 해당 오프셋이 page cache에 있는지 검사.
  2. 있으면 (cache hit) 사용자 버퍼로 복사.
  3. 없으면 (cache miss) 디스크에서 읽어 page cache에 채우고, 사용자 버퍼로 복사.

O_DIRECT만 예외. 이는 page cache를 우회하고 디스크와 사용자 버퍼 간 직접 DMA.

8.2 인덱싱 — radix tree → xarray

각 파일은 address_space 구조체를 가진다. 이 안에 xa_array가 있어서 파일 오프셋 → 페이지 매핑을 저장한다.

struct address_space {
    struct inode        *host;
    struct xarray        i_pages;       /* 페이지 캐시의 인덱스 */
    struct rw_semaphore  i_mmap_rwsem;
    /* ... */
};

xarray는 6.x에서 radix tree를 대체한 자료구조다. 트리 구조이지만 동시성 처리가 더 우아하다.

8.3 Read-Ahead

순차 read 패턴이 감지되면, 커널은 미리 다음 페이지들을 읽어둔다. 이를 readahead라 한다.

휴리스틱:

  • 첫 read에서 작은 readahead window 시작
  • 순차 패턴이 계속되면 window를 키움
  • 랜덤 read가 감지되면 window 축소

/sys/block/sdX/queue/read_ahead_kb로 디바이스별 readahead 크기를 조정할 수 있다 (기본 128KB).

8.4 Dirty Pages와 Write-Back

write(fd, buf, size)는 디스크에 즉시 쓰지 않는다. page cache의 페이지를 더럽힌다 (dirty bit 켬). 백그라운드의 writeback 스레드 (flush-X:Y, kthread)가 주기적으로 dirty 페이지를 디스크에 내보낸다.

이 지연 쓰기 덕분에:

  • 같은 페이지에 여러 번 쓰면 한 번만 디스크 IO
  • 인접한 페이지들을 묶어서 한 번에 (batching)
  • IO 큐 깊이 활용

8.5 Writeback 튜닝

/proc/sys/vm/dirty_*로 조정:

  • dirty_background_ratio: 시스템 메모리의 몇 %가 dirty면 백그라운드 writeback 시작 (기본 10%).
  • dirty_ratio: 몇 %를 넘으면 사용자 write가 차단되고 동기 writeback (기본 20%).
  • dirty_expire_centisecs: dirty 페이지가 얼마나 오래 살아남으면 강제로 writeback (기본 30초).

데이터베이스 워크로드에서는 보통 dirty_background_ratio를 5%로, dirty_ratio를 10%로 낮춰서 쓰기 지연을 분산시킨다.

8.6 fsync — 강제 동기화

파일을 닫기 전이나 트랜잭션을 커밋할 때, dirty 페이지가 정말 디스크에 쓰였는지 보장이 필요하다. fsync(fd)가 그 역할.

fsync는 비싸다. 디스크 큐를 비우고 디스크의 캐시까지 flush해야 한다 (FUA 또는 disk barrier 사용). PostgreSQL 같은 DB는 매 커밋마다 fsync를 호출한다.

8.7 fadvise와 madvise

사용자 공간이 커널에 힌트를 줄 수 있다:

  • posix_fadvise(fd, off, len, POSIX_FADV_SEQUENTIAL): 순차 패턴
  • posix_fadvise(fd, off, len, POSIX_FADV_WILLNEED): 곧 읽을 거니 readahead 해줘
  • posix_fadvise(fd, off, len, POSIX_FADV_DONTNEED): 더 이상 안 필요해, 캐시에서 빼

madvise는 mmap된 영역에 대한 동일 힌트.

대용량 파일 처리 시 POSIX_FADV_DONTNEED을 적극 사용하면 page cache를 다른 워크로드의 데이터로 채울 여유가 생긴다.


9. 스왑 — 메모리 압박 시 디스크로

9.1 언제 스왑을 쓰나

물리 메모리가 부족하면 커널은 사용 빈도가 낮은 페이지를 디스크로 옮긴다 (swap out). 다시 필요할 때 디스크에서 읽어온다 (swap in).

스왑 대상:

  • 익명 페이지 (heap, stack)
  • tmpfs의 페이지

파일 매핑은 스왑하지 않는다 (원본 파일이 스토리지에 있으므로 그냥 버린다).

9.2 LRU — Active와 Inactive

페이지의 사용 빈도를 추적하기 위해 LRU(Least Recently Used) 리스트를 유지한다. 단순한 LRU 대신, 두 리스트로 나눈다:

  • Active list: 최근 자주 접근된 페이지
  • Inactive list: 오래 안 쓴 페이지 — 회수 1순위

페이지 폴트로 새 페이지가 들어오면 inactive list로 들어간다. 다시 접근되면 active로 승격. active에서 오래 안 쓰면 inactive로 강등.

이 분리는 단순 LRU의 약점인 "한 번만 접근된 페이지가 자주 접근된 페이지를 밀어내는" 문제를 완화한다.

9.3 swappiness

/proc/sys/vm/swappiness (0-200, 기본 60)는 "anonymous 페이지 vs file-backed 페이지 회수 비율"을 조정한다:

  • 0: 가능한 한 anonymous를 스왑하지 않음 (file 캐시를 우선 회수)
  • 60 (기본): 균형
  • 100: anonymous와 file을 동등하게
  • 200: anonymous를 우선 스왑

데이터베이스 서버는 보통 1-10으로 낮춘다 (anonymous = 데이터베이스 working set이므로 안 스왑하고 싶음).

9.4 zswap과 zram

스왑이 디스크로 가면 매우 느리다. 두 가지 대안:

  • zswap: 디스크 스왑 앞에 압축된 메모리 캐시를 둠. swap out된 페이지를 압축해서 RAM에 보관. RAM이 더 부족하면 진짜 디스크 스왑으로 내려보냄.
  • zram: 압축 RAM을 스왑 디바이스로 마운트. 디스크는 아예 없음. 임베디드/모바일에서 흔함.

Android, ChromeOS, 일부 임베디드 Linux는 zram을 적극 활용한다.


10. THP — Transparent Huge Pages

10.1 동기

x86_64는 4KB 외에 2MB와 1GB 페이지도 지원한다. 큰 페이지의 이점:

  • TLB 엔트리 한 개로 더 큰 영역 커버
  • 페이지 테이블 한 단계 줄어듦 (PTE 생략)
  • 페이지 폴트가 큰 단위로 일어남

단점:

  • 단편화가 심해질 수 있음
  • 작은 영역에 큰 페이지를 쓰면 메모리 낭비
  • 쓰기-시-COW 비용이 큼

10.2 transparent의 의미

전통적으로 큰 페이지는 hugetlbfs를 통해 명시적으로 사용했다. 사용자가 큰 페이지가 필요하다고 미리 예약해야 했다.

Transparent Huge Pages는 이를 자동화한다. 사용자 코드는 일반 mmap을 쓰지만, 커널이 가능하면 백그라운드에서 4KB 페이지들을 2MB로 합친다. 사용자에게는 투명하다.

10.3 THP 모드

/sys/kernel/mm/transparent_hugepage/enabled:

  • always: 모든 적합한 매핑에 THP 시도
  • madvise: madvise(MADV_HUGEPAGE)로 명시한 영역에만 THP
  • never: THP 비활성화

기본값은 보통 madvise. 데이터베이스는 종종 never로 설정 (지연 변동성을 싫어함).

10.4 khugepaged

khugepaged 커널 스레드가 백그라운드에서 4KB 페이지들을 검사하고, 충분히 사용 중이고 인접한 경우 2MB 페이지로 합친다. CPU를 약간 소비하지만 워크로드에 거의 영향을 주지 않는다.

10.5 THP 트레이드오프 — 데이터베이스의 적

PostgreSQL, Oracle, MongoDB 등 메이저 DB들은 THP를 끄거나 madvise로 설정하라고 권장한다. 이유:

  1. 할당 지연 변동성: THP 할당 시 큰 연속 영역을 만들기 위해 compaction이 일어날 수 있고, 이는 100ms+의 stall을 유발할 수 있다.
  2. 메모리 낭비: 2MB 단위 할당은 작은 영역에 비해 낭비.
  3. fork 비용: COW 시 2MB 단위로 페이지 복사 — fork가 느려짐.

11. NUMA — Non-Uniform Memory Access

11.1 모델

멀티 소켓 시스템에서는 각 CPU 소켓에 자기 메모리가 붙어 있다. 같은 소켓의 메모리에 접근하는 것이 다른 소켓의 메모리에 접근하는 것보다 빠르다. 후자는 인터커넥트 (Intel UPI, AMD Infinity Fabric)를 거쳐야 한다.

대표적인 차이: 로컬 노드 ~80ns, 원격 노드 ~140ns. 약 2배.

11.2 First-Touch Policy

기본 메모리 정책. 페이지가 처음 폴트될 때, 그 폴트가 일어난 CPU의 NUMA 노드에 페이지를 할당한다. 즉, 페이지를 처음 접근한 CPU의 로컬 메모리가 된다.

11.3 mempolicy

set_mempolicy 시스템 콜로 다른 정책을 선택할 수 있다:

  • MPOL_DEFAULT: first-touch (기본)
  • MPOL_BIND: 지정 노드에서만 할당
  • MPOL_PREFERRED: 지정 노드 우선, 부족하면 다른 노드
  • MPOL_INTERLEAVE: 노드들에 라운드로빈으로 분산

numactl 명령어가 사용자 공간 도구:

numactl --cpubind=0 --membind=0 ./my-process

11.4 AutoNUMA

Linux 커널은 페이지의 접근 패턴을 추적해서 자동으로 마이그레이션할 수 있다. 작동 원리:

  1. 주기적으로 페이지 매핑을 PROT_NONE으로 바꿈
  2. 다음 접근 시 페이지 폴트 발생 — 어느 CPU/NUMA 노드에서 폴트되었는지 기록
  3. 주로 다른 노드에서 접근되는 페이지는 그 노드로 마이그레이션

/proc/sys/kernel/numa_balancing으로 켜고 끌 수 있다.

11.5 NUMA 인식 워크로드

데이터베이스 워크로드는 보통 NUMA 노드 단위 핀이 표준이다:

  1. CPU 핀 (cpuset.cpus = 0-7)
  2. 메모리 핀 (cpuset.mems = 0)
  3. 그 노드의 워커 스레드가 그 노드의 데이터만 다룸
  4. 노드 간 통신은 메시지로

이는 Nginx 내부 구조 글에서 본 worker 분리 모델과 같은 철학이다.


12. cgroups Memory Controller — 컨테이너의 메모리 한계

12.1 cgroup v2의 인터페이스

mkdir /sys/fs/cgroup/my-app
echo $$ > /sys/fs/cgroup/my-app/cgroup.procs
echo "1G" > /sys/fs/cgroup/my-app/memory.max

핵심 파일:

  • memory.max: 하드 캡. 이를 넘으면 OOM kill.
  • memory.high: 소프트 캡. 이를 넘으면 회수 압박이 시작되지만 즉시 죽이지는 않음.
  • memory.low: 보호선. 이 아래로 떨어지지 않게 회수 우선순위 낮춤.
  • memory.min: 절대 보호선. 이 아래로는 절대 회수 안 함.
  • memory.swap.max: 스왑 사용 한도.
  • memory.current: 현재 사용량.
  • memory.events: 이벤트 카운터 (low, high, max, oom).

12.2 OOM Killer

memory.max를 넘기고 reclaim이 더 이상 불가능하면 OOM killer가 cgroup 안의 프로세스를 죽인다. 어떤 프로세스를 죽일지는 oom_score로 결정.

cgroup OOM은 시스템 전체 OOM과 다르다. 시스템 전체 OOM은 매우 위험 (init 프로세스 같은 핵심을 죽일 수 있음). cgroup OOM은 격리되어 있어 다른 cgroup에 영향을 주지 않는다.

12.3 PSI — Pressure Stall Information

리소스 압박을 정량화하는 인터페이스. memory.pressure (또는 /proc/pressure/memory):

some avg10=0.00 avg60=0.00 avg300=0.00 total=0
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
  • some: 적어도 한 태스크가 메모리 회수 때문에 멈춘 시간 비율
  • full: 모든 태스크가 멈춘 시간 비율

avg10이 10 이상이면 cgroup이 메모리 압박을 강하게 받고 있다는 의미. 모니터링/오토스케일링 시그널로 활용.

12.4 OOMD와 systemd

systemd-oomd는 PSI 신호를 보고 cgroup 내에서 가장 무거운 프로세스를 미리 죽인다. 진짜 OOM에 도달하기 전에 부드럽게 회수.

# /etc/systemd/oomd.conf
[OOM]
DefaultMemoryPressureLimit=60%

이 모델은 ChromeOS, Android, Facebook의 인프라에서 광범위하게 사용된다.

★ Insight ─────────────────────────────────────

  • PSI는 "압박을 측정하는 새 방법": 전통적인 메모리 사용량은 "얼마나 쓰고 있나"를 보여주지만, 그것만으로는 시스템이 힘든지 안 힘든지 알기 어렵다. PSI는 "태스크들이 메모리 때문에 얼마나 멈춰 있나"를 직접 측정한다. 이는 SLI(Service Level Indicator)와 직접 매핑된다.
  • memory.high vs memory.max의 의미: high는 "여기를 넘어가면 회수 압박이 시작되지만, 죽이지는 않음." max는 "여기를 넘으면 죽음." 둘을 함께 쓰면 부드러운 회수 + 마지막 안전선이 된다.
  • systemd-oomd의 철학: 진짜 OOM은 너무 갑작스럽다. 그래서 PSI가 일정 임계를 넘으면 미리 죽여서 점진적으로 압박을 해소한다. 사용자에게는 "어떤 탭이 갑자기 사라졌네"로 보이지만, 시스템 전체 멈춤보다는 훨씬 낫다. ─────────────────────────────────────────────────

13. KSM — Kernel Samepage Merging

13.1 무엇을 푸는가

여러 VM이 같은 OS를 돌리면, 같은 내용의 메모리 페이지가 여러 개 존재할 가능성이 높다. KSM은 이를 검사해서 같은 내용의 페이지를 한 페이지로 합친다 (그리고 COW로 만들어 쓰기를 추적).

13.2 어떻게 동작하나

ksmd라는 커널 스레드가 백그라운드에서:

  1. madvise(MADV_MERGEABLE)로 표시된 페이지들을 검사.
  2. 페이지 내용의 해시를 계산.
  3. 같은 해시의 페이지를 비교 (오탐 방지).
  4. 정확히 같으면 한 페이지로 합치고 모두 그곳을 가리키게 함.
  5. 합쳐진 페이지는 read-only로 표시.
  6. 누군가 쓰기를 시도하면 COW로 분리.

13.3 활용 사례

KVM/QEMU가 가장 큰 사용자. qemu는 게스트 메모리 영역을 MADV_MERGEABLE로 표시한다. 같은 OS를 도는 여러 게스트가 있으면 메모리가 크게 절약된다.

/sys/kernel/mm/ksm/ 아래에 통계와 튜닝 노브가 있다:

  • pages_shared: 공유된 (합쳐진) 페이지 수
  • pages_sharing: 원본 페이지 수
  • pages_to_scan: 한 번에 스캔할 페이지 수
  • sleep_millisecs: 스캔 간격

14. CMA — Contiguous Memory Allocator

14.1 문제

DMA 디바이스 (특히 카메라, 비디오 디코더, 디스플레이) 중 일부는 매우 큰 (수십 MB 이상) 물리적 연속 메모리가 필요하다. 시스템이 일정 시간 돌면 단편화 때문에 이만한 영역을 찾기 어렵다.

14.2 해결책

CMA는 부팅 시 일정 영역을 "예약"한다. 이 영역은 평소에는 사용자 movable 페이지로 쓰이지만, DMA 디바이스가 요청하면 즉시 movable 페이지를 다른 곳으로 옮기고 영역을 비워준다.

부팅 파라미터: cma=256M. /proc/meminfoCmaTotal/CmaFree로 확인.

ARM 모바일/임베디드에서 매우 자주 쓰인다. 데스크탑/서버에서는 거의 사용되지 않는다.


15. 디버깅과 관찰성

15.1 /proc/meminfo

시스템 전체 메모리 통계의 중심.

MemTotal:       16384000 kB
MemFree:         2345678 kB
MemAvailable:    4567890 kB
Buffers:          123456 kB
Cached:          5678901 kB
SwapCached:        12345 kB
Active:          6789012 kB
Inactive:        2345678 kB
Active(anon):    3456789 kB
Inactive(anon):  1234567 kB
Active(file):    3333333 kB
Inactive(file):  1111111 kB
SwapTotal:       8388608 kB
SwapFree:        8388608 kB
Dirty:             12345 kB
Writeback:             0 kB
AnonPages:       4567890 kB
Mapped:          1234567 kB
Slab:             456789 kB
SReclaimable:     234567 kB
SUnreclaim:       222222 kB
KernelStack:       12345 kB
PageTables:        56789 kB
HugePages_Total:       0
HugePages_Free:        0
Hugepagesize:       2048 kB

핵심 항목:

  • MemAvailable: "사용 가능한" 메모리. MemFree + 회수 가능한 캐시.
  • Cached: 페이지 캐시.
  • Buffers: 블록 디바이스 캐시 (메타데이터).
  • Dirty: 디스크에 안 쓴 페이지.
  • Slab: 슬랩 할당자가 쓰는 메모리.
  • PageTables: 페이지 테이블 자체에 쓰는 메모리.

15.2 /proc/PID/status, /proc/PID/smaps

프로세스별 메모리 상세.

Name:    bash
VmPeak:    11264 kB    /* 최대 가상 크기 */
VmSize:    11264 kB    /* 현재 가상 크기 */
VmHWM:      4128 kB    /* 최대 RSS */
VmRSS:      4128 kB    /* 현재 RSS (실제 물리 메모리 사용) */
VmData:     2048 kB    /* 데이터 + 힙 */
VmStk:       132 kB    /* 스택 */
VmExe:       880 kB    /* 코드 */
VmLib:      2048 kB    /* 라이브러리 */
VmPTE:        24 kB    /* 페이지 테이블 */
VmSwap:        0 kB    /* 스왑 사용 */

/proc/PID/smaps는 각 VMA의 상세 정보를 줄줄이 나열한다 — 어떤 VMA가 메모리를 얼마나 쓰는지 정확히 알 수 있다. pss(Proportional Set Size)는 공유 페이지를 비례 배분한 RSS — 멀티 프로세스에서 "이 프로세스가 진짜 차지하는 메모리"를 알기 좋다.

15.3 perf와 ftrace

perf record / perf report로 페이지 폴트 핫스팟 추적:

perf record -e page-faults -e major-faults -p $(pidof my-app) sleep 10
perf report

ftrace로 reclaim 경로 추적:

echo function_graph > /sys/kernel/debug/tracing/current_tracer
echo shrink_node > /sys/kernel/debug/tracing/set_graph_function
echo 1 > /sys/kernel/debug/tracing/tracing_on

15.4 bpftrace

힘들 때 한 줄로 답을 주는 도구.

# 페이지 폴트 빈도
bpftrace -e 'tracepoint:exceptions:page_fault_user { @[comm] = count(); }'

# kmalloc 호출 빈도와 크기 분포
bpftrace -e 'kprobe:kmalloc { @sizes = hist(arg1); }'

# OOM kill 시 로그
bpftrace -e 'kprobe:oom_kill_process { printf("%s killed\n", comm); }'

15.5 BCC tools

memleak, slabratetop, cachestat, cachetop 등 BCC의 메모리 관련 도구들. 이미 빌드된 바이너리로 즉시 사용 가능.

sudo memleak -p $(pidof my-app)       # 메모리 누수 추적
sudo cachestat 1                       # 페이지 캐시 hit/miss 실시간
sudo slabratetop 1                     # slab 할당 핫스팟

16. 흔한 안티패턴

16.1 mlock 남용

mlock(buf, size)은 페이지를 스왑 불가로 만든다. 크리티컬 데이터 (암호 키 등)에는 적절하지만, 큰 영역에 무분별하게 쓰면 시스템 전체의 메모리 압박을 만든다. RLIMIT_MEMLOCK이 기본적으로 64KB 정도로 제한.

16.2 madvise(DONTNEED) 오해

madvise(buf, size, MADV_DONTNEED)은 페이지를 즉시 해제한다. 다음 접근 시 폴트가 일어나서 zero page 또는 파일에서 다시 읽는다.

이는 메모리를 절약하지 않는다 (커널 회수가 알아서 한다). 성능을 떨어뜨릴 수 있다. 정말 메모리를 돌려주려면 munmap을 써야 한다.

MADV_FREE는 다르다 — 해제 가능 상태로 표시하지만 즉시 회수하지 않는다. 다음 폴트 전에 reclaim이 일어나면 회수, 그렇지 않으면 그대로 사용.

16.3 swap 오해

"스왑은 항상 나쁘다"는 흔한 오해. 실제로는:

  • 안정적인 워크로드 + 적절한 swap = anonymous 메모리의 "콜드 백업" 역할. 자주 안 쓰는 페이지를 디스크로 보내고 page cache를 더 키울 수 있다.
  • swap == 0 + 메모리 부족 = anonymous 페이지를 회수할 수 없어 압박이 매우 빠르게 OOM으로 진행.

서버 환경에서 swap을 완전히 끄는 것보다, 작게 (4-8GB) 두는 것이 일반적으로 더 안전하다.

16.4 cgroups memory.max를 너무 빡빡하게

OOM kill을 자주 보고 있다면 memory.max가 너무 빡빡한 것이다. memory.high로 부드럽게 압박을 주거나, working set + 20% 여유를 두는 것이 일반적인 룰.

16.5 큰 mmap을 자주 만들고 풀기

mmap/munmap은 가벼워 보이지만, 페이지 테이블 갱신과 TLB shootdown(다른 CPU의 TLB도 무효화해야 함)이 들어간다. 큰 영역을 자주 만들고 풀면 TLB shootdown IPI(Inter-Processor Interrupt)가 시스템을 마비시킬 수 있다. 풀(pool) 패턴으로 재사용하라.


17. 사례 — 실전 메모리 문제 진단

17.1 사례 1: page cache가 너무 크다

증상: MemFree가 작고 Cached가 매우 크다.

진단: 정상이다. Linux는 빈 메모리를 page cache로 채운다. MemAvailable을 보라. 충분하면 문제 없음.

다만, 갑자기 큰 할당이 들어오면 page cache를 회수해야 하고, 이 과정에서 약간의 latency 스파이크가 생길 수 있다. 데이터베이스 워크로드는 vm.dirty_ratio를 낮춰서 dirty 비율을 줄이는 것이 좋다.

17.2 사례 2: anonymous 페이지가 폭증

증상: AnonPages가 늘어나고 RSS가 폭증.

진단: 메모리 누수 의심. pmap이나 /proc/PID/smaps로 어떤 VMA가 큰지 확인. heap이 커졌으면 사용자 공간 누수, mmap 영역이 커졌으면 명시적 누수.

memleak BCC 도구로 호출 스택 추적:

sudo memleak -a -p $(pidof my-app)

17.3 사례 3: kswapd가 100% CPU

증상: top에서 kswapd 프로세스가 100% CPU.

진단: 메모리가 부족해서 백그라운드 reclaim이 계속 돌고 있다. 두 가지 원인:

  1. 정말 메모리가 부족 — 더 추가하거나 워크로드를 줄여야 함.
  2. NUMA 노드 단위로 부족 — 한 노드만 꽉 차고 다른 노드는 비어 있을 수 있음. numastat으로 확인.

17.4 사례 4: OOM killer 발동

증상: dmesg에 Out of memory: Killed process ....

진단:

  1. dmesg에서 OOM 시점의 메모리 상태 확인.
  2. cgroup OOM인지 시스템 전체 OOM인지 확인.
  3. 어떤 프로세스가 죽었는지 확인 — oom_score 가장 높은 프로세스가 죽음.

해결:

  • cgroup memory.max를 늘림
  • swap 추가
  • working set 자체를 줄임
  • vm.overcommit_memory=2로 엄격 모드 (할당 시점에 거부 — 일찍 실패)

17.5 사례 5: 파일 IO 후 다른 워크로드가 느려짐

증상: 큰 파일 복사 후 데이터베이스가 느려짐.

진단: 큰 파일이 page cache를 다 채워서 데이터베이스 working set이 밀려났음.

해결:

  • 파일 복사 시 posix_fadvise(POSIX_FADV_DONTNEED)로 캐시 채우지 말기
  • nocache 명령어 (echo 1 > /proc/sys/vm/drop_caches로 일부 비우기는 위험)
  • cgroup으로 page cache 격리 (어렵다)

18. 다른 OS와의 비교

18.1 Windows 메모리 관리

Windows는 working set 모델을 쓴다. 각 프로세스가 "자기가 RAM에 들고 있을 페이지의 집합"을 가지고, 이 working set을 동적으로 조정한다. Linux의 LRU 모델과 비슷하지만 더 명시적이다.

페이지 폴트 처리, COW, demand paging 같은 핵심 개념은 동일하다. 다만 인터페이스 (VirtualAlloc 등)와 정책이 다르다.

18.2 macOS XNU + Mach VM

Mach 마이크로커널에서 가져온 VM 시스템. 모든 메모리 객체는 task의 address space에 매핑된다. 사용자 공간이 세밀하게 매핑/매핑 해제할 수 있는 풍부한 API. 그러나 일반 사용자에게는 거의 보이지 않는다.

18.3 FreeBSD UVM

FreeBSD의 메모리 관리는 매우 우아하고 잘 문서화되어 있다. Sun의 SVM 모델에서 영감을 받았다. Linux보다 단순한 코드 구조. 다만 NUMA 같은 모던 토픽은 Linux에 비해 덜 발달했다.

18.4 비교 표

기능LinuxWindowsmacOSFreeBSD
페이지 테이블4-5단계4-5단계4-5단계4-5단계
Demand paging있음있음있음있음
COW있음있음있음있음
Slab allocatorSLUBNPM Lookaside Listszone allocatorUMA
Huge pagesTHPLarge PagesSuper PagesSuper Pages
NUMAAutoNUMASoft NUMA약함약함
Cgroupsv2Job ObjectsApp SandboxRCTL/Jails
스왑있음있음있음있음
KSM있음있음약함없음

19. 미래 — 메모리 관리의 다음 도전

CXL은 PCIe 위의 새 인터커넥트로, "코어와 메모리"의 분리를 가능하게 한다. 메모리가 일종의 풀에서 동적으로 할당된다 — 한 서버에 부족하면 다른 서버에서 빌려옴.

Linux는 CXL을 지원하기 시작했다. CXL 메모리는 NUMA 노드의 한 종류로 노출된다 (먼 노드보다도 더 먼). 메모리 회수와 스왑이 CXL 인지가 되어야 한다.

19.2 메모리 계층화

DDR + CXL + persistent memory + GPU 메모리 — 이 네 가지가 한 시스템에 함께 있을 수 있다. 각각 latency, bandwidth, capacity가 다르다. 어떤 페이지를 어디에 둘지 결정하는 것이 차세대 메모리 관리의 핵심 문제.

DAMON(Data Access MONitor)이라는 새 서브시스템이 6.x에 들어갔다. 페이지의 접근 패턴을 모니터링하고 자동으로 마이그레이션 결정을 내린다.

19.3 사용자 공간 메모리 관리 — userfaultfd

userfaultfd 시스템 콜은 사용자 공간이 자기 페이지 폴트를 직접 처리할 수 있게 한다. 활용:

  • 사용자 공간 GC (객체 단위 마이그레이션)
  • 라이브 마이그레이션 (VM 마이그레이션 시 점진적 페이지 전송)
  • 분산 공유 메모리

QEMU의 라이브 마이그레이션, Java의 Shenandoah GC가 이를 사용한다.


20. 결론 — 메모리는 끝이 없다

이 글을 다 읽으면 아래 질문에 답할 수 있을 것이다:

  • 가상 메모리는 어떻게 동작하나? (페이지 테이블 4단계)
  • 페이지 폴트가 일어나면 무슨 일이 벌어지나? (handle_mm_fault → 분기)
  • COW는 어떻게 fork를 가볍게 만드나? (read-only로 공유, 쓰기 시 복사)
  • 커널은 작은 객체를 어떻게 할당하나? (SLUB)
  • 페이지 캐시가 왜 그렇게 큰가? (빈 메모리는 무료, 회수 가능)
  • 스왑은 어떻게 결정되나? (LRU active/inactive, swappiness)
  • THP가 왜 데이터베이스에서 꺼져 있나? (지연 변동성)
  • NUMA가 왜 중요한가? (원격 노드는 2배 느림)
  • cgroup memory.max를 어떻게 정해야 하나? (working set + 여유)
  • PSI는 무엇이 새로운가? ("멈춤"을 직접 측정)

그러나 이 글은 시작에 불과하다. 메모리 관리는 끝없이 깊다. DAMON, CXL, persistent memory, 사용자 공간 GC, userfaultfd, KASAN, KFENCE, SLAB poisoning, page poisoning... 한 권의 책으로도 부족하다.

이 글로 "Linux 내부 구조 시리즈"가 완성되었다. 부팅, 스케줄러, I/O, 메모리 — 이 네 가지 축이 모이면 Linux 커널의 기본 풍경이 그려진다. 다음 시리즈는 [네트워킹 스택 (TCP, BPF, XDP)] 또는 [파일시스템 (ext4, btrfs, ZFS, XFS)]를 다룰 예정이다.

이 글까지 읽었다면 이미 Linux를 충분히 깊이 이해한 사람이다. 이제 그 지식을 실전 워크로드에 적용해볼 차례이다.


부록 A — 참고 자료

부록 B — 자주 묻는 질문

Q: MemFree와 MemAvailable의 차이는? A: MemFree는 정말 비어있는 메모리. MemAvailable은 비어있는 메모리 + 회수 가능한 캐시. 후자가 "사용 가능"한 진짜 양.

Q: 스왑을 끄는 게 좋은가? A: 일반적으로 아니다. 작게 (RAM의 1/4 정도) 두는 것이 더 안전하다. anonymous 페이지의 콜드 백업이 가능해서 page cache에 더 많은 공간을 줄 수 있다.

Q: THP는 켜야 하나? A: 워크로드에 따라 다르다. 데이터베이스/지연 민감 워크로드에는 끄거나 madvise만 권장. CPU 캐시 친화적인 메모리 집약 워크로드에는 켜는 것이 좋을 수 있다.

Q: kswapd가 자주 도는데 정상인가? A: 메모리 압박 신호. MemAvailable 확인. PSI도 확인. 정말 부족하면 메모리 추가, 아니면 swappiness 조정 또는 cgroup 한도 검토.

Q: cgroup OOM과 시스템 OOM의 차이는? A: cgroup OOM은 그 cgroup 안에서만 일어남. 시스템 전체에 영향 없음. 시스템 OOM은 모든 프로세스가 후보. 컨테이너에서는 항상 cgroup OOM이 먼저 일어난다.

Q: NUMA를 안 신경 써도 되나? A: 단일 소켓 시스템이면 신경 쓸 필요 없음. 멀티 소켓이고 메모리 집약 워크로드면 반드시 신경 써야 함. numactl로 측정 시작.

Q: page cache가 너무 큰데 줄여야 하나? A: 보통 아니다. Linux는 빈 메모리를 채우는 것뿐이고, 다른 할당이 들어오면 즉시 회수된다. drop_caches는 디버깅용이지 운영용이 아니다.

Q: madvise(DONTNEED)는 메모리를 돌려주나? A: anonymous 매핑에서는 yes (페이지가 즉시 해제됨). file 매핑에서는 page cache에서만 빠지고, 사용자 매핑은 유지됨 — 다음 접근 시 다시 page cache에서 복구. 진짜 메모리를 돌려주려면 munmap.


부록 C — 미니 용어집

  • VMA: Virtual Memory Area. mm_struct 안의 한 연속 가상 주소 영역.
  • PTE: Page Table Entry. 가상→물리 매핑 한 개.
  • TLB: Translation Lookaside Buffer. 페이지 테이블 워크 결과의 캐시.
  • PCID: Process Context ID. TLB 엔트리에 컨텍스트 태그를 다는 기능.
  • Buddy: 같은 차수의 인접 페이지 쌍.
  • SLUB: Linux의 슬랩 할당자.
  • kmalloc/vmalloc: 작은/큰 할당 함수.
  • Page cache: 파일 데이터의 RAM 캐시.
  • Dirty page: 메모리에서 변경되었지만 아직 디스크에 안 쓰인 페이지.
  • Writeback: dirty 페이지를 디스크에 쓰는 백그라운드 작업.
  • fsync: 파일의 모든 dirty 페이지를 강제로 디스크에 동기화.
  • Swap: 사용 빈도 낮은 페이지를 디스크로 옮기는 메커니즘.
  • LRU: Least Recently Used. 회수 후보 결정 알고리즘.
  • THP: Transparent Huge Pages. 자동 큰 페이지.
  • NUMA: Non-Uniform Memory Access. 메모리가 CPU에 비대칭적으로 붙은 토폴로지.
  • mempolicy: 메모리 할당 정책.
  • PSI: Pressure Stall Information. 리소스 압박 측정.
  • OOM killer: 메모리 부족 시 프로세스를 죽이는 메커니즘.
  • KSM: Kernel Samepage Merging. 같은 내용의 페이지 합치기.
  • CMA: Contiguous Memory Allocator. DMA용 큰 연속 영역.
  • userfaultfd: 사용자 공간 페이지 폴트 처리 인터페이스.
  • DAMON: Data Access MONitor. 페이지 접근 패턴 추적.
  • CXL: Compute Express Link. 차세대 메모리 인터커넥트.

이 글이 "Linux 내부 구조 시리즈"의 마지막이다. 시리즈 전체:

  1. Linux 부팅 과정 딥다이브 — BIOS/UEFI에서 systemd까지
  2. Linux 스케줄러 딥다이브 — O(1)에서 EEVDF까지
  3. io_uring 완벽 가이드 — Linux 비동기 I/O의 새 표준
  4. 이 글 — 가상 메모리, Buddy/SLUB, Page Cache, NUMA, cgroups

네 글을 모두 읽으면, Linux 커널이 사용자 프로세스를 위해 해주는 일의 거의 전부를 손에 잡을 수 있다. 부팅 → 스케줄링 → I/O → 메모리 — 이 네 축이 모인 풍경이 곧 Linux의 모습이다.

현재 단락 (1/596)

Linux 커널을 처음 들여다본 사람이 가장 압도되는 부분은 거의 항상 메모리 관리다. 스케줄러는 함수 몇 개로 핵심 아이디어를 잡을 수 있고, 파일시스템은 인터페이스가 잘 정의되...

작성 글자: 0원문 글자: 27,003작성 단락: 0/596