LRU 개념
LRU(Least Recently Used)는 캐시 교체 알고리즘으로, 가장 오랫동안 사용되지 않은 항목을 우선적으로 제거합니다. 리눅스 커널은 제한된 메모리에서 최대한 많은 데이터를 캐싱하기 위해 LRU를 광범위하게 사용합니다.
- 페이지 캐시: 파일 I/O를 위한 디스크 블록 캐시
- inode 캐시: 파일 메타데이터 캐시
- dentry 캐시: 디렉토리 엔트리 경로 조회 캐시
- Slab 캐시: 커널 객체 할당자
LRU 존 분류
커널 페이지 캐시는 LRU를 활성(Active)과 비활성(Inactive) 두 개의 리스트로 분리하여 관리합니다:
한 번만 읽히고 다시 참조되지 않는 "스캔 패턴"으로부터 자주 사용되는 페이지를 보호하기 위함입니다. 새 페이지는 Inactive 리스트에 추가되며, 재참조 시에만 Active로 승격됩니다.
list_lru API
list_lru는 커널 3.12에서 도입된 범용 LRU 리스트 관리 API로,
inode 캐시와 dentry 캐시 등에서 사용됩니다. NUMA 인식 및 메모리 cgroup 지원을 제공합니다.
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 필드는 캐시 항목 회수 비용을 나타냅니다. 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,
};
커널 사용 사례
프로덕션 환경에서는 /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);
}'
메모리 압박 시 동작
메모리 부족 시 커널은 다음 순서로 회수를 시도합니다:
- Inactive LRU 페이지 회수 (clean 페이지 우선)
- Active LRU를 Inactive로 이동
- Slab 캐시 회수 (dentry, inode 등)
- Dirty 페이지 디스크에 기록 후 회수
- Swap 활용 (익명 페이지(Anonymous Page))
위 회수 단계를 모두 거쳤음에도 메모리가 부족하면 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
커널 모듈 디버깅 시 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
};
- INACTIVE_ANON -- 비활성 익명 페이지 (스왑(Swap) 후보)
- 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;
}
기존 struct page 기반 LRU에서는 compound 페이지의 tail 페이지가
실수로 LRU에 추가되는 버그가 빈번했습니다. Folio 추상화로 이러한 오류를 컴파일 타임에
방지할 수 있게 되었습니다. 또한, 대형 folio(huge page)를 하나의 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, 아무것도 하지 않음 */
}
리눅스 커널은 순수 LRU가 아닌 "Second Chance" (Clock) 변형을 사용합니다.
Inactive 리스트의 페이지를 회수하기 전에 PG_referenced 비트를 확인합니다.
비트가 설정되어 있으면 해당 페이지에게 한 번 더 기회를 주고(비트를 클리어하고 리스트 끝으로 이동),
비트가 클리어된 상태에서만 실제로 회수합니다.
Multi-Gen LRU (MGLRU)
Multi-Gen LRU는 Linux 6.1에서 도입된 차세대 페이지 회수 프레임워크입니다. 기존의 Active/Inactive 2-리스트 LRU를 여러 세대(generation)로 확장하여, 페이지 접근 패턴을 더 정밀하게 추적하고 회수 정확도를 크게 향상시킵니다.
기존 2-리스트 LRU는 워크로드가 복잡해질수록 한계를 보였습니다.
특히 대규모 메모리 시스템에서 Active/Inactive 리스트의 균형 조절이 어렵고,
shrink_active_list()에서 대량의 PTE accessed 비트 스캔으로
CPU 오버헤드가 발생했습니다. MGLRU는 Google에서 개발하여 Android, ChromeOS,
서버 워크로드에서 최대 40% 성능 개선을 보고했습니다.
세대 기반 에이징
MGLRU는 페이지를 여러 세대(generation)로 분류합니다. 가장 최근 세대(youngest)의 페이지가 가장 보호되고, 가장 오래된 세대(oldest)의 페이지가 우선 회수됩니다.
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는 CONFIG_LRU_GEN 설정으로 컴파일 시 포함되며,
런타임에 /sys/kernel/mm/lru_gen/enabled로 활성화/비활성화할 수 있습니다.
MGLRU가 비활성화되면 기존 Active/Inactive 2-리스트 LRU로 자동 폴백됩니다.
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
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);
}
}
페이지가 회수되면 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에서는 memory.high와 memory.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 방지
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_node | NUMA 노드당 1개 | 노드별 잠금 + LRU 리스트 |
list_lru_one | 노드×memcg당 1개 | 실제 이중 연결 리스트 + 카운터 |
list_lru_memcg | 노드당 1개 | memcg ID→list_lru_one 매핑 배열 |
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;
}
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별 스캔
| 단계 | 함수 | 동작 |
|---|---|---|
| 1 | shrink_slab_memcg() | memcg의 shrinker 비트맵(Bitmap) 순회 |
| 2 | do_shrink_slab() | shrinker별 count/scan 호출 |
| 3 | list_lru_shrink_count() | 해당 memcg의 nr_items 반환 |
| 4 | list_lru_shrink_walk() | 해당 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" 없이 즉시 보호
}
}
"이 페이지가 회수된 후, 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
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;
};
기본적으로 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는 회수 우선순위 낮음 (보호)
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)
존재하지 않는 파일에 대한 경로 탐색 결과를 캐싱합니다. 예: 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_LIST | LRU에 등록됨 | d_lru_add 시 설정 |
DCACHE_REFERENCED | 최근 참조됨 | LRU_ROTATE (second chance) |
DCACHE_SHRINK_LIST | shrink 격리 리스트 | 회수 진행 중 |
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);
}
I_DIRTY 상태인 inode는 LRU에 추가되지 않습니다. dirty 데이터가 디스크에
기록된 후(writeback_single_inode), dirty 플래그가 클리어되면
inode_add_lru()를 통해 LRU에 진입합니다. 이는 dirty inode를 회수하면
데이터 손실이 발생하기 때문입니다.
inode eviction 순서
| 단계 | 함수 | 동작 |
|---|---|---|
| 1 | iput() | i_count-- → 0이면 inode_add_lru() |
| 2 | prune_icache_sb() | shrinker 콜백, LRU 순회 |
| 3 | inode_lru_isolate() | 회수 가능 여부 판단, dirty/referenced 검사 |
| 4 | evict() | inode를 VFS에서 제거, 페이지 캐시 truncate |
| 5 | destroy_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;
}
페이지 캐시 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
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 batch | PAGEVEC_SIZE | 용도 |
|---|---|---|
lru_add | 31 | 새 folio를 LRU에 추가 |
lru_deactivate_file | 31 | 파일 folio를 Inactive로 강등 |
lru_deactivate | 31 | folio를 Inactive로 강등 |
lru_lazyfree | 31 | MADV_FREE folio 처리 |
activate | 31 | folio를 Active로 승격 |
커스텀 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-free | shrinker가 회수한 객체를 다른 경로에서 접근 | refcount + RCU 조합 필수 |
| double-free | LRU와 직접 경로 양쪽에서 해제 | list_lru_del 반환값 확인 |
| 잠금 역전(deadlock) | shrinker 콜백에서 LRU 잠금 외 다른 잠금 획득 | shrinker 콜백 내 잠금 최소화 |
| LRU 누수 | nr_items 계속 증가, 메모리 회수 안 됨 | 모든 해제 경로에서 list_lru_del 호출 |
| per-CPU batch 미 drain | LRU 카운트 불일치 | 정확한 카운트 필요 시 drain 선행 |
읽기 측: rcu_read_lock() + refcount_inc_not_zero()로 잠금 없이 조회.
해제 측: list_lru_isolate() 후 call_rcu()로 grace period 보장.
이 조합으로 읽기 측은 완전히 lock-free가 되며, 해제는 안전하게 지연됩니다.
lru_cache 모듈: 해시(Hash)+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 — NUMA 인식, memcg 지원, Shrinker 연동에 특화된 범용 LRU 리스트 API입니다. 페이지 캐시, dentry, inode 캐시 등 대규모 메모리 관리에 사용됩니다.
- lru_cache — 고정 크기 원소 풀(pool), 해시 기반 키 조회, 참조 카운팅, 트랜잭션(Transaction) 잠금을 통합한 블록 디바이스 캐시 모듈입니다. 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_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_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()은 비차단(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) 평균 | 아니요 | 존재 여부 확인 (읽기 전용(Read-Only)) |
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 활동 로그 튜닝
# 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를 선택하는 경우: 고정 크기 캐시, 키 기반 조회 필요, 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 플래그 의존) |
Linux 6.12 ~ 6.19 LRU Cache / list_lru + Shrinker 동향
LRU 캐시 인프라(list_lru + shrinker)는 6.7에 머지된 Qi Zheng의 refcount+RCU 기반 lockless slab shrink 재설계가 2024~2025년 구간에 완전히 정착된 단계입니다. 6.12는 "리팩토링 후속 안정화 버전"이고, 6.17의 per-node proactive reclaim이 가장 의미 있는 거동 변화입니다. 6.18(LTS)에서는 MGLRU proactive reclaim 통계가 memcg 단위로 정교화되었고, 6.19에서는 memcg pressure를 통한 네트워크 스로틀링 추적과 메모리 실패 경로의 folio 분할 최적화가 추가되었습니다.
| 커널 | 릴리스 | 변경 사항 | 실무 시사점 |
|---|---|---|---|
| 6.12 (LTS) | 2024-11 | 모든 shrinker가 shrinker_alloc() + shrinker_register() 패턴으로 통일(구 API 제거 완료). 6.7 재설계가 완전히 안정화 | shrinker 등록이 2단계(alloc+register)로 확정 — 드라이버 포팅 시 구 API 흔적이 남으면 컴파일 오류 |
| 6.13 | 2025-01 | Shadow entry 제거 경로 최적화(파일 truncation, 2커밋). list_lru/shrinker 코어 API는 안정 | 대용량 파일 truncate 경로의 XArray shadow entry 정리가 빨라짐 |
| 6.14 | 2025-03 | MGLRU 성능 최적화 다수. shrinker 코어 API 변경 없음 | — |
| 6.15 | 2025-05 | 메인 API 안정 — 주변 확장만 | — |
| 6.16 | 2025-07 | 메인 API 안정 — 주변 확장만. futex·자동 가중치 NUMA 인터리브 등 인접 영역 변화 | — |
| 6.17 | 2025-09 | Per-node proactive reclaim 도입 — memcg 범위를 넘어 노드 단위로 reclaim 트리거 가능. list_lru 자체 자료구조는 유지되나 aging 드라이브 주체 추가 | 컨테이너 외부(호스트 레벨) 메모리 압박 시나리오에서 shrinker 호출 빈도·타이밍이 달라질 수 있음 — 벤치마크 시 유의 |
| 6.18 (LTS) | 2025-11 | MGLRU proactive reclaim 통계 정교화 — mm/mglru: Update MG-LRU proactive reclaim statistics only to memcg 패치로 proactive reclaim 통계가 memcg 단위에서만 계상됨(글로벌 통계 중복 집계 제거). list_lru/shrinker 코어 API 변경 없음. SLUB sheaves(per-CPU slab 캐싱 레이어) 도입으로 슬랩 할당·해제 성능 향상 | proactive reclaim 활성화 환경에서 /sys/fs/cgroup/.../memory.reclaim 통계가 더 정확해짐 — 컨테이너 메모리 회계 시 유의 |
| 6.19 | 2026-02 | 메인 API 안정. memcg pressure를 통한 네트워크 스로틀링 추적 추가 — 네트워크 송신 경로에서 발생하는 메모리 압박을 memcg 단위로 추적. 메모리 실패(memory failure) 경로에서 folio 분할 최적화. list_lru/shrinker 코어 자료구조 변경 없음 | 네트워크 집약적 워크로드에서 memcg 메모리 압박 통계가 더 정확해짐 — OOM 킬러 튜닝 및 리소스 제한 설정에 참고 |
shrinker_alloc()(속성 설정) → shrinker_register()(활성화) 2단계 패턴을 사용해야 합니다. 6.12+에서는 구 API가 존재하지 않습니다. (2) list_lru는 <node, memcg> 2차원 구조를 유지하므로, memcg 활성 빌드에서도 메모리 과다 사용을 우려할 필요는 없습니다. (3) 6.17+ 커널에서 proactive_reclaim 인터페이스를 노드 단위로 호출할 수 있으므로, 호스트 레벨 메모리 여유 확보 정책을 컨테이너 독립적으로 운용 가능합니다. (4) 6.18(LTS)부터 MGLRU proactive reclaim 통계가 memcg 범위에서만 집계되므로, 통계 기반 모니터링 도구를 사용하는 경우 집계 방식 변화를 확인하세요. (5) list_lru + shrinker 기반 캐시를 구현할 때는 반드시 SHRINKER_MEMCG_AWARE 플래그 설정을 검토하세요 — 컨테이너 환경에서 공정한 reclaim에 필수입니다.
참고자료
커널 소스 참조
- include/linux/lru_cache.h —
lru_cache모듈의 구조체(lc_element,lru_cache)와 API(lc_get,lc_put,lc_find) 선언입니다 - lib/lru_cache.c —
lru_cache모듈의 해시 테이블 기반 조회, LRU 교체, 참조 카운팅 구현입니다 - drivers/block/drbd/drbd_actlog.c — DRBD 활동 로그 관리 코드로,
lru_cache의 대표적인 사용 사례입니다 - include/linux/list_lru.h —
list_lruAPI 정의 및 NUMA 인식 LRU 리스트 구조체를 선언합니다 - mm/list_lru.c —
list_lru_add(),list_lru_del(),list_lru_isolate()등 LRU 리스트 연산의 핵심 구현입니다 - include/linux/mm_inline.h —
folio_lru_list(),lru_to_page()등 folio LRU 인라인 함수(Inline Function)를 정의합니다 - mm/vmscan.c — 페이지 회수(Page Reclaim) 메인 로직,
shrink_lruvec(), MGLRU 에이징/축출(Eviction) 구현을 포함합니다 - include/linux/shrinker.h — Shrinker 인터페이스 정의로, LRU 캐시와 메모리 회수 경로를 연결합니다
- include/linux/mmzone.h —
lruvec,lru_gen_folio,lru_gen_mm_walk구조체가 정의되어 있습니다 - mm/swap.c —
folio_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 콜백 구현입니다
외부 참고 문서
- Page Reclaim — The Linux Kernel documentation — 커널 공식 문서로 페이지 회수 메커니즘과 LRU 리스트 관리를 설명합니다
- Unevictable LRU Infrastructure — 커널 공식 문서로
mlock()된 페이지의 LRU 격리 메커니즘을 설명합니다 - Multi-Gen LRU — Kernel Admin Guide — MGLRU의 활성화 방법, 튜닝 파라미터, 성능 특성을 다루는 관리자 가이드입니다
- Multigen LRU: a new page reclaim framework (LWN, 2022) — MGLRU가 메인라인에 병합될 당시 아키텍처와 설계 철학을 상세히 분석합니다
- The multi-generational LRU (LWN, 2021) — Yu Zhao가 제안한 MGLRU의 초기 설계 개념과 기존 active/inactive 방식의 한계를 소개합니다
- Multigenerational LRU: the next generation (LWN, 2021) — MGLRU v2 패치(Patch) 시리즈의 주요 변경 사항과 커뮤니티 피드백을 다룹니다
- Per-superblock LRU lists (LWN, 2013) — 파일시스템별 LRU 분리를 통한
list_lru인프라 도입 배경을 설명합니다 - Better active/inactive list balancing (LWN, 2012) — active/inactive 리스트 간 균형 조정 알고리즘의 개선 과정을 분석합니다
- Page replacement for huge memory systems (LWN, 2008) — 대용량 메모리 시스템에서 기존 LRU 스캔의 확장성 문제를 논의합니다
- MGLRU patchset v15 (lore.kernel.org) — Yu Zhao의 MGLRU 최종 패치 시리즈로, 메인라인 병합에 사용된 버전입니다
- MGLRU performance data (LWN, 2022) — Chrome OS, Android, 서버 워크로드에서의 MGLRU 성능 벤치마크 결과를 제시합니다
- LPC 2022: Multi-Gen LRU — Yu Zhao — Linux Plumbers Conference 2022에서 Yu Zhao가 MGLRU의 설계와 성능을 발표한 영상입니다
- An overview of memory reclaim (LWN, 2018) — 리눅스 커널의 전체 메모리 회수 아키텍처를 개괄적으로 설명합니다
- Memory management notifiers and read-only mappings (LWN, 2007) — LRU와 연관된 페이지 에이징에서 accessed 비트 처리 방식을 다룹니다
- Linked List - LRU의 기본 자료구조
- Red-Black Tree - 인덱스 검색용 자료구조
- XArray - 페이지 캐시의 새로운 인덱싱 구조 (shadow entry 저장)
- Per-CPU - NUMA 인식 캐시
- Slab Allocator - 커널 객체 캐시 및 Shrinker 연동
- Swapping 서브시스템 - 익명 페이지 회수와 swap
- Shrinker - 메모리 압박 시 캐시 회수 메커니즘
- 메모리 관리 개요 - 전체 메모리 관리 체계
- Huge Pages - 대형 folio와 LRU 관리
- NUMA - 노드별 LRU 분리와 회수 정책
- MMU & TLB - PTE accessed 비트와 에이징