리눅스 커널의 이해
12

ch15. page cache

linuxfilesystemkernel
PAGE CACHE · CH.15
LECTURE NOTE — LINUX KERNEL, CHAPTER 15

페이지 캐시, 디스크를
덜 보게 만드는 기억 장치

RAM은 빠르고 디스크는 상대적으로 매우 느립니다. 커널은 이 속도 차이를 극복하기 위해 디스크 데이터를 페이지 단위로 RAM에 캐시합니다. owner와 index를 통해 원하는 데이터를 빠르게 찾고, 수정된(dirty) 상태를 추적하여 적절한 시점에 디스크로 안전하게 동기화합니다.

block device I/O RAM에 페이지로 보관 dirty → 디스크로 flush
01

큰 그림: 페이지 캐시는 "디스크를 덜 보게 만드는 기억 장치"입니다WHY PAGE CACHE EXISTS

컴퓨터에서 병목이 가장 자주 발생하는 곳은 단연 디스크입니다. 하지만 파일 입출력은 시스템에서 끊임없이 반복됩니다. 방금 복사한 파일을 곧바로 에디터로 열거나, 여러 프로세스가 시간차를 두고 동일한 파일에 접근하기도 하죠. 이때 매번 디스크까지 내려간다면 시스템 전체의 성능이 크게 저하될 것입니다.

그래서 리눅스 커널은 RAM 내부에 디스크 데이터의 일부를 보관합니다. 이것이 바로 page cache입니다. 한마디로 요약하면 "디스크에 있는 데이터를 페이지 단위로 RAM에 임시 보관해 두는 커널의 핵심 디스크 캐싱 메커니즘"입니다.

디스크를 '창고', RAM을 '작업대'라고 생각해 봅시다. 매번 창고까지 다녀오면 작업 속도가 느려지니, 자주 쓰는 물건은 작업대에 미리 올려두는 것과 같습니다. 단, 물건이 섞이지 않도록 "이 데이터가 어느 파일의 몇 번째 페이지인지"를 정확히 기록해 두어야 합니다.

이 장의 큰 흐름은 네 가지입니다. 첫째, 페이지 캐시에 어떤 데이터가 담기는지 살펴봅니다. 둘째, 보관된 페이지들을 어떻게 검색하는지 다루며, 여기서 address_spaceradix tree가 등장합니다. 셋째, 페이지보다 작은 단위인 디스크 블록을 어떻게 페이지 캐시에 편입시키는지 알아보고, 마지막으로 수정된(dirty) 페이지를 언제 어떻게 디스크로 동기화하는지 살펴봅니다. 이 과정에서 pdflushsync 계열의 시스템 콜이 연결됩니다.

한 문장 요약: 리눅스 커널은 디스크 데이터를 페이지 단위로 RAM에 캐시하고, owner와 index 구조로 빠르게 검색하며, dirty 상태를 추적해 최적의 시점에 디스크로 동기화합니다.

핵심 O/X 퀴즈

1. 페이지 캐시는 디스크 데이터를 RAM에 보관해 반복 접근을 빠르게 만드는 장치이다.
ORAM과 디스크의 속도 차이를 줄이기 위한 핵심 구조입니다.
2. 이 장의 핵심은 CPU 스케줄링 알고리즘이다.
X이 장은 디스크 캐시인 페이지 캐시의 구조와 동작을 다룹니다.
3. 페이지 캐시의 핵심 주제에는 검색, 블록 저장, dirty page writeback이 포함된다.
Oaddress_space, buffer_head, pdflush가 각각 담당합니다.
02

페이지 캐시는 언제 쓰이나: read와 write의 기본 경로READ / WRITE PATH

프로세스가 파일 읽기를 요청할 때, 커널은 곧바로 디스크로 향하지 않습니다. 가장 먼저 페이지 캐시를 확인하여 "이 파일의 해당 위치에 해당하는 페이지가 이미 RAM에 적재되어 있는지"를 살핍니다. 캐시에 존재한다면 디스크 I/O 없이 즉시 반환하고, 없다면 새로운 페이지를 할당한 뒤 디스크에서 데이터를 읽어와 채워 넣습니다.

쓰기 작업의 흐름도 유사하지만 더 흥미롭습니다. 프로세스가 파일에 데이터를 쓸 때, 커널은 먼저 대상 페이지가 캐시에 있는지 확인하고, 없으면 새로 만들어 데이터를 기록합니다. 이때 변경된 내용을 즉시 디스크에 반영하지 않고 잠시 대기합니다. 짧은 시간 안에 같은 페이지가 반복해서 수정될 확률이 높기 때문입니다. 이를 deferred write(지연 쓰기)라고 부릅니다.

페이지 캐시가 다루는 데이터의 범위는 매우 넓습니다. 일반 파일과 디렉터리 데이터는 물론, 블록 디바이스 파일에서 직접 읽은 데이터, 스왑 아웃된 사용자 프로세스 데이터, 그리고 shm과 같은 특수 파일시스템 데이터까지 포함합니다. 여기서 중요한 점은, 페이지 캐시에 적재되는 페이지들은 대개 "특정 파일에 속한 데이터"이며, 해당 파일의 inode가 논리적인 owner(소유자) 역할을 한다는 것입니다.

O_DIRECT 플래그를 사용한 예외적인 경우(직접 캐싱을 수행하는 데이터베이스 등)를 제외하면, 거의 모든 read()write() 연산은 페이지 캐시를 거쳐 수행됩니다.

핵심 O/X 퀴즈

1. 일반적인 파일 `read()`와 `write()`는 대부분 페이지 캐시를 거친다.
OO_DIRECT 예외를 제외하면 대부분 페이지 캐시를 통합니다.
2. `O_DIRECT`로 열린 파일도 반드시 페이지 캐시를 사용한다.
XO_DIRECT는 페이지 캐시를 우회합니다.
3. 페이지 캐시의 페이지는 보통 어떤 파일 또는 inode에 속한 데이터로 이해할 수 있다.
Oinode가 해당 페이지들의 owner 역할을 합니다.
03

owner와 index: 페이지 캐시는 "누구의 몇 번째 페이지인가"로 찾는다IDENTIFICATION

페이지 캐시에 적재된 페이지를 어떻게 고유하게 식별할 수 있을까요? 페이지 하나가 물리적으로 연속된 디스크 블록들만 담고 있다고 보장할 수는 없습니다. 파일시스템의 논리적 구조를 거치면 파일 내의 오프셋과 실제 디스크 블록의 물리적 배치가 달라질 수 있기 때문입니다.

따라서 커널은 페이지 캐시 내의 페이지를 두 가지 핵심 정보로 식별합니다. 첫째는 owner(대개 inode)이고, 둘째는 index입니다. index는 owner의 주소 공간 안에서 해당 페이지가 몇 번째에 위치하는지를 나타냅니다. 예를 들어, 파일의 0~4095바이트 구간은 index 0, 4096~8191바이트 구간은 index 1이 되는 방식입니다.

이러한 논리적 구조를 담아내는 핵심 자료구조가 바로 address_space입니다. 페이지 디스크립터 내부에는 mappingindex 필드가 존재하는데, mapping은 이 페이지의 owner를 나타내는 address_space 객체를 가리키고, index는 해당 owner 내에서의 논리적 위치를 의미합니다.

동일한 디스크 데이터라도 접근 경로에 따라 페이지 캐시에 중복 적재될 수 있습니다. 일반 파일 경로로 읽은 4KB 데이터는 해당 파일의 inode를 owner로 삼지만, 동일한 파티션을 블록 디바이스 파일로 직접 읽어 들이면 블록 디바이스의 master inode가 owner가 됩니다. 담긴 데이터는 같을지라도 owner가 다르기 때문에 시스템 상에서는 별개의 페이지로 취급됩니다.

즉, 페이지 캐시는 '디스크의 특정 물리 주소 = 페이지 하나'라는 단순 매핑이 아니라, '어느 owner의 몇 번째 index인가'를 기준으로 하는 논리적 주소 체계를 사용합니다. 이 개념을 명확히 이해해야 뒤이어 등장하는 radix tree의 동작 원리를 쉽게 파악할 수 있습니다.

핵심 O/X 퀴즈

1. 페이지 캐시의 페이지는 보통 owner와 index의 조합으로 식별된다.
O논리적 주소 체계의 두 핵심 요소입니다.
2. 같은 디스크 데이터는 페이지 캐시에 절대로 중복 저장될 수 없다.
Xowner가 다르면 같은 내용의 데이터도 서로 다른 페이지가 될 수 있습니다.
3. 페이지 디스크립터의 `mapping`은 해당 페이지의 `address_space`를 가리킨다.
Omapping 필드로 owner를 찾을 수 있습니다.
04

address_space: 페이지와 연산을 연결하는 중심 허브Figure 15-1 AREA

address_space는 inode 내부에 포함된 구조체로, 해당 inode가 소유한 페이지들을 총괄적으로 관리합니다. 이름은 '주소 공간'이지만, 실제로는 "이 파일에 속한 데이터 페이지들을 어떻게 관리하고 입출력 연산을 수행할 것인가"를 정의하는 커널의 핵심 객체라고 이해하는 것이 좋습니다.

address_space 주요 필드
page_tree
owner의 페이지들을 찾기 위한 radix tree의 루트입니다. 구조적 확장을 위해 단순 리스트 대신 트리 형태를 취합니다.
tree_lock
radix tree를 보호하는 spin lock입니다. 멀티코어 환경에서 동시 접근 충돌을 방지합니다.
nrpages
현재 이 owner에 속해 페이지 캐시에 적재된 총 페이지 수입니다.
writeback_index
마지막으로 수행된 writeback 작업의 페이지 index를 기억합니다.
backing_dev_info
이 데이터가 물리적으로 저장된 블록 디바이스 정보입니다. dirty page를 flush할 때 어느 디바이스 큐로 요청을 보내야 할지 결정합니다.
a_ops
address_space_operations 테이블 포인터입니다. readpage, writepage, direct_IO 등 핵심 연산 메서드를 포함합니다.

비유하자면 페이지는 손님이 주문한 '음식'이고, address_space는 식당의 '주문 관리대'입니다. 같은 메뉴라도 식당마다 조리법이 다르듯, a_ops는 각각의 상황에 맞는 '조리법 목록' 역할을 합니다. 커널은 이 a_ops를 참조하여 특정 owner의 페이지를 어떤 방식으로 조작할지 결정합니다.

동일한 '페이지 읽기' 작업이라도 대상이 일반 파일인지, 블록 디바이스 파일인지, 혹은 스왑 영역인지에 따라 내부 처리 방식은 완전히 달라집니다. 따라서 커널은 address_space를 통해 각 상황에 맞는 최적의 연산 메서드를 동적으로 선택합니다.

핵심 O/X 퀴즈

1. `address_space`는 owner의 페이지들과 그 페이지에 적용할 연산을 연결한다.
O페이지 관리 허브이자 연산 메서드 테이블을 포함합니다.
2. `a_ops`에는 페이지 읽기, 쓰기, direct I/O 같은 메서드가 포함될 수 있다.
Oreadpage, writepage, direct_IO 등이 들어갑니다.
3. 모든 owner의 페이지는 파일 종류와 무관하게 완전히 같은 방식으로 읽고 쓴다.
Xowner 종류마다 다른 a_ops가 사용됩니다.
05

Radix tree: 큰 파일에서도 페이지를 빨리 찾는 방법Figure 15-1

리눅스 환경에서 파일의 크기는 수 기가바이트에서 테라바이트 단위까지 거대해질 수 있습니다. 만약 "이 파일의 100만 번째 페이지가 캐시에 존재하는가?"를 확인하기 위해 선형 리스트를 순회한다면 심각한 성능 저하가 발생할 것입니다. 이를 해결하기 위해 Linux 2.6의 페이지 캐시는 각 address_space마다 radix tree 자료구조를 도입했습니다.

이 트리는 페이지의 index를 일종의 탐색 경로로 해석하여 원하는 페이지 디스크립터에 O(1)에 가까운 속도로 도달하게 해줍니다. 트리의 각 노드는 최대 64개의 포인터를 가질 수 있는데, 64는 2의 6제곱이므로 32비트 index를 6비트 단위로 잘라 각 레벨의 슬롯 번호로 사용하기에 매우 효율적입니다.

Radix tree 높이와 표현 범위
높이 1
index 0 ~ 63 표현. 하위 6비트만 사용합니다.
높이 2
index 0 ~ 4095 표현. 12비트를 6비트씩 나누어 탐색합니다.
높이 6
32비트 index를 온전히 표현합니다. 32비트 아키텍처 기준 최대 16TB의 파일 크기까지 대응 가능합니다.

가상 주소를 여러 비트 필드로 나누어 Page Directory와 Page Table을 탐색하는 동작 방식과 유사합니다. Radix tree 역시 page index를 6비트씩 쪼개어 단계별 인덱싱에 사용합니다. 만약 index가 현재 트리가 표현할 수 있는 범위를 초과하면, 트리의 높이를 동적으로 늘려 더 넓은 주소 공간을 커버합니다.

결론적으로, 페이지 캐시는 'owner와 index'를 키(key)로 삼아 페이지를 검색하며, 이 검색 과정을 radix tree가 전담함으로써 파일 크기가 아무리 커지더라도 빠르고 확장성 있는 조회가 가능해집니다.

핵심 O/X 퀴즈

1. radix tree는 page index를 경로처럼 사용해 페이지 디스크립터를 찾는다.
Oindex를 6비트씩 쪼개 각 단계 슬롯 번호로 사용합니다.
2. radix tree의 각 노드는 최대 64개의 포인터를 가질 수 있다.
O2의 6제곱 = 64 슬롯입니다.
3. 파일이 커져도 radix tree의 높이는 절대로 변하지 않는다.
Xindex 범위에 따라 높이가 동적으로 늘어납니다.
06

페이지 캐시 조작 함수: 찾고, 넣고, 빼고, 최신화한다CACHE OPERATIONS

find_get_page()는 주어진 address_space와 offset(index)을 기반으로 페이지 캐시에서 특정 페이지를 검색합니다. 내부적으로 tree_lock을 획득한 뒤 radix tree를 탐색하며, 페이지를 발견하면 다른 프로세스가 임의로 메모리를 해제하지 못하도록 사용 카운터(reference count)를 증가시키고 반환합니다.

페이지 캐시 조작 핵심 함수
find_get_page()
단일 페이지를 검색합니다. 찾으면 카운터 증가 후 반환하고, 없으면 NULL을 반환합니다.
find_get_pages()
연속된 index 범위에서 다수의 페이지를 검색합니다. 중간에 누락된 페이지가 있어도 무방합니다.
find_lock_page()
찾은 페이지에 lock까지 걸어 독점적인 접근을 보장합니다. 이미 locked 상태라면 해제될 때까지 대기합니다.
find_trylock_page()
lock 획득에 실패하면 대기하지 않고 즉시 실패를 반환합니다.
find_or_create_page()
페이지를 찾고, 없으면 새 페이지를 할당해 캐시에 편입시킵니다.
add_to_page_cache()
새 페이지를 radix tree에 삽입합니다. mapping, index, nrpages 정보가 함께 갱신됩니다.
remove_from_page_cache()
radix tree에서 페이지를 제거합니다. mapping을 NULL로 설정하고 nrpages를 감소시킵니다.
read_cache_page()
페이지를 반환하되, 최신 상태가 아니라면 디스크 I/O를 발생시켜 내용을 갱신합니다.

새로운 페이지를 캐시에 추가하는 add_to_page_cache()는 트리 삽입 도중 메모리 부족으로 인한 실패를 방지하기 위해 radix_tree_preload()로 필요한 노드를 미리 할당받습니다. 이후 tree_lock을 잡고 radix_tree_insert()를 수행하며, 이때 추가된 페이지의 데이터는 아직 유효하지 않으므로 PG_locked 플래그를 설정하여 접근을 통제합니다.

read_cache_page()는 단순히 페이지의 존재 여부만 확인하는 것이 아닙니다. 페이지가 캐시에 있더라도 PG_uptodate 플래그가 해제되어 있다면(최신 상태가 아니라면), 내부적으로 readpage 콜백을 호출해 디스크로부터 최신 데이터를 읽어와 페이지를 갱신합니다.

핵심 O/X 퀴즈

1. `find_get_page()`는 페이지를 찾으면 사용 카운터를 증가시킨다.
O다른 경로가 무단으로 해제하지 않도록 보호합니다.
2. `add_to_page_cache()`는 새 페이지를 넣을 때 `mapping`과 `index`를 설정한다.
Oowner와 페이지 위치 정보를 함께 설정합니다.
3. `read_cache_page()`는 페이지가 오래되었는지와 무관하게 절대 디스크를 읽지 않는다.
XPG_uptodate가 꺼져 있으면 실제 디스크 읽기를 수행합니다.
07

Radix tree의 tag: dirty page를 빠르게 찾는 지름길PAGECACHE TAGS

시스템에 쌓인 수많은 dirty page를 디스크에 동기화해야 하는 상황을 가정해 봅시다. 특정 inode의 address_space 트리에 수십만 개의 페이지가 연결되어 있고 그중 dirty page는 극소수라면, 이들을 찾기 위해 트리의 모든 leaf 노드를 일일이 순회하는 것은 엄청난 오버헤드를 유발합니다.

이러한 탐색 비용을 최소화하기 위해 radix tree는 tag 메커니즘을 사용합니다. 트리의 각 중간 노드는 자식 포인터마다 tag 공간을 가지고 있으며, dirty tag는 "해당 자식 서브트리 아래에 dirty page가 단 하나라도 존재하는가?"를 나타내는 상태 비트입니다. 만약 특정 노드의 dirty tag가 꺼져 있다면, 그 하위 경로 전체에는 dirty page가 없다는 의미이므로 탐색을 완전히 건너뛸 수 있습니다.

대표 tag 종류
PAGECACHE_TAG_DIRTY
페이지 디스크립터의 PG_dirty 상태와 연동됩니다. 수정되었지만 아직 디스크에 쓰이지 않은 페이지의 위치를 추적합니다.
PAGECACHE_TAG_WRITEBACK
페이지 디스크립터의 PG_writeback 상태와 연동됩니다. 현재 디스크로 전송 중인 페이지를 표시합니다.
tag 관련 함수
radix_tree_tag_set()
root에서 leaf 방향으로 내려가며 경로상의 모든 tag를 활성화합니다.
radix_tree_tag_clear()
leaf에서 root 방향으로 거슬러 올라오며 tag를 지웁니다. 단, 다른 분기에 여전히 dirty page가 남아 있다면 부모 tag는 유지됩니다.
radix_tree_tagged()
트리 전체에 특정 tag가 하나라도 설정되어 있는지 O(1)에 가깝게 확인합니다.
find_get_pages_tag()
특정 tag가 붙은 페이지들만 필터링하여 반환합니다. 주로 dirty page writeback 루틴에서 활용됩니다.

Tag는 숲 속에서 'dirty page가 숨어 있는 위치를 가리키는 표지판'과 같습니다. 표지판이 없다면 숲 전체를 이 잡듯 뒤져야 하지만, 표지판이 있다면 필요한 샛길로만 직진할 수 있죠. 태그가 꺼져 있는 구역은 아예 진입조차 하지 않고 스킵하는 원리입니다.

핵심 O/X 퀴즈

1. radix tree tag는 dirty page나 writeback 중인 page를 빠르게 찾는 데 쓰인다.
OPAGECACHE_TAG_DIRTY와 PAGECACHE_TAG_WRITEBACK이 그 역할을 합니다.
2. dirty tag가 꺼진 subtree에는 dirty page가 없다고 볼 수 있다.
Otag 정보가 위로 전파되어 있으므로 subtree 전체를 건너뛸 수 있습니다.
3. dirty page를 찾기 위해 항상 radix tree의 모든 leaf를 순차 검색해야 한다.
Xfind_get_pages_tag()가 tag를 이용해 필요한 경로만 탐색합니다.
08

블록을 페이지 캐시에 저장하기: buffer cache의 흡수BUFFER PAGE & buffer_head

VFS(가상 파일시스템)와 매핑 레이어, 그리고 개별 파일시스템은 데이터를 디스크의 논리적 단위인 '블록(block)' 단위로 다룹니다. 과거의 리눅스 커널은 파일 데이터를 저장하는 page cache와, 메타데이터 등 파일시스템 관리를 위한 블록 데이터를 저장하는 buffer cache를 별도로 운영했습니다.

그러나 Linux 2.4.10 이후부터는 이원화되어 있던 구조를 통합하여, 전통적인 의미의 독립된 buffer cache는 사실상 사라졌습니다. 이제 블록 버퍼들은 buffer page라는 특수한 페이지 내부에 할당되며, 이 buffer page 자체가 page cache 체계 안으로 편입되어 관리됩니다. 즉, 페이지 캐시가 파일 수준의 데이터뿐만 아니라 블록 수준의 데이터까지 모두 아우르게 된 것입니다.

하나의 buffer page 내부에는 여러 개의 블록 버퍼가 포함될 수 있으며, 각각의 블록 버퍼는 자신만의 buffer_head 디스크립터를 가집니다. buffer_head는 커널이 해당 블록의 디스크 주소를 매핑하고 입출력 상태를 추적하기 위해 사용하는 필수적인 메타데이터입니다.

buffer_head 주요 필드
b_bdev
이 블록이 물리적으로 속한 block device 구조체입니다.
b_blocknr
해당 디바이스 내에서의 논리 블록 번호입니다.
b_data
해당 블록 버퍼가 페이지 안의 어느 위치에 있는지를 나타냅니다. high memory 여부에 따라 offset 또는 선형 주소로 해석됩니다.
b_page
이 버퍼를 품고 있는 부모 page descriptor를 가리킵니다.
b_this_page
동일한 buffer page 내에 존재하는 다음 buffer_head를 가리키며, 원형 리스트 형태로 연결됩니다.

하나의 페이지 디스크립터 안에 네 개의 버퍼가 나뉘어 담겨 있고, 각 버퍼마다 buffer_head가 부착되어 있는 구조를 떠올려 보십시오. 이 buffer_head들은 b_this_page 필드를 통해 원형 연결 리스트(Circular List) 형태로 이어지며, 페이지 디스크립터의 private 필드가 그 리스트의 시작점(첫 번째 buffer_head)을 가리키게 됩니다.

현대의 페이지 캐시는 페이지 단위 관리를 기본으로 하면서도 블록 단위 접근의 유연성을 결코 포기하지 않았습니다. 페이지 내부에 블록 버퍼들을 수용하고, buffer_head를 통해 개별 블록의 상태와 디스크 매핑 정보를 정밀하게 통제합니다.

핵심 O/X 퀴즈

1. Linux 2.4.10 이후 설명에서는 block buffer가 buffer page 형태로 page cache 안에 저장된다.
Obuffer cache가 page cache 안으로 흡수된 구조입니다.
2. `buffer_head`는 block buffer의 디스크 주소와 상태를 관리하는 descriptor이다.
Ob_bdev, b_blocknr, b_state 등이 이를 담당합니다.
3. buffer page에는 buffer_head가 전혀 필요 없다.
X각 block buffer마다 buffer_head가 반드시 필요합니다.
09

buffer_head의 상태 플래그와 사용 카운터b_state FLAGS

buffer_headb_state 필드에는 버퍼의 현재 상황을 나타내는 다양한 상태 플래그가 비트맵 형태로 저장됩니다.

b_state 주요 플래그
BH_Uptodate
버퍼가 디스크의 최신 데이터를 정확히 담고 있음을 보장합니다.
BH_Dirty
메모리 상에서 내용이 수정되었으나 아직 디스크에 동기화되지 않은 상태입니다.
BH_Lock
I/O 전송이 진행 중이거나, 다른 프로세스가 독점적으로 접근하고 있음을 의미합니다.
BH_Req
데이터를 채우기 위한 디스크 I/O 요청이 이미 하위 계층으로 전달된 상태입니다.
BH_Mapped
b_bdev와 b_blocknr 필드에 저장된 디스크 매핑 정보가 유효합니다.
BH_New
블록이 방금 할당되어 아직 사용자 데이터로 초기화되지 않았음을 나타냅니다.
BH_Async_Read / BH_Async_Write
비동기 읽기 및 쓰기 I/O가 활성화된 상태입니다.
BH_Delay
실제 물리적 디스크 블록 할당을 뒤로 미루는 지연 할당(Delayed Allocation) 상태입니다.
BH_Boundary
논리적으로 다음 블록이 현재 블록과 디스크 상에서 인접하지 않을 것이라는 최적화 힌트입니다.
BH_Ordered
저널링 파일시스템에서 트랜잭션 순서를 엄격히 지켜 쓰기를 수행해야 할 때 부여됩니다.

단순한 상태 표기 외에도 b_count(사용 카운터)의 역할이 매우 중요합니다. 커널의 특정 루틴이 블록 버퍼를 다루고자 할 때는 반드시 카운터를 증가시켜야 하며, 작업이 끝나면 감소시켜야 합니다. 카운터가 0으로 떨어진 버퍼만이 메모리 회수(Reclaim)의 대상이 될 수 있습니다. "누군가 사용 중인 데이터는 절대 임의로 해제하지 않는다"는 커널의 기본적인 동기화 철학입니다.

버퍼 사용을 완료했을 때는 주로 __brelse()__bforget() 함수를 호출합니다. 두 함수 모두 사용 카운터를 감소시키는 역할을 하지만, __bforget()의 동작이 훨씬 강력합니다. 간접 블록 리스트(indirect block list)에서 버퍼를 완전히 제거할 뿐만 아니라, dirty 상태 플래그까지 초기화하여 아직 디스크에 반영되지 않은 변경 사항을 커널이 '잊어버리게' 만듭니다.

Dirty 버퍼를 강제로 잊는다는 것은 "향후 디스크에 반영할 예정이었던 수정 내역을 완전히 포기하겠다"는 강력한 선언입니다. 따라서 커널은 현재의 컨텍스트와 데이터 보존 필요성에 따라 brelsebforget을 엄격히 구분하여 사용합니다.

핵심 O/X 퀴즈

1. `BH_Dirty`는 buffer 내용이 수정되었지만 아직 디스크에 반영되지 않았음을 뜻한다.
Odirty 상태의 buffer는 나중에 디스크에 써야 합니다.
2. `BH_Lock`이 설정된 buffer는 보통 I/O 중이거나 독점 접근 중인 상태로 볼 수 있다.
OLock 상태의 buffer에는 다른 경로가 접근할 수 없습니다.
3. `__brelse()`와 `__bforget()`은 완전히 같은 일을 한다.
X__bforget()은 dirty 상태까지 지워 변경 사항을 포기합니다.
10

buffer page의 생성과 해제: block을 page 안에 맞춰 넣기grow_buffers / try_to_release_page

Buffer page는 주로 두 가지 상황에서 생성됩니다. 첫째, 파일 데이터를 담은 페이지를 구성하는 실제 디스크 블록들이 물리적으로 연속되어 있지 않은 경우입니다. 둘째, 슈퍼블록(superblock)이나 inode 블록처럼 논리적인 파일 구조를 우회하여 단일 디스크 블록에 직접 접근해야 할 때입니다.

블록 디바이스용 buffer page를 새롭게 할당해야 할 때, 커널은 grow_buffers() 함수를 호출합니다. 이 함수는 대상 디바이스의 디스크립터(bdev), 논리 블록 번호(block), 그리고 블록 크기(size)를 인자로 받아 내부 구조를 세팅합니다.

1
페이지 준비. find_or_create_page()를 호출하여 블록 디바이스의 address_space에서 해당 논리 주소에 매핑되는 페이지를 찾거나 새로 할당받습니다.
2
크기 정합성 확인. 확보한 페이지가 이미 buffer page로 쓰이고 있다면, 기존 buffer_head들의 블록 크기가 새로 요청받은 size와 일치하는지 점검합니다. 일치하지 않는다면 기존 구조를 허물고 새로운 규격에 맞게 재구성합니다.
3
buffer_head 생성. alloc_page_buffers()를 통해 페이지 내부 영역을 블록 크기로 분할하고, 각각을 전담할 buffer_head 객체들을 생성하여 b_this_page 원형 리스트로 연결합니다.
4
필드 초기화. 페이지 디스크립터의 private 필드에 리스트의 첫 번째 buffer_head 주소를 저장하고 PG_private 플래그를 켭니다. 이후 개별 buffer_head의 b_page, b_data, b_bdev, b_blocknr, b_state 등을 알맞게 초기화합니다.

사용이 끝난 buffer page를 해제하는 과정은 생성보다 훨씬 까다롭습니다. try_to_release_page()가 호출되더라도, 페이지 자체가 PG_writeback 상태이거나 포함된 buffer_head 중 단 하나라도 BH_Dirty 또는 BH_Lock 상태를 띄고 있다면 메모리를 즉각 반환할 수 없습니다. 모든 조건이 안전하다고 판단될 때만 간접 버퍼 리스트에서 제거하고, PG_private 플래그를 지운 뒤 소속된 buffer_head 객체들을 최종적으로 메모리에서 해제합니다.

핵심 O/X 퀴즈

1. block device buffer page 안의 block buffer들은 같은 block size를 가져야 한다.
O크기가 다르면 기존 buffer_head를 해제하고 재구성합니다.
2. dirty 또는 locked buffer를 포함한 buffer page는 안전하게 바로 해제할 수 있다.
XBH_Dirty나 BH_Lock이 설정된 buffer가 있으면 해제할 수 없습니다.
3. buffer page의 `private` 필드는 보통 첫 번째 buffer_head를 가리킬 수 있다.
Opage descriptor의 private 필드가 첫 buffer_head를 가리킵니다.
11

블록 검색: LRU block cache, __find_get_block, __getblk, __breadBLOCK LOOKUP PATH

커널이 "특정 블록 하나"를 단독으로 읽거나 쓰고 싶을 때 거치는 탐색 경로입니다. 예를 들어 파일시스템 마운트 과정에서 슈퍼블록을 읽어야 한다면, 커널은 무작정 디스크로 I/O 요청을 보내지 않고 해당 블록 버퍼가 이미 page cache 내부에 존재하는지 먼저 확인합니다.

1
address_space 획득. 요청 블록이 속한 물리 디바이스의 address_space를 확인합니다. 대개 bdev->bd_inode->i_mapping 경로를 따릅니다.
2
page index 계산. 블록 크기와 시스템의 페이지 크기를 산술 비교하여 page index를 도출합니다. 예컨대 블록 크기가 1024B이고 페이지 크기가 4096B라면, 한 페이지에 4개의 블록이 수납되므로 index는 nr / 4가 됩니다.
3
buffer_head 탐색. 도출된 index를 기반으로 디바이스의 radix tree에서 buffer page를 탐색하고, 해당 페이지에 연결된 buffer_head 리스트를 순회하며 정확한 타깃 블록을 찾아냅니다.

탐색 성능을 극대화하기 위해, 시스템은 각 CPU 코어마다 bh_lrus라는 작고 빠른 LRU(Least Recently Used) 블록 캐시 배열을 운용합니다. 여기에는 가장 최근에 접근한 8개의 buffer_head 포인터가 보관됩니다. __find_get_block() 함수는 전역 page cache를 뒤지기 전에 이 CPU 로컬 캐시를 먼저 확인하며, b_bdev, b_blocknr, b_size가 정확히 일치하는 객체가 존재하면 이를 배열의 선두로 끌어올린 뒤 b_count를 증가시켜 즉시 반환합니다.

세 가지 block 검색 함수 비교
__find_get_block()
CPU 로컬 LRU 캐시를 먼저 조회하고, 실패 시 page cache를 탐색합니다. 어느 곳에도 없으면 NULL을 반환하는 가벼운 탐색 함수입니다.
__getblk()
무조건 유효한 buffer_head 구조체를 확보하여 반환하려 시도합니다. 존재하지 않을 경우 grow_buffers()를 트리거해 새로운 buffer page를 강제로 생성합니다. 단, 확보된 버퍼가 디스크의 최신 데이터(BH_Uptodate)를 담고 있다는 보장은 하지 않습니다.
__bread()
__getblk()을 통해 확보한 버퍼의 상태를 검사하고, 만약 BH_Uptodate 플래그가 꺼져 있다면 submit_bh()를 호출해 실제 디스크 읽기 I/O를 발생시킵니다. 이후 wait_on_buffer()를 통해 동기적으로 완료를 대기한 뒤 최신 데이터가 채워진 버퍼를 반환합니다.

핵심 O/X 퀴즈

1. `__find_get_block()`은 먼저 CPU별 LRU block cache를 확인할 수 있다.
Obh_lrus 배열에서 먼저 찾고, 없으면 page cache를 봅니다.
2. `__getblk()`이 반환한 buffer는 항상 최신 디스크 데이터를 담고 있다.
XBH_Uptodate는 보장하지 않습니다. 최신 데이터가 필요하면 __bread()를 써야 합니다.
3. `__bread()`는 필요하면 실제 디스크 읽기를 수행하고 완료를 기다린다.
Osubmit_bh() 후 wait_on_buffer()로 동기적으로 완료를 기다립니다.
12

submit_bh()와 ll_rw_block(): buffer_head를 실제 I/O 요청으로 바꾸기BIO LAYER BRIDGE

메모리 상의 buffer_head 객체가 실제 디스크 I/O 요청으로 변환되어 하위 계층으로 내려가는 과정입니다. 핵심 함수인 submit_bh()는 전송 방향(READ 또는 WRITE)과 타깃 buffer_head 포인터를 인자로 받습니다. 이 함수가 호출되는 시점에는 이미 b_bdev, b_blocknr, b_size 등의 논리 블록 맵핑 정보가 완벽히 세팅되어 있어야 합니다.

1
BH_Req 설정 및 초기화. 해당 버퍼가 I/O 요청 큐에 들어갔음을 명시합니다. 쓰기 요청의 경우 이전 I/O에서 발생했을 수 있는 오류 플래그들을 선제적으로 클리어합니다.
2
bio 구조체 생성. bio_alloc()을 통해 블록 레이어의 핵심 I/O 디스크립터인 bio를 할당받습니다. bi_sector에는 대상 블록의 물리적 시작 섹터 번호(대략 b_blocknr × (b_size / 512))를 명시하고, bi_bdevbi_size에 디바이스 및 전송 크기 정보를 주입합니다.
3
메모리 segment 구성. 벡터 배열인 bi_io_vec[0]에 버퍼가 위치한 실제 물리 메모리 주소를 매핑합니다. 단일 블록 전송이므로 벡터 카운트(bi_vcnt)는 1로 설정됩니다.
4
완료 콜백 등록 및 제출. I/O 완료 시그널을 처리할 bi_end_io 콜백으로 end_bio_bh_io_sync()를 등록한 뒤, 완성된 bio를 generic block layer에 최종 제출(submit)합니다.

여러 개의 블록을 일괄 처리해야 할 때는 통상 ll_rw_block()이 활약합니다. 이 함수는 배열로 전달된 각 buffer_head에 대해 BH_Lock 플래그를 test-and-set 방식으로 점검하여, 이미 I/O가 진행 중(locked)인 블록은 안전하게 건너뜁니다. 쓰기 요청 시에는 BH_Dirty 여부를, 읽기 요청 시에는 BH_Uptodate 여부를 추가로 검사하여, 실제 물리적인 전송이 불가피한 버퍼들만 선별해 submit_bh()로 넘깁니다.

이 과정의 핵심은 우아한 계층 분리(Layering)에 있습니다. buffer_head는 파일시스템 관점에서 "이 블록의 정체가 무엇인가"를 정의하고, bio 구조체는 블록 레이어 관점에서 "이 I/O 요청을 하드웨어에 어떻게 전달할 것인가"를 묘사합니다. submit_bh()는 이 서로 다른 두 세계를 매끄럽게 연결해 주는 강력한 접착제 역할을 수행합니다.

핵심 O/X 퀴즈

1. `submit_bh()`는 buffer_head 정보를 바탕으로 bio 요청을 만들어 generic block layer에 넘긴다.
Obuffer_head → bio 변환의 핵심 함수입니다.
2. `ll_rw_block()`은 여러 buffer_head를 순회하며 필요한 block만 실제 I/O로 제출할 수 있다.
OBH_Dirty, BH_Uptodate 검사로 불필요한 I/O를 건너뜁니다.
3. 이미 `BH_Uptodate`인 buffer도 읽기 요청에서는 반드시 다시 디스크에서 읽어야 한다.
XBH_Uptodate가 이미 설정되어 있으면 읽기를 생략합니다.
13

dirty page와 pdflush: 빠른 쓰기와 안전성 사이의 균형DEFERRED WRITE & pdflush

사용자 프로세스가 파일의 내용을 수정하면 해당 메모리 페이지는 'dirty' 상태가 되며, PG_dirty 플래그가 켜집니다. 커널은 이 변경 사항을 즉각 디스크로 내려쓰지 않고 메모리에 머무르게 하는 '지연 쓰기(Deferred Write)' 전략을 취합니다. 한 페이지의 특정 영역이 짧은 시간 내에 수십 번 변경되더라도, 최종적으로 디스크에는 단 한 번의 I/O만 발생하게 만들어 시스템의 전반적인 처리량을 비약적으로 상승시키기 위함입니다.

하지만 지연 쓰기 전략을 무한정 유지할 수는 없습니다. 갑작스러운 전원 차단이나 커널 패닉이 발생하면 RAM에만 머물러 있던 dirty 데이터는 영구적으로 소실되며, 쓰기 작업을 계속 미루기만 하면 시스템의 가용 메모리가 순식간에 고갈될 것이기 때문입니다. 따라서 커널은 성능과 안정성 사이의 아슬아슬한 줄타기를 하며, 특정 조건이 만족될 때마다 dirty page들을 디스크로 조심스럽게 방출(flush)합니다.

dirty page flush 발동 조건
메모리 임계치 도달
페이지 캐시가 과도하게 비대해지거나 시스템 전체 메모리 대비 dirty page의 비율이 허용치(일반적으로 약 40%)를 초과했을 때 강제로 밀어냅니다.
생존 시간 초과
특정 페이지가 디스크에 반영되지 않고 너무 오랫동안(보통 30초 이상) dirty 상태로 방치되었을 때 안전을 위해 동기화합니다.
명시적 시스템 콜
애플리케이션 계층에서 sync(), fsync(), fdatasync() 등의 동기화 명령을 명시적으로 호출했을 때 즉각 수행됩니다.

과거 리눅스의 bdflushkupdate 데몬이 하던 역할은 Linux 2.6에 이르러 pdflush라는 유연한 커널 스레드 풀(Thread Pool)로 대체되었습니다. pdflush는 특정 콜백 함수와 인자를 주입받아 다양한 조건의 flush 작업을 비동기적으로 수행하며, 시스템의 I/O 부하에 따라 스레드의 개수를 최소 2개에서 최대 8개까지 동적으로 유연하게 조절합니다.

pdflush 대표 콜백 함수
background_writeout()
dirty ratio가 백그라운드 임계치(보통 약 10%)를 넘어서면 깨어나 페이지 캐시를 순회하며 선제적으로 디스크 쓰기를 진행합니다.
wb_kupdate()
타이머에 의해 주기적(보통 5초 간격)으로 실행되며, 생존 한계를 초과하여 너무 오래 방치된 dirty page들을 색인해 flush합니다.

pdflush는 캐시라는 거대한 물탱크의 수위를 조절하는 '자동 배수 펌프'와 같습니다. 평소에는 조용히 대기하다가 물(미처리 쓰기 작업)이 위험 수위까지 차오르거나 물이 고인 지 너무 오래되면 즉각 가동되어 시스템의 성능과 데이터 무결성을 동시에 지켜냅니다. 버퍼 페이지의 경우 페이지 전체를 통째로 쓰지 않고 BH_Dirty 상태인 특정 블록들만 쏙쏙 골라서 디스크로 보냅니다.

핵심 O/X 퀴즈

1. dirty page는 수정되었지만 아직 디스크에 반영되지 않은 page이다.
OPG_dirty가 설정된 상태를 의미합니다.
2. 지연 쓰기는 성능상 이점이 있지만 전원 장애 시 데이터 손실 위험을 키울 수 있다.
ORAM에만 있는 dirty 데이터는 전원 장애 시 사라집니다.
3. Linux 2.6 설명에서 `pdflush`는 dirty data flushing과 무관하다.
Xpdflush는 dirty page를 디스크로 내보내는 핵심 kernel thread입니다.
14

background_writeout()과 writeback_inodes(): dirty page를 실제로 찾아 쓰는 절차writeback_control

수면 상태의 pdflush 스레드를 깨워 작업을 지시할 때는 wakeup_bdflush()를 호출하며, 이때 background_writeout() 콜백이 함께 전달됩니다. 인자로 넘기는 숫자는 한 번에 디스크로 밀어낼 dirty page의 목표 개수이며, 만약 0이 전달되면 시스템 내의 모든 dirty page를 긁어모아 쓰라는 강력한 전체 flush 지시가 됩니다.

background_writeout() 함수의 심장부에는 writeback_control 구조체가 자리 잡고 있습니다. 이 객체는 "어떤 데이터를, 얼마나, 어떤 방식으로 디스크에 내보낼 것인가"에 대한 상세한 작전 명령서인 동시에, "현재까지 얼마나 많은 페이지를 성공적으로 처리했는가"를 실시간으로 추적하는 상태 보고서의 역할까지 겸비합니다.

writeback_control 주요 필드
sync_mode
잠겨 있는(locked) inode를 조우했을 때, 해당 락이 풀릴 때까지 대기할지 혹은 무시하고 다음 목표로 넘어갈지 행동 방침을 결정합니다.
bdi
이 필드가 유효한 포인터를 가지면, 시스템 전체가 아닌 특정 블록 디바이스(bdi)에 묶인 dirty page들만 표적화하여 flush합니다.
older_than_this
명시된 시점 기준보다 덜 오래된(어린) inode는 처리를 건너뜁니다. 주로 장기 방치된 악성 dirty page들을 우선적으로 청소할 때 쓰입니다.
nr_to_write
이번 실행 루틴에서 최종적으로 디스크에 도달시켜야 할 남은 페이지 목표 수량입니다.
nonblocking
호출된 컨텍스트가 블록킹(blocking) 상태에 진입해도 안전한지 여부를 명시합니다.

세부적인 쓰기 과정은 writeback_inodes()가 시스템 내의 슈퍼블록(superblock) 목록을 순회하는 것으로 본격화됩니다. 각 슈퍼블록이 내부적으로 유지하고 있는 s_dirty(더티 상태의 inode 리스트)에서 당장 처리할 대상들을 선별하여 s_io(I/O 대기 리스트)로 옮긴 후, sync_sb_inodes()를 통해 하나씩 본격적인 I/O 파이프라인에 태웁니다.

1
inode 처리. __writeback_single_inode() 루틴이 개별 address_space에 정의된 writepages 메서드(혹은 커널 기본 제공 mpage_writepages())를 호출해 맵핑된 dirty page들을 취합합니다.
2
tag 활용. 이때 find_get_pages_tag()가 radix tree의 dirty tag 비트맵을 적극 활용하여, 막대한 탐색 공간 중 오직 변경점이 존재하는 경로만 초고속으로 스캔해냅니다.
3
메타데이터 쓰기. 페이지 내용뿐 아니라 inode 메타데이터 자체도 갱신(dirty)되었다면, 슈퍼블록의 write_inode 콜백을 발동시켜 디스크 상의 파일 구조체도 최신화합니다.
4
inode 재배치. 작업이 끝난 inode는 남은 변경점 여부에 따라 다시 dirty 리스트로 회귀하거나, 완전히 깔끔해졌다면 unused/in-use 리스트로 이동하여 관리 사이클을 마무리합니다.

전체 동작 흐름 요약: 임계치 도달로 **pdflush** 스레드가 깨어나고 → **writeback_control**이 작업의 청사진을 제시하며 → **superblock**과 **inode** 리스트를 징검다리 삼아 접근 대상을 확보한 뒤 → radix tree의 **tag**를 활용해 숨어있는 dirty page를 색인하여 → 최종적으로 하드웨어 **디스크**로 데이터를 안전하게 내려보냅니다.

핵심 O/X 퀴즈

1. `writeback_control`은 writeback 대상과 방식, 남은 page 수 같은 정보를 담는다.
O목표와 진행 상황을 동시에 추적합니다.
2. `writeback_inodes()`는 superblock과 inode 단위로 dirty page writeback을 진행한다.
Os_dirty → s_io → __writeback_single_inode() 흐름을 거칩니다.
3. dirty page를 찾을 때 radix tree tag 정보는 전혀 사용되지 않는다.
Xfind_get_pages_tag()가 dirty tag를 활용해 불필요한 탐색을 건너뜁니다.
15

오래된 dirty page와 sync, fsync, fdatasync: 사용자가 강제로 밀어 넣는 길wb_kupdate / SYSCALLS

만약 특정 메모리 페이지가 메모리 회수 압박도 받지 않고 명시적인 동기화 요청도 없이 캐시에 계속 방치된다면 어떻게 될까요? 커널은 특정 데이터가 영구적으로 디스크에 쓰이지 못하는 기아(Starvation) 상태를 원천적으로 차단해야 합니다. 이 최후의 방어선 역할은 주기적인 커널 타이머 wb_timerwb_kupdate() 콜백이 담당합니다.

1
타이머 세팅. page_writeback_init() 부트스트랩 루틴에서 dirty_writeback_centisecs 변수 값(기본 500 = 5초)을 간격으로 삼는 커널 타이머를 설정합니다.
2
주기적 트리거. 설정된 시간이 만료될 때마다 wb_timer_fn() 인터럽트 핸들러가 깨어나 wb_kupdate() 콜백 실행을 스케줄링합니다.
3
슈퍼블록 동기화. 본격적인 페이지 동기화에 앞서 sync_supers()를 통해 파일시스템의 뼈대인 슈퍼블록 변경점들을 먼저 디스크에 고정시킵니다.
4
방치 페이지 소거. older_than_this 기준점을 현재 시스템 시각 기준 30초 이전으로 세팅한 뒤, 30초 이상 캐시에 머물러 있던 악성 dirty page들이 모두 처리되거나 사전 설정된 목표치에 도달할 때까지 writeback_inodes()를 반복해서 호출해 캐시를 비워냅니다.
사용자 시스템 콜: sync, fsync, fdatasync
sync()
시스템 내에 존재하는 모든 dirty buffer를 디스크로 일제히 방출합니다. 첫 사이클에서는 wait=0 모드로 락을 무시하며 최대한 많은 분량을 비동기적으로 밀어 넣고, 이후 wait=1 모드로 전환해 잠겨 있던 inode들이 풀릴 때까지 대기하며 잔여 분량을 확실하게 마무리합니다.
fsync(fd)
사용자가 지정한 특정 파일 디스크립터 하나에 국한하여 데이터 동기화를 강제합니다. 실제 파일의 내용물(data)뿐만 아니라 접근 시간 등 관련된 메타데이터(inode) 정보까지 완벽하게 디스크에 내려씁니다.
fdatasync(fd)
개념적으로는 메타데이터를 제외한 '순수 파일 데이터'만 디스크에 기록하도록 설계되었습니다. 다만 이 장에서 설명하는 Linux 2.6 환경 기준으로는 이를 위한 별도의 VFS 메서드가 구비되지 않아 내부적으로 fsync 연산으로 폴백(fallback)하여 동일하게 동작합니다.

sync()fsync()의 차이는 청소의 범위와 긴급성에 비유할 수 있습니다. sync()는 "지금 즉시 건물 전체(시스템)의 모든 쓰레기통(dirty buffer)을 비우라"는 광역 명령이고, fsync()는 "다른 곳은 몰라도 이 회의실(특정 파일)의 청소만큼은 내 눈앞에서 당장 완벽히 끝내라"는 타깃형 강제 완료 지시입니다.

지금까지 살펴본 바와 같이, 리눅스의 페이지 캐시는 단순히 "디스크에서 읽어온 데이터를 쌓아두는 RAM 조각"을 아득히 뛰어넘는 정교한 아키텍처입니다. 데이터를 owner와 index의 조합으로 논리적으로 추상화하고, address_space라는 매니저로 통제하며, radix tree로 폭발적인 검색 성능을 확보합니다. 더 나아가 트리 노드에 tag를 달아 상태를 맵핑하고, buffer_head를 품어 블록 디바이스와의 하위 호환성을 완벽히 유지하며, pdflush 메커니즘을 통해 시스템 성능과 무결성의 황금비를 찾아내는 리눅스 I/O 서브시스템의 진정한 심장부이자 교차로입니다.

핵심 O/X 퀴즈

1. `wb_kupdate()`는 너무 오래 dirty 상태로 남은 page를 찾아 writeback하도록 돕는다.
O30초 이상 dirty인 page를 5초 주기로 검사해 flush합니다.
2. `sync()`는 특정 파일 하나만 대상으로 하는 시스템 콜이다.
Xsync()는 시스템 전체의 dirty buffer를 flush합니다. 특정 파일은 fsync()가 담당합니다.
3. 이 장의 Linux 2.6 설명에서는 `fdatasync()`가 별도 file method 없이 `fsync` 메서드를 사용한다고 설명된다.
O결과적으로 fsync()와 동일하게 동작합니다.

페이지 캐시는 단순한 버퍼 공간이 아니라, address_space · radix tree · buffer_head · pdflush가 유기적으로 맞물려 돌아가는 리눅스 I/O 서브시스템의 교차로입니다.

강의노트 — Linux Kernel, Chapter 15 "The Page Cache" 기반 재구성