Page Cache 심화
Linux 커널 Page Cache의 동작을 캐시 적중률이 아니라 전체 I/O 파이프라인 관점에서 분석합니다. address_space와 folio 자료구조, 파일 읽기/쓰기 시 readahead·writeback 경로, LRU 기반 회수와 메모리 압박 대응, mmap/Direct I/O와의 상호작용, dirty 페이지 임계치와 플러시 정책, cachestat 및 tracepoint 기반 관측·튜닝 방법까지 운영 환경에서 바로 적용할 수 있도록 상세히 설명합니다.
핵심 요약
- 계층 이해 — VFS, 캐시, 하위 FS 경계를 구분합니다.
- 메타데이터 우선 — inode/dentry 일관성을 먼저 확인합니다.
- 저장 정책 — 저널링/압축/할당 정책 차이를 비교합니다.
- 일관성 모델 — 로컬/원격/합성 FS의 반영 시점을 구분합니다.
- 복구 관점 — 장애 시 재구성 경로를 함께 점검합니다.
단계별 이해
- 경계 계층 파악
요청이 VFS에서 어디로 내려가는지 확인합니다. - 메타/데이터 분리
어느 경로에서 무엇이 갱신되는지 나눠 봅니다. - 동기화/플러시 확인
쓰기 반영 시점과 순서를 검증합니다. - 복구 시나리오 점검
비정상 종료 후 일관성 회복을 확인합니다.
Page Cache 개요
Page Cache는 디스크(또는 블록 디바이스)에서 읽은 파일 데이터를 메모리에 캐싱하는 커널 서브시스템입니다. 파일을 읽을 때마다 느린 디스크 I/O를 수행하는 대신, 한 번 읽은 데이터를 페이지 단위로 메모리에 보관하여 이후 접근 시 즉시 반환합니다. 쓰기 시에도 데이터를 먼저 Page Cache에 기록(dirty 페이지)한 뒤, 백그라운드에서 디스크에 플러시(writeback)합니다.
Page Cache의 역할
- 읽기 캐싱: 파일 데이터를 메모리에 보관하여 반복 읽기 시 디스크 I/O 제거
- 쓰기 버퍼링: 쓰기 데이터를 메모리에 먼저 기록하고 비동기적으로 디스크에 플러시
- readahead: 순차 읽기 패턴을 감지하여 미리 데이터를 디스크에서 프리페치
- mmap 지원: 파일을 가상 주소 공간에 매핑할 때 Page Cache의 페이지를 직접 사용
- 공유: 여러 프로세스가 동일 파일을 읽으면 하나의 캐시된 페이지를 공유
/proc/meminfo의 Cached 필드가 Page Cache 크기를 나타냅니다.
읽기/쓰기 흐름 요약
읽기 (Buffered Read) 경로:
- 사용자 프로세스가
read()시스템 콜 호출 - VFS가
file->f_op->read_iter()호출 (대부분generic_file_read_iter()) - Page Cache에서 해당 오프셋의 folio를 검색 (
filemap_get_folio()) - 캐시 히트: 즉시 사용자 버퍼에 복사
- 캐시 미스: 디스크에서 읽기 I/O 수행 후 Page Cache에 삽입, 사용자 버퍼에 복사
쓰기 (Buffered Write) 경로:
- 사용자 프로세스가
write()시스템 콜 호출 - VFS가
file->f_op->write_iter()호출 (대부분generic_file_write_iter()) - 대상 folio를 Page Cache에서 찾거나 새로 할당
- 사용자 데이터를 folio에 복사하고 dirty로 표시
- writeback 스레드가 나중에 dirty 페이지를 디스크에 플러시
address_space 구조체
struct address_space는 Page Cache의 핵심 데이터 구조입니다. 각 inode(파일)는 하나의 address_space를 가지며, 해당 파일의 모든 캐시된 페이지를 관리합니다. 파일 오프셋을 인덱스로 사용하는 XArray(i_pages)에 folio를 저장합니다.
/* include/linux/fs.h */
struct address_space {
struct inode *host; /* 소유 inode */
struct xarray i_pages; /* 캐시된 folio를 담는 XArray */
struct rw_semaphore invalidate_lock; /* 무효화 보호 */
gfp_t gfp_mask; /* 페이지 할당 플래그 */
atomic_t i_mmap_writable; /* VM_SHARED 매핑 카운터 */
struct rb_root_cached i_mmap; /* mmap 영역 트리 */
unsigned long nrpages; /* 캐시된 페이지 수 */
pgoff_t writeback_index; /* writeback 시작 위치 */
const struct address_space_operations *a_ops; /* 연산 테이블 */
unsigned long flags; /* AS_* 플래그 */
struct rw_semaphore i_mmap_rwsem; /* i_mmap 보호 */
errseq_t wb_err; /* writeback 에러 시퀀스 */
spinlock_t private_lock; /* 프라이빗 데이터 보호 */
struct list_head private_list; /* buffer_head 등 */
void *private_data; /* fs 전용 데이터 */
};
address_space_operations
파일시스템은 address_space_operations를 구현하여 Page Cache가 파일시스템별 I/O를 수행하도록 합니다.
/* include/linux/fs.h */
struct address_space_operations {
int (*writepage)(struct page *page, struct writeback_control *wbc);
int (*writepages)(struct address_space *, struct writeback_control *);
bool (*dirty_folio)(struct address_space *, struct folio *);
int (*read_folio)(struct file *, struct folio *);
void (*readahead)(struct readahead_control *);
int (*write_begin)(struct file *, struct address_space *,
loff_t pos, unsigned len,
struct page **pagep, void **fsdata);
int (*write_end)(struct file *, struct address_space *,
loff_t pos, unsigned len, unsigned copied,
struct page *page, void *fsdata);
sector_t (*bmap)(struct address_space *, sector_t);
int (*swap_activate)(struct swap_info_struct *, struct file *,
sector_t *);
void (*swap_deactivate)(struct file *);
int (*swap_rw)(struct kiocb *, struct iov_iter *);
/* ... */
};
radix_tree_root page_tree를 사용했습니다. 현재는 XArray로 대체되어 잠금 관리가 간결해지고, 멀티오더 엔트리를 통해 folio(compound page)를 효율적으로 저장합니다. XArray에 대한 자세한 내용은 XArray 문서를 참고하세요.
AS_* 플래그
| 플래그 | 설명 |
|---|---|
AS_EIO | I/O 에러 발생 |
AS_ENOSPC | 디스크 공간 부족 에러 |
AS_MM_ALL_LOCKS | 모든 매핑 잠금 보유 |
AS_UNEVICTABLE | 페이지 회수 불가 (ramfs 등) |
AS_EXITING | truncate 진행 중 |
AS_STABLE_WRITES | writeback 중 데이터 수정 금지 |
struct folio
folio는 커널 5.16에서 도입된 구조체로, Page Cache에서 struct page를 대체합니다. folio는 하나 이상의 연속된 물리 페이지(compound page)를 나타내며, 반드시 2의 거듭제곱 크기입니다. 기존 struct page API의 모호성(tail page인지 head page인지)을 제거하고, compound page를 자연스럽게 처리합니다.
/* include/linux/mm_types.h */
struct folio {
union {
struct {
unsigned long flags; /* 페이지 플래그 (PG_locked, PG_dirty, ...) */
union {
struct list_head lru; /* LRU 리스트 연결 */
};
struct address_space *mapping; /* 소유 address_space */
pgoff_t index; /* 파일 내 페이지 오프셋 인덱스 */
union {
void *private; /* fs 전용 (buffer_head 등) */
};
atomic_t _mapcount; /* 매핑 카운트 */
atomic_t _refcount; /* 참조 카운트 */
};
struct page page; /* struct page와 메모리 레이아웃 호환 */
};
};
주요 folio API
/* 참조 카운트 관리 */
void folio_get(struct folio *folio); /* 참조 카운트 증가 */
void folio_put(struct folio *folio); /* 참조 카운트 감소, 0이면 해제 */
/* 잠금 */
void folio_lock(struct folio *folio); /* PG_locked 설정 (대기 가능) */
bool folio_trylock(struct folio *folio); /* 비차단 잠금 시도 */
void folio_unlock(struct folio *folio); /* 잠금 해제 */
/* dirty 관련 */
bool folio_mark_dirty(struct folio *folio); /* dirty 표시 */
void folio_clear_dirty_for_io(struct folio *); /* I/O 전 dirty 해제 */
bool folio_test_dirty(struct folio *folio); /* dirty 상태 확인 */
/* writeback 관련 */
bool folio_test_writeback(struct folio *); /* writeback 진행 중인지 확인 */
void folio_start_writeback(struct folio *); /* writeback 시작 표시 */
void folio_end_writeback(struct folio *); /* writeback 완료 표시 */
void folio_wait_writeback(struct folio *); /* writeback 완료 대기 */
/* 크기/인덱스 */
size_t folio_size(struct folio *folio); /* folio 바이트 크기 */
unsigned int folio_order(struct folio *); /* folio order (0=4KB, 1=8KB, ...) */
unsigned long folio_nr_pages(struct folio *); /* folio 내 페이지 수 */
pgoff_t folio_index(struct folio *folio); /* 파일 내 인덱스 */
/* uptodate 관련 */
bool folio_test_uptodate(struct folio *); /* 데이터가 최신인지 */
void folio_mark_uptodate(struct folio *); /* 최신 상태로 표시 */
folio 주요 플래그
| 플래그 | 의미 | 설명 |
|---|---|---|
PG_locked | 잠금 | folio에 대한 배타적 접근을 보장 |
PG_uptodate | 최신 | 디스크에서 읽기 완료, 데이터가 유효함 |
PG_dirty | 더티 | 메모리 내용이 디스크와 다름 (쓰기 필요) |
PG_writeback | 기록 중 | 디스크에 쓰기 진행 중 |
PG_lru | LRU | LRU 리스트에 포함됨 |
PG_active | 활성 | 활성(active) LRU에 포함됨 |
PG_referenced | 참조됨 | 최근 접근됨 (LRU 승격 후보) |
PG_reclaim | 회수 대상 | 페이지 회수 대상으로 지정됨 |
PG_private | 프라이빗 | folio->private에 fs 데이터 존재 |
find_get_page() 대신 filemap_get_folio(), add_to_page_cache_lru() 대신 filemap_add_folio()를 사용합니다. 새로운 파일시스템 코드에서는 반드시 folio API를 사용해야 합니다.
대용량 Folio (Large Folio)
커널 5.18부터 Page Cache에서 large folio(4KB보다 큰 folio)를 적극적으로 활용합니다. large folio는 여러 연속 페이지를 하나의 단위로 관리하여, TLB 커버리지 증가, readahead 효율 개선, XArray 항목 감소 등의 이점을 제공합니다.
Large Folio 할당
/* mm/filemap.c — readahead에서 large folio 할당 */
struct folio *filemap_alloc_folio(gfp_t gfp, unsigned int order)
{
struct folio *folio;
/* order > 0이면 compound page (large folio) 할당 */
folio = folio_alloc(gfp, order);
/* order=0: 4KB, order=1: 8KB, order=2: 16KB, ... */
/* order=4: 64KB (일반적인 large folio 최대 크기) */
return folio;
}
/* mm/readahead.c — readahead에서 folio order 결정 */
static unsigned int ractl_max_order(
struct readahead_control *ractl,
pgoff_t index)
{
/* 파일시스템이 허용하는 최대 order */
unsigned int order = ractl->mapping->host->i_blkbits;
/* 현재 읽기 위치의 정렬에 따라 결정 */
/* index가 4로 정렬되면 order-2(16KB) 가능 */
/* index가 16으로 정렬되면 order-4(64KB) 가능 */
order = min(order, ffs(index) - 1);
return order;
}
Large Folio의 이점과 제약
| 이점 | 설명 |
|---|---|
| TLB 커버리지 | 단일 TLB 엔트리로 더 큰 범위 커버 → TLB 미스 감소 |
| XArray 효율 | multi-order 엔트리 → 트리 깊이/엔트리 수 감소 |
| I/O 효율 | 큰 bio 생성 가능 → 디스크 I/O 병합 개선 |
| 잠금 경합 | 하나의 잠금으로 여러 페이지 보호 → lock 횟수 감소 |
| dirty 추적 | folio 단위 dirty → 세밀도는 낮지만 overhead 감소 |
| 메모리 절약 | per-page 메타데이터 공유 → struct page 오버헤드 감소 |
| 제약/고려사항 | 설명 |
|---|---|
| 내부 단편화 | 4KB만 필요한데 64KB를 할당하면 나머지 낭비 |
| 파일 끝 처리 | 파일 끝이 folio 경계와 맞지 않으면 영(zero) 채움 필요 |
| 회수 단위 | 일부만 참조되어도 전체 large folio를 회수 불가 → splitting 필요 |
| 할당 실패 | 연속 물리 메모리 부족 시 fallback to order-0 |
| FS 지원 | 파일시스템이 large folio를 명시적으로 지원해야 함 |
/* 파일시스템의 large folio 지원 선언 */
/* 파일시스템이 inode 생성 시 mapping_set_large_folios() 호출 */
/* fs/ext4/inode.c */
static int ext4_set_aops(struct inode *inode)
{
/* ext4는 커널 6.3부터 large folio 지원 */
mapping_set_large_folios(inode->i_mapping);
inode->i_mapping->a_ops = &ext4_aops;
}
/* fs/xfs/xfs_aops.c */
/* XFS는 커널 6.0부터 large folio 지원 */
mapping_set_large_folios(inode->i_mapping);
/* mm/filemap.c — large folio splitting */
/* 부분적 truncate나 부분 회수 시 large folio를 분리 */
int folio_split(struct folio *folio, unsigned int new_order)
{
/* order-4(64KB) folio를 order-0(4KB) 16개로 분리 */
/* XArray 엔트리 재구성 + refcount 분배 */
}
/sys/kernel/debug/page_owner나 bpftrace로 실제 할당되는 folio order를 확인할 수 있습니다. 일반적으로 순차 읽기 시 커널이 자동으로 large folio를 할당하며, 랜덤 I/O에서는 order-0으로 fallback합니다.
페이지 캐시 조회와 삽입
Page Cache의 핵심 연산은 파일 오프셋으로 folio를 검색하는 조회(lookup)와 새로운 folio를 캐시에 추가하는 삽입(insert)입니다. 이 연산들은 mm/filemap.c에 구현되어 있습니다.
캐시 조회
/* mm/filemap.c - 기본 조회 */
struct folio *filemap_get_folio(struct address_space *mapping,
pgoff_t index);
/* 반환: folio 포인터 (참조 카운트 증가) 또는 ERR_PTR(-ENOENT)
* 호출자는 사용 후 folio_put() 필요 */
/* 잠금까지 수행하는 조회 */
struct folio *filemap_lock_folio(struct address_space *mapping,
pgoff_t index);
/* folio를 찾고 PG_locked를 설정한 뒤 반환 */
/* 범위 조회 - 여러 folio를 한 번에 가져옴 */
unsigned filemap_get_folios(struct address_space *mapping,
pgoff_t *start, pgoff_t end,
struct folio_batch *fbatch);
/* start~end 범위의 folio를 fbatch에 채워 반환 */
캐시 삽입
/* mm/filemap.c - folio 삽입 */
int filemap_add_folio(struct address_space *mapping,
struct folio *folio, pgoff_t index,
gfp_t gfp);
/* folio를 mapping의 i_pages XArray에 삽입
* 성공 시 0, 이미 존재하면 -EEXIST 반환
* folio를 LRU 리스트에도 추가 */
/* 조회 + 삽입을 원자적으로 수행 (캐시 미스 시 할당까지) */
struct folio *filemap_grab_folio(struct address_space *mapping,
pgoff_t index);
/* 캐시에 있으면 반환, 없으면 새 folio 할당 후 삽입하여 반환
* 반환된 folio는 locked 상태 */
실제 읽기 경로 예시
/* mm/filemap.c - generic_file_buffered_read() 핵심 로직 (단순화) */
static int filemap_read_folio(struct file *file,
struct address_space *mapping,
struct folio *folio)
{
int error;
/* 이미 최신이면 읽기 불필요 */
if (folio_test_uptodate(folio))
return 0;
/* 파일시스템의 read_folio 콜백 호출 */
error = mapping->a_ops->read_folio(file, folio);
if (!error) {
/* I/O 완료 대기 */
folio_wait_locked(folio);
if (!folio_test_uptodate(folio))
error = -EIO;
}
return error;
}
/* 읽기 메인 루프 (단순화) */
for (;;) {
struct folio *folio;
folio = filemap_get_folio(mapping, index);
if (IS_ERR(folio)) {
/* 캐시 미스: readahead 트리거 후 다시 시도 */
page_cache_sync_readahead(mapping, ra, file, index, count);
folio = filemap_get_folio(mapping, index);
if (IS_ERR(folio))
break;
}
if (!folio_test_uptodate(folio)) {
error = filemap_read_folio(file, mapping, folio);
if (error)
goto put_folio;
}
/* folio 데이터를 사용자 버퍼에 복사 */
copy_folio_to_iter(folio, offset, bytes, iter);
put_folio:
folio_put(folio);
}
Readahead
Readahead는 순차 읽기 패턴을 감지하여, 애플리케이션이 요청하기 전에 미리 디스크에서 데이터를 읽어 Page Cache에 적재하는 기법입니다. 디스크의 순차 읽기 성능을 최대한 활용하면서 애플리케이션의 I/O 대기 시간을 크게 줄입니다.
readahead_control 구조체
/* include/linux/pagemap.h */
struct readahead_control {
struct file *file; /* 읽기 대상 파일 */
struct address_space *mapping; /* 파일의 address_space */
pgoff_t _index; /* 현재 읽기 위치 (페이지 인덱스) */
unsigned int _nr_pages; /* 읽어야 할 총 페이지 수 */
unsigned int _batch_count; /* 현재 배치 크기 */
};
file_ra_state 구조체
/* include/linux/fs.h */
struct file_ra_state {
pgoff_t start; /* readahead 시작 인덱스 */
unsigned int size; /* readahead 창 크기 (페이지 수) */
unsigned int async_size; /* 비동기 readahead 시작 오프셋 */
unsigned int ra_pages; /* 최대 readahead 크기 */
unsigned int mmap_miss; /* mmap에서의 캐시 미스 카운터 */
loff_t prev_pos; /* 이전 읽기 위치 (바이트) */
};
적응형 Readahead 알고리즘
Linux의 readahead는 적응형(adaptive)입니다. 읽기 패턴에 따라 readahead 창 크기를 동적으로 조절합니다.
- 초기 읽기: 작은 창(보통 4페이지 = 16KB)으로 시작
- 순차 감지: 순차적 읽기가 계속되면 창 크기를 지수적으로 증가 (2배씩)
- 최대 크기:
ra_pages까지 (기본 128KB, 디바이스별 설정 가능) - 비동기 전환: 기존 readahead 데이터의 끝부분에 도달하면 비동기 readahead를 트리거하여 I/O 파이프라인 유지
- 랜덤 감지: 비순차적 접근이 감지되면 readahead 비활성화
/* mm/readahead.c - readahead 핵심 로직 (단순화) */
void page_cache_sync_readahead(struct address_space *mapping,
struct file_ra_state *ra,
struct file *file,
pgoff_t index, unsigned long req_count)
{
/* 순차 읽기인지 확인 */
if (index == ra->start + ra->size) {
/* 순차: readahead 창 확장 */
ra->start += ra->size;
ra->size = min(ra->size * 2, ra->ra_pages);
ra->async_size = ra->size;
} else {
/* 랜덤/초기: 작은 창으로 시작 */
ra->start = index;
ra->size = get_init_ra_size(req_count, ra->ra_pages);
ra->async_size = ra->size > req_count ? ra->size - req_count : 0;
}
ractl_init(&ractl, mapping, file, index, req_count);
do_page_cache_ra(&ractl, ra->size, ra->async_size);
}
fadvise를 통한 Readahead 제어
/* 사용자 공간에서 readahead 힌트 제공 */
#include <fcntl.h>
/* 순차 읽기 선언: readahead 적극적으로 수행 */
posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL);
/* 랜덤 읽기 선언: readahead 비활성화 */
posix_fadvise(fd, 0, 0, POSIX_FADV_RANDOM);
/* 곧 필요함: readahead를 즉시 시작 */
posix_fadvise(fd, offset, len, POSIX_FADV_WILLNEED);
/* 더 이상 필요 없음: 페이지 회수 힌트 */
posix_fadvise(fd, offset, len, POSIX_FADV_DONTNEED);
/* readahead 시스템 콜: 명시적 프리페치 */
readahead(fd, offset, count);
/* readahead 최대 크기 조정 */
/* /sys/block/sda/queue/read_ahead_kb (기본 128KB) */
read_ahead_kb를 늘리면 성능이 향상됩니다. 반면 랜덤 I/O 워크로드(데이터베이스)에서는 줄이거나 POSIX_FADV_RANDOM을 사용합니다.
Readahead와 Large Folio
커널 6.x에서 readahead는 가능한 경우 large folio를 할당하여 한 번에 더 큰 I/O를 생성합니다.
/* mm/readahead.c — large folio readahead */
void page_cache_ra_order(struct readahead_control *ractl,
struct file_ra_state *ra,
unsigned int new_order)
{
/* 적절한 folio order 결정 */
/* 파일시스템이 large folio를 지원하고
* 연속 페이지 범위가 충분히 크면 → 큰 order 할당 */
while (ractl->_nr_pages) {
unsigned int order = ractl_max_order(ractl, index);
order = min(order, new_order);
/* large folio 할당 (실패 시 order-0으로 fallback) */
struct folio *folio = filemap_alloc_folio(gfp, order);
if (!folio) {
order = 0;
folio = filemap_alloc_folio(gfp, 0);
}
/* Page Cache에 삽입 + readahead에 추가 */
filemap_add_folio(mapping, folio, index, gfp);
ractl->_nr_pages -= folio_nr_pages(folio);
index += folio_nr_pages(folio);
}
/* 파일시스템의 readahead 콜백 호출 → 실제 I/O */
read_pages(ractl);
}
async_size 영역에 진입하면 트리거됩니다. 이 시점에서 이전 readahead I/O는 이미 완료되었을 가능성이 높으므로, 새 readahead가 시작되어도 애플리케이션은 캐시 히트를 경험합니다. 이 파이프라인 효과가 순차 읽기 성능의 핵심입니다.
Writeback
Writeback은 Page Cache에 쌓인 dirty 페이지를 디스크에 기록하는 과정입니다. Linux는 쓰기를 즉시 디스크에 반영하지 않고, 메모리에 버퍼링한 뒤 일정 조건이 되면 백그라운드에서 플러시합니다. 이를 통해 쓰기 성능을 크게 향상시킵니다.
Writeback 트리거 조건
| 조건 | 트리거 | 설명 |
|---|---|---|
| 주기적 | dirty_writeback_centisecs | 5초(기본)마다 워커 스레드가 dirty 페이지 검사 |
| dirty 비율 초과 | dirty_background_ratio | dirty 페이지가 전체 메모리의 10%(기본) 초과 시 백그라운드 writeback 시작 |
| dirty 한계 초과 | dirty_ratio | dirty 페이지가 전체 메모리의 20%(기본) 초과 시 프로세스 쓰기 차단(throttle) |
| dirty 만료 | dirty_expire_centisecs | 30초(기본) 이상 dirty 상태인 페이지 플러시 |
| 명시적 sync | sync / fsync | 사용자가 명시적으로 디스크 기록 요청 |
| 메모리 부족 | kswapd / direct reclaim | 메모리 회수를 위해 dirty 페이지 먼저 기록 |
| 파일시스템 언마운트 | umount | 언마운트 전 모든 dirty 페이지 플러시 |
writeback_control 구조체
/* include/linux/writeback.h */
struct writeback_control {
long nr_to_write; /* 기록할 페이지 수 */
long pages_skipped; /* 건너뛴 페이지 수 */
loff_t range_start; /* 기록 범위 시작 (바이트) */
loff_t range_end; /* 기록 범위 끝 */
enum writeback_sync_modes sync_mode; /* WB_SYNC_NONE 또는 WB_SYNC_ALL */
unsigned tagged_writepages:1; /* 태그 기반 writeback */
unsigned for_kupdate:1; /* 주기적 writeback */
unsigned for_background:1; /* 백그라운드 writeback */
unsigned for_reclaim:1; /* 메모리 회수를 위한 writeback */
unsigned for_sync:1; /* sync() 호출에 의한 writeback */
};
BDI (Backing Device Info)
struct backing_dev_info(BDI)는 블록 디바이스의 writeback 특성을 기술합니다. 각 블록 디바이스마다 하나의 BDI가 있으며, 전용 writeback 워커 스레드를 관리합니다.
/* include/linux/backing-dev-defs.h */
struct backing_dev_info {
struct list_head bdi_list; /* 전역 BDI 리스트 */
unsigned long ra_pages; /* 디바이스 readahead 크기 */
unsigned long io_pages; /* 최대 I/O 크기 */
struct bdi_writeback wb; /* 기본 writeback 인스턴스 */
struct list_head wb_list; /* cgroup별 writeback 리스트 */
/* ... */
};
/* writeback 워커 */
struct bdi_writeback {
struct backing_dev_info *bdi; /* 소유 BDI */
struct list_head b_dirty; /* dirty inode 리스트 */
struct list_head b_io; /* writeback 진행 중 inode */
struct list_head b_more_io; /* 추가 writeback 대기 inode */
struct list_head b_dirty_time; /* inode 타임스탬프만 dirty */
struct delayed_work dwork; /* writeback 워크큐 작업 */
unsigned long dirty_ratelimit; /* dirty 속도 제한 */
unsigned long write_bandwidth; /* 추정 쓰기 대역폭 */
/* ... */
};
balance_dirty_pages
balance_dirty_pages()는 프로세스가 dirty 페이지를 생성할 때 호출되어, dirty 비율이 임계값을 초과하면 writeback을 트리거하거나 프로세스를 일시 정지(throttle)시킵니다.
/* mm/page-writeback.c - dirty 페이지 균형 조절 (핵심 로직 단순화) */
static void balance_dirty_pages(struct bdi_writeback *wb,
unsigned long pages_dirtied)
{
unsigned long dirty, thresh, bg_thresh;
for (;;) {
global_dirty_limits(&bg_thresh, &thresh);
dirty = global_node_page_state(NR_FILE_DIRTY) +
global_node_page_state(NR_WRITEBACK);
/* dirty 페이지가 임계값 이하면 반환 */
if (dirty <= dirty_freerun_ceiling(thresh, bg_thresh))
break;
/* 백그라운드 writeback 시작 */
if (dirty > bg_thresh)
wb_start_background_writeback(wb);
/* dirty_ratio 초과 시 프로세스 일시 정지 */
if (dirty > thresh) {
io_schedule_timeout(msecs_to_jiffies(100));
continue;
}
break;
}
}
Dirty 페이지 생명주기
페이지는 clean → dirty → writeback → clean의 상태 전이를 반복합니다. 각 전이는 명확한 커널 API를 통해 이루어지며, PG_dirty와 PG_writeback 플래그로 추적됩니다.
| 상태 전이 | API | 발생 시점 |
|---|---|---|
| Clean → Dirty | folio_mark_dirty() | write(), MAP_SHARED 쓰기, 메타데이터 변경 |
| Dirty → Writeback | folio_start_writeback() | writeback 워커, fsync, 메모리 회수 |
| Writeback → Clean | folio_end_writeback() | 디스크 I/O 완료 콜백 (bio end_io) |
| Writeback → Dirty | folio_mark_dirty() | writeback 중 재수정 (re-dirty) |
| Any → Reclaim | shrink_folio_list() | 메모리 부족 시 (dirty면 먼저 writeback) |
/proc/vmstat의 nr_dirty와 nr_writeback이 동시에 높은 값을 보이는 원인이 됩니다.
folio_mark_dirty() 내부 동작
folio_mark_dirty()는 페이지를 dirty로 표시하는 핵심 함수입니다. 단순히 플래그를 설정하는 것이 아니라, dirty 계정 갱신, inode를 dirty 리스트에 등록, PTE dirty bit 처리까지 수행합니다.
/* mm/page-writeback.c */
bool folio_mark_dirty(struct folio *folio)
{
struct address_space *mapping = folio_mapping(folio);
/* 익명 페이지(anonymous folio)는 항상 dirty */
if (unlikely(!mapping))
return !folio_test_set_dirty(folio);
/* 파일시스템별 dirty_folio 콜백 호출 */
if (mapping->a_ops->dirty_folio)
return mapping->a_ops->dirty_folio(mapping, folio);
return noop_dirty_folio(mapping, folio);
}
파일시스템마다 다른 dirty_folio 콜백을 구현합니다:
| 콜백 | 사용 파일시스템 | 동작 |
|---|---|---|
filemap_dirty_folio() | ext4, XFS, Btrfs 등 대부분 | PG_dirty 설정 + NR_FILE_DIRTY 증가 + inode dirty 마킹 |
block_dirty_folio() | buffer_head 기반 FS | 개별 buffer_head의 BH_Dirty 비트도 설정 |
noop_dirty_folio() | tmpfs, ramfs | PG_dirty만 설정 (디스크 기록 불필요) |
iomap_dirty_folio() | iomap 기반 FS (XFS 등) | iomap dirty 상태 + PG_dirty 설정 |
/* mm/folio-compat.c — filemap_dirty_folio() 핵심 로직 */
bool filemap_dirty_folio(struct address_space *mapping,
struct folio *folio)
{
/* 이미 dirty면 중복 처리 방지 */
if (folio_test_set_dirty(folio))
return 0;
/* XArray에 dirty 태그 설정 (writeback 시 빠른 검색용) */
__xa_set_mark(&mapping->i_pages,
folio_index(folio), PAGECACHE_TAG_DIRTY);
/* dirty 페이지 수 카운터 증가 */
__folio_account_dirtied(folio, mapping);
/* 소유 inode를 dirty inode 리스트에 추가 */
__mark_inode_dirty(mapping->host, I_DIRTY_PAGES);
return 1;
}
/* dirty 계정 처리 */
static void __folio_account_dirtied(struct folio *folio,
struct address_space *mapping)
{
long nr = folio_nr_pages(folio);
/* 글로벌 dirty 카운터 증가 */
__mod_node_page_state(folio_pgdat(folio), NR_FILE_DIRTY, nr);
__mod_lruvec_page_state(folio, NR_FILE_DIRTY, nr);
/* BDI별 dirty 카운터 증가 (per-BDI throttling에 사용) */
__inc_wb_stat(&mapping->host->i_sb->s_bdi->wb,
WB_DIRTIED, nr);
/* 태스크별 dirty 카운터 (balance_dirty_pages 판단 기준) */
current->nr_dirtied += nr;
}
filemap_get_folios_tag(PAGECACHE_TAG_DIRTY)로 dirty 페이지만 빠르게 찾을 수 있습니다. 수백만 개의 캐시 페이지 중 일부만 dirty인 경우 성능 차이가 매우 큽니다.
PTE Dirty Bit와 mmap 쓰기 추적
write() 시스템 콜은 커널이 직접 folio_mark_dirty()를 호출하므로 dirty 추적이 명확합니다. 그러나 mmap(MAP_SHARED)를 통한 쓰기는 CPU가 직접 메모리에 쓰므로, 커널이 하드웨어 PTE dirty bit을 활용하여 변경을 감지해야 합니다.
/* mmap 쓰기 감지 메커니즘 (3단계) */
/* 1단계: 초기 매핑 — PTE를 읽기 전용으로 설정 */
/* MAP_SHARED 파일 매핑이라도 PTE에 쓰기 권한을 주지 않음 */
/* 첫 번째 쓰기 시 write protection fault 발생 → 2단계 */
/* 2단계: page_mkwrite (write protection fault handler) */
vm_fault_t filemap_page_mkwrite(struct vm_fault *vmf)
{
struct folio *folio = page_folio(vmf->page);
folio_lock(folio);
folio_mark_dirty(folio); /* 소프트웨어 dirty 표시 */
folio_wait_stable(folio); /* writeback 중이면 완료 대기 */
return VM_FAULT_LOCKED;
/* 이후 PTE에 쓰기 권한 부여 → 이후 쓰기는 fault 없이 직접 수행 */
}
/* 3단계: writeback 전 — PTE dirty bit 수확 */
/* writeback 시작 전 folio_mkclean()으로 PTE를 다시 읽기 전용으로 변경 */
/* 이렇게 해야 writeback 완료 후 추가 수정을 다시 감지할 수 있음 */
/* mm/rmap.c — PTE dirty bit 수확 */
bool folio_mkclean(struct folio *folio)
{
bool cleaned = 0;
/* rmap을 통해 이 folio를 매핑하는 모든 PTE를 순회 */
struct rmap_walk_control rwc = {
.rmap_one = folio_mkclean_one, /* 개별 PTE 처리 함수 */
.arg = &cleaned,
};
rmap_walk(folio, &rwc);
return cleaned;
}
/* 개별 PTE를 읽기 전용으로 복원 */
static bool folio_mkclean_one(struct folio *folio,
struct vm_area_struct *vma,
unsigned long address, void *arg)
{
pte_t *ptep, entry;
ptep = pte_offset_map_lock(vma->vm_mm, ...);
entry = ptep_get(ptep);
/* HW dirty bit가 설정되어 있으면 수확 */
if (pte_dirty(entry)) {
entry = pte_mkclean(entry); /* dirty bit 클리어 */
entry = pte_wrprotect(entry); /* 쓰기 보호 복원 */
set_pte_at(vma->vm_mm, address, ptep, entry);
*(bool *)arg = 1;
}
pte_unmap_unlock(ptep, ...);
return 1; /* 계속 순회 */
}
folio_mkclean()은 PTE를 읽기 전용으로 변경한 후 TLB 플러시가 필요합니다. 수천 개의 프로세스가 같은 파일을 MAP_SHARED로 매핑한 경우 TLB 플러시 오버헤드가 상당할 수 있습니다. mmap 기반의 대규모 쓰기 워크로드에서는 이 점을 고려해야 합니다.
Writeback 워커 내부 흐름
Writeback은 per-BDI 워커 스레드(wb_workfn)에 의해 수행됩니다. 워커는 워크큐를 통해 주기적으로 또는 이벤트 기반으로 실행되며, dirty inode를 순회하면서 dirty 페이지를 디스크에 기록합니다.
/* fs/fs-writeback.c — writeback 메인 루프 (단순화) */
static long wb_writeback(struct bdi_writeback *wb,
struct wb_writeback_work *work)
{
long nr_pages = work->nr_pages;
unsigned long oldest_jiffies;
for (;;) {
/* dirty 만료 시간 계산 */
if (work->for_kupdate) {
oldest_jiffies = jiffies -
msecs_to_jiffies(dirty_expire_centisecs * 10);
}
/* b_dirty 리스트에서 만료된 inode를 b_io로 이동 */
queue_io(wb, work, oldest_jiffies);
/* b_io 리스트의 inode들에 대해 writeback 수행 */
if (!list_empty(&wb->b_io))
writeback_sb_inodes(wb, work);
/* 할당량 소진 또는 더 이상 dirty inode 없으면 종료 */
if (work->nr_pages <= 0 ||
list_empty(&wb->b_dirty))
break;
}
return nr_pages - work->nr_pages;
}
/* 개별 inode writeback */
static int __writeback_single_inode(struct inode *inode,
struct writeback_control *wbc)
{
int ret;
/* dirty 페이지를 디스크에 기록 (FS별 writepages 호출) */
ret = do_writepages(inode->i_mapping, wbc);
/* inode 메타데이터도 dirty면 기록 */
if (inode->i_state & I_DIRTY) {
int err = write_inode(inode, wbc);
if (ret == 0)
ret = err;
}
/* dirty 상태에 따라 inode를 적절한 리스트로 이동 */
requeue_inode(inode, wbc);
return ret;
}
Dirty Inode 상태 머신
inode는 dirty 데이터의 종류에 따라 세분화된 I_DIRTY_* 플래그로 관리됩니다. writeback 워커는 이 플래그들을 기반으로 어떤 데이터를 기록해야 하는지 결정합니다.
/* include/linux/fs.h — inode dirty 플래그 */
#define I_DIRTY_SYNC (1 << 0) /* 동기적 메타데이터 변경 (permissions, timestamps 등) */
#define I_DIRTY_DATASYNC (1 << 1) /* 파일 크기, 블록 할당 변경 (fdatasync 대상) */
#define I_DIRTY_PAGES (1 << 2) /* Page Cache에 dirty 페이지 존재 */
#define I_DIRTY_TIME (1 << 8) /* timestamp만 dirty (lazytime) */
/* 복합 플래그 */
#define I_DIRTY_INODE (I_DIRTY_SYNC | I_DIRTY_DATASYNC)
#define I_DIRTY (I_DIRTY_INODE | I_DIRTY_PAGES)
#define I_DIRTY_ALL (I_DIRTY | I_DIRTY_TIME)
| BDI 큐 | 내용 | 조건 |
|---|---|---|
b_dirty | dirty inode 대기열 | dirty 상태가 된 inode가 dirtied_when 순서로 삽입 |
b_io | writeback 진행 중 큐 | queue_io()가 만료된 inode를 b_dirty에서 이동 |
b_more_io | 재시도 대기 큐 | 한 번에 모든 페이지를 기록하지 못한 inode (대용량 파일) |
b_dirty_time | timestamp만 dirty | I_DIRTY_TIME만 설정된 inode (lazytime 마운트) |
/* fs/fs-writeback.c — inode를 dirty 리스트에 등록 */
void __mark_inode_dirty(struct inode *inode, int flags)
{
struct bdi_writeback *wb;
/* 새로운 dirty 플래그만 추가 (이미 설정된 건 무시) */
if ((inode->i_state & flags) == flags)
return;
inode->i_state |= flags;
/* 처음 dirty가 된 경우: BDI의 b_dirty 리스트에 추가 */
if (!(inode->i_state & I_DIRTY_ALL)) {
inode->dirtied_when = jiffies;
wb = locked_inode_to_wb_and_lock_list(inode);
inode_io_list_move_locked(inode, wb, &wb->b_dirty);
}
/* 백그라운드 writeback 시작 조건 확인 */
if (inode_attached_to_wb(inode))
wb_wakeup_delayed(wb);
}
/* writeback 완료 후 inode 재배치 */
static void requeue_inode(struct inode *inode,
struct writeback_control *wbc)
{
if (inode->i_state & I_DIRTY_PAGES) {
/* 아직 dirty 페이지 남음 → b_more_io로 이동 */
inode_io_list_move_locked(inode, wb, &wb->b_more_io);
} else if (inode->i_state & I_DIRTY) {
/* 메타데이터만 dirty → b_dirty에 유지 */
inode_io_list_move_locked(inode, wb, &wb->b_dirty);
} else {
/* 완전히 clean → 리스트에서 제거 */
inode_io_list_del_locked(inode, wb);
}
}
mount -o lazytime을 사용하면 inode timestamp 변경(atime, mtime)이 즉시 디스크에 기록되지 않고 b_dirty_time 큐에 대기합니다. 24시간 후 또는 sync 시에만 기록되어, 읽기 위주 워크로드에서 불필요한 메타데이터 I/O를 크게 줄입니다.
Per-BDI Dirty Throttling 상세
Linux의 dirty throttling은 단순한 임계값 비교가 아닙니다. 각 BDI(블록 디바이스)의 쓰기 대역폭을 실시간으로 추정하고, dirty 비율에 따라 프로세스의 쓰기 속도를 부드럽게 제어하는 정교한 피드백 루프입니다.
/* mm/page-writeback.c — Per-BDI Dirty Throttling 핵심 개념 */
/* bdi_writeback의 throttling 관련 필드 */
struct bdi_writeback {
unsigned long dirty_ratelimit; /* 현재 dirty 속도 제한 (pages/s) */
unsigned long balanced_dirty_ratelimit; /* 균형점 dirty 속도 */
unsigned long write_bandwidth; /* 추정 쓰기 대역폭 (pages/200ms) */
unsigned long avg_write_bandwidth; /* 평균 쓰기 대역폭 (지수 이동 평균) */
/* ... */
};
/* mm/page-writeback.c — pos_ratio 계산 (핵심 로직 단순화) */
static unsigned long wb_position_ratio(
struct bdi_writeback *wb,
unsigned long thresh, /* dirty_ratio 임계값 */
unsigned long bg_thresh, /* background_ratio 임계값 */
unsigned long dirty, /* 현재 global dirty 페이지 수 */
unsigned long wb_dirty) /* 현재 BDI dirty 페이지 수 */
{
unsigned long setpoint;
long long pos_ratio;
/* setpoint = freerun 상한과 thresh의 중간점 */
setpoint = (thresh + bg_thresh) / 2;
/* 글로벌 pos_ratio: dirty가 setpoint보다 높으면 1 미만 */
pos_ratio = (setpoint - dirty) * 100 /
(thresh - setpoint + 1);
/* per-BDI 보정: 이 BDI의 dirty 비율에 따라 추가 조정 */
/* 느린 디바이스는 더 적극적으로, 빠른 디바이스는 덜 제한 */
pos_ratio = wb_dirty_ratio_adjust(wb, pos_ratio,
wb_dirty, ...);
/* task_ratelimit = dirty_ratelimit * pos_ratio */
/* 이 값이 프로세스의 실제 dirty 속도 제한 */
return max(pos_ratio, 0);
}
/* 대역폭 추정: 지수 이동 평균으로 쓰기 속도 추적 */
static void wb_update_write_bandwidth(struct bdi_writeback *wb,
unsigned long elapsed,
unsigned long written)
{
unsigned long bw;
/* 이번 주기의 쓰기 대역폭 계산 */
bw = written * HZ / elapsed;
/* 지수 이동 평균: avg = (old * 7 + new) / 8 */
wb->avg_write_bandwidth =
(wb->avg_write_bandwidth * 7 + bw) / 8;
wb->write_bandwidth = bw;
}
Dirty 페이지 계정 (Accounting)
커널은 dirty 페이지 수를 여러 수준에서 추적합니다. 이 카운터들은 writeback 결정, throttling, 메모리 회수에 핵심적으로 사용됩니다.
| 카운터 | 수준 | 위치 | 용도 |
|---|---|---|---|
NR_FILE_DIRTY | NUMA 노드 | /proc/vmstat: nr_dirty | 글로벌 dirty 페이지 총 수 (balance_dirty_pages 판단 기준) |
NR_WRITEBACK | NUMA 노드 | /proc/vmstat: nr_writeback | 현재 디스크에 기록 중인 페이지 수 |
NR_WRITEBACK_TEMP | NUMA 노드 | /proc/vmstat: nr_writeback_temp | FUSE 등의 임시 writeback 페이지 |
NR_DIRTIED | NUMA 노드 | /proc/vmstat: nr_dirtied | 누적 dirty 표시 횟수 (대역폭 추정) |
NR_WRITTEN | NUMA 노드 | /proc/vmstat: nr_written | 누적 writeback 완료 횟수 (대역폭 추정) |
WB_DIRTIED | Per-BDI | BDI 내부 | BDI별 dirty 속도 추적 |
WB_WRITTEN | Per-BDI | BDI 내부 | BDI별 writeback 속도 추적 |
current->nr_dirtied | Per-task | task_struct | 태스크별 dirty 속도 (throttle 판단) |
# dirty 계정 모니터링
$ grep -E "^nr_(dirty|writeback|dirtied|written)" /proc/vmstat
nr_dirty 3456 # 현재 dirty 페이지 수
nr_writeback 128 # 현재 writeback 중인 페이지
nr_dirtied 98765432 # 누적 dirty 표시 횟수
nr_written 97654321 # 누적 writeback 완료 횟수
nr_writeback_temp 0 # FUSE 임시 writeback
# dirty 비율 실시간 추적 (초당 갱신)
$ while true; do
dirty=$(awk '/^Dirty:/{print $2}' /proc/meminfo)
wb=$(awk '/^Writeback:/{printf $2}' /proc/meminfo)
total=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)
echo "Dirty: ${dirty}kB ($(( dirty * 100 / total ))%) | WB: ${wb}kB"
sleep 1
done
# Per-BDI dirty 통계
$ cat /sys/block/sda/bdi/read_ahead_kb
$ cat /sys/block/nvme0n1/bdi/read_ahead_kb
# BDI writeback 상태 확인
$ cat /sys/kernel/debug/bdi/*/stats 2>/dev/null ||
cat /sys/kernel/debug/writeback/stats
Stable Pages
Stable pages는 writeback 진행 중 페이지 내용이 변경되지 않도록 보장하는 메커니즘입니다. 데이터 무결성이 필요한 파일시스템(저널링, 체크섬)에서 매우 중요합니다.
/* Stable page가 필요한 이유:
*
* 1. 블록 디바이스에 DMA 전송 중 페이지 내용이 변경되면
* 디스크에 부분적으로 구 데이터/신 데이터가 섞여 기록됨
*
* 2. Btrfs, ZFS 같은 체크섬 FS는 체크섬 계산 후
* 기록 전에 데이터가 변경되면 체크섬 불일치 발생
*
* 3. 저널링 FS는 저널에 기록한 데이터와
* 실제 디스크에 기록되는 데이터가 일치해야 함
*/
/* mm/page-writeback.c */
void folio_wait_stable(struct folio *folio)
{
/* BDI가 stable write를 요구하면 writeback 완료 대기 */
if (folio_inode(folio)->i_sb->s_iflags & SB_I_STABLE_WRITES)
folio_wait_writeback(folio);
}
/* folio_wait_writeback: PG_writeback 플래그가 클리어될 때까지 대기 */
void folio_wait_writeback(struct folio *folio)
{
while (folio_test_writeback(folio)) {
folio_wait_bit(folio, PG_writeback);
}
}
| 파일시스템 | Stable Pages 필요 | 이유 |
|---|---|---|
| Btrfs | 필요 | 데이터 체크섬 (CRC32C) 무결성 |
| ZFS | 필요 | 블록 체크섬 무결성 |
| ext4 (data=journal) | 필요 | 저널 데이터와 실제 데이터 일치 |
| ext4 (data=ordered) | 불필요 | 체크섬 미사용, 순서만 보장 |
| XFS | 불필요 | 메타데이터만 체크섬 (데이터 체크섬 없음) |
| NVMe (with metadata) | 필요 | PI(Protection Information) T10-DIF 체크섬 |
folio_wait_stable() 호출 위치가 최적화되어 불필요한 대기가 감소했습니다.
cgroup Writeback
커널 4.2부터 cgroup-aware writeback이 도입되어, cgroup별로 dirty 페이지를 독립적으로 추적하고 throttling합니다. 이를 통해 한 컨테이너의 대량 쓰기가 다른 컨테이너의 I/O 성능에 미치는 영향을 제한합니다.
/* cgroup writeback 구조 */
/* 각 cgroup은 BDI마다 별도의 bdi_writeback을 가짐 */
struct bdi_writeback {
struct backing_dev_info *bdi;
#ifdef CONFIG_CGROUP_WRITEBACK
struct cgroup_subsys_state *memcg_css; /* 소유 memory cgroup */
struct cgroup_subsys_state *blkcg_css; /* 소유 blkio cgroup */
struct list_head memcg_node; /* memcg의 wb 리스트 */
struct list_head blkcg_node; /* blkcg의 wb 리스트 */
#endif
/* 독립적인 dirty 카운터와 writeback 워커 */
struct list_head b_dirty;
struct list_head b_io;
unsigned long dirty_ratelimit;
unsigned long write_bandwidth;
/* ... */
};
/* inode가 어떤 cgroup의 writeback에 속하는지 결정 */
struct bdi_writeback *inode_to_wb(struct inode *inode)
{
/* inode->i_wb: 가장 많이 dirty한 cgroup의 wb 캐싱 */
return inode->i_wb;
}
/* cgroup 변경 시 inode wb 전환 */
/* inode는 여러 cgroup의 프로세스가 공유할 수 있음 */
/* 가장 많이 dirty하는 cgroup이 "소유권"을 가짐 */
/* 소유권이 바뀌면 inode_switch_wbs()로 비동기 전환 */
| 기능 | cgroup v1 | cgroup v2 |
|---|---|---|
| cgroup writeback 지원 | 제한적 (blkio) | 완전 지원 (io + memory) |
| Per-cgroup dirty throttling | 미지원 | 지원 (memory.high 연동) |
| inode 소유권 추적 | 미지원 | 자동 추적 + 전환 |
| Writeback 격리 | 약함 | 강함 (별도 wb 워커) |
| 사용 조건 | - | cgroup v2 + CONFIG_CGROUP_WRITEBACK |
# cgroup v2에서 dirty writeback 제어
# 특정 cgroup의 메모리 사용량 제한 → dirty 한계에도 영향
echo 1G > /sys/fs/cgroup/mycontainer/memory.max
# I/O 대역폭 제한 (cgroup writeback 속도에 영향)
echo "8:0 wbps=52428800" > /sys/fs/cgroup/mycontainer/io.max
# 8:0 (sda) 디바이스에 쓰기 50MB/s 제한
# cgroup별 writeback 상태 확인
cat /sys/fs/cgroup/mycontainer/io.stat
# 8:0 rbytes=1234567 wbytes=7654321 dbytes=345678 ...
# writeback이 cgroup-aware인지 확인
cat /sys/kernel/debug/writeback/stats | head
memory.max가 설정됩니다. 이는 해당 Pod 내에서 생성할 수 있는 dirty 페이지 총량에도 영향을 미칩니다. 대량 파일 쓰기를 하는 Pod의 메모리 limit이 너무 낮으면 빈번한 throttling으로 쓰기 성능이 저하될 수 있으므로, 워크로드에 맞는 적절한 메모리 할당이 중요합니다.
fsync()로 명시적으로 디스크에 기록해야 합니다. 데이터베이스는 WAL(Write-Ahead Logging)과 fdatasync()를 조합하여 내구성을 보장합니다.
LRU 리스트
Page Cache의 페이지는 LRU(Least Recently Used) 리스트로 관리됩니다. 메모리가 부족하면 커널은 LRU 리스트에서 가장 오래 사용되지 않은 페이지를 회수(reclaim)합니다.
클래식 LRU (Two-List)
전통적인 Linux LRU는 active와 inactive 두 개의 리스트로 구성됩니다.
- Inactive 리스트: 새로 할당되거나 오래 참조되지 않은 페이지. 회수 시 여기서 먼저 제거
- Active 리스트: 자주 참조되는 "hot" 페이지. 보호 대상
각 리스트는 파일 캐시 페이지용과 익명(anonymous) 페이지용으로 분리됩니다:
| LRU 리스트 | 내용 | 설명 |
|---|---|---|
LRU_INACTIVE_ANON | 비활성 익명 페이지 | 스왑 대상 |
LRU_ACTIVE_ANON | 활성 익명 페이지 | 스왑으로부터 보호 |
LRU_INACTIVE_FILE | 비활성 파일 페이지 | Page Cache 회수 대상 |
LRU_ACTIVE_FILE | 활성 파일 페이지 | Page Cache 보호 대상 |
LRU_UNEVICTABLE | 회수 불가 페이지 | mlock, ramdisk 등 |
/* include/linux/mmzone.h */
enum lru_list {
LRU_INACTIVE_ANON = 0,
LRU_ACTIVE_ANON = 1,
LRU_INACTIVE_FILE = 2,
LRU_ACTIVE_FILE = 3,
LRU_UNEVICTABLE = 4,
NR_LRU_LISTS
};
/* 각 메모리 노드의 LRU 관리 */
struct lruvec {
struct list_head lists[NR_LRU_LISTS]; /* 5개 LRU 리스트 */
unsigned long anon_cost; /* 익명 페이지 회수 비용 */
unsigned long file_cost; /* 파일 페이지 회수 비용 */
atomic_long_t nonresident_age; /* 비거주 페이지 에이징 */
unsigned long refaults[ANON_AND_FILE]; /* 재참조 카운터 */
struct pglist_data *pgdat; /* 소유 NUMA 노드 */
};
페이지 승격/강등
/* 페이지 접근 시: 참조 비트 설정 (second chance) */
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);
}
}
MGLRU (Multi-Gen LRU)
MGLRU는 커널 6.1에서 도입된 새로운 LRU 알고리즘입니다. 기존 2-리스트 LRU의 한계를 극복하여, 페이지를 여러 세대(generation)로 분류하고 정밀한 에이징을 수행합니다.
- 세대 기반: 최소 4개의 세대(gen 0 ~ gen N)로 페이지를 분류
- 에이징: 하드웨어 접근 비트를 주기적으로 스캔하여 세대 승격/강등
- 회수: 가장 오래된 세대(gen 0)부터 회수
- 성능: 특히 메모리 압박 하에서 기존 LRU 대비 상당한 성능 향상
/* include/linux/mmzone.h - MGLRU 구조 */
struct lru_gen_folio {
unsigned long max_seq; /* 현재 최신 세대 번호 */
unsigned long min_seq[ANON_AND_FILE]; /* 가장 오래된 세대 번호 */
unsigned long timestamps[MAX_NR_GENS]; /* 세대별 타임스탬프 */
struct list_head folios[MAX_NR_GENS][ANON_AND_FILE][MAX_NR_ZONES];
/* [세대][타입][존] 별 folio 리스트 */
unsigned long nr_pages[MAX_NR_GENS][ANON_AND_FILE][MAX_NR_ZONES];
/* 세대/타입/존 별 페이지 수 카운터 */
};
# MGLRU 활성화/비활성화
cat /sys/kernel/mm/lru_gen/enabled
# 0x0007 (기본: 모든 기능 활성화)
# 비활성화
echo 0 > /sys/kernel/mm/lru_gen/enabled
# 세대별 통계 확인
cat /sys/kernel/debug/lru_gen
페이지 회수 상세 (Reclaim)
메모리가 부족해지면 커널은 Page Cache의 페이지를 회수(reclaim)하여 여유 메모리를 확보합니다. 회수는 kswapd(백그라운드)와 direct reclaim(할당 경로에서 동기적)으로 수행되며, LRU 리스트에서 가장 오래된 페이지부터 회수합니다.
shrink_folio_list() 내부 로직
/* mm/vmscan.c — shrink_folio_list() 핵심 로직 (단순화) */
static unsigned int shrink_folio_list(
struct list_head *folio_list,
struct pglist_data *pgdat,
struct scan_control *sc)
{
struct folio *folio;
list_for_each_entry_safe(folio, next, folio_list, lru) {
/* 1. 잠금 시도 (실패하면 건너뜀) */
if (!folio_trylock(folio))
goto keep;
/* 2. 참조 확인: 다른 사용자가 있으면 보호 */
if (folio_mapped(folio)) {
enum folio_references refs;
refs = folio_check_references(folio, sc);
switch (refs) {
case FOLIOREF_ACTIVATE:
goto activate_locked; /* active 승격 */
case FOLIOREF_KEEP:
goto keep_locked; /* LRU에 유지 */
case FOLIOREF_RECLAIM:
case FOLIOREF_RECLAIM_CLEAN:
; /* 회수 진행 */
}
}
/* 3. dirty 페이지: writeback 시작 */
if (folio_test_dirty(folio)) {
if (folio_test_writeback(folio))
goto keep_locked; /* 이미 WB 중 → 건너뜀 */
/* writeback 시작 (비동기) */
folio_clear_dirty_for_io(folio);
mapping->a_ops->writepage(&folio->page, &wbc);
goto keep; /* WB 완료 후 다음 사이클에서 회수 */
}
/* 4. writeback 중인 페이지: 완료 대기 또는 건너뜀 */
if (folio_test_writeback(folio)) {
if (sc->reclaim_idx == 0) /* direct reclaim: 대기 */
folio_wait_writeback(folio);
else
goto keep_locked; /* kswapd: 건너뜀 */
}
/* 5. 매핑 해제: rmap으로 모든 PTE unmap */
if (folio_mapped(folio))
try_to_unmap(folio, TTU_BATCH_FLUSH);
/* 6. Page Cache에서 제거 */
__remove_mapping(mapping, folio, 0);
/* 7. 페이지 해제 → buddy allocator로 반환 */
folio_unlock(folio);
nr_reclaimed += folio_nr_pages(folio);
list_add(&folio->lru, &free_folios);
}
return nr_reclaimed;
}
Refault Distance와 워킹셋 보호
커널은 회수된 페이지가 다시 참조되는 빈도(refault)를 추적하여 회수 정책을 동적으로 조정합니다. 자주 refault가 발생하면 해당 타입(파일/익명)의 워킹셋이 보호되어야 함을 의미합니다.
/* mm/workingset.c — refault 감지 */
/* 페이지가 회수될 때: shadow entry 기록 */
void workingset_eviction(struct folio *folio,
struct mem_cgroup *target_memcg)
{
/* XArray에 shadow entry 삽입 (회수 시점의 nonresident_age 기록) */
/* 이 정보로 refault 시 "얼마나 오래 전에 회수되었는지" 계산 */
}
/* 페이지가 다시 읽혀질 때: refault 판정 */
void workingset_refault(struct folio *folio, void *shadow)
{
unsigned long refault_distance;
/* shadow에서 회수 시점의 age 복원 */
refault_distance = current_age - eviction_age;
/* refault distance가 active 리스트 크기보다 작으면
* → 이 페이지는 충분히 "뜨거움" → 바로 active로 삽입 */
if (refault_distance <= active_file_size) {
folio_set_active(folio);
folio_set_workingset(folio);
/* lruvec->refaults[FILE] 카운터 증가 */
inc_lruvec_state(lruvec, WORKINGSET_ACTIVATE_FILE);
}
}
/* refault 카운터 관찰 */
/* $ grep workingset /proc/vmstat */
/* workingset_activate_file 12345 — 파일 페이지 refault 후 활성화 */
/* workingset_activate_anon 678 — 익명 페이지 refault 후 활성화 */
/* workingset_refault_file 23456 — 파일 페이지 refault 총 수 */
/* workingset_refault_anon 1234 — 익명 페이지 refault 총 수 */
swappiness와 파일/익명 균형
| vm.swappiness | 동작 | 적합한 환경 |
|---|---|---|
0 | 거의 파일 페이지만 회수 (익명 페이지 극도로 보호) | 스왑 디스크 성능이 매우 낮은 환경 |
10 | 파일 페이지 우선, 극단적 압박 시에만 스왑 | DB 서버 (프로세스 메모리 보호) |
60 (기본) | 파일과 익명 페이지를 균형적으로 회수 | 범용 서버 |
100 | 파일과 익명 페이지를 동등하게 회수 | 대용량 파일 캐시가 중요한 환경 |
200 | 익명 페이지 우선 회수 (파일 캐시 극도로 보호) | zram 스왑이 빠른 환경 (임베디드) |
/* mm/vmscan.c — file/anon 회수 비율 계산 */
static void get_scan_count(struct lruvec *lruvec,
struct scan_control *sc,
unsigned long *nr)
{
unsigned long ap, fp;
int swappiness = mem_cgroup_swappiness(sc->target_mem_cgroup);
/* anon 스캔 비율 = swappiness */
/* file 스캔 비율 = 200 - swappiness */
ap = swappiness;
fp = 200 - swappiness;
/* refault 비용으로 추가 보정 */
/* anon_cost / file_cost가 높으면 해당 타입 회수 줄임 */
ap *= lruvec->file_cost + 1;
fp *= lruvec->anon_cost + 1;
/* 최종 스캔 수: inactive 리스트 크기 × 비율 */
nr[LRU_INACTIVE_FILE] = inactive_file_pages * fp / (ap + fp);
nr[LRU_INACTIVE_ANON] = inactive_anon_pages * ap / (ap + fp);
}
kswapd는 watermark 기반으로 동작합니다. free_pages < pages_low이면 깨어나 pages_high까지 회수합니다. Direct reclaim은 __alloc_pages()에서 여유 페이지가 부족할 때 할당 프로세스 컨텍스트에서 직접 실행되며, 할당 지연의 주요 원인입니다. /proc/vmstat의 pgsteal_kswapd와 pgsteal_direct로 각각의 회수량을 확인할 수 있습니다.
Direct I/O vs Buffered I/O
Buffered I/O(기본)는 모든 데이터가 Page Cache를 거칩니다. Direct I/O(O_DIRECT)는 Page Cache를 우회하여 사용자 버퍼와 디스크 간 직접 데이터를 전송합니다.
| 특성 | Buffered I/O | Direct I/O (O_DIRECT) |
|---|---|---|
| 경로 | User Buffer ↔ Page Cache ↔ Disk | User Buffer ↔ Disk |
| 캐싱 | Page Cache에 자동 캐싱 | 캐싱 없음 |
| 정렬 요구 | 없음 | 블록 크기 정렬 필요 (보통 512B 또는 4KB) |
| 복사 횟수 | 2회 (user ↔ kernel ↔ disk) | 0회 (DMA 직접 전송) |
| 메모리 사용 | Page Cache 메모리 소비 | 최소 메모리 사용 |
| 적합한 워크로드 | 범용, 반복 읽기, 작은 I/O | 대용량 순차 I/O, DB (자체 캐시 보유) |
/* Direct I/O 사용 예시 */
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
int fd;
void *buf;
size_t size = 4096;
/* O_DIRECT 플래그로 열기 */
fd = open("/path/to/file", O_RDONLY | O_DIRECT);
/* 정렬된 버퍼 할당 (블록 크기 = 4096) */
posix_memalign(&buf, 4096, size);
/* Direct I/O 읽기: Page Cache 우회 */
read(fd, buf, size);
free(buf);
close(fd);
return 0;
}
커널 내부 경로
/* mm/filemap.c */
ssize_t generic_file_read_iter(struct kiocb *iocb,
struct iov_iter *iter)
{
if (iocb->ki_flags & IOCB_DIRECT) {
/* Direct I/O 경로: Page Cache 우회 */
return mapping->a_ops->direct_IO(iocb, iter);
}
/* Buffered I/O 경로: Page Cache 사용 */
return filemap_read(iocb, iter, 0);
}
mmap과 Page Cache
mmap()으로 파일을 메모리에 매핑하면, Page Cache의 페이지가 프로세스의 가상 주소 공간에 직접 매핑됩니다. 이를 통해 read()/write() 시스템 콜 없이 파일 데이터에 포인터로 접근할 수 있습니다.
파일 매핑 흐름
mmap()호출: VMA(vm_area_struct) 생성, 실제 메모리 할당은 지연- 첫 접근 시 page fault 발생
- fault handler가 Page Cache에서 folio 검색 (없으면 디스크에서 읽기)
- PTE(Page Table Entry)에 folio의 물리 주소 매핑
- 이후 접근은 직접 메모리 접근 (시스템 콜 불필요)
/* mm/filemap.c - 파일 매핑 fault handler */
vm_fault_t filemap_fault(struct vm_fault *vmf)
{
struct file *file = vmf->vma->vm_file;
struct address_space *mapping = file->f_mapping;
struct folio *folio;
vm_fault_t ret = 0;
/* Page Cache에서 folio 검색 */
folio = filemap_get_folio(mapping, vmf->pgoff);
if (IS_ERR(folio)) {
/* 캐시 미스: readahead 후 다시 시도 */
if (!(vmf->flags & FAULT_FLAG_TRIED))
page_cache_ra_order(&ractl, ra, 0);
folio = filemap_get_folio(mapping, vmf->pgoff);
if (IS_ERR(folio))
return VM_FAULT_MAJOR; /* major fault (디스크 I/O 필요) */
}
/* folio가 최신인지 확인 */
if (!folio_test_uptodate(folio)) {
folio_lock(folio);
if (!folio_test_uptodate(folio)) {
ret = VM_FAULT_SIGBUS;
goto unlock;
}
}
/* vmf->page에 folio 설정 → PTE 매핑은 상위에서 수행 */
vmf->page = folio_page(folio, vmf->pgoff - folio->index);
return ret | VM_FAULT_LOCKED;
unlock:
folio_unlock(folio);
folio_put(folio);
return ret;
}
MAP_SHARED vs MAP_PRIVATE
| 특성 | MAP_SHARED | MAP_PRIVATE |
|---|---|---|
| 쓰기 가시성 | 모든 매핑 프로세스와 파일에 반영 | 해당 프로세스만 (COW) |
| Page Cache | 직접 수정, dirty 표시 | COW 시 새 페이지 할당 (Page Cache와 분리) |
| 디스크 반영 | writeback으로 반영 (msync로 명시적 가능) | 반영 안 됨 |
| 용도 | IPC, 파일 수정, mmap I/O | 라이브러리 로딩, 읽기 전용 데이터 |
/* MAP_SHARED 쓰기 시: dirty 페이지 생성 */
vm_fault_t filemap_page_mkwrite(struct vm_fault *vmf)
{
struct folio *folio = page_folio(vmf->page);
folio_lock(folio);
/* folio를 dirty로 표시 → 나중에 writeback */
folio_mark_dirty(folio);
/* writeback 중이면 완료 대기 */
folio_wait_stable(folio);
return VM_FAULT_LOCKED;
}
folio_mkclean()의 동작 원리는 PTE Dirty Bit와 mmap 쓰기 추적 섹션에서 자세히 다룹니다. folio_wait_stable()의 역할은 Stable Pages 섹션을 참고하세요.
Buffer Cache (Buffer Head)
Buffer Cache는 블록 디바이스의 메타데이터(슈퍼블록, 비트맵, 간접 블록 등)를 블록 단위로 캐싱하는 계층입니다. Linux 2.4 이전에는 Page Cache와 Buffer Cache가 별도였지만, 현대 Linux에서는 Buffer Cache가 Page Cache 위에 구현됩니다.
struct buffer_head
/* include/linux/buffer_head.h */
struct buffer_head {
unsigned long b_state; /* BH_* 상태 비트 */
struct buffer_head *b_this_page; /* 같은 페이지 내 다음 bh */
struct page *b_page; /* 소유 페이지 */
sector_t b_blocknr; /* 디스크 블록 번호 */
size_t b_size; /* 블록 크기 */
char *b_data; /* 블록 데이터 포인터 */
struct block_device *b_bdev; /* 대상 블록 디바이스 */
bh_end_io_t *b_end_io; /* I/O 완료 콜백 */
void *b_private; /* 콜백 전용 데이터 */
struct list_head b_assoc_buffers; /* 연관 버퍼 리스트 */
struct address_space *b_assoc_map; /* 연관 address_space */
atomic_t b_count; /* 참조 카운트 */
};
buffer_head vs folio
| 특성 | buffer_head | folio |
|---|---|---|
| 단위 | 블록 크기 (512B ~ 4KB) | 페이지 크기 이상 (4KB, 8KB, ...) |
| 용도 | 블록 디바이스 메타데이터 | 파일 데이터 캐싱 |
| 관계 | 하나의 page에 여러 bh가 연결 | page를 포함/대체 |
| 추세 | 레거시 (점진적 제거 중) | 현대적 표준 API |
| 사용 예 | ext4 메타데이터, JBD2 | 파일 데이터 읽기/쓰기 |
/* buffer_head 사용 예: 블록 디바이스에서 메타데이터 블록 읽기 */
struct buffer_head *bh;
/* 블록을 읽어 Page Cache에 캐싱 + buffer_head 반환 */
bh = sb_bread(sb, block_nr);
if (!bh)
return -EIO;
/* 블록 데이터 접근 */
struct ext4_super_block *es = (struct ext4_super_block *)bh->b_data;
/* 수정 후 dirty 표시 */
mark_buffer_dirty(bh);
/* 참조 해제 */
brelse(bh);
cachestat 시스템 콜
cachestat은 커널 6.5에서 추가된 시스템 콜로, 파일의 Page Cache 상태를 효율적으로 조회합니다. 기존에는 mincore()를 사용하거나 /proc을 파싱해야 했지만, cachestat은 파일 범위에 대한 캐시 통계를 한 번의 시스템 콜로 반환합니다.
/* include/uapi/linux/cachestat.h */
struct cachestat_range {
__u64 off; /* 시작 오프셋 (바이트) */
__u64 len; /* 길이 (0 = 파일 끝까지) */
};
struct cachestat {
__u64 nr_cache; /* Page Cache에 있는 페이지 수 */
__u64 nr_dirty; /* dirty 페이지 수 */
__u64 nr_writeback; /* writeback 중인 페이지 수 */
__u64 nr_evicted; /* 회수된 페이지 수 */
__u64 nr_recently_evicted; /* 최근 회수된 페이지 수 */
};
/* 시스템 콜 인터페이스 */
long cachestat(unsigned int fd,
struct cachestat_range *cstat_range,
struct cachestat *cstat,
unsigned int flags);
사용 예시
#include <sys/syscall.h>
#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#ifndef __NR_cachestat
#define __NR_cachestat 451 /* x86_64, 커널 6.5+ */
#endif
struct cachestat_range { __u64 off, len; };
struct cachestat {
__u64 nr_cache, nr_dirty, nr_writeback, nr_evicted, nr_recently_evicted;
};
int main(void)
{
int fd = open("/var/log/syslog", O_RDONLY);
struct cachestat_range range = { .off = 0, .len = 0 }; /* 전체 파일 */
struct cachestat cs = {};
if (syscall(__NR_cachestat, fd, &range, &cs, 0) == 0) {
printf("cached: %llu pages\\n", cs.nr_cache);
printf("dirty: %llu pages\\n", cs.nr_dirty);
printf("writeback: %llu pages\\n", cs.nr_writeback);
printf("evicted: %llu pages\\n", cs.nr_evicted);
}
close(fd);
return 0;
}
mincore()는 mmap된 영역에서만 동작하고, 페이지별 바이트 배열을 반환하여 대용량 파일에 비효율적입니다. cachestat()은 fd 기반으로 동작하며, 범위에 대한 집계 통계를 한 번에 반환합니다.
튜닝과 모니터링
/proc/meminfo - Page Cache 관련 필드
$ cat /proc/meminfo
MemTotal: 16384000 kB
MemFree: 1234560 kB
MemAvailable: 12345678 kB
Buffers: 234567 kB # 블록 디바이스 버퍼 (buffer cache)
Cached: 8765432 kB # Page Cache (파일 데이터 + tmpfs)
SwapCached: 12345 kB # 스왑에서 다시 읽어온 캐시
Active: 6543210 kB # Active LRU 합계
Inactive: 5432100 kB # Inactive LRU 합계
Active(file): 3210987 kB # Active 파일 캐시 페이지
Inactive(file): 4321098 kB # Inactive 파일 캐시 페이지
Dirty: 56789 kB # dirty 페이지 (디스크 기록 대기)
Writeback: 0 kB # 현재 writeback 중인 페이지
vmstat으로 캐시 활동 모니터링
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 123456 23456 876543 0 0 4 12 156 312 3 1 96 0 0
# cache: Page Cache 크기 (KB)
# bi: blocks in (디스크 → 메모리, 읽기)
# bo: blocks out (메모리 → 디스크, 쓰기 = writeback)
# /proc/vmstat에서 Page Cache 상세 통계
$ grep -E "^(pgpg|pgfault|nr_file|nr_dirty|nr_writeback)" /proc/vmstat
nr_file_pages 2345678 # Page Cache에 있는 파일 페이지 수
nr_dirty 1234 # dirty 페이지 수
nr_writeback 0 # writeback 중인 페이지 수
pgpgin 98765432 # 누적 페이지 읽기 (KB)
pgpgout 54321098 # 누적 페이지 쓰기 (KB)
pgfault 876543210 # 누적 페이지 폴트
pgmajfault 12345 # 누적 major 페이지 폴트 (디스크 I/O 필요)
주요 sysctl 파라미터
| 파라미터 | 기본값 | 설명 |
|---|---|---|
vm.dirty_ratio | 20 | 전체 메모리 대비 dirty 비율 (%) - 초과 시 프로세스 블록 |
vm.dirty_background_ratio | 10 | 백그라운드 writeback 시작 비율 (%) |
vm.dirty_bytes | 0 | dirty 한계 (바이트 단위, 0이면 ratio 사용) |
vm.dirty_background_bytes | 0 | 백그라운드 writeback 한계 (바이트) |
vm.dirty_expire_centisecs | 3000 | dirty 페이지 만료 시간 (1/100초 = 30초) |
vm.dirty_writeback_centisecs | 500 | writeback 워커 주기 (1/100초 = 5초) |
vm.vfs_cache_pressure | 100 | dentry/inode 캐시 회수 압력 (높을수록 적극 회수) |
vm.swappiness | 60 | 익명 페이지 vs 파일 페이지 회수 비율 |
vm.min_free_kbytes | 자동 | 최소 여유 메모리 (KB) |
# 현재 dirty 관련 설정 확인
$ sysctl vm.dirty_ratio vm.dirty_background_ratio vm.dirty_expire_centisecs
vm.dirty_ratio = 20
vm.dirty_background_ratio = 10
vm.dirty_expire_centisecs = 3000
# 데이터베이스 서버 최적화 예시: dirty 한계를 낮추어 쓰기 지연 감소
sysctl -w vm.dirty_ratio=5
sysctl -w vm.dirty_background_ratio=2
sysctl -w vm.dirty_expire_centisecs=500
# 대용량 순차 쓰기 최적화: 버퍼링을 늘려 처리량 극대화
sysctl -w vm.dirty_ratio=40
sysctl -w vm.dirty_background_ratio=20
# vfs_cache_pressure: dentry/inode 캐시 회수 정도
# 0: 회수 안 함, 100: 기본, 200: 적극적 회수
sysctl -w vm.vfs_cache_pressure=50 # 파일 메타데이터 캐시 보존
Page Cache 수동 제거
# Page Cache만 제거
echo 1 > /proc/sys/vm/drop_caches
# dentry + inode 캐시 제거
echo 2 > /proc/sys/vm/drop_caches
# Page Cache + dentry + inode 캐시 모두 제거
echo 3 > /proc/sys/vm/drop_caches
# 주의: dirty 페이지를 먼저 디스크에 기록한 후 실행 권장
sync; echo 3 > /proc/sys/vm/drop_caches
drop_caches는 성능 테스트나 벤치마크에서 콜드 캐시 상태를 만들 때 사용합니다. 프로덕션 환경에서 반복적으로 실행하면 오히려 성능이 급격히 저하됩니다. 캐시는 시스템이 자동으로 관리하도록 두는 것이 최선입니다.
Page Cache 일관성 모델
Page Cache는 여러 접근 방법(read/write, mmap, Direct I/O)이 동시에 사용될 수 있으므로, 데이터 일관성을 유지하기 위한 명확한 규칙이 있습니다. 일관성 문제는 특히 다른 I/O 모드 간 혼합 사용과 네트워크 파일시스템에서 복잡해집니다.
일관성 규칙
| 시나리오 | 일관성 | 메커니즘 | 주의사항 |
|---|---|---|---|
| Buffered read + Buffered write (같은 파일) | 일관적 | 동일 Page Cache folio, 잠금으로 보호 | 없음 |
| Buffered I/O + mmap(MAP_SHARED) | 일관적 | 동일 folio 공유, PTE dirty 추적 | msync() 전까지 writeback 지연 가능 |
| Buffered I/O + Direct I/O (같은 파일) | 불완전 | DIO 시 invalidate_inode_pages2() 호출 | 동시 접근 시 경합 가능, 혼합 비권장 |
| mmap + Direct I/O | 불완전 | unmap + invalidate 필요 | 매우 위험, 실질적으로 사용 불가 |
| 여러 프로세스 Buffered read (같은 파일) | 일관적 | 동일 folio 공유 (참조 카운트) | 없음 |
| NFS 클라이언트 간 | close-to-open | 파일 닫기 시 flush, 열기 시 캐시 검증 | 열린 상태에서 다른 클라이언트 변경 미반영 |
/* Direct I/O 쓰기 시 Page Cache 무효화 */
/* mm/filemap.c — generic_file_direct_write() 내부 */
/* DIO 쓰기 전: 겹치는 Page Cache 영역 무효화 */
filemap_invalidate_lock(mapping);
/* dirty 페이지를 먼저 디스크에 기록 */
filemap_write_and_wait_range(mapping, pos, end);
/* Page Cache에서 해당 범위 제거 */
invalidate_inode_pages2_range(mapping,
pos >> PAGE_SHIFT, end >> PAGE_SHIFT);
/* Direct I/O 수행 */
written = mapping->a_ops->direct_IO(iocb, iter);
filemap_invalidate_unlock(mapping);
/* NFS close-to-open 일관성 */
/* fs/nfs/file.c */
int nfs_file_open(struct inode *inode, struct file *filp)
{
/* 서버에서 파일 속성을 가져와 로컬 캐시와 비교 */
nfs_revalidate_inode(inode);
/* mtime이 변경되었으면 Page Cache 전체 무효화 */
if (nfs_attr_changed)
invalidate_inode_pages2(inode->i_mapping);
}
mount -o dax로 마운트된 파일시스템(pmem 디바이스)은 Page Cache를 전혀 사용하지 않습니다. 영구 메모리에 직접 접근하므로 페이지 캐싱이 불필요합니다. DAX 모드에서는 mmap()이 NVDIMM의 물리 주소를 직접 매핑하며, struct page 없이 동작하는 특수한 경로(dax_iomap_fault)를 사용합니다.
페이지 캐시 무효화
Page Cache의 데이터를 명시적으로 제거하거나 디스크와 동기화하는 작업을 무효화(invalidation)라 합니다. 파일 truncate, 파일시스템 동기화, 디바이스 제거 등에서 필요합니다.
truncate_inode_pages
/* mm/truncate.c */
/* 파일 크기 변경 시: 잘린 범위의 캐시 페이지 제거 */
void truncate_inode_pages_range(struct address_space *mapping,
loff_t lstart, loff_t lend);
/* lstart ~ lend 범위의 모든 페이지를 Page Cache에서 제거
* dirty 페이지도 디스크에 기록하지 않고 폐기 */
/* 파일의 모든 캐시 페이지 제거 */
void truncate_inode_pages(struct address_space *mapping,
loff_t lstart);
/* lstart부터 끝까지 모든 페이지 제거 (inode 삭제, 파일시스템 언마운트) */
/* 최종 제거 (inode evict 시 호출) */
void truncate_inode_pages_final(struct address_space *mapping);
/* AS_EXITING 플래그 설정 후 모든 페이지 제거 */
invalidate_inode_pages2
/* mm/truncate.c */
/* 캐시 무효화: dirty 페이지는 디스크에 먼저 기록 */
int invalidate_inode_pages2(struct address_space *mapping);
/* 모든 캐시 페이지를 무효화
* dirty 페이지는 writeback 후 제거
* 매핑된 페이지(mmap)는 unmap 후 제거
* Direct I/O 경로에서 Page Cache 일관성 유지에 사용 */
int invalidate_inode_pages2_range(struct address_space *mapping,
pgoff_t start, pgoff_t end);
동기화 연산
/* 파일별 동기화 */
int filemap_write_and_wait(struct address_space *mapping);
/* 모든 dirty 페이지를 디스크에 기록하고 완료 대기 */
int filemap_write_and_wait_range(struct address_space *mapping,
loff_t lstart, loff_t lend);
/* 지정 범위만 동기화 */
/* 사용자 공간 동기화 인터페이스 */
fsync(fd); /* 파일 데이터 + 메타데이터 동기화 */
fdatasync(fd); /* 파일 데이터만 동기화 (메타데이터 일부 제외) */
sync_file_range(fd, offset, nbytes, flags);
/* 지정 범위만 세밀하게 동기화 */
sync(); /* 모든 파일시스템의 dirty 데이터 동기화 */
syncfs(fd); /* fd가 속한 파일시스템만 동기화 */
무효화 연산 비교
| 함수 | dirty 페이지 처리 | mmap 처리 | 용도 |
|---|---|---|---|
truncate_inode_pages() | 폐기 (디스크 기록 안 함) | unmap | 파일 크기 변경, inode 삭제 |
invalidate_inode_pages2() | writeback 후 제거 | unmap | Direct I/O 일관성, NFS 캐시 무효화 |
filemap_write_and_wait() | writeback 후 유지 | 유지 | fsync, 동기화 |
invalidate_mapping_pages() | 건너뜀 (clean만 제거) | 건너뜀 | 메모리 회수 힌트 (FADV_DONTNEED) |
invalidate_inode_pages2()로 로컬 Page Cache를 무효화하여 다음 읽기에서 최신 데이터를 가져옵니다.
fsync / fdatasync 내부 경로
fsync()와 fdatasync()는 파일의 dirty 페이지를 디스크에 확실히 기록하는 동기화 시스템 콜입니다. 단순히 writeback을 트리거하는 것이 아니라, I/O 완료까지 대기하여 디스크에 안전하게 저장됨을 보장합니다.
파일시스템별 fsync 구현
/* fs/ext4/fsync.c — ext4_sync_file() 핵심 로직 (단순화) */
int ext4_sync_file(struct file *file, loff_t start,
loff_t end, int datasync)
{
struct inode *inode = file->f_mapping->host;
journal_t *journal = EXT4_SB(inode->i_sb)->s_journal;
int ret, needs_barrier = 0;
/* 1단계: dirty 페이지를 디스크에 기록하고 완료 대기 */
ret = file_write_and_wait_range(file, start, end);
if (ret)
return ret;
/* 저널이 없으면 (data=writeback) 여기서 종료 */
if (!journal) {
ret = ext4_fsync_nojournal(inode, datasync, &needs_barrier);
if (needs_barrier)
blkdev_issue_flush(inode->i_sb->s_bdev);
return ret;
}
/* 2단계: 저널 트랜잭션 커밋 대기 */
/* datasync=1이고 메타데이터 변경이 없으면 빠른 경로 */
if (datasync && !(inode->i_state & I_DIRTY_DATASYNC))
goto out;
/* inode의 마지막 트랜잭션 ID까지 저널 커밋 */
ret = ext4_force_commit(inode->i_sb);
out:
/* 3단계: errseq 에러 체크 */
ret = file_check_and_advance_wb_err(file);
return ret;
}
/* XFS의 fsync: iomap 기반, 더 간결한 경로 */
int xfs_file_fsync(struct file *file, loff_t start,
loff_t end, int datasync)
{
struct xfs_inode *ip = XFS_I(file->f_mapping->host);
int error;
/* dirty 페이지 writeback + 완료 대기 */
error = file_write_and_wait_range(file, start, end);
if (error)
return error;
/* XFS 로그 강제 플러시 (WAL 커밋) */
xfs_log_force_inode(ip);
/* 디스크 캐시 플러시 (barrier) */
if (xfs_ipincount(ip))
error = blkdev_issue_flush(ip->i_mount->m_ddev_targp->bt_bdev);
return error;
}
fsync vs fdatasync 비교
| 특성 | fsync() | fdatasync() |
|---|---|---|
| 데이터 동기화 | 항상 | 항상 |
| 메타데이터 동기화 | 항상 (mtime, size, permissions 등) | 데이터 무결성에 필요한 경우만 (size 변경 시) |
| 저널 커밋 | 항상 수행 | I_DIRTY_DATASYNC 미설정 시 생략 |
| 디스크 flush | 항상 | 항상 |
| 성능 | 느림 (추가 저널 I/O) | 빠름 (불필요한 메타데이터 I/O 생략) |
| 데이터베이스 사용 | 드물게 (전체 일관성 필요 시) | WAL 커밋에 주로 사용 |
sync_file_range: 세밀한 동기화
/* sync_file_range: 범위 지정 + 3단계 분리 */
int sync_file_range(int fd, loff_t offset, loff_t nbytes,
unsigned int flags);
/* flags: 3단계를 개별적으로 또는 조합하여 호출 */
SYNC_FILE_RANGE_WAIT_BEFORE /* 이전 writeback 완료 대기 */
SYNC_FILE_RANGE_WRITE /* dirty 페이지에 writeback 시작 (비동기) */
SYNC_FILE_RANGE_WAIT_AFTER /* 방금 시작한 writeback 완료 대기 */
/* 패턴 1: 비동기 writeback 시작 (대기 안 함) */
sync_file_range(fd, off, len, SYNC_FILE_RANGE_WRITE);
/* 나중에 fsync() 시 이미 기록된 데이터에 대해 빠르게 완료 */
/* 패턴 2: 완전 동기화 (fsync와 유사하지만 범위 지정) */
sync_file_range(fd, off, len,
SYNC_FILE_RANGE_WAIT_BEFORE |
SYNC_FILE_RANGE_WRITE |
SYNC_FILE_RANGE_WAIT_AFTER);
/* 패턴 3: 파이프라인 쓰기 (데이터베이스 WAL) */
/* 이전 범위 완료를 기다리면서 동시에 새 범위 writeback 시작 */
for (i = 0; i < nr_chunks; i++) {
write(fd, data[i], chunk_size);
sync_file_range(fd, i * chunk_size, chunk_size,
SYNC_FILE_RANGE_WRITE);
if (i > 0)
sync_file_range(fd, (i-1) * chunk_size, chunk_size,
SYNC_FILE_RANGE_WAIT_BEFORE | SYNC_FILE_RANGE_WAIT_AFTER);
}
sync_file_range()는 디스크 캐시 flush(barrier)를 수행하지 않습니다. 따라서 디스크 쓰기 캐시가 있는 환경에서는 데이터 안전성을 보장하지 못합니다. 데이터 무결성이 필요하면 반드시 fsync() 또는 fdatasync()를 사용하세요.
errseq_t 에러 보고 메커니즘
errseq_t는 커널 4.13에서 도입된 에러 시퀀스 카운터로, writeback 에러를 누락 없이 모든 파일 디스크립터에 보고하는 메커니즘입니다. 이전에는 writeback 에러가 첫 번째 fsync() 호출자에게만 보고되고, 같은 파일을 열고 있는 다른 프로세스는 에러를 놓치는 문제가 있었습니다.
/* include/linux/errseq.h */
typedef struct { u32 cursor; } errseq_t;
/* errseq_t 구조 (32비트):
* 비트 31: "seen" 플래그 (0=새 에러 미확인, 1=적어도 한 번 확인됨)
* 비트 30-24: 에러 코드 (EIO, ENOSPC 등)
* 비트 23-0: 시퀀스 카운터 (에러 발생 횟수)
*/
/* address_space에 에러 기록 */
void mapping_set_error(struct address_space *mapping, int error)
{
/* wb_err 시퀀스 카운터를 증가시키며 에러 코드 기록 */
errseq_set(&mapping->wb_err, error);
/* AS_EIO / AS_ENOSPC 플래그도 설정 (하위 호환) */
mapping_set_error_flag(mapping, error);
}
/* fsync에서 에러 확인 */
int file_check_and_advance_wb_err(struct file *file)
{
/* file->f_wb_err: 이 fd가 마지막으로 확인한 시퀀스 번호 */
/* mapping->wb_err: 현재 에러 시퀀스 번호 */
return errseq_check_and_advance(
&file->f_mapping->wb_err, /* 소스 */
&file->f_wb_err); /* 이 fd의 커서 */
/* 새 에러가 있으면: 에러 코드 반환 + 커서 업데이트 */
/* 새 에러 없으면: 0 반환 */
}
| 시나리오 | 이전 (errseq 없음) | 현재 (errseq_t) |
|---|---|---|
| 프로세스 A: fsync() | EIO 반환 (에러 소비됨) | EIO 반환 + A의 커서 업데이트 |
| 프로세스 B: fsync() | 0 반환 (에러 이미 소비됨!) | EIO 반환 + B의 커서 업데이트 |
| 프로세스 A: 다시 fsync() | 0 반환 | 0 반환 (이미 확인한 에러) |
| 새 에러 발생 후 A: fsync() | 불확실 | 새 에러 코드 반환 |
fdatasync()로 내구성을 보장합니다. errseq_t 도입 전에는 writeback 에러가 누락되어 데이터 손상이 감지되지 않는 심각한 버그가 있었습니다. errseq_t는 이 문제를 해결하여 모든 fd가 독립적으로 에러를 확인할 수 있게 합니다.
io_uring과 Page Cache
io_uring은 커널 5.1에서 도입된 비동기 I/O 인터페이스로, 시스템 콜 오버헤드를 최소화하면서 Page Cache와 상호작용합니다. 기존 read()/write()와 동일하게 Page Cache를 거치지만, SQ/CQ 링 버퍼를 통해 배치 처리와 폴링이 가능합니다.
io_uring Buffered I/O
/* io_uring의 buffered read는 결국 filemap_read()를 호출 */
/* io_uring 워커 스레드(io-wq)에서 실행 */
/* io_uring submission 예시 (liburing) */
#include <liburing.h>
struct io_uring ring;
io_uring_queue_init(64, &ring, 0);
/* Buffered Read: Page Cache 사용 (기본) */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, size, offset);
/* → 내부: generic_file_read_iter() → filemap_read() */
/* Direct I/O: Page Cache 우회 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd_direct, buf_aligned, size, offset);
/* fd가 O_DIRECT로 열려있으면 → iomap_dio_rw() */
/* Fixed buffer: 사전 등록된 버퍼로 mmap 오버헤드 제거 */
struct iovec iov = { .iov_base = buf, .iov_len = size };
io_uring_register_buffers(&ring, &iov, 1);
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, buf, size, offset, 0);
/* 배치 제출 + 완료 수확 */
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
/* cqe->res: 읽은 바이트 수 또는 에러 */
io_uring_cqe_seen(&ring, cqe);
io_uring과 Page Cache 상호작용
| io_uring 연산 | Page Cache 경로 | 비동기 여부 |
|---|---|---|
IORING_OP_READ | filemap_read() — 캐시 히트 시 즉시 완료 | 캐시 미스 시 io-wq 스레드에서 비동기 |
IORING_OP_WRITE | generic_file_write_iter() → dirty 표시 | dirty 생성은 동기, writeback은 비동기 |
IORING_OP_FSYNC | vfs_fsync_range() — 동기화 완료까지 대기 | io-wq 스레드에서 실행 (메인 스레드 비차단) |
IORING_OP_FADVISE | fadvise64() — readahead/dontneed 힌트 | 대부분 즉시 완료 |
IORING_OP_MADVISE | do_madvise() — 메모리 관리 힌트 | MADV_DONTNEED 시 즉시 완료 |
IORING_OP_READ_FIXED | 동일 경로 + 사전 등록 버퍼 (pin 생략) | 동일 |
/* io_uring의 캐시 히트 최적화: IOSQE_ASYNC 없이 제출하면
* Page Cache에 데이터가 있을 때 시스템 콜 없이 즉시 완료됨
* (SQ polling 모드에서 극대화) */
/* SQ Polling: 커널 스레드가 SQ를 폴링하여 syscall 완전 제거 */
struct io_uring_params params = { .flags = IORING_SETUP_SQPOLL };
io_uring_queue_init_params(64, &ring, ¶ms);
/* Page Cache 히트 시: 사용자 공간 → SQ 기록 → 커널 폴링 → CQ 기록
* 시스템 콜 0회, 컨텍스트 스위치 0회 */
/* io_uring + readahead 조합 패턴 */
/* 1. fadvise로 사전 프리페치 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_fadvise(sqe, fd, next_offset, prefetch_size,
POSIX_FADV_WILLNEED);
/* 2. 현재 범위 읽기 (프리페치된 데이터 히트) */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, size, current_offset);
io_uring_submit(&ring); /* 한 번의 submit으로 배치 */
워크로드별 튜닝 시나리오
Page Cache 튜닝은 워크로드 특성에 따라 크게 달라집니다. 아래는 대표적인 워크로드 유형별 최적화 전략을 정리한 것입니다.
OLTP 데이터베이스 (MySQL, PostgreSQL)
# 목표: 쓰기 지연 최소화, 데이터 안전성 최우선
# DB는 자체 버퍼 풀을 가지므로 Page Cache 의존도 낮춤
# dirty 한계를 낮추어 쓰기 버스트 방지
sysctl -w vm.dirty_ratio=5
sysctl -w vm.dirty_background_ratio=2
sysctl -w vm.dirty_expire_centisecs=500 # 5초 → 빠른 flush
sysctl -w vm.dirty_writeback_centisecs=100 # 1초 주기
# DB 자체 캐시 보호: vfs_cache_pressure 낮춤
sysctl -w vm.vfs_cache_pressure=50
# swappiness 낮춤 (DB 프로세스 메모리 보호)
sysctl -w vm.swappiness=10
# 데이터 파일: O_DIRECT 사용 (InnoDB innodb_flush_method=O_DIRECT)
# WAL 파일: fdatasync 사용 (innodb_flush_log_at_trx_commit=1)
# readahead: 데이터 파일은 랜덤 I/O → 줄임
blockdev --setra 64 /dev/nvme0n1 # 32KB
미디어 스트리밍 / 대용량 순차 읽기
# 목표: 순차 읽기 처리량 극대화
# readahead를 크게 늘림
blockdev --setra 4096 /dev/sda # 2MB readahead
# 읽기 후 즉시 페이지 해제 (캐시 오염 방지)
# 애플리케이션에서:
# posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL);
# posix_fadvise(fd, consumed_offset, consumed_len, POSIX_FADV_DONTNEED);
# 큰 파일이 캐시를 독점하지 않도록
sysctl -w vm.vfs_cache_pressure=200
# MGLRU: 스트리밍 데이터의 LRU 에이징 개선
echo 7 > /sys/kernel/mm/lru_gen/enabled
대용량 배치 쓰기 (로그 수집, ETL)
# 목표: 쓰기 처리량 극대화, 지연 허용
# dirty 한계를 높여 메모리에 버퍼링 극대화
sysctl -w vm.dirty_ratio=60
sysctl -w vm.dirty_background_ratio=30
sysctl -w vm.dirty_expire_centisecs=12000 # 120초
# 큰 readahead (로그 분석 시 순차 읽기)
blockdev --setra 8192 /dev/sda # 4MB
# writeback 주기를 늘려 배치 효율 극대화
sysctl -w vm.dirty_writeback_centisecs=1500 # 15초
컨테이너 / 멀티테넌트 환경
# 목표: 워크로드 간 격리, 공정한 리소스 분배
# cgroup v2 사용 (dirty writeback 격리)
# 각 컨테이너에 메모리 limit 설정
echo 4G > /sys/fs/cgroup/container-a/memory.max
echo 2G > /sys/fs/cgroup/container-b/memory.max
# I/O 대역폭 제한 (writeback 속도 격리)
echo "259:0 wbps=104857600" > /sys/fs/cgroup/container-a/io.max
# NVMe(259:0)에 쓰기 100MB/s 제한
# 글로벌 설정은 보수적으로
sysctl -w vm.dirty_ratio=10
sysctl -w vm.dirty_background_ratio=5
# MGLRU 활성화 (컨테이너 환경에서 LRU 효율 개선)
echo 7 > /sys/kernel/mm/lru_gen/enabled
가상 머신 호스트 (KVM/QEMU)
# 목표: VM 디스크 이미지의 Page Cache 효율화
# VM 디스크 이미지는 큰 파일 + 랜덤 I/O
# QEMU에서 cache=none 또는 cache=directsync 사용
# → O_DIRECT로 호스트 Page Cache 우회
qemu-system-x86_64 -drive file=vm.qcow2,cache=none,aio=io_uring
# 호스트 Page Cache를 사용하는 경우 (cache=writeback)
# dirty_ratio를 중간으로 설정
sysctl -w vm.dirty_ratio=15
sysctl -w vm.dirty_background_ratio=5
# KSM (Kernel Same-page Merging) 활성화
echo 1 > /sys/kernel/mm/ksm/run
# 동일 데이터 페이지를 공유하여 메모리 절약
# Huge pages for VM memory (Page Cache와 별도)
echo 1024 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
/proc/vmstat의 nr_dirty, nr_writeback, pgpgin/pgpgout, pgmajfault를 모니터링하여 실제 효과를 측정하세요. sar -B 1과 iostat -x 1을 병행하면 Page Cache와 디스크 I/O의 상관관계를 파악할 수 있습니다.
Tracepoint 기반 Page Cache 분석
커널은 Page Cache 동작을 추적하기 위한 다양한 tracepoint를 제공합니다. ftrace, perf, bpftrace를 사용하여 readahead 효율, writeback 패턴, 캐시 히트율 등을 실시간으로 분석할 수 있습니다.
ftrace를 이용한 Page Cache 추적
# Page Cache 관련 tracepoint 목록 확인
$ ls /sys/kernel/debug/tracing/events/filemap/
filemap_set_wb_err mm_filemap_add_to_page_cache mm_filemap_delete_from_page_cache
$ ls /sys/kernel/debug/tracing/events/writeback/
balance_dirty_pages writeback_dirty_inode writeback_pages_written
global_dirty_state writeback_dirty_inode_enqueue writeback_queue_io
writeback_bdi_register writeback_exec writeback_single_inode
writeback_congestion_wait writeback_mark_inode_dirty writeback_start
writeback_dirty_folio writeback_nowork writeback_wake_background
writeback_dirty_inode_start writeback_written writeback_write_inode
# Page Cache 삽입/제거 추적
cd /sys/kernel/debug/tracing
echo 1 > events/filemap/mm_filemap_add_to_page_cache/enable
echo 1 > events/filemap/mm_filemap_delete_from_page_cache/enable
cat trace_pipe | head -20
# 출력 예: <...>-1234 mm_filemap_add_to_page_cache: dev 259:0 ino 1234 pfn=12345 ofs=0
# writeback 활동 추적
echo 1 > events/writeback/writeback_dirty_folio/enable
echo 1 > events/writeback/writeback_written/enable
cat trace_pipe | head -20
# balance_dirty_pages (throttling) 추적
echo 1 > events/writeback/balance_dirty_pages/enable
echo 1 > events/writeback/global_dirty_state/enable
cat trace_pipe
# throttling이 발생하는 시점과 dirty 비율을 실시간 확인
bpftrace를 이용한 상세 분석
# Page Cache 히트율 측정
$ bpftrace -e '
kprobe:filemap_get_folio { @total++; }
kretprobe:filemap_get_folio /retval > 0/ { @hit++; }
kretprobe:filemap_get_folio /retval <= 0/ { @miss++; }
interval:s:5 {
$total = @total; $hit = @hit; $miss = @miss;
printf("total=%d hit=%d miss=%d ratio=%.1f%%\n",
$total, $hit, $miss,
$total > 0 ? $hit * 100.0 / $total : 0);
clear(@total); clear(@hit); clear(@miss);
}'
# Readahead 효율 분석
$ bpftrace -e '
kprobe:page_cache_ra_order {
@ra_trigger++;
@ra_pages = hist(arg3); /* nr_to_read */
}
kprobe:__folio_mark_accessed { @accessed++; }
interval:s:10 {
printf("Readahead triggers: %d, Page accesses: %d\n",
@ra_trigger, @accessed);
print(@ra_pages);
clear(@ra_trigger); clear(@accessed); clear(@ra_pages);
}'
# Dirty throttling 지연 측정 (프로세스별)
$ bpftrace -e '
kprobe:balance_dirty_pages {
@start[tid] = nsecs;
}
kretprobe:balance_dirty_pages /@start[tid]/ {
$dur = (nsecs - @start[tid]) / 1000; /* microseconds */
@throttle_us = hist($dur);
if ($dur > 1000) {
printf("%s[%d] throttled for %d us\n", comm, pid, $dur);
}
delete(@start[tid]);
}'
# fsync 지연 분석 (파일시스템별)
$ bpftrace -e '
kprobe:vfs_fsync_range {
@start[tid] = nsecs;
@file[tid] = str(((struct file *)arg0)->f_path.dentry->d_name.name);
}
kretprobe:vfs_fsync_range /@start[tid]/ {
$dur = (nsecs - @start[tid]) / 1000000; /* milliseconds */
printf("fsync(%s) = %d ms\n", @file[tid], $dur);
@fsync_ms = hist($dur);
delete(@start[tid]); delete(@file[tid]);
}'
# writeback 워커 활동 프로파일링
$ bpftrace -e '
kprobe:wb_writeback {
@wb_start[tid] = nsecs;
}
kretprobe:wb_writeback /@wb_start[tid]/ {
$dur = (nsecs - @wb_start[tid]) / 1000000;
@wb_duration_ms = hist($dur);
delete(@wb_start[tid]);
}
kprobe:__writeback_single_inode {
@inode_count++;
}
interval:s:10 {
printf("Writeback durations (ms):\n");
print(@wb_duration_ms);
printf("Inodes written: %d\n", @inode_count);
clear(@wb_duration_ms); clear(@inode_count);
}'
perf를 이용한 Page Cache 이벤트 분석
# Page Cache 관련 하드웨어 PMC 이벤트
$ perf stat -e cache-references,cache-misses,page-faults,major-faults \
-- dd if=/dev/sda of=/dev/null bs=1M count=1024
# Page Cache tracepoint 기반 통계 수집
$ perf stat -e filemap:mm_filemap_add_to_page_cache \
-e filemap:mm_filemap_delete_from_page_cache \
-e writeback:writeback_dirty_folio \
-e writeback:writeback_written \
-a sleep 10
# filemap 함수의 CPU 시간 분석
$ perf record -g -e cycles --filter 'filemap_*' -- sleep 30
$ perf report
# Page Cache 미스로 인한 I/O 대기 분석
$ perf record -e block:block_rq_insert -a -- sleep 10
$ perf report --sort=comm,dso
bpftrace와 perf는 프로덕션 환경에서도 안전하게 사용할 수 있습니다. 단, ftrace의 function_graph 트레이서나 kprobe를 대량으로 설정하면 성능에 영향을 줄 수 있으므로, 짧은 시간에만 활성화하는 것을 권장합니다. bpftrace의 interval 프로브를 사용하면 주기적 샘플링으로 오버헤드를 최소화할 수 있습니다.
종합 요약
| 구성 요소 | 역할 | 핵심 소스 |
|---|---|---|
address_space | 파일별 캐시 페이지 관리 컨테이너 | include/linux/fs.h |
folio | Page Cache의 기본 단위 (compound page) | include/linux/mm_types.h |
filemap.c | 캐시 조회, 삽입, 읽기, 쓰기 핵심 로직 | mm/filemap.c |
readahead.c | 적응형 readahead 알고리즘 | mm/readahead.c |
page-writeback.c | dirty 페이지 관리, writeback 제어, dirty throttling | mm/page-writeback.c |
fs-writeback.c | writeback 워커, dirty inode 큐 관리 | fs/fs-writeback.c |
rmap.c | PTE dirty bit 수확 (folio_mkclean) | mm/rmap.c |
| LRU / MGLRU | 페이지 에이징과 회수 정책 | mm/vmscan.c |
truncate.c | 캐시 무효화, truncate | mm/truncate.c |
buffer_head | 블록 디바이스 메타데이터 캐싱 (레거시) | fs/buffer.c |
| cgroup writeback | cgroup별 dirty 추적/throttling (v4.2+) | fs/fs-writeback.c |
cachestat | Page Cache 통계 시스템 콜 (v6.5+) | mm/filemap.c |
| Large Folio | 대용량 folio로 TLB/XArray/I/O 효율 개선 (v5.18+) | mm/filemap.c, mm/readahead.c |
errseq_t | writeback 에러 보고 (모든 fd에 누락 없이) | lib/errseq.c |
| Refault/워킹셋 | 회수 후 재참조 추적으로 회수 정책 최적화 | mm/workingset.c |
| io_uring | 비동기 I/O 인터페이스 (Page Cache + Direct I/O) | io_uring/rw.c |
관련 문서
Page Cache와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.