Page Cache 심화
Linux 커널 Page Cache의 내부 구조를 심층 분석합니다. address_space, folio, readahead, writeback, LRU 리스트, dirty 페이지 관리, Direct I/O, mmap 연동, buffer cache, cachestat 시스템 콜, 그리고 튜닝/모니터링 기법까지 종합적으로 다룹니다.
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를 사용해야 합니다.
페이지 캐시 조회와 삽입
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을 사용합니다.
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
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의 데이터를 명시적으로 제거하거나 디스크와 동기화하는 작업을 무효화(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를 무효화하여 다음 읽기에서 최신 데이터를 가져옵니다.
종합 요약
| 구성 요소 | 역할 | 핵심 소스 |
|---|---|---|
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 |