전체 흐름
VFS/filesystem 코드는 path name을 dentry/inode/file/page cache operation으로 번역하는 계층이다. 이름 해석의 consistency와 데이터 I/O의 consistency를 분리해서 봐야 한다.
함수의 입력과 처리 결과를 먼저 확인한다. 구조체를 생성하고 연결하는 시점, 다른 코드에서 사용할 수 있게 하는 시점, 더 이상 사용하지 않아 회수하는 시점을 구분한다.
정상 처리와 오류 처리를 함께 확인한다. 오류 복구, 장치의 실행 중 연결·분리(hotplug), 자원 해제 과정에서도 상태가 올바르게 정리되는지 점검한다.
왼쪽부터 처리 단계를 나열했다. 각 코드 조각에서 바뀌는 상태를 해당 단계와 대조한다.
소스 코드 위치
먼저 확인할 Linux 6.18.37 LTS 소스 파일
Linux 6.18.37 LTS: mm/filemap.c
Linux 6.18.37 LTS: mm/readahead.c
Linux 6.18.37 LTS: mm/page-writeback.c
Linux 6.18.37 LTS: include/linux/pagemap.h
설명: 진입 함수가 있는 파일과 구조체 정의, 아키텍처별 연결 코드, 콜백 구현을 함께 확인한다. 여러 파일을 대조해야 호출 관계와 각 코드의 역할을 알 수 있다.
대표 코드
filemap_read
filemap_read 함수 전체 (113줄)
ssize_t filemap_read(struct kiocb *iocb, struct iov_iter *iter,
ssize_t already_read)
{
struct file *filp = iocb->ki_filp;
struct file_ra_state *ra = &filp->f_ra;
struct address_space *mapping = filp->f_mapping;
struct inode *inode = mapping->host;
struct folio_batch fbatch;
int i, error = 0;
bool writably_mapped;
loff_t isize, end_offset;
loff_t last_pos = ra->prev_pos;
if (unlikely(iocb->ki_pos < 0))
return -EINVAL;
if (unlikely(iocb->ki_pos >= inode->i_sb->s_maxbytes))
return 0;
if (unlikely(!iov_iter_count(iter)))
return 0;
iov_iter_truncate(iter, inode->i_sb->s_maxbytes - iocb->ki_pos);
folio_batch_init(&fbatch);
do {
cond_resched();
/*
* If we've already successfully copied some data, then we
* can no longer safely return -EIOCBQUEUED. Hence mark
* an async read NOWAIT at that point.
*/
if ((iocb->ki_flags & IOCB_WAITQ) && already_read)
iocb->ki_flags |= IOCB_NOWAIT;
if (unlikely(iocb->ki_pos >= i_size_read(inode)))
break;
error = filemap_get_pages(iocb, iter->count, &fbatch, false);
if (error < 0)
break;
/*
* i_size must be checked after we know the pages are Uptodate.
*
* Checking i_size after the check allows us to calculate
* the correct value for "nr", which means the zero-filled
* part of the page is not copied back to userspace (unless
* another truncate extends the file - this is desired though).
*/
isize = i_size_read(inode);
if (unlikely(iocb->ki_pos >= isize))
goto put_folios;
end_offset = min_t(loff_t, isize, iocb->ki_pos + iter->count);
/*
* Once we start copying data, we don't want to be touching any
* cachelines that might be contended:
*/
writably_mapped = mapping_writably_mapped(mapping);
/*
* When a read accesses the same folio several times, only
* mark it as accessed the first time.
*/
if (!pos_same_folio(iocb->ki_pos, last_pos - 1,
fbatch.folios[0]))
folio_mark_accessed(fbatch.folios[0]);
for (i = 0; i < folio_batch_count(&fbatch); i++) {
struct folio *folio = fbatch.folios[i];
size_t fsize = folio_size(folio);
size_t offset = iocb->ki_pos & (fsize - 1);
size_t bytes = min_t(loff_t, end_offset - iocb->ki_pos,
fsize - offset);
size_t copied;
if (end_offset < folio_pos(folio))
break;
if (i > 0)
folio_mark_accessed(folio);
/*
* If users can be writing to this folio using arbitrary
* virtual addresses, take care of potential aliasing
* before reading the folio on the kernel side.
*/
if (writably_mapped)
flush_dcache_folio(folio);
copied = copy_folio_to_iter(folio, offset, bytes, iter);
already_read += copied;
iocb->ki_pos += copied;
last_pos = iocb->ki_pos;
if (copied < bytes) {
error = -EFAULT;
break;
}
}
put_folios:
for (i = 0; i < folio_batch_count(&fbatch); i++) {
struct folio *folio = fbatch.folios[i];
filemap_end_dropbehind_read(folio);
folio_put(folio);
}
folio_batch_init(&fbatch);
} while (iov_iter_count(iter) && iocb->ki_pos < isize && !error);
file_accessed(filp);
ra->prev_pos = last_pos;
return already_read ? already_read : error;
}확인 사항: 이 코드에서는 반환값보다 상태 변경을 먼저 확인한다. 어느 잠금을 획득한 뒤 어떤 필드를 바꾸는지, 실패 시 어느 레이블로 분기하는지, 변경된 상태를 다음 호출자가 어떤 전제로 사용하는지 추적한다.
filemap_add_folio
int filemap_add_folio(struct address_space *mapping, struct folio *folio,
pgoff_t index, gfp_t gfp)
{
void *shadow = NULL;
int ret;
struct mem_cgroup *tmp;
bool kernel_file = test_bit(AS_KERNEL_FILE, &mapping->flags);
if (kernel_file)
tmp = set_active_memcg(root_mem_cgroup);
ret = mem_cgroup_charge(folio, NULL, gfp);
if (kernel_file)
set_active_memcg(tmp);
if (ret)
return ret;
__folio_set_locked(folio);
ret = __filemap_add_folio(mapping, folio, index, gfp, &shadow);
if (unlikely(ret)) {
mem_cgroup_uncharge(folio);
__folio_clear_locked(folio);
} else {
/*
* The folio might have been evicted from cache only
* recently, in which case it should be activated like
* any other repeatedly accessed folio.
* The exception is folios getting rewritten; evicting other
* data from the working set, only to cache data that will
* get overwritten with something else, is a waste of memory.
*/
WARN_ON_ONCE(folio_test_active(folio));
if (!(gfp & __GFP_WRITE) && shadow)
workingset_refault(folio, shadow);
folio_add_lru(folio);
if (kernel_file)
mod_node_page_state(folio_pgdat(folio),
NR_KERNEL_FILE_PAGES,
folio_nr_pages(folio));
}
return ret;
}
메모리 cgroup 과금이 성공한 뒤 folio를 잠금 상태로 만들고 __filemap_add_folio()로 mapping에 연결한다. 연결 실패 시 과금과 잠금 표시를 되돌린다. 성공 경로는 shadow entry를 이용한 refault 처리와 LRU 추가를 수행한다. 삽입 성공이 파일 데이터 읽기 완료를 뜻하지는 않는다.
함수별 분석
파일 읽기는 먼저 XArray에서 folio를 찾고, 없을 때에만 readahead 또는 새 folio 생성 경로를 고려한다. 쓰기와 writeback은 별도의 상태 변화다.
filemap_read()와 filemap_add_folio()는 항상 이어지는 두 함수가 아니다. 삽입 함수는 읽기 miss 후에도 조건부로만 호출된다.
XArray 조회가 비어 있고 readahead 뒤에도 folio가 없을 때 filemap_create_folio()가 할당 → filemap_add_folio()로 삽입 → filemap_read_folio()로 데이터 읽기를 시도한다. 적중 경로에는 이 생성 단계가 없다. 근거: mm/filemap.c:2549–2599, 2616–2674, 2754–2805.
1. filemap_read
filemap_get_pages()를 통해 batch를 얻고, 파일 크기를 다시 확인한 뒤 읽을 수 있는 folio 범위를 iterator에 복사한다. 반환값은 실제 복사한 바이트 수 또는 오류다. 조회가 적중하면 새 folio를 삽입하지 않는다.
확인할 분기: 빈 batch에서 IOCB_NOIO는 -EAGAIN을 반환하고, I/O가 허용되면 동기 readahead 후 재조회한다.
2. filemap_add_folio
이미 마련된 folio와 mapping·index를 받아 과금, 잠금 표시, XArray 삽입, LRU 등록을 처리한다. 성공 시 해당 index에 folio가 존재하지만, 파일 데이터가 준비되었거나 dirty라는 뜻은 아니다.
확인할 분기: 같은 index의 기존 folio와 충돌하면 -EEXIST가 가능하며, 호출자는 재시도 여부를 결정한다.
| 함수 | 입력 | 확인 가능한 결과 | 소스에서 볼 분기 |
|---|---|---|---|
| filemap_read | kiocb, iov_iter | 준비된 범위의 복사 바이트 수 또는 오류 | 조회·readahead·생성 조건 |
| filemap_add_folio | mapping, 이미 할당한 folio, index, gfp | 성공 시 XArray 연결·LRU 등록; 데이터 읽기 완료는 별개 | 삽입 충돌·과금 되돌림 |
구조체와 필드
객체 관계와 실행 조건을 나누어 읽는다. address_space의 XArray가 index를 folio에 연결하지만, readahead는 조회 결과에 따라 시작되는 별도 요청이다.
readahead_control은 이 객체 사슬의 다음 단계가 아니다. 읽기 batch가 비었을 때의 동기 readahead나 readahead 표식이 있는 folio에서의 비동기 readahead에 쓰이는 요청 문맥이다. 근거: mm/filemap.c:2634–2660.
객체 연결과 조건부 요청 문맥을 구분해 읽는다.
| 객체·상태 | 읽는 법 |
|---|---|
| address_space | 파일별 캐시 mapping을 관리한다. |
| i_pages (XArray) | index에서 folio를 찾는다. 삽입만으로 내용 유효성이 보장되지는 않는다. |
| folio flags | Uptodate·Dirty·Writeback은 서로 다른 조건을 나타낸다. |
| readahead_control | 조건부 readahead 호출에 전달하는 요청 문맥이다. |
읽기와 쓰기의 상태 변화
조회 실패는 읽기 경로의 분기이고, Dirty·Writeback은 파일 데이터 변경과 I/O 진행을 나타내는 별도 상태다. 읽기 miss가 반드시 dirty로 이어지지는 않는다.
두 상자는 서로 다른 경로다. Writeback은 진행 중인 I/O를 나타내며, 이 표시만으로 디스크 영속화를 단정하지 않는다. 근거: mm/filemap.c:2754–2805, 3897–3917, mm/page-writeback.c:2985–3067.
아래 분기 그림과 상태 표에서 조회, 데이터 유효성, Dirty, Writeback을 각각 확인한다.
읽기 분기와 독립 상태
개념 설명의 과금·삽입·LRU 순서와 달리, 여기서는 읽기 요청이 언제 캐시 적중, IOCB_NOIO 거절, readahead, 새 folio 생성으로 갈라지는지 본다.
소스 기준의 설명용 분기이며 실측 trace가 아니다. filemap_add_folio()는 folio 삽입만 맡고 데이터 읽기는 뒤의 filemap_read_folio()가 맡는다. mm/filemap.c:2549–2674.
그림 크게 열기 · 작은 화면에서는 그림을 가로로 스크롤할 수 있다.
| 판정 축 | 소스에서 바뀌는 지점 | 이 상태만으로 알 수 없는 것 |
|---|---|---|
| XArray에 연결 | __filemap_add_folio()가 mapping->i_pages에 저장하고 nrpages를 갱신한다. filemap.c:880–959 | 파일 데이터가 이미 읽혔는지는 알 수 없다. |
| 읽을 범위 유효 | Uptodate 또는 허용된 부분 유효 범위를 검사하고, 필요하면 folio를 갱신한다. filemap.c:2466–2545 | 읽기 가능성이 Dirty 표시를 뜻하지 않는다. |
| Dirty | 예를 들어 mmap 쓰기 fault 경로는 folio_mark_dirty()를 호출한다. filemap.c:3897–3917, page-writeback.c:2795–2830 | 디스크 반영 완료나 읽기 miss 직후 상태를 뜻하지 않는다. |
| Writeback | 시작 시 PG_writeback을 설정하고, mapping이 writeback tag를 쓰면 XArray tag도 표시한다. 종료 시 표시와 계수를 정리한다. page-writeback.c:2985–3067 | 표시만으로 I/O 완료나 영속화를 뜻하지 않는다. |
표의 축은 단일 정상 진행 순서가 아니다. folio의 연결 여부, 읽을 범위의 유효성, 수정 여부, writeback 진행 여부를 각각 확인한다.
불변 조건과 실패 사례
page cache의 핵심 객체가 외부에 공개된 뒤에는 마지막 참조가 사라질 때까지 해제 경로가 callback, timer, IRQ, worker와 경합하지 않아야 한다.
다른 CPU나 하위 계층이 변경된 필드를 읽는다면 잠금과 메모리 배리어가 접근 순서를 어떻게 보장하는지 확인한다. 참조 횟수와 RCU로 관리하는 객체 수명도 별도로 확인한다.
중간 단계에서 실패하면 이미 확보한 자원을 의존 관계에 맞춰 해제해야 한다. goto 레이블이 많다면 각 분기에 도달했을 때 확보한 자원을 표로 정리한다.
임베디드 보드를 처음 구동할 때는 패닉 대신 로그 중단, 시간 초과, 장치 초기화 지연, 과도한 인터럽트 같은 증상으로 문제가 드러날 수 있다.
page cache와 buffer cache를 혼동함
dirty page가 곧 disk 반영이라고 봄
mmap fault와 read path가 같은 mapping을 공유하는 점을 놓침
계측과 검증
로그를 무작정 추가하지 말고 확인할 가설을 먼저 세운다. 상태표에서 처리가 멈춘 단계를 찾은 뒤, 상태를 바꾸는 함수와 그 결과를 사용하는 함수를 함께 살펴본다.
로그가 출력되었다고 처리가 끝난 것으로 판단하지 않는다. 원본 코드에서 로그 앞뒤의 필드 변경을 확인한다.
| 도구 | 보는 것 |
|---|---|
| filemap:* | page cache 실행이 어느 단계에서 멈추는지 확인 |
| writeback:* | page cache 실행이 어느 단계에서 멈추는지 확인 |
| vmscan:* | page cache 실행이 어느 단계에서 멈추는지 확인 |
| cat /proc/meminfo | page cache 실행이 어느 단계에서 멈추는지 확인 |
# 예시: tracefs가 켜진 보드에서 토픽별 event를 좁혀 본다.
mount -t tracefs nodev /sys/kernel/tracing
echo function_graph > /sys/kernel/tracing/current_tracer
echo ':mod:*' > /sys/kernel/tracing/set_ftrace_filter
cat /sys/kernel/tracing/trace_pipe
추가 확인 사항
- page cache 의 state machine을 네 단계로 줄였을 때, 실제 코드에서 빠지는 intermediate state는 무엇인가?
- page cache 의 fast path가 생략한 검사는 어느 init path 또는 slow path에서 보증되는가?
- 실험으로 확인한다면 'page cache와 buffer cache를 혼동함' 문제를 어떤 tracepoint와 counter로 분리할 수 있는가?
- 실험으로 확인한다면 'dirty page가 곧 disk 반영이라고 봄' 문제를 어떤 tracepoint와 counter로 분리할 수 있는가?