LRU Cache

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

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

핵심 요약

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

단계별 이해

  1. 캐시 추가 — 파일을 읽으면 페이지(Page)가 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 리스트 구조와 페이지 이동(Page Migration)
Active/Inactive 분리의 이유

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

list_lru API

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

NUMA 최적화

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

핵심 구조체(Struct)

// 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 콜백(Callback) 내에서 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번만큼의 비용"을 의미합니다. 값이 클수록 회수 우선순위(Priority)가 낮아집니다. 예: 페이지 캐시는 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를 통한 메모리 회수 프로세스(Process)

커널 사용 사례

실무 활용

프로덕션 환경에서는 /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 참조 카운트(Reference Count) 0, 자식 없음
Inode 파일 메타데이터 /proc/sys/fs/inode-state 참조 카운트 0, clean 상태
Page Cache 파일 데이터 블록 /proc/meminfo Cached Inactive LRU, dirty 비트 클리어
Buffer Head 블록 디바이스 버퍼(Buffer) /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) 연산이므로 대량 회수 시 오버헤드(Overhead)가 발생할 수 있습니다. 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를 사용하여 최근 조회된 정수를 캐싱하는 간단한 커널 모듈(Kernel Module)을 작성합니다.

학습 목표
  • 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
디버깅(Debugging) 주의사항

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

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

lruvec: 페이지 LRU 벡터

커널 내부에서 페이지 프레임(Page Frame)의 LRU 관리는 lruvec 구조체를 통해 이루어집니다. 각 메모리 노드(NUMA node)와 메모리 cgroup마다 독립적인 lruvec가 존재하며, 5개의 LRU 리스트를 유지합니다.

lruvec 구조체

// include/linux/mmzone.h
enum lru_list {
    LRU_INACTIVE_ANON = LRU_BASE,
    LRU_ACTIVE_ANON   = LRU_BASE + LRU_ACTIVE,
    LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
    LRU_ACTIVE_FILE   = LRU_BASE + LRU_FILE + LRU_ACTIVE,
    LRU_UNEVICTABLE,
    NR_LRU_LISTS
};

struct lruvec {
    struct list_head        lists[NR_LRU_LISTS];
    /* per-lruvec lru_lock for memcg */
    spinlock_t              lru_lock;
    unsigned long           anon_cost;
    unsigned long           file_cost;
    /* Eviction counters for reclaim decisions */
    atomic_long_t           nonresident_age;
    unsigned long           refaults[ANON_AND_FILE];
    unsigned long           flags;
#ifdef CONFIG_LRU_GEN
    struct lru_gen_folio    lrugen;
    struct lru_gen_mm_state mm_state;
#endif
};
5개 LRU 리스트의 의미
  • INACTIVE_ANON -- 비활성 익명 페이지 (스왑 후보)
  • ACTIVE_ANON -- 활성 익명 페이지 (스택, 힙 등)
  • INACTIVE_FILE -- 비활성 파일 백업 페이지 (회수 1순위)
  • ACTIVE_FILE -- 활성 파일 백업 페이지 (자주 읽히는 파일)
  • UNEVICTABLE -- 회수 불가 페이지 (mlock, ramdisk 등)

Folio 기반 LRU

Linux 5.16부터 struct page 대신 struct folio를 사용하는 LRU 관리가 도입되었습니다. Folio는 단일 또는 복합(compound) 페이지를 추상화하며, LRU 리스트 조작 시 tail 페이지를 별도로 처리할 필요를 제거합니다.

// include/linux/mm_inline.h

/* folio를 적절한 LRU 리스트에 추가 */
static inline void lruvec_add_folio(
    struct lruvec *lruvec,
    struct folio *folio)
{
    enum lru_list lru = folio_lru_list(folio);

    update_lru_size(lruvec, lru, folio_zonenum(folio),
                    folio_nr_pages(folio));
    if (lru != LRU_UNEVICTABLE)
        list_add(&folio->lru, &lruvec->lists[lru]);
}

/* folio의 LRU 리스트 결정 */
static inline enum lru_list folio_lru_list(
    struct folio *folio)
{
    if (folio_test_unevictable(folio))
        return LRU_UNEVICTABLE;
    if (folio_test_active(folio)) {
        if (folio_is_file_lru(folio))
            return LRU_ACTIVE_FILE;
        return LRU_ACTIVE_ANON;
    }
    if (folio_is_file_lru(folio))
        return LRU_INACTIVE_FILE;
    return LRU_INACTIVE_ANON;
}
Folio 전환의 이점

기존 struct page 기반 LRU에서는 compound 페이지의 tail 페이지가 실수로 LRU에 추가되는 버그가 빈번했습니다. Folio 추상화로 이러한 오류를 컴파일 타임에 방지할 수 있게 되었습니다. 또한, 대형 folio(huge page)를 하나의 LRU 항목으로 관리하여 리스트 길이가 줄어들고 순회 성능이 향상됩니다.

lruvec: 5개 LRU 리스트 구조 struct lruvec ACTIVE_ANON 스택, 힙, mmap(MAP_ANONYMOUS) INACTIVE_ANON 오래된 익명 페이지 (스왑 후보) ACTIVE_FILE 자주 참조되는 파일 캐시 INACTIVE_FILE 오래된 파일 캐시 (회수 1순위) UNEVICTABLE mlock(), ramdisk, SHM_LOCK -- 절대 회수 불가 강등 / 승격 강등 / 승격 Swap 영역으로 회수 INACTIVE_ANON → swap 페이지 캐시 해제 INACTIVE_FILE → free (clean) / writeback (dirty) vm.swappiness: Anon vs File 회수 비율 조절 (기본값 60, 범위 0~200)
그림 3: lruvec의 5개 LRU 리스트와 회수 경로

페이지 플래그와 LRU 상태

각 페이지(folio)의 LRU 상태는 페이지 플래그 비트로 추적됩니다. 주요 플래그와 상태 전이를 이해하면 페이지 회수 동작을 정확히 파악할 수 있습니다.

플래그 비트 의미 설정 시점
PG_lru PG_lru LRU 리스트에 포함됨 folio_add_lru() 호출 시
PG_active PG_active Active 리스트에 위치 재참조 감지 시 (folio_activate())
PG_referenced PG_referenced 최근 참조됨 (second chance) PTE accessed 비트 감지, folio_mark_accessed()
PG_unevictable PG_unevictable 회수 불가 (mlock 등) mlock(), SHM_LOCK
PG_swapbacked PG_swapbacked 익명 페이지 (swap 대상) 익명 매핑(Mapping) 생성 시
PG_workingset PG_workingset 워킹셋 감지 (refault 기반) refault 발생 시 활성화
// mm/swap.c -- folio_mark_accessed() 핵심 로직
void folio_mark_accessed(struct folio *folio)
{
    if (!folio_test_referenced(folio)) {
        /* 첫 번째 참조: referenced 플래그만 설정 */
        folio_set_referenced(folio);
    } else if (!folio_test_active(folio)) {
        /* 두 번째 참조: Inactive → Active 승격 */
        folio_activate(folio);
        folio_clear_referenced(folio);
        workingset_activation(folio);
    }
    /* Active + Referenced: 이미 Hot, 아무것도 하지 않음 */
}
Second Chance 알고리즘

리눅스 커널은 순수 LRU가 아닌 "Second Chance" (Clock) 변형을 사용합니다. Inactive 리스트의 페이지를 회수하기 전에 PG_referenced 비트를 확인합니다. 비트가 설정되어 있으면 해당 페이지에게 한 번 더 기회를 주고(비트를 클리어하고 리스트 끝으로 이동), 비트가 클리어된 상태에서만 실제로 회수합니다.

Multi-Gen LRU (MGLRU)

Multi-Gen LRU는 Linux 6.1에서 도입된 차세대 페이지 회수 프레임워크입니다. 기존의 Active/Inactive 2-리스트 LRU를 여러 세대(generation)로 확장하여, 페이지 접근 패턴을 더 정밀하게 추적하고 회수 정확도를 크게 향상시킵니다.

MGLRU 도입 배경

기존 2-리스트 LRU는 워크로드가 복잡해질수록 한계를 보였습니다. 특히 대규모 메모리 시스템에서 Active/Inactive 리스트의 균형 조절이 어렵고, shrink_active_list()에서 대량의 PTE accessed 비트 스캔으로 CPU 오버헤드가 발생했습니다. MGLRU는 Google에서 개발하여 Android, ChromeOS, 서버 워크로드에서 최대 40% 성능 개선을 보고했습니다.

세대 기반 에이징

MGLRU는 페이지를 여러 세대(generation)로 분류합니다. 가장 최근 세대(youngest)의 페이지가 가장 보호되고, 가장 오래된 세대(oldest)의 페이지가 우선 회수됩니다.

MGLRU: 세대 기반 에이징 Gen 0 (oldest) 회수 대상 오래 미참조 eviction 시작 Gen 1 에이징 중 중간 단계 Gen 2 에이징 중 비교적 최근 Gen N (youngest) 보호됨 최근 참조 신규 페이지 진입 --- 에이징 방향 (youngest → oldest) --- 재참조 시 youngest 세대로 승격 기존 LRU (2-리스트) Active / Inactive 2개 리스트 PTE scan 기반 참조 감지 수동적 에이징 MGLRU (다세대) MAX_NR_GENS (기본 4) 세대 mm_walk 기반 효율적 스캔 능동적 에이징 + 피드백 루프
그림 4: MGLRU 세대 기반 에이징 vs 기존 2-리스트 LRU

MGLRU 핵심 구조체

// include/linux/mmzone.h
#define MIN_NR_GENS     2     /* 최소 세대 수 */
#define MAX_NR_GENS     4     /* 최대 세대 수 (기본값) */

struct lru_gen_folio {
    /* youngest 세대의 시퀀스 번호 */
    unsigned long           max_seq;
    /* oldest 세대의 시퀀스 번호 */
    unsigned long           min_seq[ANON_AND_FILE];
    /* 세대별 페이지 수 */
    unsigned long           nr_pages[MAX_NR_GENS][ANON_AND_FILE]
                                                   [MAX_NR_ZONES];
    /* 세대별 LRU 리스트 (folios) */
    struct list_head        folios[MAX_NR_GENS][ANON_AND_FILE]
                                                  [MAX_NR_ZONES];
    /* 에이징을 위한 블룸 필터 */
    unsigned long           *filters[NR_BLOOM_FILTERS];
    /* 타임스탬프 (에이징 속도 제어) */
    unsigned long           timestamps[MAX_NR_GENS];
    /* 활성화 여부 */
    bool                    enabled;
};

에이징 메커니즘

MGLRU의 에이징은 lru_gen_aging()에서 수행됩니다. 새로운 세대를 생성하고(max_seq 증가), 가장 오래된 세대를 회수 대상으로 만듭니다. 이 과정에서 페이지 테이블(Page Table) 워크를 통해 PTE accessed 비트를 효율적으로 수집합니다.

// mm/vmscan.c -- MGLRU 에이징 핵심 흐름 (간략화)
static void inc_max_seq(struct lruvec *lruvec,
                        unsigned long max_seq,
                        bool can_swap)
{
    int type, zone;
    int prev = lru_gen_from_seq(max_seq - 1);
    int next = lru_gen_from_seq(max_seq);

    /* oldest 세대가 비어있지 않으면 회수 먼저 진행 */
    VM_WARN_ON_ONCE(!seq_is_valid(lruvec));

    /* 각 타입(anon/file)과 존에 대해 세대 교체 */
    for (type = 0; type < ANON_AND_FILE; type++) {
        for (zone = 0; zone < MAX_NR_ZONES; zone++) {
            /* 현재 세대 리스트가 비어있어야 함 */
            if (!list_empty(
                &lruvec->lrugen.folios[next][type][zone]))
                INIT_LIST_HEAD(
                    &lruvec->lrugen.folios[next][type][zone]);
        }
    }

    /* 시퀀스 번호 증가 → 새로운 youngest 세대 생성 */
    WRITE_ONCE(lruvec->lrugen.max_seq, max_seq + 1);
}
MGLRU와 기존 LRU의 공존

MGLRU는 CONFIG_LRU_GEN 설정으로 컴파일 시 포함되며, 런타임에 /sys/kernel/mm/lru_gen/enabled로 활성화/비활성화할 수 있습니다. MGLRU가 비활성화되면 기존 Active/Inactive 2-리스트 LRU로 자동 폴백됩니다. 대부분의 현대 배포판(Ubuntu 22.10+, Fedora 38+, Arch 등)은 MGLRU를 기본 활성화합니다.

MGLRU sysfs 인터페이스

# MGLRU 활성화 상태 확인
cat /sys/kernel/mm/lru_gen/enabled
# 0x0007 (비트 마스크: core=1, mm_walk=2, nonleaf_young=4)

# MGLRU 세대 정보 확인
cat /sys/kernel/debug/lru_gen
# memcg  id  node  gen  anon  file
#   0     0    0    3   1234  5678

# MGLRU 활성화/비활성화
echo 7 | sudo tee /sys/kernel/mm/lru_gen/enabled   # 모든 기능 활성화
echo 0 | sudo tee /sys/kernel/mm/lru_gen/enabled   # 비활성화 (기존 LRU 사용)

# 최소 TTL 설정 (밀리초, 세대 최소 수명)
echo 1000 | sudo tee /sys/kernel/mm/lru_gen/min_ttl_ms
비트 기능 설명
0 0x0001 Core MGLRU 기본 기능 활성화
1 0x0002 mm_walk 페이지 테이블 워크 기반 에이징
2 0x0004 nonleaf_young 비단말 PTE의 accessed 비트 활용

튜닝 파라미터

LRU 캐시 동작은 여러 sysctl 파라미터로 미세 조정할 수 있습니다. 워크로드에 따라 적절한 값을 설정하면 시스템 성능을 크게 개선할 수 있습니다.

파라미터 경로 기본값 범위 설명
swappiness /proc/sys/vm/swappiness 60 0~200 익명(anon) vs 파일(file) 페이지 회수 비율. 높을수록 anon 회수 적극적
vfs_cache_pressure /proc/sys/vm/vfs_cache_pressure 100 0~1000+ dentry/inode 캐시 회수 정도. 100 미만이면 캐시 보존, 초과면 적극 회수
min_free_kbytes /proc/sys/vm/min_free_kbytes 자동 계산 시스템 의존 kswapd 깨우기(Wakeup) 워터마크(Watermark). 높이면 조기 회수 시작
watermark_scale_factor /proc/sys/vm/watermark_scale_factor 10 1~3000 워터마크 간격 비율 (만분율). 높이면 kswapd 더 일찍/오래 동작
watermark_boost_factor /proc/sys/vm/watermark_boost_factor 15000 0~무제한 외부 단편화(Fragmentation) 감지 시 워터마크 일시적 상향 (만분율)
워크로드별 튜닝 가이드
  • 데이터베이스 서버: swappiness=10, vfs_cache_pressure=50 -- DB 자체 캐시를 보호, VFS 캐시도 보존
  • 파일 서버 (NFS/Samba): swappiness=60, vfs_cache_pressure=200 -- 많은 파일 접근으로 dentry/inode 적극 갱신
  • 컨테이너(Container) 호스트: swappiness=30, memcg 별도 설정 -- 컨테이너별 메모리 격리(Isolation) 활용
  • 데스크톱: swappiness=100 (MGLRU 사용 시) -- MGLRU는 swappiness 해석이 달라 기본값이 적합

모니터링 및 디버깅

# 페이지 캐시 현황 상세 조회
grep -E 'Active|Inactive|Unevictable|Slab|SReclaimable' /proc/meminfo
# Active(anon):    1234 kB
# Inactive(anon):  5678 kB
# Active(file):    9012 kB
# Inactive(file):  3456 kB
# Unevictable:     100 kB
# Slab:            2048 kB
# SReclaimable:    1500 kB

# NUMA 노드별 LRU 통계
for node in /sys/devices/system/node/node*/meminfo; do
    echo "=== $node ==="
    grep -E 'Active|Inactive' "$node"
done

# vmstat으로 실시간 회수 모니터링
vmstat 1
# si/so: swap in/out, bi/bo: block in/out

# Shrinker별 통계 확인 (Linux 6.0+)
cat /sys/kernel/debug/shrinker/*/count 2>/dev/null

# perf로 회수 경로 프로파일링
sudo perf record -g -e 'vmscan:mm_vmscan_lru_shrink_inactive' -- sleep 30
sudo perf report
회수 지연(reclaim latency) 문제 진단

Direct reclaim이 빈번하면 애플리케이션 응답 시간이 크게 증가합니다. sar -B 1에서 pgsteal/pgscand 값이 높으면 kswapd가 회수를 따라잡지 못하는 상황입니다. min_free_kbytes를 높이거나, watermark_scale_factor를 증가시켜 kswapd를 더 일찍 깨워 direct reclaim을 줄일 수 있습니다.

워킹셋 감지와 Refault Distance

커널은 회수된 페이지가 다시 필요해지는 "refault" 현상을 추적하여 회수 정책을 동적으로 조정합니다. Refault distance가 메모리 크기보다 작으면 해당 페이지는 워킹셋에 속하므로 즉시 Active 리스트로 승격시킵니다.

// mm/workingset.c -- refault distance 기반 워킹셋 감지
void workingset_refault(struct folio *folio,
                        void *shadow)
{
    unsigned long refault_distance;
    unsigned long active_file;
    struct lruvec *lruvec;
    bool workingset;

    /* shadow entry에서 eviction 시점 정보 복원 */
    unpack_shadow(shadow, &refault_distance, &workingset);

    lruvec = folio_lruvec(folio);
    active_file = lruvec_page_state(lruvec, NR_ACTIVE_FILE);

    /* refault distance가 active 크기보다 작으면
       이 페이지는 워킹셋에 속함 → 즉시 Active로 승격 */
    if (refault_distance <= active_file) {
        folio_set_active(folio);
        folio_set_workingset(folio);
        atomic_long_inc(&lruvec->nonresident_age);
    }
}
Shadow Entry

페이지가 회수되면 XArray(구 radix tree)의 해당 슬롯에 "shadow entry"를 남깁니다. 이 entry에는 회수 시점의 nonresident_age 카운터가 인코딩되어 있어, refault 발생 시 원래 회수 시점과의 거리를 계산할 수 있습니다. XArray 문서에서 인덱싱 구조를 참고하세요.

메모리 Cgroup과 LRU

컨테이너 환경에서 각 cgroup은 독립적인 LRU 리스트(lruvec)를 갖습니다. 이를 통해 컨테이너별 메모리 격리와 공정한 회수가 가능합니다.

// include/linux/memcontrol.h
struct mem_cgroup_per_node {
    struct lruvec       lruvec;       /* cgroup별 LRU 벡터 */
    unsigned long       lru_zone_size[MAX_NR_ZONES][NR_LRU_LISTS];
};

/* folio가 속한 cgroup의 lruvec 가져오기 */
static inline struct lruvec *folio_lruvec(
    struct folio *folio)
{
    struct mem_cgroup *memcg = folio_memcg(folio);
    struct pglist_data *pgdat = folio_pgdat(folio);

    if (memcg)
        return &memcg->nodeinfo[pgdat->node_id]->lruvec;
    return &pgdat->__lruvec;  /* root cgroup */
}
cgroup v2에서의 LRU 관리

cgroup v2에서는 memory.highmemory.max를 통해 소프트/하드 메모리 제한을 설정합니다. memory.high에 도달하면 해당 cgroup의 lruvec에서 우선적으로 회수가 진행됩니다. list_lru도 memcg별 분리를 지원하여, 각 컨테이너의 dentry/inode 캐시가 독립적으로 관리됩니다. 자세한 내용은 Cgroups 문서를 참고하세요.

# cgroup별 메모리/LRU 통계 확인
cat /sys/fs/cgroup/my-container/memory.stat
# anon 12345678
# file 98765432
# inactive_anon 1234
# active_anon 5678
# inactive_file 9012
# active_file 3456
# unevictable 100

# cgroup별 swappiness 설정 (cgroup v2)
echo 30 | sudo tee /sys/fs/cgroup/my-container/memory.swappiness 2>/dev/null

# 특정 cgroup의 회수 압박 강제 트리거
echo 1 | sudo tee /sys/fs/cgroup/my-container/memory.reclaim 2>/dev/null

LRU 알고리즘 비교

리눅스 커널에서 사용된 다양한 LRU 변형 알고리즘을 비교합니다. 각 알고리즘은 특정 문제를 해결하기 위해 도입되었습니다.

알고리즘 커널 버전 리스트 수 장점 단점
순수 LRU ~2.4 1 구현 단순 스캔 패턴에 취약, 워킹셋 보호 불가
2-리스트 LRU 2.6+ 2 (Active/Inactive) Hot/Cold 분리, 스캔 내성 리스트 균형 조절 어려움, 대규모 PTE 스캔
Split LRU 2.6.28+ 4 (Anon/File x Active/Inactive) swappiness 기반 Anon/File 비율 조절 여전히 2-레벨 에이징의 한계
MGLRU 6.1+ MAX_NR_GENS (기본 4) 정밀한 에이징, 낮은 CPU 오버헤드, 워킹셋 보호 구현 복잡, 일부 워크로드에서 기존 대비 약간 느릴 수 있음
스캔 패턴 문제란?

대용량 파일을 순차적으로 한 번만 읽으면(예: cp, tar), 읽힌 페이지가 캐시를 전부 채우고 기존의 유용한 캐시를 밀어냅니다. 이를 "스캔(scan) 저항" 문제라 하며, Active/Inactive 분리와 MGLRU의 세대 기반 에이징 모두 이 문제를 해결하기 위한 것입니다. 한 번만 읽힌 페이지는 Active 리스트(또는 youngest 세대)로 승격되지 않아 빠르게 회수됩니다.

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"

list_lru 내부 구조

list_lru API의 외부 인터페이스 아래에는 NUMA 토폴로지(Topology)와 memcg를 인식하는 정교한 내부 구조가 숨어 있습니다. 각 구조체의 역할과 배치를 정확히 이해해야 캐시라인 경합(Contention)이나 false sharing 문제를 피할 수 있습니다.

list_lru_one 구조체

// include/linux/list_lru.h
struct list_lru_one {
    struct list_head    list;       // LRU 이중 연결 리스트
    long                nr_items;   // 리스트 내 항목 수
};

list_lru_node 구조체

struct list_lru_node {
    spinlock_t              lock;   // 노드별 독립 락
    struct list_lru_one     lru;    // 기본(root memcg) LRU
    long                    nr_items;
} ____cacheline_aligned_in_smp;          // 캐시라인 정렬로 false sharing 방지
____cacheline_aligned_in_smp

SMP 시스템에서 각 list_lru_node가 서로 다른 캐시라인에 배치되어, 한 노드의 락 획득이 다른 노드의 캐시라인을 무효화(Invalidation)하지 않습니다. 64바이트(x86) 또는 128바이트(ARM64) 경계에 정렬됩니다.

memcg 인식 list_lru

// memcg 인식 확장 구조
struct list_lru_memcg {
    struct rcu_head         rcu;
    struct list_lru_one     *mlrus[];  // memcg ID 인덱스 배열
};

struct list_lru {
    struct list_lru_node   *node;           // node[0..MAX_NUMNODES]
    struct list_head       list;            // 전역 list_lru 등록 체인
    int                    shrinker_id;     // 연결된 shrinker ID
    bool                   memcg_aware;     // memcg 분리 여부
    struct list_lru_memcg __rcu *memcg_lrus; // RCU 보호 memcg 배열
};

할당/등록 경로

// mm/list_lru.c - 초기화 핵심 경로
int __list_lru_init(struct list_lru *lru, bool memcg_aware,
                    struct lock_class_key *key,
                    struct shrinker *shrinker)
{
    // 1. NUMA 노드 수만큼 list_lru_node 배열 할당
    lru->node = kcalloc(nr_node_ids, sizeof(*lru->node), GFP_KERNEL);

    // 2. 각 노드 초기화
    for_each_node(i) {
        spin_lock_init(&lru->node[i].lock);
        INIT_LIST_HEAD(&lru->node[i].lru.list);
        lru->node[i].nr_items = 0;
    }

    // 3. memcg 인식이면 memcg_lrus 할당
    if (memcg_aware)
        memcg_init_list_lru(lru, memcg_aware);

    // 4. 전역 list_lrus 리스트에 등록 (shrinker 연동)
    list_add_tail(&lru->list, &list_lrus);
}
구조체인스턴스 수역할
list_lru캐시 종류당 1개최상위 컨테이너 (dentry_lru, inode_lru 등)
list_lru_nodeNUMA 노드당 1개노드별 락 + LRU 리스트
list_lru_one노드×memcg당 1개실제 이중 연결 리스트 + 카운터
list_lru_memcg노드당 1개memcg ID→list_lru_one 매핑 배열
list_lru 내부 구조: NUMA 노드별 + memcg 분리 struct list_lru node[0] (list_lru_node) spinlock_t lock cacheline aligned list_lru_one (root) list + nr_items memcg_lrus → [cg0] [cg1] [cg2] ... (list_lru_one 각각) RCU 보호, memcg ID 인덱스 node[1] (list_lru_node) spinlock_t lock cacheline aligned list_lru_one (root) list + nr_items memcg_lrus → [cg0] [cg1] [cg2] ... 독립 락, 원격 노드 접근 불필요 list_lru_add(lru, item) → page_to_nid(page) → node[nid].lock 획득 → list_add_tail memcg_aware=true → memcg_from_slab_obj(item) → memcg별 list_lru_one에 추가 전역 list_lrus 체인 [sb_dentry_lru] → [sb_inode_lru] → [xfs_buf_lru] → ... (shrinker 순회 대상)
그림 5: list_lru 내부 구조 — NUMA 노드별 분리와 memcg 인식

memcg 인식 LRU

CONFIG_MEMCG_KMEM 활성화 시 커널 슬랩 객체(dentry, inode 등)도 memcg별로 독립된 LRU 리스트에서 관리됩니다. 이를 통해 컨테이너 환경에서 메모리 제한이 정확히 동작하고, 한 cgroup의 캐시 폭증이 다른 cgroup에 영향을 주지 않습니다.

memcg LRU 동작 경로

// list_lru_add 의 memcg 경로 (mm/list_lru.c)
bool list_lru_add(struct list_lru *lru,
                  struct list_head *item,
                  int nid, struct mem_cgroup *memcg)
{
    struct list_lru_node *nlru = &lru->node[nid];
    struct list_lru_one *l;

    spin_lock(&nlru->lock);
    if (list_empty(item)) {
        // memcg별 list_lru_one 찾기
        l = list_lru_from_memcg_idx(lru, nid, memcg_kmem_id(memcg));
        list_add_tail(item, &l->list);
        l->nr_items++;
        nlru->nr_items++;
        set_shrinker_bit(memcg, nid, lru->shrinker_id);
        spin_unlock(&nlru->lock);
        return true;
    }
    spin_unlock(&nlru->lock);
    return false;
}
set_shrinker_bit

memcg에 shrinker 비트를 설정하면, 메모리 회수 시 해당 memcg에 실제 회수 가능한 객체가 있는 것을 빠르게 판별할 수 있습니다. 비트가 설정되지 않은 memcg는 shrinker가 건너뛰어 불필요한 순회를 방지합니다.

memcg 마이그레이션 시 LRU 이동

// cgroup 마이그레이션 시 LRU 재배치
void memcg_reparent_list_lrus(struct mem_cgroup *memcg,
                              struct mem_cgroup *parent)
{
    // 모든 등록된 list_lru에 대해
    list_for_each_entry(lru, &list_lrus, list) {
        for_each_node(nid) {
            // 자식 memcg의 리스트를 부모로 병합
            src = list_lru_from_memcg_idx(lru, nid, memcg_kmem_id(memcg));
            dst = list_lru_from_memcg_idx(lru, nid, memcg_kmem_id(parent));
            list_splice_init(&src->list, &dst->list);
            dst->nr_items += src->nr_items;
        }
    }
}

shrinker의 memcg별 스캔

단계함수동작
1shrink_slab_memcg()memcg의 shrinker 비트맵(Bitmap) 순회
2do_shrink_slab()shrinker별 count/scan 호출
3list_lru_shrink_count()해당 memcg의 nr_items 반환
4list_lru_shrink_walk()해당 memcg LRU만 순회하며 회수
memcg별 독립 LRU 리스트 root cgroup cgroup-A (512M) cgroup-B (1G) cgroup-C (256M) list_lru_one[root] dentry: 1,200 inode: 800 list_lru_one[A] dentry: 450 inode: 320 list_lru_one[B] dentry: 2,100 inode: 1,500 list_lru_one[C] dentry: 180 inode: 90 shrink_slab_memcg() → shrinker 비트맵 검사 비트 ON인 memcg만 순회 → list_lru_shrink_walk_one(memcg) memcg 삭제 시: reparent cgroup-C 삭제 → list_splice(&C.list, &parent.list) → nr_items 합산
그림 6: memcg별 독립 LRU 분리 — shrinker 비트맵과 reparent
memcg LRU의 메모리 오버헤드

memcg 수 × NUMA 노드 수 × sizeof(list_lru_one)만큼 추가 메모리가 필요합니다. cgroup 수가 수천 개에 달하는 환경에서는 이 오버헤드가 무시할 수 없으므로, 커널 6.1에서 XArray 기반으로 전환하여 희소(sparse) 할당을 지원합니다.

워킹셋 감지 (Shadow Entries)

커널은 회수된 페이지의 "흔적"을 shadow entry로 남겨, 다시 접근될 때 즉시 Active 리스트로 승격시킬지 결정합니다. 이 메커니즘은 mm/workingset.c에 구현되어 있으며, thrashing을 감지하는 핵심 수단입니다.

shadow entry 개념

페이지가 회수될 때 XArray(페이지 캐시 인덱스)에서 실제 folio 포인터 대신 shadow entry를 저장합니다. shadow entry에는 회수 시점의 eviction counter 값이 인코딩되어 있어, 나중에 refault 발생 시 "얼마나 빨리 다시 접근되었는지"를 계산할 수 있습니다.

// include/linux/swap.h - shadow entry 인코딩
static inline void *pack_shadow(int memcgid,
    pg_data_t *pgdat, unsigned long eviction, bool workingset)
{
    eviction >>= bucket_order;
    eviction &= EVICTION_MASK;
    eviction = (eviction << MEMCGID_BITS) | memcgid;
    eviction = (eviction << NODES_SHIFT) | pgdat_to_nid(pgdat);
    eviction = (eviction << 1) | workingset;
    return xa_mk_value(eviction);
}

// shadow entry에 저장되는 정보:
// [eviction_counter | memcg_id | node_id | workingset_bit]

refault distance 계산

// mm/workingset.c
void workingset_refault(struct folio *folio, void *shadow)
{
    int memcgid;
    unsigned long eviction, refault_distance;
    struct lruvec *lruvec;

    // 1. shadow entry에서 eviction 시점 추출
    unpack_shadow(shadow, &memcgid, &pgdat, &eviction, &workingset);

    // 2. 현재 nonresident_age와의 차이 = refault distance
    refault_distance = atomic_long_read(&lruvec->nonresident_age)
                     - eviction;

    // 3. refault distance < inactive 리스트 크기이면 Active 승격
    if (refault_distance <= lruvec_lru_size(lruvec, LRU_INACTIVE_FILE, MAX_NR_ZONES)) {
        folio_set_active(folio);
        folio_set_workingset(folio);
        // Active 리스트에 바로 추가 → "second chance" 없이 즉시 보호
    }
}
refault distance의 직관적 의미

"이 페이지가 회수된 후, inactive 리스트 하나를 가득 채울 만큼의 다른 페이지가 evict되기 전에 다시 접근되었다면 → 워킹셋에 속할 가능성이 높으므로 Active로 보호합니다." 즉, refault distance가 작을수록 해당 페이지의 재사용 빈도가 높다는 뜻입니다.

thrashing 모니터링

# /proc/vmstat에서 워킹셋 관련 통계
grep workingset /proc/vmstat
# workingset_nodes        12345   ← shadow entry 수
# workingset_refault_anon  0      ← 익명 페이지 refault
# workingset_refault_file  5678   ← 파일 페이지 refault
# workingset_activate_anon 0      ← refault 후 Active 승격
# workingset_activate_file 4321   ← refault 후 Active 승격
# workingset_restore_anon  0      ← 복원된 워킹셋
# workingset_restore_file  2100   ← 복원된 워킹셋
# workingset_nodereclaim   100    ← shadow node 회수

# thrashing 비율 계산
# refault 비율 = workingset_refault_file / (pgpgout + pswpout)
# 비율이 높으면 → 메모리 부족으로 워킹셋이 반복 evict
Shadow Entry와 Refault Distance 흐름 1. 페이지 존재 XArray[offset]=folio 2. 회수 (evict) eviction=age++ 3. Shadow 저장 XArray[offset]=shadow 4. Refault 페이지 재접근 refault distance = current_age - eviction_age distance ≤ inactive_size → Active 즉시 승격 (워킹셋) distance > inactive_size → Inactive에 추가 (일반 경로) 워킹셋 감지 → Active 승격 set_active + set_workingset 일반 접근 → Inactive 추가 표준 LRU 경로 Shadow Node 회수: shadow entry가 메모리를 과도 소비 시 shadow_lru_isolate() → XArray node 해제 (workingset_nodereclaim++)
그림 7: Shadow Entry 생명주기와 Refault Distance 계산

MGLRU 내부 메커니즘

Multi-Gen LRU(MGLRU)는 기존 Active/Inactive 2단계를 다세대(multi-generation) 구조로 대체하여 더 정밀한 에이징과 회수를 수행합니다. 핵심 구조체와 내부 알고리즘을 상세히 분석합니다.

lru_gen_folio 구조체

// include/linux/mmzone.h
struct lru_gen_folio {
    unsigned long       max_seq;        // 최신 세대 번호
    unsigned long       min_seq[ANON_AND_FILE]; // 최고령 세대 (anon/file 별도)
    unsigned long       timestamps[MAX_NR_GENS]; // 세대별 생성 시각
    struct list_head    folios[MAX_NR_GENS][ANON_AND_FILE][MAX_NR_ZONES];
    unsigned long       sizes[MAX_NR_GENS][ANON_AND_FILE][MAX_NR_ZONES];
    unsigned long       avg_refaulted[ANON_AND_FILE][MAX_NR_TIERS];
    unsigned long       avg_total[ANON_AND_FILE][MAX_NR_TIERS];
    atomic_long_t       evicted[ANON_AND_FILE][MAX_NR_TIERS];
    atomic_long_t       refaulted[ANON_AND_FILE][MAX_NR_TIERS];
    bool                enabled;
};
MAX_NR_GENS = 4

기본적으로 4개 세대(generation 0~3)가 유지됩니다. min_seq가 가장 오래된 세대, max_seq가 가장 최신 세대입니다. 새 세대가 필요하면 max_seq를 증가시키고(aging), 회수 시에는 min_seq 세대부터 처리합니다(eviction).

에이징 (Aging): PTE Young Bit 검사

// mm/vmscan.c - 에이징 핵심 경로
static void inc_max_seq(struct lruvec *lruvec, unsigned long max_seq)
{
    // 1. 모든 mm_struct을 순회하며 PTE young bit 검사
    iterate_mm_list(lruvec, max_seq, &mm_list);

    // 2. young bit 설정된 folio → 최신 세대(max_seq+1)로 이동
    // 3. young bit 없는 folio → 현재 세대 유지 (나이 들어감)

    // 4. max_seq 증가
    WRITE_ONCE(lruvec->lrugen.max_seq, max_seq + 1);
}

Bloom Filter 최적화

MGLRU는 page table walk 비용을 줄이기 위해 bloom filter를 사용합니다. 이미 스캔한 PTE를 기록하여, 동일 세대 내 중복 워크를 방지합니다.

구성 요소역할효과
mm_struct 리스트에이징 대상 프로세스 관리활성 프로세스만 순회
Bloom filter이미 스캔한 mm/VMA 기록중복 page table walk 방지
Generation 번호folio에 세대 태그O(1) 세대 판별
Tier 시스템file page 접근 빈도 다층화hot file page 보호 강화

tier 시스템: 파일 페이지(File-backed Page) 다중 등급

// 파일 페이지의 tier 계산 (접근 횟수 기반)
// tier 0: 한 번 접근 (가장 차가움)
// tier 1: 두 번 접근
// tier 2: 세 번 이상 접근 (가장 뜨거움)
// → avg_refaulted[tier] / avg_total[tier] 비율로 회수 여부 결정
// refault 비율이 높은 tier는 회수 우선순위 낮음 (보호)
MGLRU Generation 흐름: Aging → Eviction 사이클 Gen 0 (min_seq) 가장 오래됨 ← 회수 대상 Gen 1 중간 단계 young bit 없으면 유지 Gen 2 비교적 최근 young bit 있으면 승격 Gen 3 (max_seq) 최신 세대 ← 신규/승격 PTE young=1 → 최신 세대 이동 (aging) evict (회수/swap) inc_max_seq: max_seq++ → 새 세대 생성 조건: min_seq + MAX_NR_GENS ≤ max_seq + 1 inc_min_seq: min_seq++ → 최고령 세대 폐기 조건: min_seq 세대의 모든 folio 회수 완료 File Tier: tier0(cold) → tier1(warm) → tier2(hot) | avg_refaulted/avg_total 비율로 회수 임계값 결정
그림 8: MGLRU 세대 흐름 — Aging/Eviction 사이클과 Tier 시스템

dentry 캐시 LRU

디렉터리 엔트리(dentry) 캐시는 파일 경로 탐색 성능의 핵심입니다. 참조 카운트가 0이 된 dentry는 즉시 해제되지 않고 LRU 리스트에 보관되어 재사용 가능성을 유지합니다.

d_lru 필드와 추가/제거

// include/linux/dcache.h
struct dentry {
    ...
    struct list_head    d_lru;      // LRU 리스트 엔트리
    struct list_head    d_child;    // 부모의 자식 리스트
    unsigned int        d_flags;    // DCACHE_LRU_LIST 등
    ...
};

// fs/dcache.c
static void d_lru_add(struct dentry *dentry)
{
    d_set_flag(dentry, DCACHE_LRU_LIST);
    this_cpu_inc(nr_dentry_unused);
    if (d_is_negative(dentry))
        this_cpu_inc(nr_dentry_negative);
    list_lru_add_obj(&dentry->d_sb->s_dentry_lru, &dentry->d_lru);
}

static void d_lru_del(struct dentry *dentry)
{
    d_clear_flag(dentry, DCACHE_LRU_LIST);
    this_cpu_dec(nr_dentry_unused);
    if (d_is_negative(dentry))
        this_cpu_dec(nr_dentry_negative);
    list_lru_del_obj(&dentry->d_sb->s_dentry_lru, &dentry->d_lru);
}

음성 dentry(negative dentry)

음성 dentry란?

존재하지 않는 파일에 대한 경로 탐색 결과를 캐싱합니다. 예: stat("/tmp/foo") → ENOENT 결과를 기억하여 반복 조회 시 디스크 접근 없이 즉시 ENOENT를 반환합니다. 컨테이너 환경에서 라이브러리 검색 경로(ld.so)의 반복 실패를 효율적으로 처리합니다.

prune_dcache_sb: 슈퍼블록별 회수

// fs/dcache.c - dentry shrinker 콜백
static enum lru_status dentry_lru_isolate(
    struct list_head *item,
    struct list_lru_one *lru,
    spinlock_t *lru_lock,
    void *arg)
{
    struct dentry *dentry = container_of(item, struct dentry, d_lru);

    // 1. 참조 카운트 > 0이면 LRU에서 제거만
    if (dentry->d_lockref.count) {
        d_lru_isolate(lru, dentry);
        return LRU_REMOVED;
    }

    // 2. 최근 참조(DCACHE_REFERENCED)면 플래그 클리어 후 재배치
    if (dentry->d_flags & DCACHE_REFERENCED) {
        dentry->d_flags &= ~DCACHE_REFERENCED;
        return LRU_ROTATE;  // second chance
    }

    // 3. 회수 대상으로 격리
    d_lru_shrink_move(lru, dentry, freeable);
    return LRU_REMOVED;
}
플래그의미LRU 동작
DCACHE_LRU_LISTLRU에 등록됨d_lru_add 시 설정
DCACHE_REFERENCED최근 참조됨LRU_ROTATE (second chance)
DCACHE_SHRINK_LISTshrink 격리 리스트회수 진행 중
dentry 캐시 LRU 회수 경로 dput() d_count → 0 d_lru_add() sb->s_dentry_lru 슈퍼블록별 dentry LRU [oldest] ← d1 ← d2 ← d3 ← ... ← dN → [newest] HEAD (회수 후보) TAIL (최근 추가) 메모리 압박 발생 shrink_slab → prune_dcache_sb dentry_lru_isolate() REFERENCED? → ROTATE LRU_ROTATE LRU_REMOVED d_free() 음성 dentry (Negative Dentry) d_is_negative()=true: 존재하지 않는 파일 경로 캐싱 → ENOENT 즉시 반환 모니터링: slabtop | grep dentry / cat /proc/sys/fs/dentry-state
그림 9: dentry 캐시 LRU 회수 경로와 Second Chance 메커니즘

inode 캐시 LRU

inode 캐시는 파일 메타데이터(소유자, 권한, 크기, 블록 맵핑 등)를 메모리에 유지합니다. 마지막 참조가 해제된 inode는 LRU 리스트로 이동하여 메모리 압박 시 회수 대상이 됩니다.

i_lru 필드와 상태

// include/linux/fs.h
struct inode {
    ...
    struct list_head    i_lru;      // LRU 리스트 엔트리
    unsigned long       i_state;    // I_DIRTY, I_FREEING 등
    atomic_t            i_count;    // 참조 카운트
    ...
};

// fs/inode.c
void inode_add_lru(struct inode *inode)
{
    if (!(inode->i_state & (I_DIRTY_ALL | I_SYNC |
                I_FREEING | I_WILL_FREE))) {
        if (list_lru_add_obj(&inode->i_sb->s_inode_lru,
                            &inode->i_lru))
            this_cpu_inc(nr_unused);
    }
}

void inode_lru_list_del(struct inode *inode)
{
    if (list_lru_del_obj(&inode->i_sb->s_inode_lru,
                        &inode->i_lru))
        this_cpu_dec(nr_unused);
}
dirty inode는 LRU에 추가되지 않음

I_DIRTY 상태인 inode는 LRU에 추가되지 않습니다. dirty 데이터가 디스크에 기록된 후(writeback_single_inode), dirty 플래그가 클리어되면 inode_add_lru()를 통해 LRU에 진입합니다. 이는 dirty inode를 회수하면 데이터 손실이 발생하기 때문입니다.

inode eviction 순서

단계함수동작
1iput()i_count-- → 0이면 inode_add_lru()
2prune_icache_sb()shrinker 콜백, LRU 순회
3inode_lru_isolate()회수 가능 여부 판단, dirty/referenced 검사
4evict()inode를 VFS에서 제거, 페이지 캐시 truncate
5destroy_inode()inode 메모리 해제 (슬랩 반환)
// fs/inode.c - 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);

    // 참조 중이면 LRU에서 제거만
    if (atomic_read(&inode->i_count))
        return LRU_REMOVED;

    // dirty이면 건너뛰기 (writeback 필요)
    if (inode->i_state & I_DIRTY_ALL)
        return LRU_ROTATE;

    // I_REFERENCED 면 second chance
    if (inode->i_state & I_REFERENCED) {
        inode->i_state &= ~I_REFERENCED;
        return LRU_ROTATE;
    }

    // 회수 대상으로 격리
    return LRU_REMOVED;
}
inode 캐시 LRU 생명주기 open()/stat() i_count++ 활성 사용 중 i_count > 0 iput() i_count → 0 inode_add_lru !I_DIRTY 확인 sb LRU s_inode_lru I_DIRTY → LRU 제외 메모리 압박 prune_icache_sb inode_lru_isolate referenced → ROTATE evict() truncate pages destroy_inode() 슬랩 메모리 반환 모니터링 포인트 slabtop -o | grep inode_cache | cat /proc/sys/fs/inode-state | /proc/slabinfo
그림 10: inode 캐시 LRU 생명주기 — open → LRU → evict → destroy

페이지 캐시 LRU 상세

페이지 캐시(file-backed 페이지)가 LRU 리스트에 추가되는 과정은 성능을 위해 per-CPU 배치(batch) 처리로 이루어집니다. lru_pvecs 구조를 통해 spinlock 경합을 최소화하는 메커니즘을 상세히 분석합니다.

folio_add_lru: 배치 LRU 추가

// mm/swap.c
void folio_add_lru(struct folio *folio)
{
    struct folio_batch *fbatch;

    folio_get(folio);            // 참조 카운트 증가
    local_lock(&cpu_fbatches.lock);
    fbatch = this_cpu_ptr(&cpu_fbatches.lru_add);
    folio_batch_add_and_move(fbatch, folio, lru_add_fn);
    local_unlock(&cpu_fbatches.lock);
}

// per-CPU batch가 가득 차면 (PAGEVEC_SIZE=31) 실제 LRU로 flush
static void lru_add_fn(struct lruvec *lruvec,
                       struct folio *folio)
{
    int was_unevictable = folio_test_unevictable(folio);
    enum lru_list lru = folio_lru_list(folio);

    folio_set_lru(folio);
    lruvec_add_folio(lruvec, folio);
}

folio_mark_accessed → folio_activate 경로

// mm/swap.c - 접근 패턴에 따른 LRU 승격
void folio_mark_accessed(struct folio *folio)
{
    if (!folio_test_referenced(folio)) {
        // 첫 번째 접근: PG_referenced 설정
        folio_set_referenced(folio);
    } else if (!folio_test_active(folio)) {
        // 두 번째 접근: Active로 승격
        folio_activate(folio);
        folio_clear_referenced(folio);
    }
    // 이미 Active이면 → folio_set_workingset()
    if (folio_test_idle(folio))
        folio_clear_idle(folio);
}

pagevec drain: lru_add_drain_all

언제 drain이 필요한가?

per-CPU batch에 있는 folio는 아직 LRU에 보이지 않습니다. LRU 순회/통계가 정확해야 하는 경우(migration, compaction, LRU scan) 반드시 lru_add_drain_all()을 호출하여 모든 CPU의 batch를 flush해야 합니다.

// mm/swap.c - drain 시점
void lru_add_drain_all(void)
{
    // 모든 online CPU에 대해 IPI 또는 work queue로 drain 요청
    __lru_add_drain_all(false);
}

// drain이 필요한 주요 호출자:
// - migrate_pages()     : 페이지 마이그레이션 전
// - compact_zone()      : compaction 전
// - shrink_active_list(): LRU scan 전
// - munlock_folio()     : unevictable → evictable 전환
per-CPU batchPAGEVEC_SIZE용도
lru_add31새 folio를 LRU에 추가
lru_deactivate_file31파일 folio를 Inactive로 강등
lru_deactivate31folio를 Inactive로 강등
lru_lazyfree31MADV_FREE folio 처리
activate31folio를 Active로 승격
per-CPU folio_batch 기반 LRU 추가 흐름 CPU 0 folio_batch[31] [f0|f1|...|f30] local_lock 보호 CPU 1 folio_batch[31] [f0|f1|...|fN] local_lock 보호 CPU N folio_batch[31] [f0|f1|...|fM] local_lock 보호 folio_add_lru() ① this_cpu batch에 추가 ② batch 가득 차면 → flush to LRU batch full (31개) → lru_lock 획득 → 일괄 추가 lruvec LRU 리스트 INACTIVE_FILE ← 31개 folio 한번에 추가 (락 1회) lru_add_drain_all() migration/compaction 전 → 모든 CPU batch flush IPI 또는 work_queue로 각 CPU에 drain 요청
그림 11: per-CPU folio_batch를 통한 LRU 배치 추가와 drain

커스텀 LRU 패턴

커널 서브시스템에서 list_lru를 활용한 자체 캐시를 구현할 때 반복되는 설계 패턴과 흔한 실수를 정리합니다. 참조 카운팅, RCU, Shrinker를 올바르게 조합하는 것이 핵심입니다.

기본 구현 패턴

// 커스텀 LRU 캐시 뼈대
struct my_cache_entry {
    struct list_head    lru;        // list_lru 연결
    struct rcu_head     rcu;        // RCU 해제용
    refcount_t          ref;        // 참조 카운트
    unsigned long       key;
    void                *data;
};

static struct list_lru my_lru;
static struct shrinker *my_shrinker;

// 패턴 1: 참조 해제 시 LRU 추가
void my_cache_put(struct my_cache_entry *entry)
{
    if (refcount_dec_and_test(&entry->ref))
        list_lru_add(&my_lru, &entry->lru);
}

// 패턴 2: 조회 시 LRU에서 제거
struct my_cache_entry *my_cache_get(unsigned long key)
{
    struct my_cache_entry *entry;

    rcu_read_lock();
    entry = my_lookup(key);  // RCU 보호 조회
    if (entry && refcount_inc_not_zero(&entry->ref))
        list_lru_del(&my_lru, &entry->lru);
    else
        entry = NULL;
    rcu_read_unlock();
    return entry;
}

Shrinker 연동 패턴

// Shrinker 콜백 구현
static unsigned long my_count(struct shrinker *s,
    struct shrink_control *sc)
{
    return list_lru_shrink_count(&my_lru, sc);
}

static enum lru_status my_isolate(
    struct list_head *item,
    struct list_lru_one *list,
    spinlock_t *lock, void *arg)
{
    struct my_cache_entry *e =
        container_of(item, struct my_cache_entry, lru);

    // 참조 중이면 건너뛰기
    if (refcount_read(&e->ref) > 0)
        return LRU_SKIP;

    // 인덱스에서 제거 후 RCU 해제
    my_index_remove(e);
    list_lru_isolate(list, item);
    call_rcu(&e->rcu, my_free_rcu);
    return LRU_REMOVED;
}

static unsigned long my_scan(struct shrinker *s,
    struct shrink_control *sc)
{
    return list_lru_shrink_walk(&my_lru, sc,
                                my_isolate, NULL);
}

흔한 실수와 방지책

실수증상방지책
use-after-freeshrinker가 회수한 객체를 다른 경로에서 접근refcount + RCU 조합 필수
double-freeLRU와 직접 경로 양쪽에서 해제list_lru_del 반환값 확인
락 역전(deadlock)shrinker 콜백에서 LRU 락 외 다른 락 획득shrinker 콜백 내 락 최소화
LRU 누수nr_items 계속 증가, 메모리 회수 안 됨모든 해제 경로에서 list_lru_del 호출
per-CPU batch 미 drainLRU 카운트 불일치정확한 카운트 필요 시 drain 선행
RCU + LRU 조합의 핵심 원칙

읽기 측: rcu_read_lock() + refcount_inc_not_zero()로 락 없이 조회. 해제 측: list_lru_isolate()call_rcu()로 grace period 보장. 이 조합으로 읽기 측은 완전히 lock-free가 되며, 해제는 안전하게 지연됩니다.

커스텀 LRU 캐시 구현 패턴 조회 경로 (Hot Path) rcu_read_lock() lookup → refcount_inc_not_zero list_lru_del (LRU에서 제거) rcu_read_unlock() 해제 경로 refcount_dec_and_test() → ref=0: list_lru_add (LRU에 추가, 재사용 대기) Shrinker 회수 경로 메모리 압박 시 호출 list_lru_isolate() index_remove → call_rcu grace period 후 kfree list_lru (ref=0인 엔트리들) [oldest] ← entry ← entry ← ... → [newest] del (재사용) isolate (회수) 안전성 보장 3원칙 1. refcount: 동시 접근 보호 2. RCU: 읽기 측 lock-free 3. list_lru_isolate: 원자적 분리 흔한 실수 ✗ refcount 없이 kfree → use-after-free ✗ call_rcu 없이 해제 → reader crash ✗ list_lru_del 누락 → LRU 누수
그림 12: 커스텀 LRU 캐시 구현 패턴 — 조회/해제/회수 3경로

lru_cache 모듈: 해시+LRU 이중 구조

리눅스 커널에는 list_lru 외에도 lib/lru_cache.c에 구현된 독립적인 lru_cache 모듈이 존재합니다. 이 모듈은 DRBD(Distributed Replicated Block Device)에서 활동 로그(Activity Log) 캐싱을 위해 개발되었으며, 해시 테이블(Hash Table)을 통한 O(1) 조회와 이중 연결 리스트(Doubly-Linked List)를 통한 LRU 순서 관리를 하나의 자료구조에 결합합니다.

list_lru vs lru_cache
  • list_lru — NUMA 인식, memcg 지원, Shrinker 연동에 특화된 범용 LRU 리스트 API입니다. 페이지 캐시, dentry, inode 캐시 등 대규모 메모리 관리에 사용됩니다.
  • lru_cache — 고정 크기 원소 풀(pool), 해시 기반 키 조회, 참조 카운팅, 트랜잭션 잠금을 통합한 블록 디바이스 캐시 모듈입니다. DRBD 활동 로그에 특화되어 있습니다.

핵심 구조체

// include/linux/lru_cache.h

/* 캐시 원소 하나를 나타내는 기본 구조체 */
struct lc_element {
    struct hlist_node   colision;    // 해시 충돌 체인
    struct list_head    list;        // LRU 리스트 또는 free 리스트
    unsigned            lc_number;   // 논리 번호 (키)
    unsigned            lc_index;    // 슬롯 인덱스 (0..nr_elements-1)
    unsigned            refcnt;      // 참조 카운트
    unsigned            lc_new_number; // 트랜잭션 중 새 번호
};

/* lru_cache 전체 컨테이너 */
struct lru_cache {
    struct list_head    in_use;      // 사용 중인 원소 LRU 리스트
    struct list_head    lru;         // 해제 가능한 원소 LRU 리스트
    struct list_head    free;        // 미사용 원소 풀
    struct list_head    to_be_changed; // 트랜잭션 변경 대기열

    size_t              element_size;  // 원소 크기 (lc_element 포함 확장 구조체)
    unsigned int        nr_elements;   // 총 원소 수
    unsigned int        used;          // 현재 사용 중인 원소 수
    unsigned long       flags;         // LC_LOCKED, LC_DIRTY 등

    struct lc_element   **lc_slot;     // 인덱스별 원소 포인터 배열
    struct hlist_head   *lc_hash;      // 해시 테이블

    unsigned int        new_number;    // 현재 트랜잭션 대상 번호

    /* 통계 카운터 */
    unsigned long       hits, misses, starving, locked, changed;

    const char          *name;         // 디버그용 이름
};
lru_cache 이중 구조: 해시 테이블 + LRU 리스트 lc_hash[] (해시 테이블, O(1) 조회) bucket[0] bucket[1] bucket[2] bucket[...] elem #47 elem #15 elem #22 elem #8 elem #33 lc_slot[]: 인덱스 직접 접근 O(1) [0] [1] [2] ... [nr_elements-1] free 리스트: 미할당 원소 풀 lru_cache_alloc 시 사전 할당된 슬롯 LRU 순서 관리 (이중 연결 리스트) in_use 리스트 (refcnt > 0) lc_get()으로 참조 중인 원소, 교체 불가 ← MRU ... LRU → lru 리스트 (refcnt == 0) lc_put()으로 해제된 원소, 교체 후보 ← MRU ... LRU(evict 대상) → to_be_changed: 트랜잭션 변경 대기 원소 이중 구조의 장점 1. lc_find(nr) → 해시 테이블로 O(1) 조회 2. lc_get(nr) → 조회 + LRU 순서 갱신 + refcnt++ 3. lc_put() → refcnt-- → 0이면 lru 리스트로 이동 4. 캐시 미스 → lru 리스트 tail에서 교체 후보 선택
그림 13: lru_cache 이중 구조 — 해시 테이블(O(1) 조회)과 LRU 리스트(교체 순서)

lru_cache_alloc / lru_cache_free: 생명주기

// lib/lru_cache.c

/* lru_cache 할당 및 초기화
 * e_count: 원소 수, e_size: 원소 크기 (>= sizeof(struct lc_element)),
 * e_off: lc_element의 오프셋 (확장 구조체 내)
 */
struct lru_cache *lc_create(const char *name,
    struct kmem_cache *cache,
    unsigned max_pending_changes,
    unsigned e_count, size_t e_size, size_t e_off)
{
    struct lru_cache *lc;
    unsigned i;

    // 1. lru_cache 구조체 할당
    lc = kzalloc(sizeof(*lc), GFP_KERNEL);

    // 2. 해시 테이블 할당 (e_count 크기)
    lc->lc_hash = kcalloc(e_count, sizeof(struct hlist_head),
                          GFP_KERNEL);

    // 3. 인덱스별 슬롯 배열 할당
    lc->lc_slot = kcalloc(e_count, sizeof(struct lc_element *),
                          GFP_KERNEL);

    // 4. 리스트 헤드 초기화
    INIT_LIST_HEAD(&lc->in_use);
    INIT_LIST_HEAD(&lc->lru);
    INIT_LIST_HEAD(&lc->free);
    INIT_LIST_HEAD(&lc->to_be_changed);

    // 5. 모든 원소 사전 할당 → free 리스트에 추가
    for (i = 0; i < e_count; i++) {
        void *p = kmem_cache_alloc(cache, GFP_KERNEL);
        struct lc_element *e = p + e_off;
        e->lc_index = i;
        e->lc_number = LC_FREE;   // 미할당 상태
        e->refcnt = 0;
        lc->lc_slot[i] = e;
        list_add(&e->list, &lc->free);
    }

    lc->nr_elements = e_count;
    return lc;
}

/* lru_cache 해제 */
void lc_destroy(struct lru_cache *lc)
{
    kfree(lc->lc_hash);
    kfree(lc->lc_slot);
    kfree(lc);
}
고정 크기 풀의 장점

lru_cache는 생성 시 모든 원소를 사전 할당합니다. 런타임에 메모리 할당이 발생하지 않으므로, I/O 경로처럼 메모리 할당 실패가 허용되지 않는 컨텍스트에서 안전하게 사용할 수 있습니다. DRBD의 활동 로그가 대표적인 사례입니다.

lc_get / lc_put: 원소 접근과 참조 카운팅

// lib/lru_cache.c

/* 번호(키)로 원소 조회/할당
 * 캐시 히트: 참조 카운트 증가, LRU 순서 갱신
 * 캐시 미스: LRU 리스트에서 가장 오래된 원소 교체
 * 반환: lc_element 포인터, 실패 시 NULL
 */
struct lc_element *lc_get(struct lru_cache *lc, unsigned int enr)
{
    struct lc_element *e;

    // 1. 해시 테이블에서 조회
    e = __lc_find(lc, enr, 1);
    if (e) {
        // 캐시 히트: 참조 카운트 증가
        ++e->refcnt;
        ++lc->hits;
        if (e->refcnt == 1)
            lc->used++;
        // in_use 리스트 앞쪽(MRU)으로 이동
        list_move(&e->list, &lc->in_use);
        return e;
    }

    ++lc->misses;

    // 2. 캐시 미스: free 리스트에서 먼저 확인
    if (!list_empty(&lc->free)) {
        e = list_first_entry(&lc->free,
                            struct lc_element, list);
    } else if (!list_empty(&lc->lru)) {
        // 3. free 없으면 → lru 리스트 tail(가장 오래된 원소) 교체
        e = list_entry(lc->lru.prev,
                      struct lc_element, list);
        // 이전 해시 엔트리 제거
        hlist_del(&e->colision);
    } else {
        // 4. 모든 원소가 in_use: 할당 불가
        ++lc->starving;
        return NULL;
    }

    // 5. 새 번호 할당, 해시 테이블에 삽입
    e->lc_number = enr;
    e->refcnt = 1;
    lc->used++;
    hlist_add_head(&e->colision,
                   &lc->lc_hash[lc_hash_fn(lc, enr)]);
    list_move(&e->list, &lc->in_use);
    return e;
}

/* 원소 해제: 참조 카운트 감소
 * refcnt가 0이 되면 lru 리스트로 이동 (교체 후보)
 */
unsigned int lc_put(struct lru_cache *lc,
                     struct lc_element *e)
{
    WARN_ON(!e->refcnt);
    --e->refcnt;

    if (e->refcnt == 0) {
        // lru 리스트 앞쪽(MRU)으로 이동
        list_move(&e->list, &lc->lru);
        lc->used--;
    }
    return e->refcnt;
}
lc_element 생명주기: free → in_use → lru → evict FREE lc_number = LC_FREE refcnt = 0 free 리스트에 위치 IN_USE lc_number = enr refcnt > 0 in_use 리스트에 위치 LRU (교체 후보) lc_number = enr refcnt = 0 lru 리스트에 위치 EVICT (교체) 이전 번호 해제 새 번호 할당 해시 재삽입 lc_get() (첫 사용) lc_put() ref→0 miss 새 번호로 in_use 진입 lc_get() 캐시 히트 통계 카운터 hits: 해시 조회 성공 (캐시 히트) misses: 해시 조회 실패 (캐시 미스, 교체 발생) starving: 모든 원소 in_use, 할당 불가 changed: 트랜잭션으로 변경된 원소 수 동시성 제어 lc_try_lock() → test_and_set_bit(LC_LOCKED) lc_unlock() → clear_bit(LC_LOCKED) LC_LOCKED 상태에서만 lc_get/lc_put 안전 LC_DIRTY: 트랜잭션 중 변경 발생 표시
그림 14: lc_element 생명주기 상태 전이 — free, in_use, lru, evict

lc_try_lock / lc_unlock: 동시성 제어

// include/linux/lru_cache.h

/* 캐시 잠금 (비차단, 원자적) */
static inline int lc_try_lock(struct lru_cache *lc)
{
    return !test_and_set_bit(__LC_LOCKED, &lc->flags);
}

/* 캐시 잠금 해제 */
static inline void lc_unlock(struct lru_cache *lc)
{
    clear_bit_unlock(__LC_LOCKED, &lc->flags);
}

/* 잠금 상태 확인 */
static inline int lc_is_used(struct lru_cache *lc,
                              unsigned int enr)
{
    struct lc_element *e = lc_find(lc, enr);
    return e && e->refcnt;
}
lc_try_lock 사용 규칙

lc_try_lock()은 비차단(non-blocking)이므로, 실패 시 호출자가 대기하거나 재시도해야 합니다. DRBD에서는 wait_event와 조합하여 잠금을 획득합니다. LC_LOCKED 상태에서만 lc_get(), lc_put(), lc_del() 등을 호출할 수 있습니다.

lc_find / lc_element_by_index: 조회 경로

// lib/lru_cache.c

/* 해시 테이블 기반 O(1) 조회
 * 참조 카운트를 변경하지 않음 (읽기 전용 조회) */
struct lc_element *lc_find(struct lru_cache *lc,
                           unsigned int enr)
{
    struct lc_element *e;
    struct hlist_node *n;

    hlist_for_each_entry(e, n,
        &lc->lc_hash[lc_hash_fn(lc, enr)], colision) {
        if (e->lc_number != enr)
            continue;
        return e;
    }
    return NULL;
}

/* 인덱스 직접 접근 O(1)
 * 해시를 거치지 않고 슬롯 배열로 바로 접근 */
struct lc_element *lc_element_by_index(struct lru_cache *lc,
                                       unsigned int i)
{
    BUG_ON(i >= lc->nr_elements);
    BUG_ON(lc->lc_slot[i] == NULL);
    return lc->lc_slot[i];
}
함수시간 복잡도참조 카운트 변경용도
lc_find(enr)O(1) 평균아니요존재 여부 확인 (읽기 전용)
lc_get(enr)O(1) 평균예 (refcnt++)원소 획득 + LRU 갱신
lc_put(e)O(1)예 (refcnt--)원소 해제, 0이면 lru로 이동
lc_element_by_index(i)O(1)아니요슬롯 인덱스로 직접 접근
lc_try_get(enr)O(1)히트 시만 증가캐시 히트만 처리 (교체 없음)

DRBD 활동 로그 캐싱

lru_cache 모듈은 DRBD에서 활동 로그(Activity Log, AL)를 관리하기 위해 개발되었습니다. DRBD는 블록 디바이스를 네트워크를 통해 미러링하는 커널 모듈로, 노드 장애 시 어떤 영역을 재동기화해야 하는지 추적하기 위해 활동 로그를 사용합니다.

활동 로그(Activity Log) 개념

활동 로그란?

DRBD는 디스크를 일정 크기의 "익스텐트(extent)"로 나누고, 현재 쓰기 진행 중인 익스텐트를 활동 로그에 기록합니다. 노드 장애 후 복구 시 전체 디스크 대신 활동 로그에 기록된 익스텐트만 재동기화하면 되므로, 복구 시간이 극적으로 단축됩니다.

// drivers/block/drbd/drbd_int.h

/* DRBD 활동 로그 확장 구조체 */
struct lc_element;

/* DRBD 리소스 구조체의 활동 로그 필드 */
struct drbd_device {
    ...
    struct lru_cache   *act_log;        // 활동 로그 LRU 캐시
    struct lru_cache   *resync_lru;     // 재동기화 LRU 캐시
    unsigned int       al_nr_extents;   // 활동 로그 익스텐트 수
    ...
};

// 활동 로그 익스텐트 크기: 4MB (기본값)
// 활동 로그 슬롯 수: 127~65534 (기본 1237)
// → 최대 약 5GB의 동시 쓰기 영역 추적 가능

DRBD 쓰기 I/O 경로와 활동 로그

// drivers/block/drbd/drbd_req.c (간략화)

/* 쓰기 요청 처리 시 활동 로그 갱신 */
static int drbd_al_begin_io(struct drbd_device *device,
                            struct drbd_interval *i)
{
    struct lru_cache *al = device->act_log;
    struct lc_element *al_ext;
    unsigned int enr;

    // 1. 쓰기 대상 섹터(Sector) → 익스텐트 번호 변환
    enr = i->sector >> (AL_EXTENT_SHIFT - 9);

    // 2. 활동 로그 잠금
    wait_event(device->al_wait, lc_try_lock(al));

    // 3. 해당 익스텐트를 활동 로그에 등록
    al_ext = lc_get(al, enr);
    if (!al_ext) {
        // 모든 슬롯이 사용 중 → 대기
        lc_unlock(al);
        return -EBUSY;
    }

    // 4. 새 익스텐트(캐시 미스)면 메타데이터 기록
    if (al_ext->lc_number != al_ext->lc_new_number) {
        // 디스크에 활동 로그 트랜잭션 기록
        drbd_al_write_transaction(device);
    }

    lc_unlock(al);
    return 0;
}

/* 쓰기 완료 시 활동 로그 해제 */
void drbd_al_complete_io(struct drbd_device *device,
                         struct drbd_interval *i)
{
    unsigned int enr = i->sector >> (AL_EXTENT_SHIFT - 9);
    struct lc_element *al_ext;

    spin_lock_irq(&device->al_lock);
    al_ext = lc_find(device->act_log, enr);
    if (al_ext) {
        lc_put(device->act_log, al_ext);
        // refcnt→0 이면 lru 리스트로 이동 (나중에 재사용/교체 가능)
    }
    spin_unlock_irq(&device->al_lock);
    wake_up(&device->al_wait);
}
DRBD 활동 로그: lc_get/lc_put 참조 카운팅 흐름 쓰기 I/O 요청 sector → extent nr lc_try_lock(al) 활동 로그 잠금 lc_get(al, enr) refcnt++ 히트: I/O 즉시 진행 미스: 교체 + AL 기록 AL 트랜잭션 메타데이터 디스크 기록 블록 I/O 수행 (로컬 + 원격 미러) 익스텐트 refcnt > 0 동안 보호됨 I/O 완료 drbd_al_complete_io() lc_put(al, ext) → refcnt-- 0이면 lru 리스트로 이동 (교체 가능) 노드 장애 후 복구 1. 활동 로그 읽기 (메타데이터 디스크) 2. 기록된 익스텐트만 재동기화 3. 전체 디스크 대비 복구 시간 단축 (예: 500GB 중 20MB만 재동기화) 활동 로그 성능 지표 al->hits / (hits + misses) = 히트율 al->starving = 포화 상태 횟수 높은 히트율: 로컬리티 좋은 I/O 패턴 잦은 starving: al_nr_extents 증가 필요 wake_up(&al_wait) 대기 중인 I/O 요청 깨우기
그림 15: DRBD 쓰기 I/O와 활동 로그 lc_get/lc_put 흐름

DRBD 활동 로그 튜닝

# DRBD 활동 로그 익스텐트 수 설정 (기본 1237)
drbdadm disk-options --al-extents=3389 r0

# 활동 로그 통계 확인
cat /proc/drbd
# ... al: used=45 hits=12345 misses=67 starving=0 ...

# 활동 로그 크기와 복구 시간의 트레이드오프:
# - 큰 AL: 동시 쓰기 영역 증가, 포화 감소 → 복구 시간 증가
# - 작은 AL: 복구 시간 단축 → 포화 빈번, I/O 대기 증가
활동 로그 크기 결정 기준
  • starving 카운터가 빈번하게 증가하면 al-extents를 늘려야 합니다
  • 각 익스텐트 = 4MB이므로, al-extents=1237이면 최대 약 4.8GB의 동시 쓰기 영역을 추적합니다
  • 노드 장애 후 복구 시간은 al-extents x 4MB에 비례합니다
  • SSD 환경에서는 복구 속도가 빠르므로 큰 AL이 유리합니다

범용 블록 레벨 캐싱 패턴

lru_cache 모듈은 DRBD 외에도 블록 디바이스 레벨의 범용 캐싱 패턴을 구현하는 데 활용할 수 있습니다. 고정 크기 원소 풀, 해시 기반 O(1) 조회, 자동 LRU 교체를 제공하므로, I/O 경로에서 할당 실패 없이 안전한 캐싱이 가능합니다.

블록 메타데이터 캐시 구현 예제

// 블록 디바이스 메타데이터를 lru_cache로 캐싱하는 예제
#include <linux/lru_cache.h>

/* 캐시 원소: lc_element을 내장하는 확장 구조체 */
struct block_meta_entry {
    struct lc_element   lce;           // lru_cache 연결
    sector_t            start_sector;  // 시작 섹터
    unsigned int        flags;         // DIRTY, VALID 등
    u8                  metadata[512]; // 메타데이터 버퍼
};

static struct kmem_cache *meta_slab;
static struct lru_cache *meta_cache;

/* 초기화 */
static int meta_cache_init(unsigned int nr_entries)
{
    meta_slab = kmem_cache_create("block_meta",
        sizeof(struct block_meta_entry), 0, 0, NULL);
    if (!meta_slab)
        return -ENOMEM;

    meta_cache = lc_create("block_meta_cache", meta_slab,
        0,           // max_pending_changes
        nr_entries,  // 원소 수 (예: 4096)
        sizeof(struct block_meta_entry),
        offsetof(struct block_meta_entry, lce));
    if (!meta_cache) {
        kmem_cache_destroy(meta_slab);
        return -ENOMEM;
    }
    return 0;
}

/* 메타데이터 읽기: 캐시 히트 시 즉시 반환, 미스 시 디스크에서 로드 */
static struct block_meta_entry *meta_cache_read(
    unsigned int block_nr)
{
    struct lc_element *e;
    struct block_meta_entry *entry;

    if (!lc_try_lock(meta_cache))
        return NULL;   // 잠금 실패, 재시도 필요

    e = lc_get(meta_cache, block_nr);
    if (!e) {
        lc_unlock(meta_cache);
        return NULL;   // 모든 슬롯 사용 중
    }

    entry = container_of(e, struct block_meta_entry, lce);

    if (!(entry->flags & META_VALID)) {
        // 캐시 미스: 디스크에서 메타데이터 로드
        load_metadata_from_disk(block_nr, entry->metadata);
        entry->start_sector = block_nr * SECTORS_PER_BLOCK;
        entry->flags |= META_VALID;
    }

    lc_unlock(meta_cache);
    return entry;
}

/* 메타데이터 해제: 참조 카운트 감소 */
static void meta_cache_release(
    struct block_meta_entry *entry)
{
    lc_put(meta_cache, &entry->lce);
}

/* 해제 */
static void meta_cache_destroy(void)
{
    lc_destroy(meta_cache);
    kmem_cache_destroy(meta_slab);
}
lru_cache vs list_lru 선택 기준
  • lru_cache를 선택하는 경우: 고정 크기 캐시, 키 기반 조회 필요, I/O 경로에서 할당 실패 불허, 트랜잭션 지원 필요
  • list_lru를 선택하는 경우: 동적 크기 캐시, NUMA/memcg 인식 필요, Shrinker 연동 필요, 대규모 항목 관리
  • lru_cache는 Shrinker와 직접 연동되지 않으므로, 메모리 압박 시 자동 회수가 필요하면 list_lru를 사용해야 합니다
특성lru_cache (lib/lru_cache.c)list_lru (mm/list_lru.c)
크기고정 (사전 할당)동적 (런타임 추가/제거)
키 조회해시 테이블 O(1)없음 (리스트 순회만)
NUMA미지원노드별 분리 지원
memcg미지원cgroup별 분리 지원
Shrinker미연동직접 연동
참조 카운트내장 (refcnt)외부 관리 필요
트랜잭션지원 (to_be_changed)미지원
주요 사용처DRBD 활동 로그dentry, inode, 페이지 캐시
런타임 할당없음 (안전)있음 (GFP 플래그 의존)

참고 자료

커널 소스 참조

  • include/linux/lru_cache.hlru_cache 모듈의 구조체(lc_element, lru_cache)와 API(lc_get, lc_put, lc_find) 선언입니다
  • lib/lru_cache.clru_cache 모듈의 해시 테이블 기반 조회, LRU 교체, 참조 카운팅 구현입니다
  • drivers/block/drbd/drbd_actlog.c — DRBD 활동 로그 관리 코드로, lru_cache의 대표적인 사용 사례입니다
  • include/linux/list_lru.hlist_lru API 정의 및 NUMA 인식 LRU 리스트 구조체를 선언합니다
  • mm/list_lru.clist_lru_add(), list_lru_del(), list_lru_isolate() 등 LRU 리스트 연산의 핵심 구현입니다
  • include/linux/mm_inline.hfolio_lru_list(), lru_to_page() 등 folio LRU 인라인 함수를 정의합니다
  • mm/vmscan.c — 페이지 회수(Page Reclaim) 메인 로직, shrink_lruvec(), MGLRU 에이징/축출 구현을 포함합니다
  • include/linux/shrinker.h — Shrinker 인터페이스 정의로, LRU 캐시와 메모리 회수 경로를 연결합니다
  • include/linux/mmzone.hlruvec, lru_gen_folio, lru_gen_mm_walk 구조체가 정의되어 있습니다
  • mm/swap.cfolio_mark_accessed(), folio_activate() 등 LRU 리스트 간 이동 로직입니다
  • mm/workingset.c — 워킹셋 감지, shadow entry 관리, refault distance 계산을 구현합니다
  • fs/dcache.c — Dentry 캐시의 LRU 관리 및 dentry_lru_isolate() shrinker 콜백 구현입니다
  • fs/inode.c — Inode 캐시의 LRU 관리 및 inode_lru_isolate() shrinker 콜백 구현입니다

외부 참고 문서

관련 페이지