ch17. page frame reclaiming
페이지 프레임 회수,
커널이 메모리를 되찾는 법
운영체제 커널의 역할은 단순히 메모리를 할당하는 데 그치지 않습니다. 한정된 물리 메모리(RAM) 환경에서 오래된 페이지를 선별하고, 공유 페이지의 참조를 빈틈없이 역추적하며, 필요시 데이터를 디스크로 안전하게 옮겨 새로운 여유 공간을 확보합니다. 이 문서는 PFRA(Page Frame Reclaiming Algorithm)의 기본 원리부터 Swap Cache의 동기화 기법까지 그 전 과정을 깊이 있게 탐구합니다.
큰 그림: 왜 커널은 페이지 프레임을 "되찾아야" 하는가WHY PFRA EXISTS
운영체제에게 메모리 할당만큼이나 중요한 것은, 할당된 메모리를 다시 안전하게 '회수'하는 기술입니다.
커널은 시스템이 요구하는 순간마다 물리 메모리인 페이지 프레임을 할당합니다. 동시에 커널은 남는 RAM 공간을 결코 낭비하지 않습니다. 파일 데이터, 디스크 블록, inode, dentry 등 빈번하게 접근하는 정보들을 캐시(Cache) 영역에 보관하여 시스템 성능을 극대화합니다. 그러나 이러한 캐시는 스스로 "이제 사용하지 않으니 메모리를 반환하겠다"고 나서지 않습니다. 시간이 흐르면 RAM은 점차 프로세스가 할당받은 페이지와 커널의 캐시 데이터로 가득 차게 됩니다.
바로 이 한계 상황에서 개입하는 것이 PFRA, 즉 Page Frame Reclaiming Algorithm입니다. 이번 장을 관통하는 세 가지 핵심 질문은 다음과 같습니다. 무엇을 회수할 것인가? 회수 과정에서 시스템의 어떤 부분을 수정해야 하는가? 그리고 회수된 페이지가 훗날 다시 필요해질 때 어떻게 원상 복구할 것인가?
RAM을 한정된 크기의 도서관 열람실 책상이라고 상상해 봅시다. 학생들이 책을 잔뜩 펼쳐두고 공부하고 있으며, 사서 역시 자주 찾는 참고서들을 책상 모서리에 쌓아둡니다. 책상이 포화 상태에 이르면, 사서는 어떤 책을 서고로 돌려보낼지 결정해야 합니다. 방금까지 들춰보던 책은 남겨두고, 가장 오랫동안 펼쳐보지 않은 책부터 치우는 것이 합리적일 것입니다. 만약 치우려는 책에 학생이 중요한 메모를 적어두었다면, 서고로 보내기 전에 반드시 복사본을 만들어 두어야 합니다. 시스템 내에서 PFRA가 수행하는 역할이 바로 이 꼼꼼한 사서와 같습니다.
이 장은 크게 네 가지 주제로 전개됩니다. 첫째, PFRA의 근본적인 목적과 회수 대상이 되는 페이지의 분류 체계를 살펴봅니다. 둘째, 여러 프로세스가 공유하는 페이지를 안전하게 회수하기 위한 핵심 기술인 reverse mapping을 다룹니다. 셋째, 실제 Linux 환경에서 PFRA가 LRU 리스트와 각종 shrink 함수, kswapd 데몬, 그리고 최후의 보루인 OOM killer를 통해 어떻게 유기적으로 동작하는지 추적합니다. 마지막으로, 원본 파일이 없는 anonymous page를 디스크에 보관하는 swapping 메커니즘을 학습합니다.
핵심 O/X 퀴즈
1. PFRA의 주된 목적은 이미 free list에 포함된 페이지 프레임들을 효율적으로 재분류하는 것이다.
2. Linux는 잉여 RAM 공간을 캐시로 적극 활용하기 때문에, 캐시가 비대해질수록 이를 적절히 줄여주는 메모리 회수 메커니즘의 중요성이 더욱 커진다.
3. Swapping은 주로 원본 파일이 이미 존재하는 page cache 페이지를, 필요할 때 파일로부터 다시 읽어오기 위해 고안된 기능이다.
회수 대상 페이지의 종류: 회수할 수 있는 것과 없는 것의 경계PAGE CATEGORIES
커널이 메모리 부족을 겪는다고 해서 아무 페이지나 무작정 회수해도 될까요? 당연히 그렇지 않습니다. 어떤 페이지는 건드리는 순간 시스템 전체가 붕괴할 수 있고, 어떤 페이지는 내용이 유실되지 않도록 사전에 디스크에 기록(writeback)해야 하며, 또 어떤 페이지는 쿨하게 즉시 버려도 무방합니다. 따라서 PFRA는 모든 페이지를 철저하게 네 가지 범주로 분류하여 관리합니다.
절대 회수해서는 안 되거나, 시스템 동작 상 회수할 필요 자체가 없는 핵심 페이지들입니다. PFRA의 손길이 닿지 않는 성역입니다.
파일 시스템에 백업본(원본)이 존재하지 않습니다. 따라서 이 페이지들을 회수하려면, 그 내용을 디스크의 swap area에 안전하게 보관해야만 합니다.
디스크의 파일 등과 연결된 원본이 존재합니다. 데이터가 변경된 상태(dirty)라면 먼저 디스크와 동기화해야 하며, 변경 내역이 없다면(clean) 즉시 버릴 수 있습니다.
디스크 기록이나 별도의 백업 없이 즉시 메모리에서 폐기할 수 있는, 커널 입장에서 가장 다루기 편한 회수 대상입니다.
분류 시 또 하나 간과해선 안 될 중요한 기준은 해당 페이지가 shared(공유됨) 상태인지, 아니면 non-shared(독점 사용됨) 상태인지의 여부입니다. fork() 시스템 콜 이후 부모와 자식 프로세스가 Copy-On-Write(COW) 방식으로 페이지를 공유하거나, 여러 프로세스가 mmap을 통해 동일한 파일을 메모리에 매핑할 경우, 단일 물리 페이지 프레임을 여러 프로세스가 동시에 가리키게 됩니다. 이러한 공유 페이지를 안전하게 회수하려면, 해당 물리 프레임을 가리키는 '모든' Page Table Entry(PTE)를 일일이 찾아내어 연결을 끊어야 합니다. 이 복잡한 과정을 해결하기 위해 뒤이어 등장하는 개념이 바로 reverse mapping입니다.
결론적으로 PFRA는 "오래된 페이지를 맹목적으로 폐기하는 단순 알고리즘"이 아닙니다. 대상 페이지의 본질적인 성격을 파악하여, 즉시 버릴 것인지, 파일과 동기화할 것인지, 스왑 영역에 대피시킬 것인지, 아니면 아예 건드리지 않고 보호할 것인지를 정교하게 판별하는 지능적인 시스템입니다.
핵심 O/X 퀴즈
1. Anonymous page는 디스크 상의 파일에 원본이 없으므로, 이를 메모리에서 회수하기 위해서는 일반적으로 그 내용을 swap area에 저장해야 한다.
2. Dirty 상태의 mapped page는 이미 특정 파일과 연결되어 있다는 사실이 보장되므로, 디스크에 기록하는 과정 없이 바로 회수해도 데이터 안전성에 문제가 없다.
3. Shared page frame을 회수하기 위해서는, 해당 물리 프레임을 참조하고 있는 시스템 내의 모든 page table entry를 반드시 고려하여 처리해야 한다.
PFRA의 설계 원칙: 가장 안전한 것부터, 가장 오래 쉰 것부터DESIGN HEURISTICS
수천, 수만 개의 회수 가능한 페이지가 존재할 때, 커널은 과연 어떤 녀석부터 타겟으로 삼아야 할까요? 안타깝게도 PFRA의 설계에 있어 수학적으로 증명된 '완벽한 정답'은 존재하지 않습니다. 대신 철저하게 시스템 성능과 안정성을 고려한 실전적 경험칙(heuristic)들의 정교한 조합으로 이루어집니다.
가장 널리 알려진 오해 중 하나는 PFRA가 교과서적인 완벽한 LRU(Least Recently Used)를 구현했다는 것입니다. 하지만 80x86을 비롯한 최신 프로세서들은 모든 메모리 페이지의 정확한 타임스탬프를 실시간으로 기록해 주지 않습니다. 따라서 PFRA는 최근 참조 여부, dirty 상태, 매핑 존재 여부, 스왑 아웃 가능성 등의 복합적인 조건을 중첩하여 평가하는, 매우 실용적인 'LRU 근사(Approximation) 알고리즘'이라고 이해해야 합니다.
핵심 O/X 퀴즈
1. Linux의 PFRA는 이론 컴퓨터 과학에서 정의하는 완벽하고 이상적인 LRU 알고리즘을 하드웨어 레벨에서 그대로 구현해 낸 것이다.
2. PFRA는 가능한 한 사용자 프로세스의 page table을 탐색하고 수정하는 비용을 피하기 위해, 상대적으로 다루기 쉬운 커널의 캐시 페이지를 먼저 회수하려는 경향을 띤다.
3. 매우 오랜 기간 sleep 상태에 머물러 있는 프로세스의 anonymous page라 할지라도, locked 상태가 아니라면 시스템 메모리 확보를 위해 점진적으로 회수될 가능성이 있다.
Reverse Mapping의 필요성: 물리 메모리에서 사용자를 역추적하다OBJECT-BASED REVERSE MAPPING
가정해 봅시다. 커널이 절박하게 물리 메모리를 확보해야 해서 특정 page frame 하나를 타겟으로 삼았습니다. 과연 이 물리 프레임을 현재 '어느 프로세스'의 '어떤 page table entry(PTE)'들이 가리키고 있을까요? 메모리를 탐색하는 일반적인 방향은 가상 주소(Virtual Address)에서 출발하여 Page Table을 거쳐 물리적인 Page Frame에 도달하는 정방향입니다. 하지만 PFRA에게 당장 필요한 것은 정확히 그 반대의 시야입니다. 주어진 물리 페이지 프레임에서 출발하여 역으로 그것을 참조하는 모든 사용자 PTE들을 색출해 내는 기술, 이것이 바로 Reverse Mapping(역방향 매핑)입니다.
가장 단순 무식한 해결책은 물리 페이지를 관리하는 page descriptor 구조체마다 "나를 바라보고 있는 모든 PTE의 포인터 목록"을 주렁주렁 매달아 두는 것입니다. 하지만 이 방식은 PTE가 생성되거나 소멸할 때마다 막대한 업데이트 비용을 발생시키며, 수십만 개에 달하는 page descriptor의 크기를 비대하게 만들어 오히려 메모리를 낭비합니다. 이를 극복하기 위해 Linux 2.6 버전부터 도입된 우아한 해결책이 바로 object-based reverse mapping입니다. 페이지마다 개별 PTE 목록을 저장하는 대신, 해당 페이지를 품고 있는 '메모리 영역(Memory Region)' 등의 상위 객체를 찾아내고, 그 객체들을 통해 역으로 PTE를 추적하는 간접적이고 효율적인 방식을 취합니다.
- _mapcount
- 이 물리 페이지 프레임을 참조하고 있는 활성 PTE의 개수를 추적합니다. 내부 구현상 -1부터 카운트를 시작하며, 커널 코드에서
page_mapcount()매크로를 호출하면 +1로 보정된 실제 참조 횟수를 반환합니다. 이 값이 0이면 non-shared, 0을 초과하면 shared page임을 알 수 있습니다. - mapping
- 해당 페이지의 소속과 특성을 지시하는 다목적 포인터입니다. 만약 NULL이라면 swap cache 등에 속한 고립된 페이지일 확률이 높습니다. 흥미롭게도 커널은 이 포인터의 최하위 비트(LSB)를 플래그로 활용합니다. LSB가 1이면 이 페이지는 anonymous page이며 포인터는
anon_vma구조체를 가리킵니다. 반대로 LSB가 0이면 mapped page이며, 파일 시스템과 연관된address_space객체를 가리키게 됩니다.
실제 메모리 매핑 해제의 관문 역할을 하는 함수는 try_to_unmap()입니다. 이 함수는 앞서 언급한 mapping 필드의 최하위 비트를 검사하여, 페이지의 태생이 anonymous인지 file-mapped인지 판별합니다. 그 후 각자의 특성에 맞게 try_to_unmap_anon() 또는 try_to_unmap_file()로 제어권을 넘깁니다. 최종적으로 해당 페이지가 더 이상 어떤 프로세스에도 매핑되지 않은 상태가 되면, 비로소 회수 작전이 성공한 것으로 간주합니다. 포인터 주소가 메모리에 정렬(alignment)되는 특성을 십분 활용해 빈 하위 비트를 상태 플래그로 전용하는 것은, 메모리 바이트 하나조차 아끼려는 시스템 프로그래밍 특유의 치열한 최적화 기법입니다.
핵심 O/X 퀴즈
1. Reverse mapping은 CPU가 가상 주소를 물리 주소로 변환할 때 사용하는 일반적인 page table 탐색(walk)과 동일한 방향의 메모리 접근 메커니즘이다.
2. Linux 2.6에 도입된 object-based reverse mapping 아키텍처는, 모든 개별 페이지마다 자신을 참조하는 PTE 리스트를 직접 보관하는 무거운 방식을 탈피했다.
3. Page descriptor 구조체 내부의 mapping 필드는, 해당 메모리 페이지가 anonymous 특성을 띠는지 혹은 파일 기반 매핑(mapped)인지를 식별하는 데 결정적으로 활용된다.
Anonymous Page의 reverse mapping: anon_vma라는 공유의 끈ANON_VMA
Anonymous page는 말 그대로 디스크 상의 '이름 있는 파일'과 아무런 연결 고리가 없는 순수 메모리 조각입니다. 개발자가 동적으로 할당받는 프로세스의 Heap이나, 함수 호출 시 생성되는 Stack 영역이 대표적입니다. fork() 시스템 콜이 호출되면 부모와 자식 프로세스는 Copy-On-Write(COW)라는 효율적인 방식으로 같은 물리 페이지 프레임을 당분간 사이좋게 공유하게 됩니다. Linux 커널은 이렇게 얽히고설킨 익명 메모리 영역(Anonymous Region)들을 체계적으로 추적하기 위해 anon_vma라는 특별한 연결 고리를 사용합니다.
anon_vma 객체를 생성합니다. 그리고 이 영역을 묘사하는 vm_area_struct 구조체를 anon_vma 내부 리스트에 등록합니다. 마지막으로 할당된 page descriptor의 mapping 필드에 anon_vma 객체의 주소를, 익명 페이지임을 알리는 비트 마커와 함께 새겨 넣습니다.try_to_unmap_one()을 호출하여 실질적인 회수 작업을 지시합니다.잊지 말아야 할 것은, anonymous page의 회수가 단순히 메모리를 '삭제'하는 거친 작업이 아니라는 점입니다. 기존의 PTE를 안전한 swap entry로 섬세하게 치환하고, 참조 카운터를 줄이며, 수정된 내역(dirty)이 있다면 이를 온전히 보존하는 정교한 외과 수술과도 같습니다. PTE가 있던 자리에 'Swapped-out page identifier'라는 이정표를 꽂아두어야만, 훗날 프로세스가 이 페이지에 접근하려 할 때 발생하는 page fault를 통해 "아, 이 데이터는 지금 스왑 영역에 보관되어 있구나"라고 무사히 찾아올 수 있습니다.
핵심 O/X 퀴즈
1. fork() 시스템 콜이 실행된 직후, 부모와 자식 프로세스는 서로의 anonymous page를 Copy-On-Write 방식으로 공유하며 효율적으로 메모리를 사용한다.
2. `try_to_unmap_one()` 함수는 시스템 메모리 확보가 시급할 경우, Accessed bit가 켜져(set) 있는 페이지라도 예외 없이 강제로 회수해 버린다.
3. Anonymous page를 메모리에서 완전히 밀어낼(unmap) 때, 비워진 PTE 자리에는 훗날 다시 데이터를 복원(swap-in)할 수 있도록 swapped-out page identifier 형태의 정보가 기록된다.
Mapped Page의 reverse mapping: priority search tree의 도입PRIORITY SEARCH TREE
Mapped page는 디스크 상의 특정 파일 블록과 운명 공동체로 묶인 페이지입니다. 가장 대표적인 예로 C 표준 라이브러리(libc) 같은 공유 객체를 들 수 있는데, 시스템 상의 거의 모든 활성 프로세스가 이를 자신의 주소 공간에 매핑하여 사용합니다. 만약 이런 초거대 공유 페이지를 회수하기 위해 커널 내의 모든 vm_area_struct를 일일이 순차 탐색(linear scan)한다면, 시스템 성능은 끔찍하게 저하될 것입니다. 이를 타개하기 위해 Linux 2.6은 mapped page 관리에 특화된 Priority Search Tree (PST)라는 훌륭한 자료구조를 도입했습니다.
커널의 관점에서, 특정 파일과 연결된 각각의 메모리 영역(memory region)은 그 파일 내에서의 시작 페이지 인덱스와 끝 페이지 인덱스를 양 끝점으로 하는 하나의 연속된 구간(interval)으로 추상화할 수 있습니다. PST는 바로 이 구간 데이터들을 최적화하여 구성한 트리로, "특정 파일의 N번째 페이지 인덱스를 포함하고 있는 모든 메모리 영역들을 즉시 내놓아라!"라는 쿼리에 극적으로 빠르게 응답하도록 설계되었습니다. 각 파일은 자신만의 고유한 PST를 보유하며, 트리의 최상위 루트 노드는 해당 파일의 inode 구조체 내부에 위치한 address_space 객체의 i_mmap 필드에 고이 모셔져 있습니다.
- Linear Mapping (선형 매핑)
- 가장 일반적이고 효율적인 경우입니다. 대상 페이지의 page->mapping 구조체에 속한 i_mmap_lock을 획득한 뒤,
vma_prio_tree_foreach()라는 강력한 매크로를 사용하여 PST 트리를 순회합니다. 이 과정을 통해 타겟 페이지의 인덱스 구간을 겹치게 포함하는 모든 타당한 메모리 영역(vma)들을 순식간에 골라내고, 찾아낸 각각의 vma를 향해 정밀 타격 함수인try_to_unmap_one()을 호출합니다. - Non-linear Mapping (비선형 매핑)
- 파일의 조각들이 가상 주소 공간에 연속적이지 않게, 임의의 순서로 조각조각 매핑된 특수한 상황입니다. 이 경우 page descriptor가 가진 인덱스 정보만으로는 프로세스의 정확한 선형 주소(linear address)를 한 번에 역산출해 낼 수 없습니다. 어쩔 수 없이 별도로 관리되는
i_mmap_nonlinear리스트를 뒤지며,try_to_unmap_cluster()라는 별도의 우회 함수를 통해 일정 범위의 페이지들을 휴리스틱(heuristic)하게 묶어서 스캔하는 무거운 작업을 수행해야 합니다.
본질적으로 추구하는 바는 같습니다. Anonymous page가 가계도를 그리는 anon_vma 리스트를 핵심 무기로 삼는다면, Mapped page는 파일의 인덱스를 기반으로 한 address_space와 PST 트리를 핵심 무기로 삼을 뿐입니다. 양쪽 모두 최종 목표는 단 하나, — "이 고립된 물리 페이지 프레임으로부터 출발하여, 시스템 전체에 흩어진 채 이 프레임을 쳐다보고 있는 모든 사용자 PTE들을 남김없이 색출하고 단절시키는 것"입니다.
핵심 O/X 퀴즈
1. Mapped page는 시스템의 수많은 프로세스가 단일 파일 페이지를 동시에 공유할 확률이 매우 높기 때문에, 단순 리스트를 넘어선 고효율 검색 자료구조의 도입이 절실히 요구된다.
2. Linux 2.6 버전은 mapped page에 대한 reverse mapping을 수행할 때, 무조건적으로 시스템 상의 모든 프로세스가 가진 모든 메모리 영역을 처음부터 끝까지 훑는 무식한 방식을 고수한다.
3. Non-linear mapping 환경에서는 page descriptor에 기록된 파일 내부 인덱스 값 하나만 가지고는 해당 데이터가 매핑된 가상 주소(linear address)를 손쉽게 유추할 수 없다.
PFRA의 실행 경로: 고갈, 동면, 그리고 주기적인 청소Figure 17-3
PFRA 시스템이 가동되는 '방아쇠(trigger)'는 크게 세 가지 경로로 나뉩니다. 첫 번째이자 가장 급박한 입구는 Low on memory reclaiming (메모리 고갈 시 회수)입니다. 시스템이 메모리를 요청했는데 더 이상 줄 공간이 없을 때 비상사태처럼 발동합니다. 버퍼 페이지를 생성하지 못하거나, buddy system이 요구된 연속된 페이지 프레임을 토해내지 못하면 try_to_free_pages()와 같은 핵심 구원 투수들이 즉각 마운드에 오릅니다. 두 번째 입구는 Hibernation reclaiming (동면을 위한 회수)입니다. 시스템이 suspend-to-disk(최대 절전 모드) 상태로 안전하게 전환되려면, 현재 RAM의 상태를 덤프하기 전에 메모리를 최대한 깔끔하게 압축하고 정리해 두어야 합니다. 세 번째 입구는 평상시 건강을 유지하는 Periodic reclaiming (주기적 회수)으로, 백그라운드에서 묵묵히 일하는 kswapd 커널 스레드가 그 주인공입니다.
Low memory reclaiming이 이미 심박수가 떨어지고 숨이 넘어가는 환자를 살리기 위한 '응급실의 심폐소생술'이라면, Periodic reclaiming은 큰 병을 예방하기 위해 주기적으로 영양제를 맞고 운동을 하는 '예방의학적 건강검진'과 같습니다. 커널의 인터럽트 핸들러나 일부 핵심 경로(path)들은 I/O 작업이 끝날 때까지 한가롭게 잠들어 기다릴 여유가 없습니다. 따라서 당장 급하지 않더라도 평상시에 꾸준히 여유 메모리(free page)의 수위를 안전선 위로 유지해 주는 kswapd의 존재는 시스템의 부드러운 동작을 위해 절대적입니다.
응급실의 헤드 닥터 격인 try_to_free_pages()는 메모리 부족 사태를 해결하기 위한 최전선 지휘관입니다. 이 함수는 shrink_caches와 shrink_slab 함수를 반복적으로 호출하며, 마치 나사를 조이듯 점진적으로 회수 압박의 강도를 높여갑니다. 이 강도를 조절하는 변수가 바로 priority(우선순위)인데, 가장 여유로운 12에서 시작해 가장 극단적인 0까지 점차 카운트다운 됩니다. 우선순위 숫자가 낮아질수록 상황이 절박함을 의미하며, 스캔하고 검사하는 페이지의 범위도 폭발적으로 늘어납니다. 이들의 일차적 목표는 시스템이 숨을 쉴 수 있는 최소한의 단위인 32개의 페이지 프레임을 긴급 수혈하는 것입니다. 만약 강도를 0까지 낮추며 무려 13번이나 쥐어짜냈음에도 불구하고 이 목표 달성에 실패한다면, 커널은 시스템 전체의 마비를 막기 위해 최후의 사형 집행인인 OOM(Out-Of-Memory) killer를 소환할 수밖에 없습니다.
핵심 O/X 퀴즈
1. PFRA는 사용자가 터미널에서 명시적으로 스왑(swap) 관련 명령어를 타이핑했을 때에만 수동으로 동작하는 유틸리티에 가깝다.
2. kswapd는 시스템의 물리 메모리가 1바이트도 남지 않고 완전히 고갈되어 시스템 패닉이 임박한 순간에 최초로 깨어나는 응급 구조조이다.
3. `try_to_free_pages()` 함수는 내부적으로 회수 강도(priority)를 점진적으로 상향시키면서, 여러 차례에 걸쳐 반복적으로 페이지 프레임 확보를 시도하는 끈질긴 로직을 가지고 있다.
LRU 리스트: 활성화된 자와 잊혀져 가는 자Figure 17-4
Linux 커널은 사용자 프로세스의 주소 공간에 매핑된 페이지들과 파일 시스템의 page cache에 속한 페이지들을 효율적으로 통제하기 위해, 전체 페이지들을 거대한 두 개의 리스트로 나누어 관리합니다. 하나는 최근까지 활발하게 참조된, 아직 따뜻한 온기가 남아있는 페이지들이 모인 Active List (활성 리스트)입니다. 다른 하나는 한동안 누구의 관심도 받지 못해 차갑게 식어버린, 회수 1순위 후보군이 모인 Inactive List (비활성 리스트)입니다. 메모리가 부족해질 때 PFRA가 매처럼 눈을 번뜩이며 주로 덮치는 사냥터가 바로 이 inactive list입니다.
- PG_lru
- 이 플래그가 켜져(set) 있다면, 해당 물리 페이지가 현재 커널의 LRU 알고리즘 통제하에 놓여 리스트 어딘가에 연결되어 있음을 확증하는 도장과 같습니다.
- PG_active
- 이 플래그가 켜져 있으면 페이지는 영광스러운 active list의 일원이며, 꺼져(clear) 있다면 차가운 inactive list에 머물고 있다는 직관적인 신호입니다.
- lru
- 실제 리스트의 노드들을 물리적으로 엮어주는 핵심 링크(포인터) 역할을 수행합니다.
그렇다면 inactive list의 그늘에 있던 페이지는 어떤 과정을 거쳐 active list로 화려하게 승격될까요? 커널 내부에서 "이 페이지가 방금 참조되었다!"라는 확신이 들 때 호출되는 함수가 mark_page_accessed()입니다. 하지만 커널은 성급하지 않습니다. 단 한 번 접근되었다고 해서 섣불리 페이지를 active list 최상단으로 끌어올리지 않습니다. 여기서 PG_referenced 플래그가 일종의 '진동 댐퍼(완충 장치)' 역할을 수행합니다. 완전히 잊혀졌던 페이지(PG_referenced=0)에 첫 번째 접근(access)이 발생하면, 커널은 페이지를 당장 승격시키는 대신 '관심 대상'이라는 의미로 플래그만 1로 켜둡니다. 그리고 그 플래그가 켜진 상태에서 짧은 시간 안에 두 번째 접근이 발생했을 때 비로소 "이 페이지는 확실히 다시 쓰이고 있다"고 판단하여 active list로 당당히 올려보냅니다.
수만 개의 페이지가 끊임없이 리스트 사이를 오갈 때마다 커널이 전역 락(lru_lock)을 쥐었다 놨다 한다면 시스템은 극심한 병목 현상에 시달릴 것입니다. 커널 엔지니어들은 이 문제를 해결하기 위해 pagevec이라는 작은 '임시 대합실'을 고안했습니다. 이동해야 할 페이지 디스크립터들을 이 대합실에 14개 한도로 차곡차곡 모아두었다가, 꽉 차는 순간 단 한 번만 락을 걸고 리스트에 우르르 쏟아붓는 방식입니다. 이는 락 경합의 오버헤드를 극적으로 줄이는, 매우 실전적이고 아름다운 성능 최적화(꼼수)입니다.
물론 active list로 올라갔다고 해서 영원한 안락함이 보장되는 것은 아닙니다. 시스템의 메모리 상황과 페이지의 접근 패턴에 따라 신분은 언제든 강등될 수 있습니다. 평소 백그라운드에서 page_referenced()와 refill_inactive_zone() 메커니즘이 active list를 끊임없이 훑어보며, 한동안 접근된 흔적이 없는 게으른 페이지들을 가차 없이 inactive list로 끌어내려 다음 회수의 희생양으로 준비시킵니다.
핵심 O/X 퀴즈
1. 메모리 압박이 심해질 때, PFRA 알고리즘이 물리 페이지를 뜯어내기 위해 우선적으로 뒤지는 곳은 active list보다 inactive list이다.
2. 커널의 PG_referenced 플래그는, 오랫동안 방치된 inactive 페이지에 단 한 번의 접근만 감지되더라도 지체 없이 즉각 active list로 승격시키기 위한 트리거(trigger) 목적으로 고안되었다.
3. pagevec이라는 구조체는, LRU 리스트에 페이지들을 넣거나 뺄 때 발생하는 lru_lock의 획득 및 해제 빈도를 극적으로 줄여주어 전반적인 동시성 성능을 끌어올리는 일등 공신이다.
refill_inactive_zone과 swap tendency: 빼앗을 것인가, 놔둘 것인가의 치열한 저울질SWAP TENDENCY
시스템 메모리가 부족해진다고 해서 커널이 곧바로 사용자의 프로세스 페이지를 강제로 뜯어내 스왑 영역으로 쫓아낼까요? 그렇지 않습니다. 가급적 사용자 경험에 타격을 주는 일은 피하려 노력합니다. refill_inactive_zone()은 활성 리스트(active list)에 있는 페이지들을 골라 비활성 리스트(inactive list)로 강등시키는 심판관 역할을 합니다. 이 심판관이 "사용자 프로세스의 페이지까지 자비 없이 끌어내릴 것인가?"를 결정하는 공격성의 핵심 지표가 바로 Swap Tendency(스왑 경향성)입니다.
- 공식
mapped_ratio / 2 + distress + swappiness- mapped_ratio
- 해당 구역(zone)에서 할당 가능한 전체 물리 페이지 프레임 중, 현재 사용자 모드(User Mode) 가상 주소 공간에 묶여 있는(mapped) 페이지가 차지하는 비중(%)입니다. 즉, "유저가 RAM을 얼마나 탐욕스럽게 쥐고 있는가"를 나타냅니다.
- distress
- PFRA가 이전에 메모리를 회수하려고 시도했을 때 얼마나 고전했는지를 수치화한 값입니다. 해당 존(zone)의 스캔 우선순위(priority)가 바닥을 향할수록, 즉 상황이 절망적일수록 이 수치는 급격히 상승합니다.
- swappiness
- 시스템 관리자가
/proc/sys/vm/swappiness파일을 통해 직접 튜닝할 수 있는 외부 변수입니다. 기본값은 보통 60으로 설정되어 있습니다.
위 공식의 작동 원리는 명확합니다. 세 항목을 더해 산출된 swap tendency 수치가 '100'이라는 마지노선을 넘어서는 순간, PFRA는 눈빛을 바꾸어 유저 모드의 매핑된 페이지들까지 가차 없이 inactive list로 강등시키며 스왑 아웃의 제물로 삼기 시작합니다. 만약 관리자가 swappiness 값을 0으로 극단적으로 낮춰둔다면, distress 값이 미친 듯이 치솟는 시스템 붕괴 직전의 상황이 오기 전까지는 유저 프로세스의 메모리에는 손대지 않으려 버틸 것입니다. 반대로 100으로 올려둔다면, 커널은 평상시에도 유저 페이지 캐시 페이지를 차별 없이 적극적으로 회수 대상 테이블에 올리게 됩니다.
refill_inactive_zone() 함수의 동선 또한 흥미롭습니다. 이 함수는 언제나 active list의 꼬리(tail), 즉 리스트 내에서도 가장 오랫동안 관심에서 멀어져 있던 녀석들부터 끄집어내어 심문을 시작합니다. 만약 최근에 다시 참조된 흔적이 명백하거나, 방금 계산한 swap tendency 수치가 낮아 유저 페이지를 굳이 건드릴 명분이 없거나, 심지어 익명(anonymous) 페이지인데 스왑 공간조차 마련되지 않은 불쌍한 처지라면 다시 active list의 머리(head) 쪽으로 온정 있게 돌려보냅니다. 이 모든 가혹한 심사를 뚫고 강등 판정을 받은 페이지들만이 비로소 inactive list의 심연으로 떨어지게 됩니다.
핵심 O/X 퀴즈
1. Swap tendency 지표가 높게 계산될수록, 커널이 시스템 캐시뿐만 아니라 사용자 프로세스가 소유한 주소 공간의 페이지들까지 매몰차게 회수 대상으로 끌어내릴 확률이 상승한다.
2. 시스템 관리자가 /proc 경로를 통해 swappiness 값을 0으로 고정해 두면, 시스템이 어떤 끔찍한 메모리 부족 상황에 직면하더라도 절대 User Mode 페이지가 스왑 아웃되는 일은 일어나지 않는다.
3. `refill_inactive_zone()` 함수는 active list에 머물고 있는 페이지들의 상태를 하나씩 검사하여, 조건에 부합하는 대상들만 선별적으로 inactive list로 이동시키는 문지기 역할을 수행한다.
Low memory reclaiming의 톱니바퀴: try_to_free_pages에서 shrink_cache로 이어지는 작전 명령RECLAIM CALL CHAIN
메모리 할당 요청이 실패했을 때, 커널 내부에서는 생존을 위한 어떤 다급한 연쇄 반응이 일어날까요? 가장 먼저 free_more_memory()가 호출되어 백그라운드에서 잠자던 pdflush 스레드를 다급히 깨웁니다. 이들에게 메모리에 쌓인 더티(dirty) 데이터들을 서둘러 디스크로 쏟아내라고 독촉하기 위함입니다. 그 직후, 커널은 각 메모리 노드의 부족 상태에 놓인 구역(low memory zone)들을 타겟으로 삼아 구원 투수인 try_to_free_pages() 함수를 본격적으로 투입합니다. 이 함수는 앞서 언급한 우선순위(priority) 레벨을 12에서 0까지 단계적으로 조이면서, 내부적으로 shrink_caches()와 shrink_slab()이라는 양대 회수 엔진을 미친 듯이 회전시킵니다.
shrink_zone()에게 회수 명령을 하달합니다. 이 과정에서 각 존이 과거에 얼마나 고전했는지(prev_priority)와 현재 얼마나 급박한지(temp_priority)의 척도를 실시간으로 갱신합니다.refill_inactive_zone() 작업과, 비활성 리스트에서 실질적으로 메모리를 탈취하는 shrink_cache() 작업을 적절한 비율로 조율합니다. 처리 효율을 위해 통상 32개 단위(batch)로 묶어 지시합니다.shrink_list()에게 일괄 인계합니다. 운 좋게 사형을 면한 페이지들은 상태에 따라 원래의 active나 inactive 리스트로 방면됩니다.priority 수치가 낮아져 상황이 점차 절박해질수록, 알고리즘은 active 및 inactive 리스트에서 스캔하는 페이지의 '비율'을 무자비하게 끌어올립니다. 여기서 커널의 꼼꼼함이 빛을 발하는데, 이번 회수 턴에서 스캔해야 할 할당량이 너무 적어(예: 32개 미만) 무시하고 넘어간 스캔 분량은 nr_scan_active와 nr_scan_inactive 변수에 조용히 누적(마일리지처럼 적립)해 둡니다. 그리고 다음번 호출 시 이 빚을 한꺼번에 청구하여, 단 하나의 잉여 페이지도 알고리즘의 그물망을 빠져나가지 못하도록 철저히 계산합니다.
핵심 O/X 퀴즈
1. `try_to_free_pages()` 함수는 메모리 확보 시도를 단 한 차례만 수행하며, 이 단판 승부에서 실패할 경우 뒤도 돌아보지 않고 즉시 OOM killer의 칼춤을 시작한다.
2. `shrink_zone()`은 자신이 담당하는 단일 메모리 구역 내부에서, active 리스트의 거품을 걷어내는 작업과 inactive 리스트를 실제 회수하는 양방향 작업을 유기적으로 통제한다.
3. `shrink_cache()`는 성능 저하의 주범인 전역 락(lock)의 유지 시간을 최소화하기 위해, inactive 리스트에서 타겟 페이지들을 임시 local list로 덜어낸 뒤 `shrink_list()`에 한꺼번에 넘기는 영리한 배칭(batching) 기법을 사용한다.
shrink_list와 pageout: 페이지가 소멸하는 최후의 심문소Figure 17-5
모든 지시를 거쳐 도달한 shrink_list()는 PFRA 알고리즘의 진정한 핵심 엔진이자, 페이지들의 운명이 결정되는 최후의 심문소입니다. 건네받은 page_list 묶음을 펼치고 페이지 하나하나를 현미경 보듯 깐깐하게 검열하며 회수를 시도합니다. 이 치열한 심문의 결과는 보통 세 갈래로 나뉩니다. 첫째, 모든 결점을 털어내고 완벽히 '자유의 몸'이 되어 buddy system(가용 메모리 풀)으로 환원되거나. 둘째, 당장은 조금 얽힌 실타래가 남아있지만 곧 풀릴 듯하여 inactive 리스트에 조금 더 보류되거나. 셋째, 누군가 최근에 다시 접근했거나 처리하기 너무 까다로워 다시 영광의 active 리스트로 복권되는 것입니다.
page_referenced() 함수를 동원해 PG_referenced 플래그와 하드웨어의 Accessed bit를 샅샅이 뒤집니다. 최근 누군가 터치한 흔적이 뚜렷하고, 이것이 유저 모드 메모리나 실행 파일 캐시 등에 속한다면 징발을 취소하고 active 리스트로 돌려보냅니다.add_to_swap()을 호출하여 디스크 스왑 영역에 미리 묫자리(slot)를 예약하고 스왑 캐시에 등록하는 절차를 밟습니다. 자리가 없어 실패하면 강제로 active 리스트로 쫓겨납니다.try_to_unmap()이라는 가위를 들고 찾아가 프로세스와 연결된 모든 PTE 가닥을 가차 없이 끊어냅니다. 하나라도 끊어내는 데 실패하면 회수를 포기해야 합니다.pageout()을 호출해 변경된 내용을 디스크로 밀어내는 writeback 작업을 트리거합니다. 디스크 I/O 큐가 꽉 차 있다면 이마저도 연기될 수 있습니다.try_to_release_page()를 통해 이것들을 깔끔하게 떼어냅니다.free_cold_page()를 외치며 buddy system이라는 자유의 공간으로 페이지를 날려 보냅니다.shrink_list()의 코드를 들여다보면 쉼 없는 질문의 연속임을 알 수 있습니다. "잠겨 있는가? 최근에 쓰였는가? 스왑 갈 준비는 마쳤나? PTE는 깔끔하게 잘라냈나? 변경된 데이터는 디스크에 잘 썼나? 찌꺼기 버퍼는 뗐나? 진짜 아무도 안 쓰고 있는 것 맞나?" 이토록 편집증에 가까운 7단계의 허들을 단 하나도 빠짐없이 무사 통과한 무결점 페이지 프레임만이 비로소 시스템에 새 생명을 불어넣는 free page로 거듭나는 것입니다.
핵심 O/X 퀴즈
1. `shrink_list()` 함수는 시스템 메모리 확보가 최우선 목표이므로, 일시적으로 locked(잠금) 상태이거나 한창 디스크와 writeback(동기화) I/O가 진행 중인 페이지조차도 예외 없이 공격적으로 압수해 버린다.
2. Anonymous page를 안전하게 수거하여 빈 프레임으로 만들기 위해서는, 필수불가결하게 swap cache를 생성하고 디스크 스왑 영역에 물리적인 기록 공간(slot)을 확보하는 선행 작업이 요구된다.
3. 수정을 거쳐 Dirty 상태로 마킹된 페이지는, 그 변경분을 디스크로 방출(pageout)하여 완벽히 Clean한 상태로 세탁됨이 증명된 이후에야 비로소 폐기 및 회수가 가능하다.
shrinkable disk cache와 slab 회수: LRU 리스트 너머의 은밀한 캐시들SHRINKER FUNCTIONS
커널의 PFRA가 먹잇감을 찾는 곳이 오로지 사용자 페이지와 파일이 얽힌 LRU 리스트뿐일까요? 아닙니다. 보이지 않는 곳에서 메모리를 야금야금 포식하고 있는 거대한 그림자들이 또 있습니다. 바로 파일 경로를 캐싱하는 dentry cache, 파일 메타데이터를 품은 inode cache, 디스크 쿼터(quota) 캐시 등 각종 파일 시스템 부속물들입니다. 이 녀석들이 무분별하게 비대해지는 것을 막기 위해, 어떤 특수 목적 캐시가 PFRA의 통제를 받고 싶다면 시스템 부팅이나 초기화 시점에 스스로 Shrinker function (수축 함수)을 등록하는 예의를 갖춰야 합니다. set_shrinker() 인터페이스를 통해 등록을 마치면, 이 함수 포인터들은 커널 전역의 shrinker_list라는 체인에 주렁주렁 매달리게 됩니다.
메모리가 부족해질 때 호출되는 shrink_slab()이라는 함수는 이름 때문에 종종 "Slab Allocator(슬랩 할당자) 전용 청소기"로 뼈아픈 오해를 받곤 합니다. 그러나 실상 이 함수의 진정한 정체는 등록된 수많은 'shrinkable disk cache'들의 shrinker 함수들을 순차적으로 깨워 실행시키는 무자비한 집행관입니다. 이 함수는 임의로 캐시를 날려버리지 않고, 일반 LRU 페이지를 회수할 때의 비용 대비 이 특수 캐시들을 파괴했을 때의 비용을 정교한 수식으로 저울질하며, 캐시 크기를 얼마나 줄이는 것이 시스템 전체에 가장 유리할지 그 타겟 수치를 치밀하게 계산해 냅니다.
- shrink_dcache_memory()
- 현재 어떤 프로세스나 파일 동작에서도 참조되지 않는(unused) 잉여 dentry 객체들을 사냥합니다. 내부적으로
prune_dcache()를 호출하여, 해시 테이블과 부모 자식 연결 리스트, 그리고 inode 링크망에서 해당 dentry를 조용히 도려냅니다. - shrink_icache_memory()
- 자신을 호위하는 dentry가 모두 소멸하여 끈 떨어진 연 신세가 된 unused inode들을 색출합니다. 프라이빗 버퍼를 회수하고, 엮여 있던 깨끗한(clean) 페이지 캐시들을 무효화시킨 뒤 최종적으로 destroy_inode의 철퇴를 내립니다.
- vfs_cache_pressure
- 관리자가
/proc/sys/vm/vfs_cache_pressure를 통해 개입할 수 있는 튜닝 노브입니다. 기본값은 100이며, 숫자를 낮추면 커널이 dentry/inode 캐시에 관대해져 가급적 보존하려 하고, 수치를 대폭 높이면 일반 페이지보다 이 캐시들을 먼저 박살 내어 메모리를 확보하려 하는 맹견 모드로 돌변합니다.
많은 이들이 메모리 최적화를 논할 때 "어떤 프로세스를 죽이고 스왑을 얼마나 늘릴까"에만 집착합니다. 하지만 지혜로운 커널은 프로세스의 생살여탈권을 쥐고 흔들기 전에, 먼저 백그라운드에 조용히 쌓여있던 불필요한 page cache, 수명을 다한 dentry cache와 inode cache, 낭비되고 있는 slab allocator 잉여 객체 등 희생시켜도 전혀 아프지 않은 유휴 메모리 블록부터 찾아내어 수술대에 올립니다.
핵심 O/X 퀴즈
1. Shrinker function이란, 특정 서브시스템이나 커널 캐시가 메모리 부족 상황에서 스스로의 몸집을 줄일 수 있도록 PFRA 프레임워크에 자발적으로 등록해 두는 맞춤형 콜백(callback) 함수를 의미한다.
2. `shrink_slab()` 함수는 오직 커널의 메모리 할당기인 slab allocator 내부에 쌓인 빈 객체(free slab)만을 회수하도록 하드코딩 되어 있으며, 파일 시스템의 dentry나 inode 캐시 축소와는 전혀 무관한 동작을 수행한다.
3. 시스템 튜닝 변수인 `vfs_cache_pressure`의 수치를 극단적으로 끌어올리면, 커널은 프로세스의 페이지보다 파일 시스템 관련 메타데이터 캐시(dentry/inode)들을 우선적으로 파괴하여 메모리를 회수하는 성향을 띠게 된다.
Periodic reclaiming: kswapd와 cache_reap, 쉬지 않는 정원사들BACKGROUND RECLAIM
메모리 할당이 처참하게 실패하고 나서야 허겁지겁 회수를 시작하는 수동적인 전략은 프로덕션 시스템에서 시한폭탄과 다름없습니다. 커널의 어떤 경로(path)들은 실행 도중 절대로 잠들어서는 안 되며(non-blocking), 이미 치명적인 락(lock)이나 핵심 자원을 선점한 상태라면 느릿느릿한 디스크 I/O를 기다릴 여유조차 없습니다. 따라서 Linux 커널은 폭풍우가 몰아치기 전에, 항상 일정 수준의 여유 방벽(free page)을 백그라운드에서 묵묵히 다져놓는 예방적 접근을 취합니다.
- pages_min
- 시스템의 숨통이 끊어지지 않기 위해 우주가 멸망해도 사수해야 하는 최소한의 가용 페이지 생명선입니다.
- pages_low
- 저수지의 수위가 이 선창 아래로 내려가면, 사이렌이 울리며 잠자던 kswapd가 부스스 눈을 뜨고 업무에 돌입하는 최초의 경고 수위(warning threshold)입니다.
- pages_high
- kswapd가 땀 흘려 여유 페이지를 확보하다가 이 수위에 도달하면, "이제 충분히 안전하다"고 안도하며 다시 기나긴 단잠에 빠져들게 되는 목표 안전 수위입니다.
이 선제적 방벽 구축의 주인공은 단연 kswapd 커널 스레드입니다. 거대한 NUMA 아키텍처를 고려하여 각 메모리 노드마다 배속된 이 수호자들은 평상시엔 이벤트 대기열(wait queue)에서 코를 골며 잠들어 있습니다. 그러나 각 구역의 여유 공간이 pages_low 아래로 추락하는 순간 화들짝 깨어납니다. 기상과 동시에 이들은 balance_pgdat() 함수를 호출하여 앞서 배운 우선순위 12부터 0까지의 촘촘한 빗질(shrink_zone, shrink_slab)을 시작하고, 수위가 pages_high라는 안도선에 도달할 때까지 최소 32페이지 묶음 단위로 쉬지 않고 메모리를 확보해 나갑니다.
한편, 스왑 데몬 옆에는 구석구석을 쓸고 닦는 또 다른 정원사 cache_reap()이 존재합니다. 이 친구의 주 임무는 slab allocator 내부에 흩어진, 아무도 쓰지 않는 껍데기 객체(unused slab)들을 주기적으로 거두어들이는 것입니다. 미리 정의된 워크 큐(work queue) 스케줄러에 의해 약 2초마다 기계적으로 깨어나며, 로컬 CPU 캐시와 시스템 공유 캐시에 고인 자원들을 쓸어내리고 휴리스틱 공식을 돌려 얼마나 많은 빈 슬랩을 파괴할지 각을 잽니다. 계산이 끝나면 free slab 리스트를 돌며 가차 없이 slab_destroy를 연사하여 파편화된 빈 공간을 큼직한 가용 메모리로 뭉쳐냅니다.
비유하자면, kswapd는 대형 도서관 전체를 순찰하며 "열람실 전체 좌석(메모리 Zone) 대비 남은 빈자리(Free Page)가 20% 미만으로 떨어졌으니 서둘러 자리를 치워라!"라고 지시하는 총괄 매니저입니다. 반면 cache_reap은 2초에 한 번씩 시계추처럼 나타나 열람실 구석구석을 돌아다니며 "책상 위에 방치된 다 마신 커피 컵(Slab Allocator의 빈 객체)만 조용히 치우고 사라지는" 성실한 청소 노동자와 같습니다. 바라보는 타겟과 일하는 템포는 다르지만, 시스템의 쾌적함을 유지한다는 궁극적인 목적은 완벽히 일치합니다.
핵심 O/X 퀴즈
1. kswapd 커널 스레드는 성능 확장을 위해 각 메모리 노드(memory node)마다 독립적으로 하나씩 띄워질 수 있으며, 여유 메모리가 경고 수위(pages_low) 아래로 침몰할 때만 대기열에서 스스로 깨어나 활동을 시작한다.
2. kswapd의 행동 대장 격인 `balance_pgdat()` 함수는 무지성으로 메모리를 지우는 것이 아니라, 내부적으로 zone watermark를 주시하면서 필요에 따라 `shrink_zone()`과 `shrink_slab()`을 엮어 호출하는 섬세한 컨트롤타워 역할을 한다.
3. `cache_reap()` 함수는 사용자 프로세스가 동적으로 할당한 anonymous page 데이터를 물리 디스크의 swap area 영역으로 내쫓는 핵심적인 스와핑 엔진이다.
OOM killer와 swap token: 극단 상황의 파국과 몸부림의 완화LAST RESORT & THRASH PREVENTION
PFRA가 땀을 비 오듯 쏟으며 모든 페이지를 쥐어짜고 캐시를 부숴도, 요구되는 메모리양이 물리적 한계를 아득히 초과한다면 시스템은 멈출 수밖에 없습니다. 이 절망적인 한계 상황에서 커널이 꺼내 드는 차갑고 잔혹한 최후의 수단, 그것이 바로 OOM(Out-Of-Memory) killer입니다. 메모리가 고갈되었다는 사형 선고인 out_of_memory()가 울려 퍼지면, 사형 집행인인 select_bad_process()가 차가운 눈빛으로 희생양을 물색하기 시작합니다.
- 덩치가 큰 놈부터 (막대한 page 점유)
- 잔혹하지만 가장 효율적인 기준입니다. 기왕 죽일 거라면, 녀석이 죽었을 때 시스템에 반환되는 메모리의 양이 가장 방대한 프로세스를 1순위로 조준합니다.
- 힘이 약한 놈부터 (낮은 priority)
- 프로세스 스케줄링의 우선순위(static priority) 수치인 nice 값이 크고 중요도가 떨어지는 백그라운드 작업일수록 쉽게 타겟팅 됩니다.
- 귀족은 피한다 (root 권한 제외 경향)
- root 권한으로 실행된 프로세스는 시스템 생존과 직결된 크리티컬한 역할을 수행할 확률이 높으므로, 가급적 살생부 리스트에서 뒤쪽으로 미뤄둡니다.
- 불사신 (절대 제외 대상)
- 메모리 관리를 책임지는 swapper(PID 0), 시스템의 아버지 init(PID 1), 그리고 커널 내부에서 도는 수많은 kernel thread들은 이들의 죽음이 곧 커널 패닉으로 직결되므로 사살 명단에서 완벽히 면책됩니다.
완전한 파국(OOM)은 아니지만 시스템이 늪에 빠져 끔찍하게 느려지는 또 다른 치명적 질병이 있습니다. 바로 Swap Thrashing(스왑 스래싱)입니다. 이는 PFRA가 피눈물 흘려가며 기껏 스왑 아웃시켜 빈 공간을 만들었더니, 야속한 사용자 프로세스가 0.1초도 안 되어 그 페이지에 다시 접근(page fault)하여 디스크에서 꾸역꾸역 스왑 인(swap-in) 해오는 악순환이 무한 반복되는 현상입니다. 이 상태에 빠지면 CPU는 연산은 하지 못하고 디스크 I/O를 기다리며 헛돌게 됩니다. 이를 구제하기 위해 고안된 우아한 해결책이 Swap Token (스왑 토큰)입니다. 이 황금 토큰을 부여받은 프로세스의 소유 페이지는 일정 시간 동안 PFRA의 무자비한 강등 압박(inactive list로의 추락)으로부터 어느 정도 면책 특권을 받게 됩니다. 이렇게 밀어주기를 통해 토큰을 쥔 프로세스가 스래싱 없이 빠르게 연산을 끝마치고(진전을 이루고) 스스로 종료되어, 한꺼번에 방대한 메모리를 자연스럽게 뱉어내도록 유도하는 고도의 정치적 전략입니다.
비교하자면 OOM killer는 빙산과 충돌해 가라앉는 타이타닉호에서 다른 다수를 살리기 위해 몸집이 제일 큰 승객 한 명을 강제로 바다로 밀어버리는 비정한 '희생 강요 전략'입니다. 반면 Swap Token 제도는 서로 먼저 문을 통과하려다 아무도 나가지 못하고 있는 아수라장(Thrashing) 속에서, 임시 반장에게 마이크와 통행증(Token)을 쥐여주어 한 명이라도 먼저 방을 무사히 빠져나가도록 길을 터주는 현명한 '교통정리 보호 전략'이라 할 수 있습니다.
핵심 O/X 퀴즈
1. OOM killer의 본질은 시스템이 메모리 부족으로 완전히 데드락에 빠지거나 멈춰버리는 최악의 사태를 회피하기 위해, 눈물을 머금고 단일(혹은 복수의) 프로세스에 강제 종료 시그널(SIGKILL)을 날리는 방어 메커니즘이다.
2. 시스템 안정성을 담보하기 위해, OOM killer의 희생양(victim)은 무조건 시스템의 심장부인 root 권한의 프로세스들 중에서 최우선으로 선발되도록 프로그래밍 되어 있다.
3. Swap token의 존재 이유는, 시스템 과부하 상태에서 특정 프로세스가 발급받은 토큰의 위력으로 자신의 페이지들이 PFRA에 의해 섣불리 회수되는 것을 막고, 지긋지긋한 디스크 I/O 늪(스래싱)에서 벗어나 연산을 마칠 수 있도록 돕기 위함이다.
Swapping의 기본 원리: 원본 잃은 고아 페이지들의 대피소SWAP BASICS
디스크 상의 어떤 파일에도 자신의 그림자를 드리우지 못한 anonymous page를 메모리 밖으로 쫓아내야만 할 때, 그 안의 소중한 데이터들은 허공으로 증발해야 할까요?
그 해답은 바로 Swap Area(스왑 영역)에 있습니다. 동적으로 할당된 Heap이나 프로세스의 로컬 변수를 담고 있는 Stack 같은 익명 페이지들은, 매핑된 파일이 없으므로 날려버리면 영영 복구할 길이 없습니다. 따라서 커널은 이 페이지들을 강제로 압수하기 직전에 하드 디스크에 미리 떼어놓은 특별한 스왑 파티션(swap partition)이나 스왑 파일(swap file) 구석에 그 내용물을 고스란히 복사해 두는 백업 작업을 수행합니다. 익명 페이지 외에도, 여러 프로세스가 꼼지락대며 더럽혀놓은(dirty) private memory 매핑 페이지나, 프로세스 간 통신을 위해 뚫어놓은 IPC shared memory 페이지들 역시 갈 곳이 없어 스왑 영역이라는 대피소로 향하는 주요 손님들입니다.
스와핑 기법의 철학적 전제는 '완벽한 투명성(transparency)'입니다. 사용자 응용 프로그램은 자신의 데이터가 RAM에 있는지 하드 디스크 구석에 처박혀 있는지 전혀 눈치채선 안 됩니다. 커널은 이 환상을 유지하기 위해 마술을 부리는데, 그 트릭의 핵심이 PTE(Page Table Entry)에 달린 Present flag입니다. 페이지가 RAM에 무사히 살아있다면 이 비트는 1(set)을 가리킵니다. 하지만 스왑 아웃되어 디스크로 유배를 떠나는 순간, 커널은 이 비트를 0(clear)으로 내리고, 그로 인해 남게 된 PTE의 빈 비트 공간들에 "이 페이지는 스왑 파티션 X번의 Y번째 슬롯에 잠들어 있다"는 Swapped-out page identifier (스왑 위치 식별자)를 빼곡히 적어 넣습니다. 훗날 무지한 프로세스가 그 주소를 읽으려 접근하면 당연히 CPU는 하드웨어 예외(page fault)를 터뜨리게 되고, 커널의 듬직한 fault handler가 달려와 PTE의 암호를 해독한 뒤 디스크에서 먼지 묻은 데이터를 건져내어 다시 RAM으로 우아하게 스왑 인(swap-in) 시켜줍니다.
- 기반(Infrastructure) 설정
- 물리 디스크 위에 스왑을 담아둘 거대한 바구니를 규정합니다. 이것은 원시 파티션(raw partition) 형태일 수도 있고, 파일 시스템 위에 올라간 단일 파일(swap file) 형태일 수도 있습니다.
- 슬롯(Slot) 할당 및 반환
- 스왑 영역 내부를 촘촘한 벌집 모양의 페이지 슬롯 단위로 쪼개고, 방 빼는 페이지에게 번호표를 쥐여주며 남는 방을 할당하거나 반환받는 장부를 관리합니다.
- 데이터 이동(Swap-out / Swap-in)
- 초고속 RAM의 페이지 데이터를 느릿느릿한 디스크 슬롯으로 밀어내거나(out), 반대로 디스크의 데이터를 다시 RAM 프레임으로 퍼 올리는(in) 무거운 I/O 파이프라인입니다.
- 식별자(Identifier) 관리
- 페이지가 RAM을 비우고 떠날 때, 텅 빈 PTE 자리에 정확한 귀환 좌표(swapped-out page identifier)를 각인하여 미아 발생을 막는 네비게이터 시스템입니다.
개발자들이 흔히 저지르는 뼈아픈 착각 중 하나는 "RAM이 모자라니 스왑을 크게 잡아 시스템 성능을 빵빵하게 올리자!"는 생각입니다. 스왑 영역의 데이터에 접근하는 행위는 본질적으로 디스크의 헤드를 움직이는 기계적인 I/O를 동반하므로, 순수 RAM 접근 속도에 비해 수만 배 이상 끔찍하게 느립니다. 스왑은 결코 '성능 향상을 위한 마법의 부스터'가 아닙니다. 그저 물리 메모리가 터져나가는 최악의 가뭄 속에서 시스템 패닉이라는 파국을 막아내기 위해 억지로 들이켜는 '연명용 수액'에 불과함을 잊지 말아야 합니다.
핵심 O/X 퀴즈
1. 스와핑(Swapping)이 동작하려면, C언어 등으로 작성된 사용자 응용 프로그램의 코드 내부에 `swap_in()` 혹은 `swap_out()`과 같은 명시적인 시스템 콜이 반드시 하드코딩 되어 있어야 한다.
2. 디스크에 설정된 Swap area의 맨 앞부분 첫 번째 페이지 슬롯(slot 0)은, 실제 프로세스에서 쫓겨난 데이터를 욱여넣는 공간이 아니라 해당 스왑 파티션의 정체성과 메타데이터를 증명하는 간판 용도로 사용된다.
3. 스왑 영역은 하드 디스크 드라이브라는 매우 안정적이고 영구적인 매체를 활용하므로, 휘발성인 RAM을 무리하게 증설하기보다는 스왑 파티션의 용량을 수백 기가바이트로 늘려 시스템의 전반적인 처리 속도와 연산 성능을 대폭 끌어올리는 것이 권장되는 튜닝 기법이다.
Swap Area Descriptor와 암호화된 식별표Figure 17-6
활성화되어 가동 중인 각각의 스왑 영역(파티션이나 파일)들은 커널 메모리 내부에서 swap_info_struct라는 거대한 디스크립터 구조체로 대표됩니다. 이 구조체의 심장부에는 swap_map이라는 거대한 카운터 배열이 자리 잡고 있는데, 스왑 영역에 존재하는 수백만 개의 페이지 슬롯 하나하나마다 전담 카운터가 하나씩 배정된 꼴입니다. 특정 슬롯의 카운터 값이 0이라면 그 방은 현재 먼지만 날리는 빈방(free slot)이라는 뜻이고, 양수(1 이상)라면 현재 그 데이터를 공동으로 소유하고 기대고 있는 참조자(owner) 프로세스의 숫자를 나타냅니다. 특이하게도 최대치인 SWAP_MAP_MAX 값이 찍혀 있다면 영구적으로 지워지지 않는 데이터라는 뜻이고, 불길한 SWAP_MAP_BAD 값이 찍혀 있다면 그 부분의 하드 디스크 표면에 물리적인 스크래치(배드 섹터)가 발생하여 접근해선 안 되는 망가진 슬롯임을 커널에 경고하는 것입니다.
- flags
- 현재 이 스왑 영역의 건강 상태를 나타내는 신호등입니다. 관리자가 켜둔 상태(SWP_USED)인지, 디스크에 쓰기 권한이 온전한지(SWP_WRITEOK)를 확인하며, 이 둘이 결합된 온전한 상태일 때만 커널은 당당히 SWP_ACTIVE라는 최종 승인 마크를 부여합니다.
- swap_map
- 위에서 설명한, 디스크 슬롯 하나당 방명록을 남기는 거대한 카운터 배열의 시작 포인터입니다.
- priority
- 시스템에 여러 개의 스왑 장치가 달릴 수 있습니다. 느려 터진 HDD 스왑보다는 쌩쌩한 NVMe SSD 스왑 파티션에 높은 우선순위(priority) 번호를 매겨두면, 커널이 빈방을 찾을 때 빠릿빠릿한 장치부터 먼저 꽉 채워 쓰는 스마트함을 발휘합니다.
- swap_list.next
- 다음번 스왑 아웃 희생자가 발생했을 때, 여러 스왑 영역 중 어느 파티션을 향해 데이터를 던질지 커널이 짚어두는 다음 타겟의 '배열 인덱스' 번호입니다. 복잡한 포인터가 아닌 가벼운 정수형 번호를 사용합니다.
이번에는 텅 비어버린 PTE 자리에 박히는 암호표, Swapped-out page identifier의 해부도를 살펴봅시다. 앞서 말했듯 이 마법은 PTE의 Present 비트를 0으로 뭉개면서 시작됩니다. 그러고 나면 PTE에 남은 수십 개의 잉여 비트 공간이 생기는데, 커널은 이 공간을 반으로 갈라 한쪽에는 "이 페이지가 갇혀있는 스왑 파티션의 고유 번호"를, 다른 한쪽에는 "그 파티션 내부의 몇 번째 방(page slot index)인지"를 꼼꼼하게 새겨 넣습니다. 이로써 CPU가 바라보는 PTE의 운명은 세 가지로 극명히 갈립니다. PTE 비트가 통째로 모두 0(null)이라면 이는 태초에 물리 프레임을 할당조차 받은 적 없는 처녀지(unallocated)이고, Present 비트는 0인데 나머지 비트에 요상한 좌표 숫자들이 적혀 있다면 "스왑 디스크로 이사 갔으니 데려와라"라는 뜻이며, Present 비트가 당당히 1로 빛나고 있다면 현재 눈부신 RAM 위에서 활발히 뛰놀고 있는 건강한 페이지임을 방증합니다.
부모 자식 간에 공유된 익명 페이지(shared anonymous page)가 스왑 아웃될 때는 조금 독특한 일이 벌어집니다. 찢겨나간 여러 개의 프로세스 PTE가 모두 단 하나의 동일한 스왑 디스크 슬롯을 가리키는 복제된 identifier를 지니게 됩니다. 이때 swap_duplicate() 함수가 호출되어 디스크 슬롯의 swap_map 카운터를 슬쩍 +1씩 증가시키며, "이 좁은 방 하나를 여러 명이 주시하고 있으니 함부로 지워선 안 된다"는 참조 횟수 추적의 막중한 책임을 다하게 됩니다.
핵심 O/X 퀴즈
1. 쫓겨난 페이지를 가리키는 swapped-out page identifier 속에는, 물리 디스크 상의 어떤 스왑 파티션(영역)인지 식별하는 번호와 그 파티션 내부에서의 방 번호(page slot index)가 나란히 압축되어 저장된다.
2. 만약 swap_info_struct 배열 내 특정 슬롯의 swap_map 카운터 값이 숫자 '0'을 가리키고 있다면, 이는 해당 슬롯이 현재 프로세스에 의해 꽉 차서 활발하게 사용 중(used)이라는 경고등이다.
3. 하드웨어 MMU가 주소를 변환하다가 특정 PTE를 까보았을 때 Present 비트는 0으로 꺼져 있으나 나머지 비트 필드에 의미 있는 swapped-out identifier 값이 적혀 있는 것을 발견한다면, 그 즉시 page fault 예외를 발생시키고 커널의 핸들러가 디스크로부터 데이터를 길어 올리는(swap-in) 작업을 착수하게 된다.
스왑 영역의 불을 켜고 끄기: Swapon과 아찔한 SwapoffSWAPON / SWAPOFF
시스템 관리자가 터미널에 swapon 명령어를 쳐서 메마른 대지에 단비를 내리려 할 때, 커널의 막후에서는 sys_swapon()이라는 거대한 의식이 치러집니다. 이 함수는 가장 먼저 명령을 내린 자가 정말로 시스템을 좌지우지할 권한(CAP_SYS_ADMIN)이 있는지 엄격하게 신분증을 검사합니다. 자격이 확인되면, 시스템 전역에 할당된 swap_info 배열을 훑으며 아직 아무도 선점하지 않은 텅 빈 디스크립터 자리를 하나 꿰찹니다. 그리고 지정된 파티션의 맨 앞, 슬롯 0번지에 기록된 헤더를 더듬어 읽고 "SWAPSPACE2"라는 약속된 매직 암호가 똑똑히 적혀 있는지 확인하여 엉뚱한 디스크를 포맷하는 참사를 방지합니다. 마지막으로 그 파티션의 크기만큼 vmalloc을 호출해 swap_map 카운터 배열이라는 거대한 장부를 메모리에 펼쳐내고, 마침내 승인 도장인 SWP_ACTIVE를 쾅 찍어 스왑 영역을 공식적으로 개장합니다.
반면, sys_swapoff()를 통한 비활성화 선언은 차원이 다른 복잡한 재앙과도 같습니다. 단순히 셔터를 내린다고 끝날 일이 아니라, 그 스왑 파티션 안에 아직 수십수백 개 프로세스의 잃어버린 고아 페이지들이 잠들어 있을 수 있기 때문입니다. 건물을 철거하기 전에 세입자들을 모두 밖으로 빼내야 하듯, 커널은 눈물을 머금고 try_to_unuse()라는 극한 직업 함수를 투입합니다. 이 함수는 swap_map 장부 전체를 이 잡듯이 뒤져 누군가 사용 중(카운터>0)인 슬롯을 끈질기게 찾아내고, 해당 데이터를 꾸역꾸역 좁아터진 RAM으로 다시 길어 올린(swap-in) 뒤, 시스템 전체의 프로세스 PTE를 모조리 뒤져 그 식별자(identifier)를 갖고 있던 녀석들의 주소를 방금 퍼 올린 실제 RAM 주소로 일일이 바꿔 끼우는 노가다를 수행해야만 비로소 파티션의 숨통을 끊을 수 있습니다.
- 클러스터(Cluster) 연속 할당
- 빈방을 찾을 때 듬성듬성 배정하지 않습니다. 디스크 헤드의 끔찍한 기계적 이동(seek) 시간을 줄이기 위해, 가급적 이전에 방을 잡아준 곳 바로 옆에 이웃한 연속된 슬롯(클러스터 묶음)을 찾아 우선 배정하는 영리함을 발휘합니다.
- 순환 재탐색의 미학
- 목표한 클러스터 크기만큼 방을 다 채워주었거나 파티션의 끝단에 도달하여 막다른 길에 다다르면, 미련 없이 다시 파티션의 맨 앞부분으로 돌아와 혹시 그사이에 비워진 방이 없는지 순환 탐색(round-robin)을 시작합니다.
- 경계선 제한(Boundaries)
- 무식하게 수백만 개의 배열 전체를 매번 뒤지면 CPU가 낭비되므로, lowest_bit(가장 빠른 빈방 후보)와 highest_bit(가장 늦은 빈방 후보)라는 양 극단 포인터를 실시간으로 조여나가며 검색해야 할 범위를 획기적으로 압축합니다.
전역 공간에서 새로운 빈방을 구하는 get_swap_page() 함수는 시스템에 활성화된 모든 스왑 파티션을 대상으로 매물을 검색합니다. 이때 무작정 첫 번째 파티션만 편애하지 않고, 관리자가 지정한 priority(우선순위)가 높은 고속 SSD 스왑 파티션부터 훑어봅니다. 만약 우선순위가 동일한 파티션이 여러 개 묶여 있다면, 특정 디스크만 과로사하지 않도록 라운드 로빈(round-robin) 방식으로 공평하게 슬롯을 뽑아 쓰는 세심함을 보여줍니다. 반대로 세입자가 방을 비우는 swap_free() 함수가 호출되면, 해당 슬롯의 swap_map 카운터를 단순히 -1 깎아주며, 이 카운터가 완전히 0으로 떨어지는 순간 그 슬롯은 다시 티 없이 맑은 공실(free slot)로 초기화됩니다.
핵심 O/X 퀴즈
1. 시스템 관리자가 swapoff 명령을 내려 스왑 파티션을 닫으려 할 때, 해당 영역에 아직 프로세스의 잔여 데이터가 남아 있다면 커널은 이를 모두 RAM으로 다시 강제 견인(swap-in)해야 하므로 swapon보다 훨씬 치명적이고 기나긴 랙(lag)을 유발할 수 있다.
2. `get_swap_page()` 함수는 관리자가 설정한 스왑 영역의 우선순위(priority) 따위는 가볍게 무시하고, 오로지 커널 배열 내부의 인덱스 번호가 작은 스왑 파티션부터 맹목적으로 데이터를 욱여넣는 단순 무식한 할당 방식을 취한다.
3. 스왑 데이터의 반환을 알리는 `swap_free()` 함수는 호출 즉시 해당 슬롯의 swap_map 카운터를 차감시키며, 이 값이 최종적으로 0에 도달하는 순간 비로소 해당 디스크 슬롯이 완전한 자유 상태(free slot)로 해방될 수 있다.
Swap Cache: 혼돈의 I/O 경합을 잠재우는 최후의 중재자Figure 17-8
두 프로세스가 동일한 anonymous page(예: 부모와 자식이 fork로 공유한 메모리)를 서로 나란히 공유하고 있는 흥미로운 상황을 가정해 봅시다. 어느 날 두 녀석이 약속이나 한 듯, 동시에 그 페이지에 접근하려 들어 page fault를 유발하고 경쟁적으로 swap-in을 시도한다면 무슨 일이 벌어질까요? 끔찍하게도 디스크에서 같은 데이터를 두 번 읽어와 물리 메모리상에 두 개의 독립된 쌍둥이 페이지 프레임으로 쪼개져 버리는(데이터 불일치) 대참사가 일어날 수 있습니다. 또 다른 시나리오로, 커널의 PFRA가 시스템을 살리겠다고 특정 페이지를 디스크로 내보내는(swap-out write) 와중에, 눈치 없는 다른 프로세스가 그 페이지가 필요한 줄 알고 읽어 들이려(swap-in read) 달려든다면 읽기와 쓰기 파이프라인이 무참히 꼬여버리고 맙니다. 이 혼돈의 레이스 컨디션(race condition)을 철통같이 방어하기 위한 커널의 유일무이한 황금률은 매우 간결합니다.
시스템 내의 그 어떤 주체도 스왑 디스크로 향하는 문을 열기 전(swap-in이든 swap-out이든), 반드시 중앙 안내소 격인 'Swap Cache(스왑 캐시)'를 가장 먼저 조회하고 거쳐야만 한다. 스왑 캐시는 엉뚱하게도 기존 파일 시스템의 page cache 인프라를 절묘하게 빌려와 구현되었습니다. 디스크로 쫓겨나는 모든 익명 페이지들은 임시로 swapper_space라는 거대한 단일 가상 address_space 안으로 흡수되며, 이때 검색의 핵심 키(key)는 파일 오프셋이 아닌 그 페이지의 고유한 swapped-out page identifier가 됩니다.
shrink_list()가 결단을 내리고 add_to_swap()을 호출하여 디스크의 빈 방(slot)을 예약함과 동시에, 페이지를 이 든든한 스왑 캐시 대합실에 밀어 넣습니다. 뒤이어 try_to_unmap()이라는 가위로 얽혀있는 모든 PTE를 스왑 엔트리(identifier)로 치환해 버린 뒤, pageout()을 통해 기나긴 디스크 쓰기(writeback)를 시작합니다. 긴 시간 디스크 쓰기가 무사히 종료되고 나서야 비로소 delete_from_swap_cache()를 호출하여 캐시 대합실에서 이름을 지우고, 페이지는 buddy system의 품으로 돌아갑니다.do_swap_page()가 출동해 묻혀있던 identifier 암호를 해독하고 다짜고짜 스왑 캐시 대합실 문부터 두드립니다. 만약 그곳에 찾던 페이지가 없다면(아직 디스크에만 있다면), read_swap_cache_async()를 호출해 텅 빈 페이지 프레임을 하나 갓 구워낸 뒤 대합실에 자신의 이름표를 붙여 먼저 등록(선점)해 버립니다. 그러고 나서 느긋하게 디스크에서 데이터를 퍼 올립니다. 만반의 준비가 끝나면, 기다리던 PTE 공간에 진짜 물리 페이지 프레임 주소를 꽂아 넣어 귀환을 완성합니다.- lookup_swap_cache()
- 주어진 identifier 암호문을 열쇠로 삼아, 스왑 캐시 내부의 방대한 radix tree를 눈썹 휘날리게 탐색하여 페이지 디스크립터의 존재 유무를 확인합니다.
- add_to_swap_cache()
- 스왑 슬롯의 유효성을
swap_duplicate()로 교차 검증한 후, 동시성 오류가 없음을 확신하는 순간 radix tree 안에 페이지를 강력히 박아 넣는(insert) 앵커 역할을 합니다. - delete_from_swap_cache()
- 모든 역할이 끝났을 때 radix tree에서 페이지를 부드럽게 뽑아내고, 스왑 슬롯의 참조 카운터와 페이지 자체의 참조 횟수를 가지런히 깎아내려 뒷정리를 완벽히 수행합니다.
귀환의 최선봉장 read_swap_cache_async()의 꼼꼼함은 혀를 내두를 정도입니다. 녀석은 다짜고짜 디스크부터 읽지 않고 무조건 radix tree 대합실부터 조회합니다. 만약 텅 비어있어 새 물리 프레임을 할당받아 캐시에 방을 잡으려는 찰나, 그 수 마이크로초의 틈을 타고 경쟁자 프로세스가 번개처럼 같은 페이지를 캐시에 찔러 넣었을 수 있습니다. 놀랍게도 커널은 이때 화내지 않고, 자신이 방금 힘들게 할당받은 빈 프레임을 미련 없이 쓰레기통에 버린 뒤, 처음 조회 단계(lookup)로 조용히 롤백하여 경쟁자가 올려둔 페이지에 무임승차합니다. 이 지독하리만치 집요한 확인 및 재시도 로직 덕분에, 동일한 쫓겨난 페이지(swapped-out)에 대해서는 시스템 전체를 통틀어 단 하나의 유일무이한 페이지 프레임만이 존재하도록 완벽한 동기화(Synchronization)의 기적이 완성되는 것입니다.