LRU Cache

리눅스 커널의 LRU(Least Recently Used) 캐시 메커니즘을 다룹니다. 페이지 캐시, inode/dentry 캐시의 효율적인 관리를 위한 list_lru API와 메모리 부족 시 자동으로 오래된 항목을 회수하는 Shrinker 인터페이스를 설명합니다.

전제 조건: Linked List메모리 관리 문서를 먼저 읽으세요. LRU는 연결 리스트 기반 자료구조이며, 메모리 회수 메커니즘과 밀접하게 연관되어 있습니다.
일상 비유: 이 주제는 책상 위 서류 정리와 비슷합니다. 자주 보는 서류는 손 닿는 곳에 두고, 오래 안 본 서류는 서랍에 넣듯이, 자주 접근하는 데이터는 메모리에, 오래된 데이터는 디스크에 보관합니다.

핵심 요약

  • LRU 목적 — 제한된 메모리에서 최대한 많은 데이터를 캐싱하면서, 오래된 데이터를 자동으로 제거
  • 주요 캐시 — 페이지 캐시(파일 데이터), inode 캐시(파일 메타데이터), dentry 캐시(경로 조회)
  • 핵심 APIlist_lru: NUMA 인식 LRU 리스트 관리, shrinker: 메모리 압박 시 자동 회수
  • 성능 효과 — 디스크 대비 100~1000배 빠른 메모리 접근으로 I/O 성능 극대화

단계별 이해

  1. 캐시 추가 — 파일을 읽으면 페이지가 Inactive LRU 리스트 끝에 추가됩니다
  2. 재사용 감지 — 같은 페이지를 다시 읽으면 Active LRU로 승격 (Hot 데이터 보호)
  3. 메모리 부족 — kswapd가 메모리 압박을 감지하면 Shrinker를 호출합니다
  4. 자동 회수 — Shrinker가 Inactive 리스트 앞쪽(오래된 항목)부터 회수합니다
  5. 캐시 축소 — 디스크에 기록 후 메모리 해제, 필요 시 다시 디스크에서 읽습니다

시각적 예제

초기 상태: [ ]

파일 A 읽기 → [A]          (Inactive 리스트에 추가)
파일 B 읽기 → [A, B]       (B도 Inactive에 추가)
파일 C 읽기 → [A, B, C]    (C도 Inactive에 추가)

파일 A 다시 읽기 → [B, C] (Inactive), [A] (Active로 승격)

메모리 부족 → [C] (Inactive), [A] (Active)
              (B 제거됨, Inactive 리스트 앞쪽이 가장 오래된 항목)

유용한 명령어

# 현재 캐시 상태 확인
free -h
# Mem: 16G total, 2G used, 1G free, 13G buff/cache

# 페이지 캐시 강제 해제 (테스트 용도, 주의!)
sudo sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches'

# Dentry/Inode 캐시 통계
cat /proc/sys/fs/dentry-state
cat /proc/sys/fs/inode-state

# 캐시 효과 비교
# 첫 번째 읽기 (디스크에서)
time cat /var/log/syslog > /dev/null

# 두 번째 읽기 (캐시에서, 훨씬 빠름)
time cat /var/log/syslog > /dev/null
흔한 실수
  • 캐시 = 낭비? 아닙니다! "사용 가능한 메모리"에는 캐시도 포함됩니다. 필요 시 즉시 해제되므로 걱정할 필요 없습니다.
  • 캐시 수동 해제: drop_caches는 벤치마크 용도로만 사용하세요. 운영 환경에서는 오히려 성능이 떨어집니다.
  • 메모리 부족 오판: free가 0에 가까워도 available이 충분하면 정상입니다.
다음 단계

LRU 개념

LRU(Least Recently Used)는 캐시 교체 알고리즘으로, 가장 오랫동안 사용되지 않은 항목을 우선적으로 제거합니다. 리눅스 커널은 제한된 메모리에서 최대한 많은 데이터를 캐싱하기 위해 LRU를 광범위하게 사용합니다.

커널의 주요 LRU 캐시
  • 페이지 캐시: 파일 I/O를 위한 디스크 블록 캐시
  • inode 캐시: 파일 메타데이터 캐시
  • dentry 캐시: 디렉토리 엔트리 경로 조회 캐시
  • Slab 캐시: 커널 객체 할당자

LRU 존 분류

커널 페이지 캐시는 LRU를 활성(Active)과 비활성(Inactive) 두 개의 리스트로 분리하여 관리합니다:

신규 페이지 (Tail에 추가) Active LRU List • 최근 두 번 이상 참조 • Hot 페이지 • 교체 우선순위 낮음 Inactive LRU List • 최근 한 번 참조 • Cold 페이지 • 교체 우선순위 높음 ← HEAD (오래됨) TAIL (최신) → ← HEAD (회수 대상) TAIL (최신) → 재참조 시 승격 크기 초과 시 강등 디스크 (Swap/File) 회수된 페이지 메모리 부족 시 회수
그림 1: LRU Active/Inactive 리스트 구조와 페이지 이동
Active/Inactive 분리의 이유

한 번만 읽히고 다시 참조되지 않는 "스캔 패턴"으로부터 자주 사용되는 페이지를 보호하기 위함입니다. 새 페이지는 Inactive 리스트에 추가되며, 재참조 시에만 Active로 승격됩니다.

list_lru API

list_lru는 커널 3.12에서 도입된 범용 LRU 리스트 관리 API로, inode 캐시와 dentry 캐시 등에서 사용됩니다. NUMA 인식 및 메모리 cgroup 지원을 제공합니다.

NUMA 최적화

list_lru는 NUMA 노드별로 별도의 리스트를 유지합니다. 각 노드의 메모리 회수 시 원격 노드 접근을 최소화하여 성능을 향상시킵니다. 멀티 소켓 서버에서 특히 효과적입니다.

핵심 구조체

// include/linux/list_lru.h
struct list_lru_node {
    spinlock_t      lock;
    struct list_lru_one    lru;
    long            nr_items;
} ____cacheline_aligned_in_smp;

struct list_lru {
    struct list_lru_node   *node;
    struct list_head       list;
};

초기화 및 해제

// LRU 리스트 초기화
int list_lru_init(struct list_lru *lru);
int list_lru_init_memcg(struct list_lru *lru, struct shrinker *shrinker);

// LRU 리스트 해제
void list_lru_destroy(struct list_lru *lru);

기본 연산

// 항목 추가 (리스트 끝에 추가)
bool list_lru_add(struct list_lru *lru, struct list_head *item);

// 항목 제거
bool list_lru_del(struct list_lru *lru, struct list_head *item);

// 항목 수 조회
unsigned long list_lru_count_node(struct list_lru *lru, int nid);
unsigned long list_lru_count_one(struct list_lru *lru, int nid,
                                struct mem_cgroup *memcg);

순회 및 회수

// 순회 콜백 반환값
enum lru_status {
    LRU_REMOVED,        // 항목 제거됨
    LRU_REMOVED_RETRY,  // 항목 제거됨, 다시 순회
    LRU_ROTATE,         // 항목을 리스트 끝으로 이동
    LRU_SKIP,           // 항목 유지, 다음으로
    LRU_RETRY,          // 락 재획득 필요
};

typedef enum lru_status (*list_lru_walk_cb)(
    struct list_head *item,
    struct list_lru_one *list,
    spinlock_t *lock,
    void *cb_arg
);

// LRU 리스트 순회
unsigned long list_lru_walk_node(
    struct list_lru *lru,
    int nid,
    list_lru_walk_cb isolate,
    void *cb_arg,
    unsigned long *nr_to_walk
);
락킹 주의사항

list_lru_walk_node 콜백 내에서 LRU_RETRY를 반환하면 spinlock이 해제됩니다. 콜백은 항목이 여전히 유효한지 재확인해야 합니다.

Shrinker 인터페이스

Shrinker는 메모리 부족 시 커널이 자동으로 캐시를 회수하도록 하는 콜백 메커니즘입니다. 각 서브시스템(VFS, slab 등)은 자신의 shrinker를 등록하여 메모리 압박 시 협력합니다.

Shrinker 구조체

// include/linux/shrinker.h
struct shrinker {
    unsigned long (*count_objects)(struct shrinker *,
                                     struct shrink_control *);
    unsigned long (*scan_objects)(struct shrinker *,
                                   struct shrink_control *);

    long batch;          // 한 번에 회수할 최대 항목 수
    int seeks;           // 회수 비용 (DEFAULT_SEEKS = 2)
    unsigned flags;

    struct list_head list;
    int id;
};

struct shrink_control {
    gfp_t gfp_mask;
    int nid;                    // NUMA 노드 ID
    unsigned long nr_to_scan;  // 스캔할 항목 수
    unsigned long nr_scanned;  // 실제 스캔된 항목 수
    struct mem_cgroup *memcg;
};
seeks 값의 의미

seeks 필드는 캐시 항목 회수 비용을 나타냅니다. DEFAULT_SEEKS = 2는 "디스크 seek 2번만큼의 비용"을 의미합니다. 값이 클수록 회수 우선순위가 낮아집니다. 예: 페이지 캐시는 seeks = 2, inode 캐시는 더 높은 값 사용.

Shrinker 등록

// Shrinker 등록
int register_shrinker(struct shrinker *shrinker, const char *fmt, ...);

// Shrinker 해제
void unregister_shrinker(struct shrinker *shrinker);

Shrinker 구현 예제

// fs/dcache.c - dentry 캐시 shrinker
static unsigned long dcache_lru_count(
    struct shrinker *shrink,
    struct shrink_control *sc)
{
    return list_lru_shrink_count(&dentry_cache, sc);
}

static unsigned long dcache_lru_scan(
    struct shrinker *shrink,
    struct shrink_control *sc)
{
    return list_lru_shrink_walk(&dentry_cache, sc,
                               dentry_lru_isolate, NULL);
}

static struct shrinker dcache_shrinker = {
    .count_objects = dcache_lru_count,
    .scan_objects = dcache_lru_scan,
    .seeks = DEFAULT_SEEKS,
};
메모리 회수 프로세스 kswapd 메모리 부족 감지 Shrinker 순회 count/scan 호출 LRU 회수 오래된 항목 제거 각 Shrinker 호출 Shrinkers • dcache_shrinker • icache_shrinker • sb_shrinker • slab_shrinker • ... LRU Lists • dentry_cache • inode_cache • buffer_heads • page_cache • ... 회수 결과 • 메모리 확보 • 캐시 크기 감소 • Free 페이지 증가 scan_objects 항목 해제
그림 2: Shrinker를 통한 메모리 회수 프로세스

커널 사용 사례

실무 활용

프로덕션 환경에서는 /proc/meminfo의 Cached와 Buffers 크기를 모니터링합니다. 갑자기 캐시가 급감하면 메모리 압박 상황이므로, 애플리케이션 메모리 사용량을 점검해야 합니다. vm.vfs_cache_pressure sysctl로 캐시 회수 정책을 조정할 수 있습니다.

Dentry 캐시

Dentry 캐시는 파일 경로 조회를 가속화하는 핵심 구조입니다. /usr/bin/ls 같은 경로를 매번 디스크에서 조회하지 않고 메모리에 캐싱합니다.

// fs/dcache.c
static struct list_lru dentry_cache;

void d_lru_add(struct dentry *dentry)
{
    if (list_lru_add(&dentry_cache, &dentry->d_lru))
        this_cpu_inc(nr_dentry_unused);
}

void d_lru_del(struct dentry *dentry)
{
    if (list_lru_del(&dentry_cache, &dentry->d_lru))
        this_cpu_dec(nr_dentry_unused);
}

Inode 캐시

Inode 캐시는 파일의 메타데이터(크기, 권한, 타임스탬프 등)를 메모리에 유지합니다.

// fs/inode.c
static struct list_lru inode_lru;

static enum lru_status inode_lru_isolate(
    struct list_head *item,
    struct list_lru_one *lru,
    spinlock_t *lru_lock,
    void *arg)
{
    struct inode *inode = container_of(item, struct inode, i_lru);

    if (atomic_read(&inode->i_count))
        return LRU_SKIP;  // 사용 중인 inode는 유지

    if (inode->i_state & (I_REFERENCED | I_DIRTY))
        return LRU_ROTATE;  // 최근 참조됨, 리스트 끝으로

    // inode 회수
    list_lru_isolate(lru, &inode->i_lru);
    return LRU_REMOVED;
}
캐시 용도 크기 확인 회수 조건
Dentry 경로 조회 가속 /proc/sys/fs/dentry-state 참조 카운트 0, 자식 없음
Inode 파일 메타데이터 /proc/sys/fs/inode-state 참조 카운트 0, clean 상태
Page Cache 파일 데이터 블록 /proc/meminfo Cached Inactive LRU, dirty 비트 클리어
Buffer Head 블록 디바이스 버퍼 /proc/meminfo Buffers 참조 카운트 0, 동기화 완료
캐시 통계 조회
# Dentry 캐시 상태
cat /proc/sys/fs/dentry-state
# nr_dentry nr_unused age_limit want_pages

# Inode 캐시 상태
cat /proc/sys/fs/inode-state
# nr_inodes nr_free_inodes preshrink

# 전체 메모리 캐시
grep -E '(Cached|Buffers|Slab)' /proc/meminfo

성능 특성

LRU 연산 성능

연산 시간 복잡도 설명
list_lru_add O(1) 리스트 끝에 추가
list_lru_del O(1) 리스트에서 제거
list_lru_walk_node O(n) n개 항목 순회
list_lru_count_node O(1) 캐시된 카운터 조회
성능 최적화 팁

LRU 리스트 순회는 O(n) 연산이므로 대량 회수 시 오버헤드가 발생할 수 있습니다. list_lru_walk_node 사용 시 nr_to_walk를 적절히 제한하여 한 번에 너무 많은 항목을 처리하지 않도록 합니다. 일반적으로 128~1024 범위가 적절합니다.

캐시 히트율 측정

# dentry 캐시 히트율 추적 (bpftrace 필요)
sudo bpftrace -e 'kprobe:d_lookup { @lookups++; }
                 kretprobe:d_lookup /retval != 0/ { @hits++; }
                 interval:s:5 {
                   printf("Hit rate: %.2f%%\n",
                          (@hits * 100.0) / @lookups);
                   clear(@lookups); clear(@hits);
                 }'

메모리 압박 시 동작

메모리 부족 시 커널은 다음 순서로 회수를 시도합니다:

  1. Inactive LRU 페이지 회수 (clean 페이지 우선)
  2. Active LRU를 Inactive로 이동
  3. Slab 캐시 회수 (dentry, inode 등)
  4. Dirty 페이지 디스크에 기록 후 회수
  5. Swap 활용 (익명 페이지)
OOM Killer

위 회수 단계를 모두 거쳤음에도 메모리가 부족하면 OOM(Out Of Memory) Killer가 메모리를 많이 사용하는 프로세스를 강제 종료합니다.

벤치마크 결과

시나리오 캐시 없음 LRU 캐시 사용 개선율
동일 파일 반복 읽기 15.2 MB/s (디스크) 2.8 GB/s (메모리) 184배
경로 조회 (stat) 18,000 ops/s 1,200,000 ops/s 67배
대용량 파일 순차 읽기 120 MB/s 115 MB/s 0.96배 (캐시 오버헤드)

측정 환경: Intel Xeon E5-2680 v4 (2.4GHz), 64GB RAM, NVMe SSD, Linux 6.1

캐시 효과 체감하기

동일한 대용량 파일을 두 번 읽어보세요. 첫 번째는 디스크에서, 두 번째는 캐시에서 읽어 극적인 속도 차이를 경험할 수 있습니다. 예: time cat large.log > /dev/null를 연속 실행하면 두 번째가 10~100배 빠릅니다.

Hands-on: 커스텀 LRU 캐시

list_lru를 사용하여 최근 조회된 정수를 캐싱하는 간단한 커널 모듈을 작성합니다.

학습 목표
  • list_lru_init로 LRU 리스트 초기화
  • list_lru_add로 항목 추가
  • list_lru_walk_node로 순회 및 회수
  • Shrinker 등록하여 메모리 압박 시 자동 회수

simple_lru.c

// simple_lru.c - LRU 캐시 예제
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/slab.h>
#include <linux/list_lru.h>
#include <linux/shrinker.h>
#include <linux/proc_fs.h>

#define MAX_CACHE_ITEMS 100

struct cache_item {
    int value;
    struct list_head lru;
};

static struct list_lru my_lru;
static struct shrinker *my_shrinker;
static struct kmem_cache *item_cache;

// Shrinker count 콜백
static unsigned long my_count_objects(
    struct shrinker *shrink,
    struct shrink_control *sc)
{
    return list_lru_count_node(&my_lru, sc->nid);
}

// LRU 순회 콜백
static enum lru_status item_lru_isolate(
    struct list_head *item,
    struct list_lru_one *list,
    spinlock_t *lock,
    void *arg)
{
    struct cache_item *ci = container_of(item, struct cache_item, lru);

    // 리스트에서 제거
    list_lru_isolate(list, item);

    // spinlock 해제 후 해제 (LRU_REMOVED_RETRY)
    spin_unlock(lock);
    kmem_cache_free(item_cache, ci);
    spin_lock(lock);

    return LRU_REMOVED_RETRY;
}

// Shrinker scan 콜백
static unsigned long my_scan_objects(
    struct shrinker *shrink,
    struct shrink_control *sc)
{
    return list_lru_shrink_walk(&my_lru, sc,
                               item_lru_isolate, NULL);
}

// 캐시에 값 추가
static void cache_add(int value)
{
    struct cache_item *ci;

    ci = kmem_cache_alloc(item_cache, GFP_KERNEL);
    if (!ci)
        return;

    ci->value = value;
    INIT_LIST_HEAD(&ci->lru);

    // LRU에 추가
    list_lru_add(&my_lru, &ci->lru);

    pr_info("Added %d to cache (count=%lu)\n",
            value, list_lru_count_node(&my_lru, 0));
}

// /proc/simple_lru 파일 읽기
static ssize_t simple_lru_read(
    struct file *file,
    char __user *buf,
    size_t count,
    loff_t *ppos)
{
    char kbuf[64];
    int len;

    if (*ppos > 0)
        return 0;

    len = snprintf(kbuf, sizeof(kbuf), "Cache items: %lu\n",
                    list_lru_count_node(&my_lru, 0));

    if (copy_to_user(buf, kbuf, len))
        return -EFAULT;

    *ppos = len;
    return len;
}

// /proc/simple_lru 파일 쓰기
static ssize_t simple_lru_write(
    struct file *file,
    const char __user *buf,
    size_t count,
    loff_t *ppos)
{
    char kbuf[32];
    int value;

    if (count >= sizeof(kbuf))
        return -EINVAL;

    if (copy_from_user(kbuf, buf, count))
        return -EFAULT;

    kbuf[count] = '\0';

    if (kstrtoint(kbuf, 10, &value))
        return -EINVAL;

    cache_add(value);

    return count;
}

static const struct proc_ops simple_lru_ops = {
    .proc_read = simple_lru_read,
    .proc_write = simple_lru_write,
};

static int __init simple_lru_init(void)
{
    struct shrinker s = {
        .count_objects = my_count_objects,
        .scan_objects = my_scan_objects,
        .seeks = DEFAULT_SEEKS,
    };
    int i;

    // Slab 캐시 생성
    item_cache = kmem_cache_create("simple_lru_cache",
                                     sizeof(struct cache_item),
                                     0, 0, NULL);
    if (!item_cache)
        return -ENOMEM;

    // LRU 리스트 초기화
    if (list_lru_init(&my_lru)) {
        kmem_cache_destroy(item_cache);
        return -ENOMEM;
    }

    // Shrinker 등록
    my_shrinker = shrinker_alloc(0, "simple_lru");
    if (!my_shrinker) {
        list_lru_destroy(&my_lru);
        kmem_cache_destroy(item_cache);
        return -ENOMEM;
    }

    my_shrinker->count_objects = my_count_objects;
    my_shrinker->scan_objects = my_scan_objects;
    my_shrinker->seeks = DEFAULT_SEEKS;
    shrinker_register(my_shrinker);

    // /proc 파일 생성
    proc_create("simple_lru", 0666, NULL, &simple_lru_ops);

    // 초기 데이터 추가
    for (i = 1; i <= 10; i++)
        cache_add(i * 100);

    pr_info("simple_lru module loaded\n");
    return 0;
}

static void __exit simple_lru_exit(void)
{
    remove_proc_entry("simple_lru", NULL);
    shrinker_free(my_shrinker);
    list_lru_destroy(&my_lru);
    kmem_cache_destroy(item_cache);

    pr_info("simple_lru module unloaded\n");
}

module_init(simple_lru_init);
module_exit(simple_lru_exit);

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Simple LRU cache example");
MODULE_AUTHOR("MINZKN");

Makefile

obj-m += simple_lru.o

KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean

테스트

# 빌드
make

# 모듈 로드
sudo insmod simple_lru.ko

# 캐시 항목 수 확인
cat /proc/simple_lru
# Cache items: 10

# 새 값 추가
echo "999" | sudo tee /proc/simple_lru
cat /proc/simple_lru
# Cache items: 11

# 메모리 압박 시뮬레이션 (실제로는 메모리 부족 시 자동 호출)
# Shrinker가 오래된 항목을 회수합니다

# 커널 로그 확인
dmesg | tail -20

# 모듈 언로드
sudo rmmod simple_lru
디버깅 주의사항

커널 모듈 디버깅 시 printk 레벨에 주의하세요. KERN_DEBUG는 기본적으로 콘솔에 출력되지 않으므로 dmesg로 확인해야 합니다. LRU 콜백 내에서는 GFP_ATOMIC만 사용 가능하며, 슬립 함수(mutex_lock, kmalloc(GFP_KERNEL))를 호출하면 커널 패닉이 발생합니다.

실험 아이디어
  • 항목마다 타임스탬프를 추가하여 실제 LRU 순서 확인
  • /proc/simple_lru 읽기 시 전체 항목 출력
  • 특정 값 검색 기능 추가 (LRU walk 사용)
  • 메모리 압박 시뮬레이션: stress-ng --vm 1 --vm-bytes 90%

LRU 운영 플레이북

LRU 기반 캐시는 "정책은 맞지만 락/회수 타이밍이 틀려서" 실패하는 경우가 많습니다. 삽입·접근·회수 세 경로를 분리해 검증하세요.

검사 항목핵심 질문점검 방법
접근 갱신hit 시 최근성 갱신이 되는가?trace/log로 LRU 이동 여부 확인
회수 순서오래된 항목부터 제거되는가?리스트 head/tail 방향 검증
동시성회수 중 use-after-free 위험이 없는가?락/참조카운트/RCU 조합 점검
# 캐시/회수 동작 관측
dmesg | grep -Ei "lru|shrink|reclaim" | tail -n 100
cat /proc/meminfo | grep -E "Cached|Slab|SReclaimable"

참고 자료

  • include/linux/list_lru.h - list_lru API 정의
  • mm/list_lru.c - list_lru 구현
  • include/linux/shrinker.h - Shrinker 인터페이스
  • mm/vmscan.c - 페이지 회수 메인 로직
  • fs/dcache.c - Dentry 캐시 LRU
  • fs/inode.c - Inode 캐시 LRU
  • Unevictable LRU Infrastructure
  • Per-superblock LRU lists - LWN 아티클
관련 페이지