메모리 관리 (Memory Management)
Linux 커널의 물리 및 가상 메모리 관리 서브시스템을 심층 분석합니다. Buddy Allocator, Slab/SLUB, 페이지 테이블, NUMA 아키텍처 등을 다룹니다.
핵심 요약
- 물리 메모리는 Node → Zone → Page(4KB) 계층으로 관리됩니다.
- Buddy Allocator — 페이지 단위(2^n)로 물리 메모리를 할당/해제하며, 외부 단편화를 줄입니다.
- Slab/SLUB — 커널 오브젝트(task_struct, inode 등)를 캐시하여 빈번한 할당을 최적화합니다.
- 페이지 테이블 — 가상 주소를 물리 주소로 변환하는 다단계(PGD→PUD→PMD→PTE) 매핑 구조입니다.
- kmalloc / vmalloc — 커널 내 메모리 할당 함수. kmalloc은 물리 연속, vmalloc은 가상 연속입니다.
단계별 이해
- 물리 구조 파악 — RAM은 Node(NUMA 단위) → Zone(DMA, Normal, HighMem) → Page Frame(4KB)으로 조직됩니다.
cat /proc/buddyinfo로 현재 시스템의 Buddy 상태를 직접 확인해 보세요. - Buddy Allocator 이해 — 요청된 크기를 만족하는 가장 작은 2^n 블록을 찾아 할당하고, 해제 시 인접 블록과 병합(coalescing)합니다.
이 과정이 "buddy"라는 이름의 유래입니다 — 짝(buddy)끼리 합쳐지기 때문입니다.
- Slab 할당자 이해 — 자주 생성/소멸되는 커널 오브젝트를 위해 미리 할당해 둔 캐시(pool)입니다.
cat /proc/slabinfo로 현재 활성 Slab 캐시를 확인할 수 있습니다. - 가상 메모리 매핑 — 각 프로세스는 독립적인 가상 주소 공간을 가지며, 페이지 테이블을 통해 물리 메모리에 매핑됩니다.
TLB(Translation Lookaside Buffer)가 이 변환을 하드웨어 수준에서 캐싱합니다.
- 시각적 개요 — 메모리 계층 구조 전체를 한눈에 파악
- 물리 메모리 구조 — 존(Zone), struct page 등 기본 개념
- Buddy/Slab Allocator — 실제 메모리 할당 메커니즘
- DDR 심화 (선택) — 하드웨어 수준 최적화가 필요한 경우만 참고
난이도 표시: "심화" 또는 "🔬" 아이콘이 있는 섹션은 고급 내용입니다. 일반적인 커널 개발에는 필수가 아니므로, 필요시에만 참고하세요.
시각적 개요: 메모리 계층 구조
- 가상 메모리 → 페이지 테이블 → 물리 메모리: 각 프로세스는 독립된 가상 주소 공간을 가지며, MMU가 페이지 테이블을 통해 물리 주소로 변환
- Node (NUMA): 멀티 소켓 시스템에서 각 CPU 소켓에 가까운 메모리 뱅크. 로컬 액세스가 빠름
- Zone: 하드웨어 제약에 따른 구분 (DMA: 24비트, NORMAL: 일반, MOVABLE: 메모리 압축/핫플러그 가능)
- Buddy Allocator: 2^n 페이지 블록 단위로 할당. 외부 단편화 최소화
- Slab/SLUB: 커널 오브젝트 캐시. 빈번한 할당/해제 최적화 (내부 단편화 감소)
물리 메모리 구조 (Physical Memory Organization)
Linux 커널은 물리 메모리를 노드(Node), 존(Zone), 페이지 프레임(Page Frame)의 3단계 계층으로 관리합니다. 이 구조는 NUMA와 UMA 시스템을 모두 지원하도록 설계되었습니다.
메모리 존 (Memory Zones)
각 NUMA 노드의 물리 메모리는 하드웨어 특성에 따라 존(Zone)으로 분류됩니다:
| Zone | 범위 (x86_64) | 용도 |
|---|---|---|
ZONE_DMA | 0 ~ 16MB | ISA DMA 전용 (레거시 디바이스) |
ZONE_DMA32 | 0 ~ 4GB | 32비트 주소 DMA 가능 디바이스 |
ZONE_NORMAL | 4GB ~ 끝 | 일반 커널 메모리 할당 |
ZONE_MOVABLE | 설정 가능 | 메모리 핫플러그, 마이그레이션 가능 |
32비트 시스템에서는 ZONE_HIGHMEM (896MB 이상)이 존재하지만, 64비트 시스템에서는 모든 물리 메모리가 직접 매핑되므로 ZONE_HIGHMEM이 필요 없습니다.
struct page
커널은 모든 물리 페이지 프레임을 struct page로 추적합니다. 이 구조체는 메모리 효율을 위해 union을 적극 활용합니다:
struct page {
unsigned long flags; /* Atomic flags (PG_locked, PG_dirty, ...) */
union {
struct {
union {
struct list_head lru; /* LRU list */
struct {
void *__filler;
unsigned int mlock_count;
};
};
struct address_space *mapping;
pgoff_t index;
unsigned long private;
};
struct { /* slab allocator */
unsigned long _slab_cache;
void *freelist;
};
struct { /* compound page (huge page) */
unsigned long compound_head;
};
};
atomic_t _refcount;
atomic_t _mapcount;
};
DDR 심화: 하드웨어 동작과 커널 관점
커널의 페이지 할당과 NUMA 정책은 결국 DDR DRAM의 물리적 특성 위에서 동작합니다. DDR은 단순히 "RAM 용량"이 아니라 채널 병렬성, 행 활성화(activate)/프리차지(precharge) 비용, 리프레시(refresh) 정지 구간 같은 제약을 가지며, 이 특성이 메모리 지연 시간과 처리량을 결정합니다.
DDR 물리 조직
| 계층 | 의미 | 커널/시스템 영향 |
|---|---|---|
| 채널 (Channel) | 독립 데이터 경로 (DDR5는 DIMM 내부 서브채널 분할) | 채널 수가 늘면 병렬 처리량 증가, 메모리 대역폭 상한 상승 |
| 랭크 (Rank) | 동시에 선택되는 DRAM 칩 묶음 | 랭크 인터리빙으로 버블 감소, 하지만 제어 복잡도 증가 |
| 뱅크 그룹/뱅크 | 동시 접근 단위를 세분화한 내부 배열 | 은행 충돌(bank conflict) 시 지연 증가, 주소 인터리빙 정책이 중요 |
| 행/열 (Row/Column) | DRAM 셀의 실제 저장 좌표 | row hit는 빠르고 row miss는 tRP + tRCD + tCL 비용 발생 |
현대 DDR 세대 (DDR4/5/6)
현대 서버와 워크스테이션에서 주로 사용되는 DDR4, DDR5, DDR6의 특성을 비교합니다. 세대가 올라갈수록 더 높은 전송률, 더 넓은 내부 병렬성, 더 복잡한 신호/전력 관리가 적용됩니다.
| 세대 | 주요 특징 | 성능/안정성 특성 | 커널 관점 포인트 |
|---|---|---|---|
| DDR4 | 뱅크 그룹 구조 강화, 고클럭/고용량화 | 대역폭 효율 개선, 서버 표준으로 장기 사용 | 원격 NUMA 접근과 reclaim 지연이 tail latency에 크게 반영 |
| DDR5 | DIMM 서브채널(32+32bit), BL16, PMIC, On-Die ECC | 대역폭 크게 증가, 지연 변동 관리 중요 | 처리량은 좋아도 reclaim/원격 접근/리프레시 영향으로 지연 꼬리가 커질 수 있음 |
| DDR6 | 차세대 고대역폭 중심 설계(신호 방식/채널 구조 고도화) | 표준/제품 구현이 초기 단계, 플랫폼별 편차 가능 | 드라이버/펌웨어/BIOS 성숙도와 RAS 검증이 성능만큼 중요 |
# 세대/속도 확인 (SMBIOS)
sudo dmidecode -t memory | grep -E "Type:|Speed:|Configured Memory Speed:"
# 메모리 병목 징후 확인
numastat -p $(pidof 주요프로세스)
cat /proc/pressure/memory
cat /proc/vmstat | grep -E "allocstall|pgscan|pgsteal"
HBM 심화: DDR과 다른 점
HBM (High Bandwidth Memory)은 CPU 소켓 바깥 DIMM에 꽂는 DDR과 달리, 패키지 인접(2.5D/3D 적층) 구조로 매우 넓은 데이터 경로를 확보해 대역폭을 높이는 메모리입니다. 핵심 목표는 절대 용량보다 대역폭 밀도와 전력당 처리량입니다.
| 항목 | DDR 계열 | HBM 계열 | 커널 관점 차이 |
|---|---|---|---|
| 물리 배치 | 메인보드 DIMM 슬롯 | 프로세서/가속기 패키지 근접 | 토폴로지가 고정적이며, NUMA 또는 디바이스 메모리로 노출 |
| 목표 | 용량 + 범용성 | 초고대역폭 + 에너지 효율 | 대규모 스트리밍/행렬 연산에서 유리 |
| 접근 주체 | CPU 중심 | GPU/가속기 중심(플랫폼별 CPU 직접성 상이) | HMM, ZONE_DEVICE, DMA 경로 설계가 중요 |
| 용량 특성 | 상대적으로 확장 용이 | 패키지 제약으로 용량 제한적 | 핫 데이터만 HBM에 배치하는 티어링 전략 필요 |
Linux 메모리 모델에서 HBM 위치
Linux에서는 플랫폼에 따라 HBM이 다음 두 방식으로 노출됩니다.
- NUMA 노드 메모리: CPU가 직접 주소화 가능한 HBM (노드별 지연/대역폭 차이 존재)
- 디바이스 메모리:
ZONE_DEVICE로 관리되는 가속기 로컬 메모리
특히 GPU/가속기 환경에서는 HMM(Heterogeneous Memory Management)과 migrate_vma 경로를 통해 페이지를 시스템 메모리(DDR)와 디바이스 메모리(HBM) 사이에서 이동시킵니다.
/* ZONE_DEVICE 등록 개념 예시 (요약) */
struct dev_pagemap pgmap = {
.type = MEMORY_DEVICE_PRIVATE, /* 디바이스 전용(HBM 등) */
.range = {
.start = hbm_phys_start,
.end = hbm_phys_end,
},
};
int ret = memremap_pages(&pgmap, NUMA_NO_NODE);
if (ret)
pr_err("HBM ZONE_DEVICE 등록 실패\\n");
운영/성능 점검 포인트
# NUMA 노드 형태로 HBM이 노출된 경우
numactl --hardware
numastat -p $(pidof 주요프로세스)
# 메모리 압박/이동 징후
cat /proc/pressure/memory
cat /proc/vmstat | grep -E "pgscan|pgsteal|pgmigrate|numa"
# 가속기 드라이버 통계(벤더별 경로 상이)
ls /sys/class/drm
ls /sys/kernel/debug # debugfs의 HMM/마이그레이션 통계 확인
DDR5 핵심 변화와 커널에 미치는 영향
DDR5는 단순 클럭 상승이 아니라 내부 병렬성과 전력 관리 방식을 함께 바꿨습니다. 커널 개발 관점에서는 NUMA 정책, 메모리 대역폭 활용, 오류 관측 방식에 직접적인 영향을 줍니다.
| 항목 | DDR4 | DDR5 | 커널/운영 영향 |
|---|---|---|---|
| DIMM 내부 채널 | 64-bit 1채널 | 32-bit + 32-bit 서브채널 | 짧은 버스트의 병렬 처리 효율 개선, 큐 지연 완화 |
| Burst Length | BL8 | BL16 (또는 BC8) | 순차 접근 처리량 유리, 작은 랜덤 접근은 지연 민감 |
| Bank 구조 | Bank Group 기반 | 뱅크/뱅크 그룹 병렬성 확대 | 주소 인터리빙 최적화 시 충돌 감소 |
| 전력 관리 | 메인보드 PMIC 중심 | DIMM PMIC 내장 | 고부하 시 발열/전력 안정성이 성능 유지에 중요 |
| 오류 보호 | 모듈 ECC(옵션) | On-Die ECC + 모듈 ECC(옵션) | On-Die ECC는 칩 내부 신뢰성용, 시스템 ECC 대체 아님 |
DDR5 대역폭과 지연 시간 해석
DDR5-5600, DDR5-6400 같은 표기는 전송률(MT/s) 중심입니다. 실제 애플리케이션 체감 성능은 절대 지연(ns)과 접근 패턴에 크게 좌우됩니다.
대략적인 읽기 지연 계산 (row hit 기준):
실제 클럭(MHz) = MT/s / 2
tCK(ns) = 1 / 실제 클럭(MHz)
CAS 지연(ns) ≈ tCL × tCK
예) DDR5-5600 CL46
실제 클럭 = 2800 MHz
tCK ≈ 0.357 ns
CAS 지연 ≈ 46 × 0.357 = 16.4 ns (근사치)
랜덤 접근 워크로드는 CAS 지연보다 tRCD, tRP, tRFC의 누적 영향이 커집니다. 따라서 커널 메모리 성능 분석 시에는 단일 수치가 아니라 지연 분포와 미스 패턴을 같이 봐야 합니다.
DDR5 시스템 관측 포인트
Linux에서 DDR5를 직접 제어하지는 않지만, 다음 정보를 조합하면 메모리 병목과 안정성 문제를 실무적으로 추적할 수 있습니다.
# 메모리 모듈 속도/세대/채널 정보 (SMBIOS 기반)
sudo dmidecode -t memory
# 커널이 인식한 NUMA 토폴로지와 노드 메모리
numactl --hardware
lscpu | grep -E "NUMA|Socket"
# 압박 지표: 메모리 stall 비율 확인
cat /proc/pressure/memory
# 페이지 회수/할당 실패 경향
vmstat 1
cat /proc/vmstat | grep -E "pgscan|pgsteal|allocstall"
# ECC 오류 추세 (서버 환경)
cat /sys/devices/system/edac/mc/mc*/ce_count
cat /sys/devices/system/edac/mc/mc*/ue_count
핵심 DDR 타이밍 파라미터 (심화)
메모리 주파수(예: DDR5-5600)만 보면 실제 지연 시간을 놓치기 쉽습니다. 성능 병목 분석 시에는 다음 타이밍 파라미터를 함께 확인해야 합니다:
| 타이밍 | 의미 | 지연 시간 영향 | 일반값 (DDR5) |
|---|---|---|---|
tCL (CAS Latency) | READ 명령 후 데이터 유효까지 대기 | row hit 경로의 핵심 지연 | 40-46 클럭 |
tRCD | ACTIVATE 후 READ/WRITE 가능까지 대기 | row miss 시 추가 비용 | 40-46 클럭 |
tRP | 기존 행 닫기(precharge) 시간 | row conflict에서 필수 비용 | 40-46 클럭 |
tRAS | 행 활성 유지 최소 시간 | 짧게 줄이면 안정성 저하 위험 | 52-76 클럭 |
tRFC | 리프레시 동작 시간 | 리프레시 중 접근 지연 급증 | 295-410 ns |
perf mem나 Intel VTune으로 row buffer hit율을 측정하세요.
# 메모리 타이밍 확인 (DMI decode)
sudo dmidecode -t memory | grep -E "Configured|Type:|Speed:"
# 메모리 접근 패턴 프로파일링 (perf mem)
perf mem record -a sleep 10
perf mem report --stdio
부팅 초기화와 커널이 받는 메모리 맵
Linux 커널은 DDR를 직접 훈련(training)하지 않습니다. 초기화는 펌웨어 단계에서 완료되고, 커널은 그 결과를 메모리 맵 형태로 전달받습니다.
- 펌웨어(BIOS/UEFI/부트로더)가 MRC(메모리 컨트롤러 초기화)와 훈련을 수행
- 가용/예약 영역을 E820 또는 UEFI memmap으로 정리
- 커널 초기화에서 전달된 맵을 기준으로 buddy/zone 초기화
# 펌웨어가 전달한 물리 메모리 맵 확인
dmesg | grep -E "e820|EFI memmap"
# 런타임에서 커널이 인식한 메모리 구간
cat /proc/iomem
# NUMA 노드별 메모리 분포
numactl --hardware
# 메모리 컨트롤러 ECC/오류 통계 (EDAC)
ls /sys/devices/system/edac/mc
cat /sys/devices/system/edac/mc/mc*/ce_count
cat /sys/devices/system/edac/mc/mc*/ue_count
ECC, EDAC, RAS 관점
서버 환경에서는 DDR 안정성 검증이 성능 못지않게 중요합니다. 커널은 EDAC 서브시스템으로 정정 가능 오류(CE)와 정정 불가 오류(UE)를 수집하며, 장기 추세를 통해 DIMM 열화나 채널 문제를 조기에 감지할 수 있습니다.
DDR 특성은 페이지 폴트 처리, reclaim 지연, NUMA 원격 접근 비용으로 직접 연결됩니다. 따라서 메모리 성능 이슈를 볼 때는 커널 통계(vmstat, PSI, perf)와 EDAC/펌웨어 로그를 함께 해석해야 원인 분리가 정확해집니다.
Buddy Allocator
Buddy Allocator는 물리 페이지 할당의 핵심 알고리즘입니다. 메모리를 2의 거듭제곱 크기의 블록으로 관리하며, 외부 단편화(external fragmentation)를 최소화합니다.
Buddy 알고리즘 원리
커널은 0차(4KB)부터 10차(4MB)까지 총 11개의 free list를 유지합니다. 페이지 할당 시 요청된 order의 free list에서 블록을 꺼내고, 없으면 상위 order를 분할합니다.
페이지 할당 API
/* Low-level page allocation */
struct page *alloc_pages(gfp_t gfp, unsigned int order);
void __free_pages(struct page *page, unsigned int order);
/* Get the virtual address from alloc_pages */
unsigned long __get_free_pages(gfp_t gfp, unsigned int order);
void free_pages(unsigned long addr, unsigned int order);
/* Single page shortcuts */
struct page *alloc_page(gfp_t gfp);
unsigned long __get_free_page(gfp_t gfp);
unsigned long get_zeroed_page(gfp_t gfp);
사용 예제: alloc_pages() 흐름
/* Order 2 (4 pages = 16KB) 할당 예제 */
static int example_buddy_alloc(void)
{
struct page *pages;
void *vaddr;
unsigned int order = 2; /* 2^2 = 4 페이지 */
/* 1. 페이지 할당 — GFP_KERNEL: 차단 가능, 재회수 가능 */
pages = alloc_pages(GFP_KERNEL, order);
if (!pages) {
pr_err("메모리 할당 실패 (order=%d)\\n", order);
return -ENOMEM;
}
/* 2. struct page → 가상 주소 변환 */
vaddr = page_address(pages);
if (!vaddr) {
pr_err("high memory page (kmap 필요)\\n");
__free_pages(pages, order);
return -EINVAL;
}
/* 3. 메모리 사용 */
pr_info("할당 성공: %d 페이지 @ %p (PFN=%lu)\\n",
1 << order, vaddr, page_to_pfn(pages));
memset(vaddr, 0xAA, PAGE_SIZE << order);
/* 4. 페이지 해제 — order 반드시 일치해야 함! */
__free_pages(pages, order);
pr_info("해제 완료: buddy에 반환됨\\n");
return 0;
}
/* Buddy 내부 흐름 (mm/page_alloc.c 간소화) */
struct page *__alloc_pages(gfp_t gfp, unsigned int order, ...)
{
struct zone *zone;
struct free_area *area;
/* 1. Zone 순회 (ZONE_NORMAL → ZONE_DMA32 → ZONE_DMA) */
for_each_zone(zone) {
/* 2. Watermark 검사: free pages > low watermark? */
if (!zone_watermark_ok(zone, order))
continue;
/* 3. 요청된 order의 free list 확인 */
area = &zone->free_area[order];
if (!list_empty(&area->free_list[MIGRATE_UNMOVABLE])) {
/* free list에 블록 있음 → 바로 반환 */
return rmqueue(zone, order);
}
/* 4. 없으면 상위 order 분할 (split) */
for (int current_order = order + 1;
current_order <= MAX_ORDER; current_order++) {
if (!list_empty(&zone->free_area[current_order].free_list)) {
/* 상위 블록 분할: order 3 → order 2 + order 2 */
expand(zone, page, order, current_order);
return page;
}
}
}
/* 5. 모든 zone 고갈 → 페이지 회수 트리거 */
return slowpath_alloc(gfp, order); /* kswapd wakeup */
}
/proc/buddyinfo를 통해 각 존의 buddy 상태를 확인할 수 있습니다. 각 열은 order 0부터 10까지의 free 블록 수를 나타냅니다.
Slab/SLUB Allocator
Buddy Allocator는 최소 한 페이지(4KB) 단위로 할당하므로, 작은 오브젝트(수 바이트~수 KB)에는 비효율적입니다. Slab Allocator는 이 문제를 해결하기 위해 오브젝트 풀링을 제공합니다. 동일 크기의 오브젝트를 미리 할당된 슬랩 페이지에 배치하여 내부 단편화를 줄이고, 할당/해제 속도를 극대화합니다.
현대 Linux 커널(6.x)은 기본적으로 SLUB allocator를 사용합니다. 기존 SLAB(복잡한 큐 기반)과 SLOB(임베디드용 최소 구현)은 커널 6.5에서 제거되었습니다.
| 항목 | SLAB (제거됨) | SLUB (현재 기본) | SLOB (제거됨) |
|---|---|---|---|
| 설계 철학 | 복잡한 per-CPU 큐 (array_cache) | 간결한 per-CPU slab + 인라인 메타데이터 | K&R 스타일 first-fit 할당 |
| 메타데이터 | 별도 관리 구조체 (off-slab) | 슬랩 페이지 내 인라인 (struct slab) | 블록 헤더 |
| 캐시 머징 | 미지원 | 동일 크기/정렬 캐시 자동 병합 | 해당 없음 |
| NUMA 지원 | 있음 | 있음 (노드별 partial list) | 없음 |
| 디버깅 | 제한적 | 풍부 (red zone, poisoning, tracking) | 없음 |
| 대상 환경 | 범용 | 범용 (모든 환경) | 임베디드 (메모리 < 64MB) |
SLUB 아키텍처
SLUB의 핵심 설계 원칙은 간결성입니다. 별도의 slab 관리 구조체 없이 페이지 디스크립터(struct slab, struct page와 union)에 메타데이터를 저장합니다. 3단계 계층 구조로 할당 성능을 최적화합니다:
핵심 자료구조
kmem_cache — 캐시 디스크립터
각 오브젝트 타입(또는 kmalloc 크기 클래스)마다 하나의 kmem_cache가 존재합니다. 캐시의 오브젝트 크기, 정렬, 슬랩 페이지 order, per-CPU/per-Node 관리 구조를 포함합니다.
/* include/linux/slub_def.h */
struct kmem_cache {
/* Per-CPU 데이터 (Level 1) */
struct kmem_cache_cpu __percpu *cpu_slab;
/* 오브젝트 크기 정보 */
unsigned int object_size; /* 사용자 요청 크기 */
unsigned int size; /* 실제 할당 크기 (정렬 + 메타데이터 포함) */
unsigned int offset; /* freelist 포인터 오프셋 (오브젝트 내 위치) */
/* 슬랩 페이지 레이아웃 */
unsigned int oo; /* 최적 order + objects 수 (인코딩) */
unsigned int min; /* 최소 order + objects 수 (fallback) */
unsigned int max; /* 최대 order */
unsigned int inuse; /* 오브젝트 내 사용 바이트 수 */
unsigned int align; /* 정렬 요구사항 */
/* 관리 제어 */
slab_flags_t flags; /* SLAB_HWCACHE_ALIGN, SLAB_POISON, ... */
unsigned int cpu_partial; /* CPU partial list 최대 오브젝트 수 */
unsigned int min_partial; /* Node partial list 최소 슬랩 수 */
gfp_t allocflags; /* Buddy 할당 시 GFP 플래그 */
int refcount; /* 참조 카운트 (머징 시 공유) */
/* 초기화 콜백 */
void (*ctor)(void *); /* 오브젝트 생성자 (선택적) */
/* Per-Node 데이터 (Level 3) */
struct kmem_cache_node *node[MAX_NUMNODES];
/* 식별 */
const char *name; /* 캐시 이름 (/proc/slabinfo에 표시) */
struct list_head list; /* slab_caches 전역 리스트 연결 */
/* 보안 하드닝 */
unsigned long random; /* freelist 포인터 XOR 난수 */
/* 사용자 추적 (CONFIG_SLUB_DEBUG) */
unsigned int useroffset; /* usercopy 허용 영역 시작 오프셋 */
unsigned int usersize; /* usercopy 허용 영역 크기 */
};
kmem_cache_cpu — Per-CPU 슬랩 관리
/* include/linux/slub_def.h */
struct kmem_cache_cpu {
union {
struct {
void **freelist; /* 다음 할당 가능 오브젝트 포인터 */
unsigned long tid; /* 트랜잭션 ID (cmpxchg_double용) */
};
freelist_aba_t freelist_tid;
};
struct slab *slab; /* 현재 활성 슬랩 페이지 */
#ifdef CONFIG_SLUB_CPU_PARTIAL
struct slab *partial; /* CPU 로컬 partial 리스트 (Level 2) */
#endif
};
tid (Transaction ID)는 Per-CPU slab의 락프리 할당을 구현하는 핵심입니다. this_cpu_cmpxchg_double()로 freelist와 tid를 원자적으로 교체하여, 다른 CPU의 간섭 없이 할당/해제를 수행합니다. tid가 불일치하면 재시도합니다.
struct slab — 슬랩 페이지 메타데이터
/* mm/slab.h — struct page와 union으로 동일 메모리 공유 */
struct slab {
unsigned long __page_flags;
struct kmem_cache *slab_cache; /* 소속 캐시 */
union {
struct {
union {
struct list_head slab_list; /* node partial 리스트 연결 */
struct {
struct slab *next; /* CPU partial 리스트 연결 */
int slabs; /* 남은 슬랩 수 (리스트 내) */
};
};
void *freelist; /* 첫 번째 free 오브젝트 */
union {
unsigned long counters;
struct {
unsigned inuse:16; /* 사용 중인 오브젝트 수 */
unsigned objects:15; /* 총 오브젝트 수 */
unsigned frozen:1; /* Per-CPU 활성 슬랩 여부 */
};
};
};
};
unsigned int __unused;
atomic_t __page_refcount;
};
frozen=1이면 이 슬랩이 특정 CPU의 활성 슬랩으로 사용 중이라는 뜻입니다. frozen 상태의 슬랩은 node partial list에서 제외되며, 해당 CPU만 접근할 수 있어 별도의 락이 필요 없습니다. 슬랩이 가득 차거나 비워지면 frozen이 해제되고 node partial list로 이동합니다.
kmem_cache_node — NUMA 노드별 관리
/* mm/slab.h */
struct kmem_cache_node {
spinlock_t list_lock; /* partial 리스트 보호 락 */
unsigned long nr_partial; /* partial 슬랩 수 */
struct list_head partial; /* partial 슬랩 이중 연결 리스트 */
#ifdef CONFIG_SLUB_DEBUG
atomic_long_t nr_slabs; /* 총 슬랩 수 */
atomic_long_t total_objects; /* 총 오브젝트 수 */
struct list_head full; /* full 슬랩 리스트 (디버그 전용) */
#endif
};
오브젝트 메모리 레이아웃
SLUB에서 각 오브젝트는 다음과 같은 레이아웃을 가집니다. 디버깅 플래그에 따라 추가 영역이 삽입됩니다.
/* mm/slub.c — freelist 포인터 위치 결정 */
/*
* offset: 오브젝트 내 freelist 포인터 저장 위치
* - 기본값: object 시작 (offset = 0)
* - 생성자(ctor)가 있으면 object_size 바로 뒤
* - SLUB은 free 오브젝트의 freelist 포인터를
* user data 영역 내에 저장 (별도 메모리 불필요)
*/
static inline void *freelist_ptr(
const struct kmem_cache *s,
void *ptr, unsigned long ptr_addr)
{
#ifdef CONFIG_SLAB_FREELIST_HARDENED
/* 보안 하드닝: FP를 XOR로 난독화 */
return (void *)((unsigned long)ptr ^
s->random ^
swab(ptr_addr));
#else
return ptr;
#endif
}
할당 경로 (Allocation Path)
SLUB 할당은 3단계 fallback 구조로 설계되어, 대부분의 할당이 락 없이 빠르게 완료됩니다.
/* mm/slub.c — 할당 Fastpath (인라인, ~10ns) */
static __always_inline void *slab_alloc_node(
struct kmem_cache *s, struct list_lru *lru,
gfp_t gfpflags, int node, unsigned long addr,
size_t orig_size)
{
void *object;
struct kmem_cache_cpu *c;
struct slab *slab;
unsigned long tid;
/* === Fastpath: Per-CPU freelist에서 바로 할당 === */
redo:
/* 현재 CPU의 freelist와 tid를 읽기 */
c = raw_cpu_ptr(s->cpu_slab);
tid = c->tid;
barrier();
object = c->freelist;
slab = c->slab;
/* freelist가 비어있거나 NUMA 노드 불일치 → slowpath */
if (unlikely(!object || !slab ||
!node_match(slab, node))) {
object = __slab_alloc(s, gfpflags, node, addr, c, orig_size);
} else {
/* freelist에서 첫 오브젝트 팝 (다음 free 오브젝트로 이동) */
void *next_object = get_freepointer_safe(s, object);
/* 원자적 CAS: freelist=next, tid 증가 */
if (unlikely(!this_cpu_cmpxchg_double(
s->cpu_slab->freelist, s->cpu_slab->tid,
object, tid,
next_object, next_tid(tid))))
goto redo; /* 경쟁 발생 시 재시도 */
prefetch_freepointer(s, next_object); /* L1 캐시 프리페치 */
}
return object;
}
/* mm/slub.c — Slowpath 요약 (__slab_alloc) */
static void *__slab_alloc(
struct kmem_cache *s, gfp_t gfpflags,
int node, unsigned long addr,
struct kmem_cache_cpu *c, size_t orig_size)
{
/* Step 1: 현재 슬랩의 slab→freelist 시도 */
/* → cpu_slab→freelist가 비었지만 slab 자체에 free obj 있을 수 있음 */
/* Step 2: CPU Partial List에서 슬랩 가져오기 (lockless) */
/* → c→partial에서 슬랩을 꺼내 c→slab으로 승격 */
/* Step 3: Node Partial List에서 가져오기 (list_lock 필요) */
/* → 노드의 partial 리스트에서 적합한 슬랩 검색 */
/* Step 4: Buddy Allocator에서 새 슬랩 페이지 할당 */
/* → allocate_slab() → alloc_pages() */
/* 각 단계에서 성공하면 즉시 오브젝트 반환 */
}
해제 경로 (Free Path)
/* mm/slub.c — 해제 Fastpath */
static __always_inline void do_slab_free(
struct kmem_cache *s, struct slab *slab,
void *head, void *tail, int cnt, unsigned long addr)
{
void *prior;
unsigned long tid;
struct kmem_cache_cpu *c;
redo:
c = raw_cpu_ptr(s->cpu_slab);
tid = c->tid;
barrier();
/* Fastpath: 해제하려는 오브젝트가 현재 CPU의 슬랩에 속하면 */
if (likely(slab == c->slab)) {
/* freelist 헤드에 오브젝트를 push (LIFO) */
set_freepointer(s, tail, c->freelist);
if (unlikely(!this_cpu_cmpxchg_double(
s->cpu_slab->freelist, s->cpu_slab->tid,
c->freelist, tid,
head, next_tid(tid))))
goto redo; /* 경쟁 시 재시도 */
} else {
/* Slowpath: 다른 CPU의 슬랩 → __slab_free() */
/* frozen 상태 확인, partial list 이동, 빈 슬랩 해제 등 */
__slab_free(s, slab, head, tail, cnt, addr);
}
}
/* Slowpath 해제 시 발생하는 상황들 */
/*
* 1. 해제 후 slab이 full→partial: node partial list에 추가
* 2. 해제 후 slab이 비어짐 (inuse=0):
* - node partial이 min_partial 이하면 partial list에 유지
* - min_partial 초과면 Buddy에 반환 (discard_slab)
* 3. 원격 CPU의 frozen slab: slab→freelist에 직접 push (cmpxchg)
*/
할당과 해제 모두 Fastpath에서 락을 사용하지 않습니다. this_cpu_cmpxchg_double()을 통한 원자적 연산만으로 freelist를 조작하므로, 일반적인 할당/해제는 약 10~20ns에 완료됩니다. 리눅스 커널의 전체 kmalloc 호출 중 약 90% 이상이 이 Fastpath로 처리됩니다.
슬랩 머징 (Slab Merging)
SLUB은 크기, 정렬, 플래그가 호환되는 캐시를 자동으로 병합하여 메모리 효율을 높입니다. 예를 들어, 동일 크기의 두 kmem_cache_create() 호출이 하나의 캐시를 공유할 수 있습니다.
/* mm/slab_common.c — 머징 가능 여부 판단 */
static struct kmem_cache *find_mergeable(
unsigned int size, unsigned int align,
slab_flags_t flags, const char *name,
void (*ctor)(void *))
{
struct kmem_cache *s;
/* 머징 불가 조건 */
if (flags & SLAB_NEVER_MERGE) return NULL;
if (ctor) return NULL; /* 생성자 있으면 불가 */
if (flags & SLAB_NO_MERGE) return NULL;
list_for_each_entry_reverse(s, &slab_caches, list) {
if (s->size - size >= sizeof(void *))
continue; /* 크기 차이 너무 큼 */
if (s->size < size)
continue; /* 너무 작음 */
if ((flags & SLUB_MERGE_SAME) != (s->flags & SLUB_MERGE_SAME))
continue; /* 플래그 불일치 */
if (s->align < align)
continue; /* 정렬 부족 */
s->refcount++; /* 참조 카운트 증가 */
return s; /* 머징 대상 발견 */
}
return NULL;
}
# 머징 상태 확인
cat /sys/kernel/slab/*/aliases | sort -rn | head
# 특정 캐시의 별칭(aliased) 캐시 확인
cat /sys/kernel/slab/kmalloc-192/aliases
# 머징 비활성화 (부트 파라미터)
slub_nomerge # 모든 캐시 머징 비활성화
# → /proc/slabinfo에 개별 캐시가 모두 표시됨
# → 디버깅 시 어떤 서브시스템이 메모리를 사용하는지 명확히 구분 가능
# 프로그래밍으로 머징 방지
# kmem_cache_create() 시 SLAB_NO_MERGE 또는 생성자(ctor) 지정
슬랩 플래그
| 플래그 | 설명 | 용도 |
|---|---|---|
SLAB_HWCACHE_ALIGN | 오브젝트를 하드웨어 캐시 라인에 정렬 | L1 캐시 효율 최적화 (빈번 접근 구조체) |
SLAB_POISON | 할당/해제 시 패턴 채움 (0x6b / 0xa5) | use-after-free, 미초기화 탐지 |
SLAB_RED_ZONE | 오브젝트 앞뒤에 red zone 삽입 (0xbb) | 버퍼 오버런/언더런 탐지 |
SLAB_ACCOUNT | cgroup 메모리 카운터에 계상 | 컨테이너 메모리 제한 적용 |
SLAB_RECLAIM_ACCOUNT | 회수 가능 슬랩으로 마크 | shrinker로 메모리 회수 가능 |
SLAB_PANIC | 캐시 생성 실패 시 커널 패닉 | 필수 캐시 (fork 실패 = 시스템 무용) |
SLAB_TYPESAFE_BY_RCU | RCU grace period 내 슬랩 페이지 유지 | RCU 보호 오브젝트 (슬랩 재사용 안전) |
SLAB_MEM_SPREAD | NUMA 노드 간 오브젝트 분산 | VFS inode/dentry 캐시 등 (6.2에서 제거) |
SLAB_NO_MERGE | 이 캐시를 다른 캐시와 병합 금지 | 디버깅, 보안 격리 |
SLAB_CACHE_DMA | DMA zone에서 페이지 할당 | ISA DMA 장치용 (레거시) |
SLAB_CACHE_DMA32 | DMA32 zone에서 페이지 할당 | 32비트 DMA 주소 필요 장치 |
보안 하드닝
SLUB은 공격자가 freelist를 조작하여 커널 코드 실행을 탈취하는 것을 방지하기 위한 여러 하드닝 메커니즘을 제공합니다.
| CONFIG 옵션 | 메커니즘 | 방어 대상 |
|---|---|---|
CONFIG_SLAB_FREELIST_RANDOM | 슬랩 초기화 시 freelist 순서를 랜덤으로 셔플 | 힙 스프레이 공격 (오브젝트 배치 예측 방지) |
CONFIG_SLAB_FREELIST_HARDENED | freelist 포인터를 XOR(random, swab(addr))로 난독화 | freelist 포인터 덮어쓰기 공격 |
CONFIG_INIT_ON_ALLOC_DEFAULT_ON | 할당 시 오브젝트를 0으로 초기화 | 정보 누수 (이전 데이터 잔류) |
CONFIG_INIT_ON_FREE_DEFAULT_ON | 해제 시 오브젝트를 0으로 초기화 | use-after-free 시 데이터 노출 |
CONFIG_RANDOM_KMALLOC_CACHES | kmalloc 캐시를 16개 랜덤 버킷으로 분리 (6.6+) | cross-cache 공격 (동일 캐시 슬랩 재사용 공격) |
CONFIG_HARDENED_USERCOPY | copy_to/from_user() 시 슬랩 경계 검증 | 슬랩 오버리드/정보 누수 |
CONFIG_KFENCE | 샘플링 기반 electric-fence 오류 탐지 (프로덕션용) | out-of-bounds, use-after-free (낮은 오버헤드) |
/* mm/slub.c — Freelist Randomization */
static void shuffle_freelist(
struct kmem_cache *s, struct slab *slab)
{
unsigned int count = slab->objects;
void **list; /* 임시 배열 */
unsigned int idx, i;
/* Fisher-Yates 셔플로 freelist 순서 랜덤화 */
for (i = count - 1; i > 0; i--) {
idx = get_random_u32_below(i + 1);
/* list[i] ↔ list[idx] 교환 */
swap(list[i], list[idx]);
}
/* 셔플된 순서로 freelist 포인터 재연결 */
for (i = 0; i < count - 1; i++)
set_freepointer(s, list[i], list[i + 1]);
set_freepointer(s, list[count - 1], NULL);
}
/* CONFIG_RANDOM_KMALLOC_CACHES (6.6+) — 랜덤 버킷 선택 */
/*
* kmalloc-256 → kmalloc-rnd-01-256, kmalloc-rnd-02-256, ..., kmalloc-rnd-15-256
* 할당 시 호출자 주소의 해시로 버킷 선택
* → cross-cache 공격이 16배 어려워짐
*/
Slab API
/* kmem_cache 생성/해제 */
struct kmem_cache *kmem_cache_create(
const char *name, /* /proc/slabinfo에 표시할 이름 */
unsigned int size, /* 오브젝트 크기 */
unsigned int align, /* 정렬 (0이면 자동) */
slab_flags_t flags, /* SLAB_HWCACHE_ALIGN 등 */
void (*ctor)(void *) /* 생성자 (NULL 가능) */
);
void kmem_cache_destroy(struct kmem_cache *s);
/* 지정 usersize로 생성 (usercopy hardening) */
struct kmem_cache *kmem_cache_create_usercopy(
const char *name, unsigned int size,
unsigned int align, slab_flags_t flags,
unsigned int useroffset, /* copy_to_user 허용 시작 오프셋 */
unsigned int usersize, /* copy_to_user 허용 크기 */
void (*ctor)(void *)
);
/* Object 할당/해제 */
void *kmem_cache_alloc(struct kmem_cache *s, gfp_t gfp);
void *kmem_cache_alloc_node(struct kmem_cache *s, gfp_t gfp, int node);
void *kmem_cache_zalloc(struct kmem_cache *s, gfp_t gfp); /* zero-init */
void kmem_cache_free(struct kmem_cache *s, void *obj);
/* 벌크 할당/해제 (배치 처리로 오버헤드 최소화) */
int kmem_cache_alloc_bulk(struct kmem_cache *s, gfp_t gfp,
size_t nr, void **p); /* nr개 일괄 할당 */
void kmem_cache_free_bulk(struct kmem_cache *s,
size_t nr, void **p); /* nr개 일괄 해제 */
/* 캐시 축소 (메모리 압박 시 빈 슬랩 반환) */
int kmem_cache_shrink(struct kmem_cache *s);
/* 범용 할당 (내부적으로 size에 맞는 kmem_cache 사용) */
void *kmalloc(size_t size, gfp_t gfp);
void *kzalloc(size_t size, gfp_t gfp); /* zero-initialized */
void kfree(const void *obj);
Slab Cache 사용 예제
struct my_data {
int id;
char name[64];
struct list_head list;
};
static struct kmem_cache *my_cache;
static int __init my_init(void)
{
struct my_data *obj;
my_cache = kmem_cache_create("my_data_cache",
sizeof(struct my_data), 0,
SLAB_HWCACHE_ALIGN, NULL);
if (!my_cache)
return -ENOMEM;
obj = kmem_cache_alloc(my_cache, GFP_KERNEL);
if (!obj) {
kmem_cache_destroy(my_cache);
return -ENOMEM;
}
obj->id = 42;
strscpy(obj->name, "hello", sizeof(obj->name));
/* ... use obj ... */
kmem_cache_free(my_cache, obj);
return 0;
}
static void __exit my_exit(void)
{
/* 모든 오브젝트가 해제된 후에만 호출 가능 */
kmem_cache_destroy(my_cache);
}
/* 벌크 할당 예제 (네트워크 패킷 버퍼 등) */
static void batch_alloc_example(void)
{
void *objects[32];
int allocated;
/* 32개를 한 번에 할당 (반복 kmem_cache_alloc보다 ~30% 빠름) */
allocated = kmem_cache_alloc_bulk(my_cache, GFP_KERNEL,
32, objects);
if (allocated < 32) {
/* 부분 성공 — allocated개만 할당됨 */
kmem_cache_free_bulk(my_cache, allocated, objects);
return;
}
/* ... use objects[0..31] ... */
/* 일괄 해제 */
kmem_cache_free_bulk(my_cache, 32, objects);
}
커널 주요 슬랩 캐시
| 캐시 이름 | 오브젝트 | 사용처 | 일반적 크기 |
|---|---|---|---|
task_struct | struct task_struct | 프로세스/스레드 디스크립터 | ~6~10KB |
mm_struct | struct mm_struct | 메모리 디스크립터 | ~1KB |
vm_area_struct | struct vm_area_struct | 가상 메모리 영역 | ~200B |
dentry | struct dentry | 디렉토리 엔트리 캐시 | ~192B |
inode_cache | struct inode | VFS inode 캐시 | ~600B |
filp | struct file | 파일 디스크립터 | ~256B |
signal_cache | struct signal_struct | 프로세스 시그널 정보 | ~1KB |
files_cache | struct files_struct | FD 테이블 | ~704B |
TCP | struct tcp_sock | TCP 소켓 | ~2KB |
sk_buff_head_cache | struct sk_buff | 네트워크 패킷 메타데이터 | ~256B |
radix_tree_node | struct radix_tree_node | 기수 트리 (페이지 캐시) | ~576B |
kmalloc-{8..8192} | 범용 | kmalloc()/kzalloc() | 8~8192B |
kmalloc-cg-* | cgroup 계상 범용 | cgroup 메모리 제한 대상 kmalloc | 8~8192B |
# 상위 슬랩 캐시 메모리 사용량 확인
slabtop -o -s c | head -20
# 특정 캐시 상세 정보
cat /sys/kernel/slab/dentry/object_size # 오브젝트 크기
cat /sys/kernel/slab/dentry/slab_size # 메타데이터 포함 크기
cat /sys/kernel/slab/dentry/objs_per_slab # 슬랩당 오브젝트 수
cat /sys/kernel/slab/dentry/order # 슬랩 페이지 order
cat /sys/kernel/slab/dentry/partial # partial 슬랩 수
cat /sys/kernel/slab/dentry/cpu_partial # CPU partial 최대값
cat /sys/kernel/slab/dentry/objects # 현재 오브젝트 수
# 캐시 통계 (CONFIG_SLUB_STATS 필요)
cat /sys/kernel/slab/dentry/alloc_fastpath
cat /sys/kernel/slab/dentry/alloc_slowpath
cat /sys/kernel/slab/dentry/free_fastpath
cat /sys/kernel/slab/dentry/free_slowpath
페이지 테이블 (Page Table)
x86_64에서 Linux는 5단계 페이지 테이블 구조를 사용합니다 (4단계가 기본, 5단계는 CONFIG_X86_5LEVEL 시 활성):
PTE 플래그
| 플래그 | 비트 | 설명 |
|---|---|---|
_PAGE_PRESENT | 0 | 페이지 존재 여부 |
_PAGE_RW | 1 | 읽기/쓰기 권한 (0=읽기전용) |
_PAGE_USER | 2 | 유저 모드 접근 가능 |
_PAGE_PWT | 3 | Page Write-Through |
_PAGE_PCD | 4 | Page Cache Disable |
_PAGE_ACCESSED | 5 | 최근 접근됨 |
_PAGE_DIRTY | 6 | 수정됨 (쓰기 발생) |
_PAGE_NX | 63 | No-Execute (실행 방지) |
GFP 플래그 (Get Free Pages)
메모리 할당 함수에 전달하는 GFP 플래그는 할당 동작을 결정합니다:
| 플래그 | 컨텍스트 | 설명 |
|---|---|---|
GFP_KERNEL | 프로세스 | 일반 커널 할당. 슬립 가능, I/O 가능, 파일시스템 재진입 가능 |
GFP_ATOMIC | 인터럽트/atomic | 슬립 불가. 비상 예약 풀 사용 가능 |
GFP_NOWAIT | 어디서든 | 슬립 불가, 비상 풀 미사용 |
GFP_NOIO | I/O 서브시스템 | 슬립 가능하나 I/O 불가 |
GFP_NOFS | 파일시스템 | 슬립 가능하나 파일시스템 호출 불가 |
GFP_DMA | DMA | ZONE_DMA에서 할당 |
GFP_DMA32 | DMA | ZONE_DMA32에서 할당 |
GFP_HIGHUSER | 유저 페이지 | 유저 공간 페이지 (HIGHMEM 선호) |
인터럽트 핸들러, spinlock 보유 중, softirq 컨텍스트에서는 반드시 GFP_ATOMIC을 사용해야 합니다. GFP_KERNEL을 사용하면 스케줄링이 발생하여 deadlock이나 BUG가 트리거됩니다.
vmalloc과 가상 메모리
vmalloc()은 가상적으로 연속된 메모리를 할당하지만, 물리적으로는 비연속일 수 있습니다. DMA에는 사용할 수 없으며, 페이지 테이블 조작이 필요하므로 kmalloc보다 느립니다.
메모리 할당 API 비교
| API | 물리 연속 | 최대 크기 | 용도 |
|---|---|---|---|
kmalloc() | 연속 | ~4MB (order 10) | 일반 커널 오브젝트, DMA 버퍼 |
vmalloc() | 비연속 가능 | 가상 주소 공간 한도 | 큰 버퍼, 모듈 로딩 |
alloc_pages() | 연속 | ~4MB | low-level 페이지 할당 |
kvmalloc() | 시도 후 fallback | 무제한 | kmalloc 시도 → 실패 시 vmalloc |
dma_alloc_coherent() | 연속 | 드라이버 의존 | DMA 버퍼 (coherent) |
NUMA 아키텍처 지원
NUMA(Non-Uniform Memory Access) 시스템에서는 CPU마다 로컬 메모리 노드가 있어 접근 지연 시간이 다릅니다. 커널은 pg_data_t (= struct pglist_data)로 각 NUMA 노드를 관리합니다.
/* NUMA-aware allocation */
void *kmalloc_node(size_t size, gfp_t gfp, int node);
struct page *alloc_pages_node(int nid, gfp_t gfp, unsigned int order);
/* Get current NUMA node */
int node = numa_node_id();
/* Memory policy */
set_mempolicy(MPOL_BIND, &nodemask, maxnode);
NUMA Distance Matrix
# NUMA distance 확인
cat /sys/devices/system/node/node0/distance
# 출력 예: node 0: 10 20 20 30
# numactl로 토폴로지 확인
numactl --hardware
# available: 4 nodes (0-3)
# node distances:
# node 0 1 2 3
# 0: 10 20 20 30
# 1: 20 10 30 20
OOM Killer
시스템의 물리 메모리와 스왑이 모두 고갈되어 페이지 할당이 불가능해지면, 커널의 OOM (Out-Of-Memory) Killer가 최후의 수단으로 프로세스를 선택적으로 종료하여 메모리를 확보합니다. OOM Killer의 핵심 로직은 mm/oom_kill.c에 구현되어 있으며, 단순한 kill 동작 이면에 오버커밋 정책, 점수 계산 알고리즘, cgroup 메모리 제한, 비동기 메모리 회수(OOM Reaper) 등 복잡한 메커니즘이 관여합니다.
OOM 발동 조건과 흐름
페이지 할당 요청이 실패하면 커널은 즉시 OOM Killer를 호출하지 않습니다. 먼저 여러 단계의 메모리 회수를 시도하고, 모든 수단이 실패한 후에야 OOM Killer가 발동합니다.
out_of_memory() 함수는 OOM Killer의 진입점으로, kill 대상을 선택하고 SIGKILL을 전송합니다.
/* mm/oom_kill.c — out_of_memory() 핵심 로직 (단순화) */
bool out_of_memory(struct oom_control *oc)
{
/* 1. sysctl vm.panic_on_oom이 설정되어 있으면 패닉 */
check_panic_on_oom(oc);
/* 2. 현재 프로세스가 SIGKILL 대기 중이면 빠른 종료 */
if (task_will_free_mem(current))
return true;
/* 3. vm.oom_kill_allocating_task가 설정되면 현재 태스크 kill */
if (sysctl_oom_kill_allocating_task &&
!oom_unkillable_task(current) && current->mm) {
get_task_struct(current);
oc->chosen = current;
oom_kill_process(oc, "Out of memory (oom_kill_allocating_task)");
return true;
}
/* 4. oom_badness()로 점수가 가장 높은 프로세스 선택 */
select_bad_process(oc);
if (!oc->chosen) /* kill 가능한 프로세스 없음 */
return false;
/* 5. 선택된 프로세스에 SIGKILL 전송 */
oom_kill_process(oc, "Out of memory");
return true;
}
__GFP_NOFAIL 플래그로 할당을 요청하면, 커널은 할당이 성공할 때까지 무한 재시도합니다. 이 경우에도 OOM Killer가 반복 발동될 수 있으므로, __GFP_NOFAIL은 반드시 필요한 경우에만 사용해야 합니다.
oom_badness() 점수 계산
OOM Killer는 oom_badness() 함수로 각 프로세스의 "나쁜 정도"를 점수화하여, 가장 높은 점수를 받은 프로세스를 kill 대상으로 선택합니다. 커널 2.6.36 이후 현재까지 사용되는 알고리즘은 매우 단순합니다.
/* mm/oom_kill.c — oom_badness() (단순화) */
long oom_badness(struct task_struct *p, unsigned long totalpages)
{
long points;
long adj;
/* oom_score_adj가 OOM_SCORE_ADJ_MIN(-1000)이면 kill 면제 */
adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN)
return LONG_MIN;
/* RSS + swap 사용량을 기반으로 점수 계산 */
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS);
/* oom_score_adj를 전체 메모리 대비 비율로 가산 */
adj *= totalpages / 1000;
points += adj;
return points;
}
점수 계산 원리: 기본 점수는 프로세스의 RSS(Resident Set Size) + 스왑 사용량(페이지 단위)이며, 여기에 oom_score_adj 값을 전체 메모리 대비 비율로 환산하여 가감합니다. 결과적으로 메모리를 가장 많이 사용하는 프로세스가 우선 kill 대상이 됩니다.
/proc/<pid>/oom_score와 /proc/<pid>/oom_score_adj는 별도의 인터페이스입니다.
| 인터페이스 | 범위 | 용도 |
|---|---|---|
/proc/<pid>/oom_score | 0 ~ (가변) | 읽기 전용. 커널이 계산한 현재 OOM 점수 (높을수록 kill 우선) |
/proc/<pid>/oom_score_adj | -1000 ~ +1000 | 읽기/쓰기. 사용자가 설정하는 점수 조정값 |
oom_score_adj 주요 값과 의미:
| 값 | 의미 | 사용 예 |
|---|---|---|
| -1000 | OOM kill 완전 면제 | init, 필수 시스템 데몬 |
| -900 | 매우 낮은 우선순위 (kill 가능성 최소) | DB 서버, 핵심 서비스 |
| 0 | 기본값 (조정 없음) | 일반 프로세스 |
| +500 | 높은 우선순위 (kill 가능성 증가) | 임시 배치 작업 |
| +1000 | 최우선 kill 대상 | 테스트/디버그 프로세스 |
# 프로세스의 OOM score 확인
cat /proc/<pid>/oom_score
# OOM score 조정 (-1000 ~ 1000, -1000이면 kill 면제)
echo -1000 > /proc/<pid>/oom_score_adj
# 시스템 전체 프로세스의 OOM score 순위 확인
for pid in /proc/[0-9]*; do
p=$(basename $pid)
score=$(cat $pid/oom_score 2>/dev/null) || continue
name=$(cat $pid/comm 2>/dev/null)
adj=$(cat $pid/oom_score_adj 2>/dev/null)
echo "$score $adj $p $name"
done | sort -rn | head -20
오버커밋과 OOM의 관계
리눅스는 기본적으로 실제 물리 메모리보다 더 많은 가상 메모리를 프로세스에 할당(오버커밋)할 수 있습니다. 이는 fork 시 CoW(Copy-on-Write)로 실제 메모리 사용이 지연되기 때문에 효율적이지만, 모든 프로세스가 동시에 할당된 메모리를 사용하면 OOM이 발생합니다.
vm.overcommit_memory sysctl로 오버커밋 정책을 제어합니다.
| 모드 | 값 | 동작 | OOM 가능성 |
|---|---|---|---|
| 휴리스틱 | 0 (기본) | 커널이 "합리적" 범위 내에서 오버커밋 허용. 명백히 과도한 요청만 거부 | 중간 |
| 항상 허용 | 1 | 모든 mmap()/brk() 요청을 무조건 성공시킴. commit 제한 없음 | 높음 |
| 금지 | 2 | CommitLimit 초과 시 할당 거부. 오버커밋 불가 | 낮음 (대신 할당 실패 발생) |
모드 2에서 CommitLimit 계산:
CommitLimit = (Physical RAM × overcommit_ratio / 100) + Swap 크기
/proc/meminfo의 Committed_AS가 현재 커밋된 총 가상 메모리이며, 이 값이 CommitLimit을 초과하면 새 할당이 거부됩니다.
/* mm/util.c — __vm_enough_memory() 핵심 로직 (단순화) */
int __vm_enough_memory(struct mm_struct *mm, long pages, int cap_sys_admin)
{
long allowed;
if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS) /* 모드 1 */
return 0; /* 항상 허용 */
if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) { /* 모드 0 */
/* 휴리스틱: free + page cache + reclaimable slab 기반 */
allowed = totalram_pages() - hugetlb_total_pages();
allowed -= sysctl_admin_reserve_kbytes >> (PAGE_SHIFT - 10);
if (pages <= allowed)
return 0;
}
/* 모드 2: CommitLimit = RAM × ratio/100 + Swap */
allowed = vm_commit_limit();
if (!cap_sys_admin)
allowed -= sysctl_admin_reserve_kbytes >> (PAGE_SHIFT - 10);
if (percpu_counter_read_positive(&vm_committed_as) + pages > allowed)
return -ENOMEM; /* 커밋 한도 초과: 할당 거부 */
return 0;
}
# 오버커밋 정책 설정
# 0: 휴리스틱 오버커밋 (기본값)
# 1: 항상 오버커밋 허용
# 2: 오버커밋 금지 (CommitLimit 기반)
sysctl -w vm.overcommit_memory=2
sysctl -w vm.overcommit_ratio=80
# CommitLimit과 현재 커밋량 확인
grep -E "CommitLimit|Committed_AS" /proc/meminfo
# CommitLimit: 12345678 kB
# Committed_AS: 8765432 kB
모드 2(OVERCOMMIT_NEVER)는 OOM 발생을 줄이지만, 대규모 fork()가 필요한 워크로드(예: 셸 스크립트에서 자식 프로세스 대량 생성)에서 할당 실패(ENOMEM)가 빈번해질 수 있습니다. 특히 스왑이 없는 시스템에서는 CommitLimit이 물리 RAM × ratio로 제한되므로, overcommit_ratio 값을 워크로드에 맞게 조정해야 합니다.
OOM Reaper
커널 4.6에서 도입된 OOM Reaper는 OOM kill 대상 프로세스의 메모리를 비동기적으로 회수하는 커널 스레드입니다. 기존에는 OOM kill 대상 프로세스가 exit_mmap()을 통해 자신의 메모리를 직접 해제해야 했는데, 프로세스가 uninterruptible sleep(TASK_UNINTERRUPTIBLE) 상태에 있거나, 락을 잡고 있으면 메모리 해제가 지연되어 연쇄 OOM이 발생하는 문제가 있었습니다.
/* mm/oom_kill.c — oom_reap_task_mm() 핵심 로직 (단순화) */
static bool oom_reap_task_mm(struct task_struct *tsk,
struct mm_struct *mm)
{
struct vm_area_struct *vma;
bool ret = true;
/* mm->mmap_lock을 trylock으로 획득 시도 (블로킹 방지) */
if (!mmap_read_trylock(mm)) {
trace_skip_task_reaping(tsk->pid);
return false;
}
/* VMA를 순회하며 anonymous 페이지를 unmap하여 회수 */
vma_iter_init(&vmi, mm, 0);
for_each_vma(vmi, vma) {
if (vma_is_anonymous(vma) || !(VM_SHARED & vma->vm_flags)) {
struct mmu_gather tlb;
tlb_gather_mmu(&tlb, mm);
unmap_page_range(&tlb, vma, vma->vm_start,
vma->vm_end, NULL);
tlb_finish_mmu(&tlb);
}
}
mmap_read_unlock(mm);
return ret;
}
OOM Reaper의 핵심 특징:
oom_reaper커널 스레드가 kill 대상의mm_struct에서 직접 anonymous 페이지를 unmapmmap_read_trylock()을 사용하여 락 경합 시 대기하지 않고 재시도- file-backed 페이지와 shared 매핑은 건너뛰고, private anonymous 매핑만 회수
- 프로세스가 실제로 종료되지 않아도 메모리를 즉시 확보할 수 있어 연쇄 OOM 방지
OOM Reaper가 mmap_lock 획득에 실패하면 일정 시간 후 재시도합니다. 최대 재시도 횟수를 초과하면 MMF_OOM_SKIP 플래그를 설정하여 해당 프로세스를 건너뛰고, 다른 OOM kill 대상을 선택하도록 합니다.
cgroup OOM (memcg)
컨테이너나 서비스별 메모리 제한을 사용할 때, cgroup 메모리 컨트롤러(memcg)가 별도의 OOM Killer를 트리거합니다. 전역 OOM과 달리 특정 cgroup 내부에서만 kill 대상을 선택합니다.
| 항목 | cgroup v1 | cgroup v2 |
|---|---|---|
| 메모리 하드 제한 | memory.limit_in_bytes | memory.max |
| 메모리 소프트 제한 (throttling) | memory.soft_limit_in_bytes | memory.high |
| 그룹 kill | 지원 안 함 | memory.oom.group = 1 |
| OOM 이벤트 모니터링 | memory.oom_control | memory.events |
cgroup v2의 3단계 메모리 압박 제어:
memory.high초과 → 커널이 해당 cgroup의 할당 속도를 throttling (OOM 없이 감속)memory.max초과 → memcg 내부 direct reclaim 시도- reclaim 실패 → memcg OOM Killer 발동 (cgroup 내 프로세스만 대상)
# cgroup v2에서 메모리 제한 설정
echo 512M > /sys/fs/cgroup/myservice/memory.max
echo 480M > /sys/fs/cgroup/myservice/memory.high
# OOM 발생 시 cgroup 내 전체 프로세스를 그룹 kill
echo 1 > /sys/fs/cgroup/myservice/memory.oom.group
# OOM 이벤트 모니터링 (cgroup v2)
cat /sys/fs/cgroup/myservice/memory.events
# low 0 ← memory.low 이하로 회수된 횟수
# high 42 ← memory.high 초과로 throttling된 횟수
# max 3 ← memory.max 초과로 reclaim 시도된 횟수
# oom 1 ← OOM 발생 횟수
# oom_kill 2 ← OOM으로 kill된 프로세스 수
# oom_group_kill 1 ← 그룹 kill 발생 횟수
OOM 예방 전략: memory.high을 memory.max보다 약간 낮게 설정하면(예: max의 90~95%), OOM 전에 throttling이 먼저 동작하여 프로세스가 느려지지만 kill되지 않습니다. 이를 통해 갑작스러운 메모리 스파이크를 흡수하고, 워크로드가 자체적으로 메모리를 줄일 기회를 줍니다.
OOM 모니터링 및 진단
OOM이 발생하면 커널은 dmesg에 상세한 정보를 기록합니다. 이 로그를 해석하면 OOM의 원인과 kill 대상을 파악할 수 있습니다.
# 커널 OOM 로그 확인
dmesg | grep -i oom
# 일반적인 OOM 로그 형식:
# [timestamp] myprocess invoked oom-killer: gfp_mask=0x..., order=0, oom_score_adj=0
# [timestamp] ... (메모리 상태 덤프) ...
# [timestamp] Out of memory: Killed process 1234 (myprocess) total-vm:1234kB,
# anon-rss:5678kB, file-rss:910kB, shmem-rss:0kB, UID:1000
# ↑ 핵심: kill된 프로세스와 메모리 사용량
dmesg OOM 로그 주요 필드:
invoked oom-killer: OOM을 트리거한 프로세스와gfp_mask(할당 플래그)order: 요청한 페이지 order (0=4KB, 1=8KB, ...)Mem-Info: 전체 메모리 상태 (free, active, inactive, slab 등)Killed process: 실제 kill된 프로세스 PID, 이름, 메모리 사용량
PSI (Pressure Stall Information)를 이용한 조기 경보:
커널 4.20+에서 /proc/pressure/memory를 통해 메모리 압박 수준을 실시간으로 모니터링할 수 있습니다. OOM이 발생하기 전에 조기 경보를 받아 선제적으로 대응할 수 있습니다.
# PSI 메모리 압박 현황 확인
cat /proc/pressure/memory
# some avg10=0.50 avg60=1.20 avg300=0.80 total=12345678
# full avg10=0.00 avg60=0.10 avg300=0.05 total=1234567
# some: 일부 태스크가 메모리 대기 중인 시간 비율 (%)
# full: 모든 태스크가 메모리 대기 중인 시간 비율 (%)
# OOM 로그 파싱: kill된 프로세스 목록 추출
dmesg | grep "Killed process" | \
awk '{print $9, $10, $11}'
# systemd 서비스에서 메모리 제한 설정 (cgroup v2 연동)
# /etc/systemd/system/myservice.service.d/memory.conf
# [Service]
# MemoryMax=512M
# MemoryHigh=480M
# OOM 발생 시 할당을 요청한 태스크를 직접 kill (기본값: 0)
sysctl -w vm.oom_kill_allocating_task=1
# OOM 발생 시 커널 패닉 유발 (기본값: 0)
sysctl -w vm.panic_on_oom=1
vm.panic_on_oom 주의사항: 이 옵션을 1로 설정하면 OOM 발생 시 시스템이 즉시 패닉되어 재부팅됩니다. kernel.panic sysctl과 함께 사용하여 자동 재부팅을 설정하는 경우가 많지만, OOM의 근본 원인(메모리 누수, 부적절한 cgroup 설정 등)을 해결하지 않으면 재부팅 루프에 빠질 수 있습니다. 프로덕션 환경에서는 PSI 기반 모니터링과 memory.high throttling을 먼저 활용하고, panic_on_oom은 최후의 안전장치로만 사용하세요.
실사용 보호 설정
데이터베이스 보호: 데이터베이스는 대용량 메모리를 사용하여 OOM 대상이 되기 쉽지만, 종료되면 데이터 무결성 위험과 재시작 비용이 큽니다. oom_score_adj = -900을 설정하여 최후 수단으로만 kill되도록 합니다.
# PostgreSQL 보호 (런타임)
echo -900 > /proc/$(pgrep -x postgres)/oom_score_adj
# MySQL — systemd override로 영구 적용
# /etc/systemd/system/mysql.service.d/override.conf
# [Service]
# OOMScoreAdjust=-900
# Oracle DB: SGA 크기 고정 (mlock으로 스왑 방지)
# /etc/security/limits.d/oracle.conf
# oracle soft memlock unlimited
# oracle hard memlock unlimited
컨테이너/Kubernetes 환경: 컨테이너 환경에서는 cgroup 메모리 제한을 통해 개별 컨테이너가 전체 노드 OOM을 유발하지 않도록 합니다.
# Docker: 메모리 제한 + 예약
docker run -m 2g --memory-reservation 1.5g --oom-score-adj -500 myapp
# Kubernetes: requests/limits로 QoS 클래스 결정
# Guaranteed (requests == limits): oom_score_adj = -997
# Burstable (requests < limits): oom_score_adj = 2~1000
# BestEffort (no limits): oom_score_adj = 1000
| Kubernetes QoS | 조건 | oom_score_adj | OOM 우선순위 |
|---|---|---|---|
| Guaranteed | requests == limits | -997 | 최하 (마지막에 kill) |
| Burstable | requests < limits | 2 ~ 1000 (동적) | 중간 |
| BestEffort | 제한 없음 | 1000 | 최우선 kill |
earlyoom — 사전 OOM 감지
커널 OOM Killer는 메모리가 완전히 고갈된 후에야 동작하여 시스템이 수십 초간 응답 불능(thrashing) 상태에 빠집니다. earlyoom은 사용자 공간에서 메모리를 주기적으로 감시하여, 커널 OOM이 발동되기 전에 선제적으로 프로세스를 종료하여 시스템 응답성을 유지합니다.
# 설치
apt install earlyoom # Debian/Ubuntu
dnf install earlyoom # Fedora/RHEL
# 실행: 메모리 10%, 스왑 5% 이하로 떨어지면 종료 시작
earlyoom -m 10 -s 5
# systemd 서비스로 영구 설정
systemctl enable --now earlyoom
# /etc/default/earlyoom 설정 예시
# EARLYOOM_ARGS="-m 5 -s 5 --prefer '^(java|python)' --avoid '^(postgres|mysql)'"
# 동작 로그 확인
journalctl -u earlyoom -f
# earlyoom: memory 9% below minimum, killing 'chrome' (pid 5678)
earlyoom vs 커널 OOM Killer: earlyoom은 oom_score를 기반으로 우선순위를 정하지만, --prefer/--avoid 옵션으로 특정 프로세스를 우선 종료하거나 보호할 수 있습니다. 또한 SIGTERM을 먼저 보내 정상 종료를 시도한 후 SIGKILL을 보내므로, 커널 OOM보다 데이터 안전성이 높습니다.
OOM 방지 모범 사례
- cgroup 메모리 제한 설정 — 서비스별
memory.max+memory.high로 격리.memory.high는memory.max의 90~95%로 설정하여 throttling이 먼저 동작하도록 합니다. - 스왑 확보 — zswap 또는 zram으로 최소 RAM의 50% 이상 스왑 공간 확보. 스왑이 없으면 OOM 발생 가능성이 크게 증가합니다.
- 중요 프로세스 보호 — 데이터베이스, 시스템 데몬은
oom_score_adj = -900또는OOMScoreAdjust=-900(systemd)으로 보호합니다. - PSI 모니터링 + 알림 —
/proc/pressure/memory의some avg60이 1%를 넘으면 경보를 설정합니다. OOM 전 수십 초 미리 경보가 발생합니다. - earlyoom 설치 — 사용자 공간 OOM 감지로 시스템 응답성 유지. 커널 OOM thrashing 방지에 효과적입니다.
- 메모리 누수 탐지 —
valgrind,perf mem, ASAN으로 메모리 누수 조기 발견. 누수가 없어도 workload에 맞는 메모리 용량을 확보합니다.
메모리 할당자 심화
커널 메모리 할당은 호출 컨텍스트(프로세스/인터럽트/소프트IRQ), GFP 플래그 선택, 메모리 단편화, NUMA 토폴로지, 메모리 압박 상황 등 복잡한 고려사항을 수반합니다. 할당 컨텍스트별 제약(GFP_KERNEL vs GFP_ATOMIC vs GFP_NOIO), kmalloc 내부 크기 클래스, SLUB 디버깅/튜닝, Per-CPU 할당자, mempool, CMA, compaction, 메모리 회수(kswapd/direct reclaim), 할당자 선택 전략 등을 이해해야 안정적인 커널 코드를 작성할 수 있습니다.
할당 컨텍스트별 GFP 제약사항, kmalloc 크기 클래스 상세, SLUB 디버깅, Per-CPU 할당자, mempool, CMA, compaction, 메모리 회수 메커니즘, 할당 주의사항, 할당자 선택 결정 트리 등은 메모리 관리 심화 — 메모리 할당자 심화에서 상세히 다룹니다.
메모리 관리 주요 버그 사례
리눅스 커널의 메모리 관리 서브시스템은 오랜 역사 속에서 다양한 버그와 취약점을 경험해왔습니다. 이러한 사례를 학습하면 커널 개발 시 동일한 실수를 피하고, 보다 견고한 코드를 작성할 수 있습니다.
Meltdown (CVE-2017-5754) 과 KPTI
2018년 1월 공개된 Meltdown 취약점은 현대 CPU의 투기적 실행(speculative execution) 메커니즘을 악용하여, 사용자 공간 프로세스가 커널 메모리를 읽을 수 있는 치명적인 하드웨어 수준의 결함이었습니다. CPU가 권한 검사 결과를 확정하기 전에 투기적으로 데이터를 캐시에 적재하는 동작을 이용하여, side-channel 공격으로 커널 메모리 내용을 추출할 수 있었습니다.
Meltdown의 핵심 위협: 사용자 공간 프로그램이 커널 페이지 테이블 항목(PTE)을 통해 커널 주소 공간의 데이터에 접근할 수 있었습니다. 이는 패스워드, 암호화 키, 기타 민감한 커널 데이터의 유출로 이어질 수 있는 심각한 취약점이었습니다. 특히 클라우드 환경에서 가상 머신 간 격리를 우회할 수 있어 영향 범위가 광범위했습니다.
커널은 KPTI (Kernel Page Table Isolation)를 도입하여 이 문제를 해결했습니다. KPTI는 사용자 모드와 커널 모드에서 서로 다른 페이지 테이블 세트를 사용하도록 분리합니다. 사용자 모드 페이지 테이블에는 커널 메모리 매핑이 최소한으로만 포함되어, 투기적 실행으로도 커널 데이터에 접근할 수 없게 됩니다.
/* arch/x86/mm/pti.c — KPTI 초기화 핵심 로직 */
void __init pti_init(void)
{
/* CPU가 Meltdown에 취약하지 않으면 KPTI 비활성화 */
if (!boot_cpu_has_bug(X86_BUG_CPU_MELTDOWN))
return;
pr_info("Kernel/User page tables isolation: enabled\\n");
/* 사용자 모드 페이지 테이블에서 커널 매핑 제거 */
pti_clone_user_shared();
/* 커널 진입/이탈 시 CR3 전환을 위한 trampoline 설정 */
pti_setup_espfix64();
pti_setup_vsyscall();
}
# KPTI 활성화 상태 확인
cat /sys/devices/system/cpu/vulnerabilities/meltdown
# 출력 예: Mitigation: PTI
# 부트 옵션으로 KPTI 비활성화 (테스트 환경에서만 사용)
# 커널 커맨드라인에 추가:
nopti
# 또는 KASLR과 함께 비활성화
nokaslr nopti
# 빌드 시 CONFIG 옵션
CONFIG_PAGE_TABLE_ISOLATION=y
성능 영향: KPTI 활성화 시 시스템 콜마다 CR3 레지스터를 전환하여 TLB flush가 발생하므로, 시스템 콜 빈도가 높은 워크로드에서 5~30%의 성능 저하가 관측됩니다. PCID (Process-Context Identifiers)를 지원하는 CPU에서는 TLB flush 비용이 크게 감소하여 성능 영향이 줄어듭니다. 최신 CPU(Intel Ice Lake 이후)는 하드웨어 수준에서 Meltdown이 수정되어 KPTI가 불필요합니다.
GFP_KERNEL in atomic context 버그 패턴
커널 메모리 할당에서 가장 빈번하게 발생하는 버그 패턴 중 하나는 atomic context에서 sleep 가능한 할당 함수를 호출하는 것입니다. 인터럽트 핸들러, 스핀락 보유 구간, RCU read-side critical section 등 sleep이 불가능한 컨텍스트에서 GFP_KERNEL 플래그로 메모리를 할당하면, 할당자가 메모리 회수를 위해 sleep을 시도하여 데드락 또는 스케줄러 오류가 발생합니다.
치명적 결과: atomic context에서 GFP_KERNEL을 사용하면 커널이 직접 메모리 회수(direct reclaim)를 시도하면서 schedule()을 호출합니다. 스핀락을 보유한 상태에서 스케줄링이 발생하면 다른 CPU가 같은 스핀락을 획득하려 할 때 데드락이 발생하며, 인터럽트 컨텍스트에서는 BUG: scheduling while atomic 커널 패닉이 발생합니다.
/* 잘못된 패턴: 스핀락 내에서 GFP_KERNEL 사용 */
spinlock_t my_lock;
void buggy_handler(void)
{
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
/* BUG: GFP_KERNEL은 sleep 가능 — 스핀락 내에서 사용 금지! */
char *buf = kmalloc(4096, GFP_KERNEL); /* DEADLOCK 위험! */
spin_unlock_irqrestore(&my_lock, flags);
}
/* 올바른 패턴: atomic context에서는 GFP_ATOMIC 사용 */
void correct_handler(void)
{
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
/* OK: GFP_ATOMIC은 sleep하지 않음 (할당 실패 가능성 있음) */
char *buf = kmalloc(4096, GFP_ATOMIC);
if (!buf) {
spin_unlock_irqrestore(&my_lock, flags);
return -ENOMEM;
}
spin_unlock_irqrestore(&my_lock, flags);
}
/* 더 나은 패턴: 스핀락 밖에서 미리 할당 */
void best_handler(void)
{
unsigned long flags;
/* 스핀락 진입 전에 GFP_KERNEL으로 할당 (sleep 가능) */
char *buf = kmalloc(4096, GFP_KERNEL);
if (!buf)
return -ENOMEM;
spin_lock_irqsave(&my_lock, flags);
/* 이미 할당된 버퍼 사용 */
do_work(buf);
spin_unlock_irqrestore(&my_lock, flags);
kfree(buf);
}
커널은 might_sleep() 매크로를 통해 이러한 버그를 런타임에 탐지할 수 있습니다. CONFIG_DEBUG_ATOMIC_SLEEP을 활성화하면, sleep이 불가능한 컨텍스트에서 sleep 가능 함수가 호출될 때 경고 메시지를 출력합니다.
/* include/linux/kernel.h — might_sleep 디버깅 매크로 */
#ifdef CONFIG_DEBUG_ATOMIC_SLEEP
#define might_sleep() do { \
___might_sleep(__FILE__, __LINE__, 0); \
might_sleep_check(); \
} while (0)
#else
#define might_sleep() do { } while (0)
#endif
/* kmalloc 내부에서 GFP_KERNEL 계열 플래그 시 might_sleep() 호출 */
static __always_inline void *kmalloc(size_t size, gfp_t flags)
{
if (!(flags & __GFP_DIRECT_RECLAIM))
goto alloc;
might_sleep(); /* atomic context에서 호출 시 경고 출력 */
alloc:
return __kmalloc(size, flags);
}
디버깅 팁: CONFIG_DEBUG_ATOMIC_SLEEP=y와 CONFIG_PROVE_LOCKING=y(lockdep)를 함께 활성화하면, sleep-in-atomic 패턴을 매우 효과적으로 탐지할 수 있습니다. lockdep은 락 의존성 그래프를 추적하여 잠재적 데드락도 사전에 경고합니다. 개발 및 테스트 빌드에서는 항상 이 옵션들을 활성화하는 것을 권장합니다.
OOM Killer 오동작 사례
OOM Killer의 알고리즘과 동작 방식은 커널 버전에 따라 크게 변해왔으며, 다양한 오동작 사례가 보고되었습니다. 현대 커널의 oom_badness() 점수 계산과 sysctl 튜닝, cgroup OOM 설정에 대한 상세 내용은 위 OOM Killer 섹션을 참조하세요.
oom_score 계산 알고리즘의 역사적 변천: 초기 리눅스(2.6.x)에서는 badness() 함수가 프로세스의 RSS, 자식 프로세스 메모리, nice 값, 실행 시간 등 복잡한 휴리스틱으로 점수를 계산했습니다. 이 방식은 예측이 어렵고 중요한 서비스가 예상치 못하게 종료되는 문제가 있었습니다. 커널 2.6.36에서 David Rientjes가 알고리즘을 대폭 단순화하여, RSS 기반 비례 점수 + oom_score_adj 조정으로 변경했습니다 (oom_badness() 점수 계산 참조).
cgroup v1 memcg OOM과 전역 OOM 충돌: cgroup v1에서 memory.limit_in_bytes를 설정한 경우, memcg OOM이 전체 시스템 OOM과 독립적으로 동작합니다. memcg 내 프로세스가 종료되어도 전역 메모리 압박이 해소되지 않을 수 있으며, memcg OOM 처리 중 전역 OOM이 동시에 트리거되면 이중 kill이 발생하여 예상치 못한 서비스 중단으로 이어지는 사례가 보고되었습니다. cgroup v2의 memory.oom.group 기능으로 이 문제가 개선되었습니다 (cgroup OOM 참조).
교훈: OOM Killer 관련 문제를 예방하려면 ① oom_score_adj로 중요 서비스를 보호하고, ② memory.high로 OOM 전에 throttling을 적용하며, ③ PSI(/proc/pressure/memory)로 메모리 압박을 사전에 모니터링하는 것이 권장됩니다. 상세 설정 방법은 OOM 모니터링 및 진단을 참조하세요.
Slab 메모리 누수 탐지
커널 모듈이나 드라이버에서 kmalloc()/kmem_cache_alloc()으로 할당한 slab 객체를 해제하지 않으면 slab 메모리 누수가 발생합니다. 커널은 사용자 공간과 달리 프로세스 종료 시 자동 정리가 되지 않으므로, 누수된 메모리는 시스템을 재부팅하기 전까지 영구적으로 소실됩니다.
# /proc/slabinfo로 slab 캐시별 메모리 사용 현황 확인
cat /proc/slabinfo | head -20
# name <active_objs> <num_objs> <objsize> <objperslab> ...
# kmalloc-4096 1205 1232 4096 8 ...
# task_struct 312 340 6720 4 ...
# slabtop으로 실시간 모니터링 (누수 시 특정 캐시의 active_objs가 지속 증가)
slabtop -s c # 캐시 크기 순 정렬
# kmemleak 활성화 (CONFIG_DEBUG_KMEMLEAK=y 필요)
# 부트 옵션: kmemleak=on
echo scan > /sys/kernel/debug/kmemleak # 수동 스캔 트리거
cat /sys/kernel/debug/kmemleak # 누수 의심 보고서 출력
echo clear > /sys/kernel/debug/kmemleak # 보고서 초기화
kmemleak은 커널의 메모리 누수 탐지기로, mark-and-sweep 가비지 컬렉터와 유사한 방식으로 동작합니다. 주기적으로 커널 메모리를 스캔하여 어디에서도 참조되지 않는 할당된 객체를 찾아냅니다.
/* 버그 패턴 1: 할당 후 해제 누락 */
void leaky_function(void)
{
struct my_data *data = kmalloc(sizeof(*data), GFP_KERNEL);
if (!data)
return;
process_data(data);
/* BUG: kfree(data) 누락 — data 포인터가 스택에서 사라지면 누수 */
}
/* 버그 패턴 2: 에러 경로에서 해제 누락 */
int init_device(void)
{
struct resource *res_a = kmalloc(sizeof(*res_a), GFP_KERNEL);
struct resource *res_b = kmalloc(sizeof(*res_b), GFP_KERNEL);
if (!res_a || !res_b)
return -ENOMEM; /* BUG: res_a가 할당 성공했어도 해제하지 않음! */
/* ... */
return 0;
}
/* 올바른 에러 처리 패턴 */
int init_device_fixed(void)
{
struct resource *res_a, *res_b;
res_a = kmalloc(sizeof(*res_a), GFP_KERNEL);
if (!res_a)
return -ENOMEM;
res_b = kmalloc(sizeof(*res_b), GFP_KERNEL);
if (!res_b)
goto err_free_a;
/* ... */
return 0;
err_free_a:
kfree(res_a);
return -ENOMEM;
}
이중 해제(double free) 위험: 이미 해제된 slab 객체를 다시 kfree()하면 slab allocator의 freelist가 손상되어 메모리 커럽션이 발생합니다. 이는 즉시 크래시하지 않고 나중에 전혀 관련 없는 코드에서 예측 불가능한 오류로 나타날 수 있어 디버깅이 매우 어렵습니다. CONFIG_SLUB_DEBUG=y와 부트 옵션 slub_debug=FZPU를 사용하면 free 후 poisoning과 red zone 검사를 통해 이중 해제를 조기에 탐지할 수 있습니다.
/* 이중 해제(double free) 버그 패턴 */
void double_free_bug(void)
{
struct buffer *buf = kmalloc(sizeof(*buf), GFP_KERNEL);
use_buffer(buf);
kfree(buf);
/* ... 다른 코드 ... */
kfree(buf); /* BUG: 이중 해제 — slab freelist 손상! */
}
/* 안전한 패턴: 해제 후 NULL로 초기화 */
void safe_free(void)
{
struct buffer *buf = kmalloc(sizeof(*buf), GFP_KERNEL);
use_buffer(buf);
kfree(buf);
buf = NULL; /* NULL 포인터에 kfree는 안전하게 무시됨 */
/* ... 다른 코드 ... */
kfree(buf); /* OK: kfree(NULL)은 no-op */
}
# SLUB 디버깅 부트 옵션 (이중 해제/오버플로/use-after-free 탐지)
# F: sanity checks, Z: red zoning, P: poisoning, U: user tracking
slub_debug=FZPU
# 특정 slab 캐시에만 디버깅 적용 (성능 영향 최소화)
slub_debug=FZPU,kmalloc-256,task_struct
# kmemleak 누수 보고서 예시 출력
# unreferenced object 0xffff888012345678 (size 128):
# comm "modprobe", pid 1234, jiffies 4294937200
# backtrace:
# kmalloc+0x4a/0x80
# my_driver_init+0x23/0x60 [my_module]
# do_one_initcall+0x56/0x2e0
slab 누수 방지를 위한 모범 사례: (1) 모든 에러 경로에서 할당된 자원을 해제하는 goto 기반 정리 패턴을 일관되게 사용합니다. (2) devm_kmalloc() 등 devres (device resource management) API를 활용하면 디바이스 해제 시 자동으로 메모리가 정리됩니다. (3) 개발 단계에서 CONFIG_DEBUG_KMEMLEAK=y와 slub_debug를 항상 활성화하여 누수를 조기에 발견합니다. (4) 모듈의 exit 함수에서 init에서 할당한 모든 자원을 해제하는지 꼼꼼히 확인합니다.
메모리 운영 플레이북
메모리 이슈는 단일 지표만으로 판단하면 오진하기 쉽습니다. 할당(buddy/slab), 회수(kswapd/shrinker), 매핑(page table) 세 축을 함께 확인해야 정확한 원인 분리가 가능합니다.
| 증상 | 우선 점검 | 권장 조치 |
|---|---|---|
| 간헐적 OOM | /proc/meminfo, dmesg | grep -i oom | 누수 경로 점검, reclaim 정책/limit 재조정 |
| 고차 페이지 할당 실패 | /proc/buddyinfo | 단편화 완화(compaction), hugepage 정책 점검 |
| slab 급증 | /proc/slabinfo, SReclaimable | 캐시별 shrinker 경로 점검 |
| 지속적 swap I/O | vmstat si/so, swappiness | working set 축소, zswap/zram 정책 재검토 |
# 메모리 문제 기본 수집 세트
cat /proc/meminfo > meminfo.log
cat /proc/buddyinfo > buddyinfo.log
cat /proc/slabinfo > slabinfo.log
vmstat 1 10 > vmstat.log
dmesg | grep -Ei "oom|page allocation failure|memory" > mem-dmesg.log
관련 문서
메모리 관리와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.