Page Cache 심화

Linux 커널 Page Cache의 내부 구조를 심층 분석합니다. address_space, folio, readahead, writeback, LRU 리스트, dirty 페이지 관리, Direct I/O, mmap 연동, buffer cache, cachestat 시스템 콜, 그리고 튜닝/모니터링 기법까지 종합적으로 다룹니다.

관련 문서: Page Cache는 VFS와 메모리 관리 서브시스템의 교차점에 위치합니다. VFS 계층, 메모리 관리, 메모리 관리 심화, Block I/O 문서를 함께 참고하세요.

Page Cache 개요

Page Cache는 디스크(또는 블록 디바이스)에서 읽은 파일 데이터를 메모리에 캐싱하는 커널 서브시스템입니다. 파일을 읽을 때마다 느린 디스크 I/O를 수행하는 대신, 한 번 읽은 데이터를 페이지 단위로 메모리에 보관하여 이후 접근 시 즉시 반환합니다. 쓰기 시에도 데이터를 먼저 Page Cache에 기록(dirty 페이지)한 뒤, 백그라운드에서 디스크에 플러시(writeback)합니다.

Page Cache의 역할

핵심 원리: Linux에서 "여유 메모리"가 적게 보이는 것은 정상입니다. 사용되지 않는 메모리는 Page Cache로 활용되며, 애플리케이션이 메모리를 요청하면 Page Cache를 회수하여 할당합니다. /proc/meminfoCached 필드가 Page Cache 크기를 나타냅니다.
Page Cache 위치: VFS - 메모리 관리 - Block I/O User Process: read() / write() / mmap() VFS Layer (file_operations) Page Cache (address_space + folio) Memory Management (LRU, reclaim) Block I/O (bio, blk-mq) Storage (HDD / SSD / NVMe)
Page Cache는 VFS와 Block I/O 사이에서 디스크 데이터를 메모리에 캐싱합니다

읽기/쓰기 흐름 요약

읽기 (Buffered Read) 경로:

  1. 사용자 프로세스가 read() 시스템 콜 호출
  2. VFS가 file->f_op->read_iter() 호출 (대부분 generic_file_read_iter())
  3. Page Cache에서 해당 오프셋의 folio를 검색 (filemap_get_folio())
  4. 캐시 히트: 즉시 사용자 버퍼에 복사
  5. 캐시 미스: 디스크에서 읽기 I/O 수행 후 Page Cache에 삽입, 사용자 버퍼에 복사

쓰기 (Buffered Write) 경로:

  1. 사용자 프로세스가 write() 시스템 콜 호출
  2. VFS가 file->f_op->write_iter() 호출 (대부분 generic_file_write_iter())
  3. 대상 folio를 Page Cache에서 찾거나 새로 할당
  4. 사용자 데이터를 folio에 복사하고 dirty로 표시
  5. 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 *);
    /* ... */
};
i_pages (XArray): 커널 4.20 이전에는 radix_tree_root page_tree를 사용했습니다. 현재는 XArray로 대체되어 잠금 관리가 간결해지고, 멀티오더 엔트리를 통해 folio(compound page)를 효율적으로 저장합니다. XArray에 대한 자세한 내용은 XArray 문서를 참고하세요.

AS_* 플래그

플래그설명
AS_EIOI/O 에러 발생
AS_ENOSPC디스크 공간 부족 에러
AS_MM_ALL_LOCKS모든 매핑 잠금 보유
AS_UNEVICTABLE페이지 회수 불가 (ramfs 등)
AS_EXITINGtruncate 진행 중
AS_STABLE_WRITESwriteback 중 데이터 수정 금지

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_lruLRULRU 리스트에 포함됨
PG_active활성활성(active) LRU에 포함됨
PG_referenced참조됨최근 접근됨 (LRU 승격 후보)
PG_reclaim회수 대상페이지 회수 대상으로 지정됨
PG_private프라이빗folio->private에 fs 데이터 존재
page에서 folio로의 전환: 커널 5.16부터 Page Cache 관련 API가 점진적으로 folio 기반으로 전환되고 있습니다. 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 창 크기를 동적으로 조절합니다.

/* 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) */
readahead 튜닝: 대용량 순차 읽기 워크로드(백업, 미디어 스트리밍)에서는 read_ahead_kb를 늘리면 성능이 향상됩니다. 반면 랜덤 I/O 워크로드(데이터베이스)에서는 줄이거나 POSIX_FADV_RANDOM을 사용합니다.

Writeback

Writeback은 Page Cache에 쌓인 dirty 페이지를 디스크에 기록하는 과정입니다. Linux는 쓰기를 즉시 디스크에 반영하지 않고, 메모리에 버퍼링한 뒤 일정 조건이 되면 백그라운드에서 플러시합니다. 이를 통해 쓰기 성능을 크게 향상시킵니다.

Writeback 트리거 조건

조건트리거설명
주기적dirty_writeback_centisecs5초(기본)마다 워커 스레드가 dirty 페이지 검사
dirty 비율 초과dirty_background_ratiodirty 페이지가 전체 메모리의 10%(기본) 초과 시 백그라운드 writeback 시작
dirty 한계 초과dirty_ratiodirty 페이지가 전체 메모리의 20%(기본) 초과 시 프로세스 쓰기 차단(throttle)
dirty 만료dirty_expire_centisecs30초(기본) 이상 dirty 상태인 페이지 플러시
명시적 syncsync / 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_dirtyPG_writeback 플래그로 추적됩니다.

Dirty Page 상태 전이 (State Machine) Clean Dirty Writeback folio_mark_dirty() folio_start_writeback() folio_end_writeback() — I/O 완료 콜백 re-dirty 가능 PG_dirty=0 PG_writeback=0 PG_dirty=1 PG_writeback=0 PG_dirty=0 PG_writeback=1 Reclaim: dirty → writeback 후 회수
dirty 페이지는 clean → dirty → writeback → clean 순환을 따릅니다. writeback 중에도 re-dirty될 수 있습니다.
상태 전이API발생 시점
Clean → Dirtyfolio_mark_dirty()write(), MAP_SHARED 쓰기, 메타데이터 변경
Dirty → Writebackfolio_start_writeback()writeback 워커, fsync, 메모리 회수
Writeback → Cleanfolio_end_writeback()디스크 I/O 완료 콜백 (bio end_io)
Writeback → Dirtyfolio_mark_dirty()writeback 중 재수정 (re-dirty)
Any → Reclaimshrink_folio_list()메모리 부족 시 (dirty면 먼저 writeback)
Re-dirty 문제: writeback 진행 중 동일 페이지가 다시 수정되면 re-dirty가 발생합니다. 이 경우 현재 writeback이 완료된 후 다음 writeback 사이클에서 다시 기록됩니다. 데이터베이스처럼 동일 영역을 빈번히 갱신하는 워크로드에서 흔히 발생하며, /proc/vmstatnr_dirtynr_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, ramfsPG_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;
}
XArray PAGECACHE_TAG_DIRTY: dirty 페이지를 XArray에 태그로 표시하면, writeback 시 전체 캐시를 순회하지 않고 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 완료 후 추가 수정을 다시 감지할 수 있음 */
mmap(MAP_SHARED) 쓰기 시 Dirty 추적 흐름 1. mmap 매핑 PTE: R/O (clean) 쓰기 권한 미부여 2. 첫 쓰기 Write Protection Fault → page_mkwrite() 3. Dirty 상태 PTE: R/W + HW dirty bit 이후 쓰기: fault 없음 4. Writeback folio_mkclean() PTE → R/O (재감지 준비) 사이클 반복: 다음 쓰기 시 다시 fault 발생 folio_mkclean() — PTE dirty bit 수확 모든 VMA의 해당 PTE를 순회 → HW dirty bit 확인 → 읽기 전용으로 복원 rmap(reverse mapping)을 사용하여 folio를 매핑하는 모든 프로세스의 PTE를 찾음
mmap 쓰기 추적은 PTE 보호 비트 조작과 page_mkwrite fault handler의 협력으로 구현됩니다
/* 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;  /* 계속 순회 */
}
TLB 플러시 비용: folio_mkclean()은 PTE를 읽기 전용으로 변경한 후 TLB 플러시가 필요합니다. 수천 개의 프로세스가 같은 파일을 MAP_SHARED로 매핑한 경우 TLB 플러시 오버헤드가 상당할 수 있습니다. mmap 기반의 대규모 쓰기 워크로드에서는 이 점을 고려해야 합니다.

Writeback 워커 내부 흐름

Writeback은 per-BDI 워커 스레드(wb_workfn)에 의해 수행됩니다. 워커는 워크큐를 통해 주기적으로 또는 이벤트 기반으로 실행되며, dirty inode를 순회하면서 dirty 페이지를 디스크에 기록합니다.

Writeback 워커 호출 체인 wb_workfn() — 워크큐 엔트리 wb_do_writeback() — 작업 큐 처리 wb_writeback() — 메인 루프 writeback_sb_inodes() — SB별 inode 순회 __writeback_single_inode() — 개별 inode 처리 do_writepages() → a_ops->writepages() delayed_work callback nr_to_write 감소 루프 b_dirty → b_io 이동 FS별 구현
Writeback 워커는 wb_workfn()에서 시작하여 최종적으로 파일시스템의 writepages() 콜백을 호출합니다
/* 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_dirtydirty inode 대기열dirty 상태가 된 inode가 dirtied_when 순서로 삽입
b_iowriteback 진행 중 큐queue_io()가 만료된 inode를 b_dirty에서 이동
b_more_io재시도 대기 큐한 번에 모든 페이지를 기록하지 못한 inode (대용량 파일)
b_dirty_timetimestamp만 dirtyI_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);
    }
}
lazytime 마운트 옵션: 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; /* 평균 쓰기 대역폭 (지수 이동 평균) */
    /* ... */
};
Dirty Throttling: pos_ratio 곡선 dirty 페이지 비율 (전체 메모리 대비) 쓰기 속도 (task ratelimit) bg_thresh (10%) thresh (setpoint) (15%) dirty_ratio (20%) freerun zone (제한 없음) throttle zone pos_ratio=1 pos_ratio > 1 (최대 속도) pos_ratio → 0 (거의 차단)
dirty 비율이 setpoint를 넘으면 pos_ratio가 1 이하로 감소하여 쓰기 속도를 점진적으로 제한합니다
/* 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;
}
Per-BDI 공정성: 시스템에 NVMe SSD(쓰기 3GB/s)와 HDD(쓰기 150MB/s)가 함께 있을 때, 단순 글로벌 dirty 제한만으로는 느린 HDD의 dirty 페이지가 전체 메모리를 잠식합니다. Per-BDI throttling은 각 디바이스의 쓰기 대역폭에 비례하여 dirty 할당량을 자동 조절하여, 빠른 디바이스의 성능이 느린 디바이스에 의해 저하되는 것을 방지합니다.

Dirty 페이지 계정 (Accounting)

커널은 dirty 페이지 수를 여러 수준에서 추적합니다. 이 카운터들은 writeback 결정, throttling, 메모리 회수에 핵심적으로 사용됩니다.

카운터수준위치용도
NR_FILE_DIRTYNUMA 노드/proc/vmstat: nr_dirty글로벌 dirty 페이지 총 수 (balance_dirty_pages 판단 기준)
NR_WRITEBACKNUMA 노드/proc/vmstat: nr_writeback현재 디스크에 기록 중인 페이지 수
NR_WRITEBACK_TEMPNUMA 노드/proc/vmstat: nr_writeback_tempFUSE 등의 임시 writeback 페이지
NR_DIRTIEDNUMA 노드/proc/vmstat: nr_dirtied누적 dirty 표시 횟수 (대역폭 추정)
NR_WRITTENNUMA 노드/proc/vmstat: nr_written누적 writeback 완료 횟수 (대역폭 추정)
WB_DIRTIEDPer-BDIBDI 내부BDI별 dirty 속도 추적
WB_WRITTENPer-BDIBDI 내부BDI별 writeback 속도 추적
current->nr_dirtiedPer-tasktask_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 체크섬
Stable pages 오버헤드: writeback 중 동일 페이지에 쓰기를 시도하면 writeback 완료까지 프로세스가 차단됩니다. 고속 SSD에서는 짧은 대기지만, HDD에서는 수 밀리초~수십 밀리초 대기가 발생할 수 있습니다. 커널 5.19부터 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 v1cgroup 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
Kubernetes와 dirty writeback: Kubernetes 환경에서 Pod의 메모리 limit을 설정하면 cgroup v2의 memory.max가 설정됩니다. 이는 해당 Pod 내에서 생성할 수 있는 dirty 페이지 총량에도 영향을 미칩니다. 대량 파일 쓰기를 하는 Pod의 메모리 limit이 너무 낮으면 빈번한 throttling으로 쓰기 성능이 저하될 수 있으므로, 워크로드에 맞는 적절한 메모리 할당이 중요합니다.
데이터 손실 주의: dirty 페이지가 디스크에 기록되기 전에 시스템이 비정상 종료되면 데이터가 유실됩니다. 중요한 데이터는 fsync()로 명시적으로 디스크에 기록해야 합니다. 데이터베이스는 WAL(Write-Ahead Logging)과 fdatasync()를 조합하여 내구성을 보장합니다.

LRU 리스트

Page Cache의 페이지는 LRU(Least Recently Used) 리스트로 관리됩니다. 메모리가 부족하면 커널은 LRU 리스트에서 가장 오래 사용되지 않은 페이지를 회수(reclaim)합니다.

클래식 LRU (Two-List)

전통적인 Linux LRU는 activeinactive 두 개의 리스트로 구성됩니다.

각 리스트는 파일 캐시 페이지용과 익명(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)로 분류하고 정밀한 에이징을 수행합니다.

/* 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/ODirect I/O (O_DIRECT)
경로User Buffer ↔ Page Cache ↔ DiskUser 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);
}
O_DIRECT와 Page Cache 일관성: Direct I/O로 파일에 쓰면 Page Cache에 이전에 캐시된 데이터와 불일치가 발생할 수 있습니다. 커널은 Direct I/O 쓰기 시 해당 범위의 Page Cache를 무효화합니다. 같은 파일에 Buffered I/O와 Direct I/O를 혼합하는 것은 일반적으로 권장되지 않습니다.

mmap과 Page Cache

mmap()으로 파일을 메모리에 매핑하면, Page Cache의 페이지가 프로세스의 가상 주소 공간에 직접 매핑됩니다. 이를 통해 read()/write() 시스템 콜 없이 파일 데이터에 포인터로 접근할 수 있습니다.

파일 매핑 흐름

  1. mmap() 호출: VMA(vm_area_struct) 생성, 실제 메모리 할당은 지연
  2. 첫 접근 시 page fault 발생
  3. fault handler가 Page Cache에서 folio 검색 (없으면 디스크에서 읽기)
  4. PTE(Page Table Entry)에 folio의 물리 주소 매핑
  5. 이후 접근은 직접 메모리 접근 (시스템 콜 불필요)
/* 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_SHAREDMAP_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;
}
상세 분석: mmap 쓰기 시 PTE dirty bit 추적과 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_headfolio
단위블록 크기 (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);
buffer_head의 미래: 커널 커뮤니티는 buffer_head 의존성을 점진적으로 제거하고 있습니다. 새로운 파일시스템(bcachefs 등)은 buffer_head를 사용하지 않으며, 기존 파일시스템(ext4, XFS)도 folio 기반 경로로 전환 중입니다. iomap 프레임워크가 buffer_head를 대체하는 주요 방향입니다.

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;
}
cachestat vs mincore: 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_ratio20전체 메모리 대비 dirty 비율 (%) - 초과 시 프로세스 블록
vm.dirty_background_ratio10백그라운드 writeback 시작 비율 (%)
vm.dirty_bytes0dirty 한계 (바이트 단위, 0이면 ratio 사용)
vm.dirty_background_bytes0백그라운드 writeback 한계 (바이트)
vm.dirty_expire_centisecs3000dirty 페이지 만료 시간 (1/100초 = 30초)
vm.dirty_writeback_centisecs500writeback 워커 주기 (1/100초 = 5초)
vm.vfs_cache_pressure100dentry/inode 캐시 회수 압력 (높을수록 적극 회수)
vm.swappiness60익명 페이지 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 후 제거unmapDirect I/O 일관성, NFS 캐시 무효화
filemap_write_and_wait()writeback 후 유지유지fsync, 동기화
invalidate_mapping_pages()건너뜀 (clean만 제거)건너뜀메모리 회수 힌트 (FADV_DONTNEED)
NFS와 Page Cache: NFS 같은 네트워크 파일시스템에서는 서버 측 변경을 클라이언트가 감지해야 합니다. NFS는 파일 속성(mtime 등) 변경 감지 시 invalidate_inode_pages2()로 로컬 Page Cache를 무효화하여 다음 읽기에서 최신 데이터를 가져옵니다.

종합 요약

구성 요소역할핵심 소스
address_space파일별 캐시 페이지 관리 컨테이너include/linux/fs.h
folioPage Cache의 기본 단위 (compound page)include/linux/mm_types.h
filemap.c캐시 조회, 삽입, 읽기, 쓰기 핵심 로직mm/filemap.c
readahead.c적응형 readahead 알고리즘mm/readahead.c
page-writeback.cdirty 페이지 관리, writeback 제어, dirty throttlingmm/page-writeback.c
fs-writeback.cwriteback 워커, dirty inode 큐 관리fs/fs-writeback.c
rmap.cPTE dirty bit 수확 (folio_mkclean)mm/rmap.c
LRU / MGLRU페이지 에이징과 회수 정책mm/vmscan.c
truncate.c캐시 무효화, truncatemm/truncate.c
buffer_head블록 디바이스 메타데이터 캐싱 (레거시)fs/buffer.c
cgroup writebackcgroup별 dirty 추적/throttling (v4.2+)fs/fs-writeback.c
cachestatPage Cache 통계 시스템 콜 (v6.5+)mm/filemap.c