[JUNGLE] TIL_44. 코어 타임하다가 궁금한 점

모깅·2025년 10월 26일

JUNGLE

목록 보기
45/56
post-thumbnail

Q. 운영체제에서 새로운 프로세스의 페이지 테이블에 공유 객체를 매핑해주려면 어딘가에서 공유 객체의 존재 상태를 유지하고 있어야 할텐데 어떻게 동작하는걸까?

운영체제는 "현재 어떤 공유 객체(파일)의 어느 부분이 어떤 물리 메모리 프레임에 올라와 있는지"를 커널 내의 전역적인 자료구조를 통해 관리합니다. 이 정보가 없으면 공유 매핑 자체가 불가능합니다.

이 관리의 핵심 주체는 VFS(Virtual File System) 계층과 메모리 관리 서브시스템이 연동하여 사용하는 vnode (또는 inode)페이지 캐시(Page Cache)입니다.


공유 객체 추적의 핵심: vnode와 페이지 캐시

프로세스가 100개이든 1개이든, libc.so 파일은 디스크 상에서 단 하나입니다. 커널은 이 파일이 메모리에 로드될 때, 이 파일의 커널 내 대표 객체(vnode)를 중심으로 물리 메모리 상태를 관리합니다.

동작 원리

1. 최초 로드 (프로세스 A)

  1. 프로세스 A가 libc.so를 로드합니다. (보통 mmap() 시스템 콜을 통해)
  2. 커널은 libc.so 파일의 vnode(Linux에서는 inode)를 찾습니다.
  3. 커널은 이 vnode에 연결된 페이지 캐시(struct address_space in Linux)를 확인합니다. "이 파일의 코드 세그먼트(0~40KB 오프셋)가 메모리에 있는가?"
  4. (Cache Miss) 없습니다.
  5. 커널은 비어있는 물리 메모리 프레임 (P)을 할당받습니다.
  6. 디스크에서 libc.so의 코드 세그먼트를 P로 읽어들입니다.
  7. vnode의 페이지 캐시에 등록합니다: "libc.so의 (오프셋 0~40KB)는 (물리 프레임 P)에 있다."
  8. 프로세스 A의 페이지 테이블에 매핑합니다: (프로세스 A의 가상 주소 VA_A) \rightarrow (물리 프레임 P)

2. 공유 로드 (프로세스 B)

  1. 프로세스 B가 동일한 libc.so를 로드합니다.
  2. 커널은 동일한 libc.so 파일의 vnode를 찾습니다.
  3. 커널은 이 vnode에 연결된 페이지 캐시를 확인합니다.
  4. (Cache Hit) vnode의 페이지 캐시에 "libc.so의 (오프셋 0~40KB)는 (물리 프레임 P)에 있다."는 정보가 이미 존재합니다.
  5. 디스크 I/O가 발생하지 않습니다.
  6. 커널은 프로세스 B의 페이지 테이블에 이 정보를 바로 매핑합니다: (프로세스 B의 가상 주소 VA_B) \rightarrow (물리 프레임 P)

핵심 커널 자료구조 (Linux 기준)

"어딘가"는 구체적으로 다음과 같은 커널 자료구조의 조합입니다.

1. struct address_space (파일 중심)

  • 모든 inode는 자신만의 address_space 구조체를 가집니다.
  • 이것이 바로 페이지 캐시의 본체입니다.
  • 이 구조체는 "이 파일의 특정 오프셋(논리적 블록)이 어떤 물리 페이지에 캐시되어 있는가"를 추적하는 래딕스 트리(Radix Tree) (최근에는 XArray)를 가지고 있습니다.
  • 이것이 프로세스와 독립적으로 공유 객체의 물리 메모리 위치를 유지하는 "전역적인" 저장소입니다.

2. struct vm_area_struct (VMA) (프로세스 중심)

  • 이것은 각 프로세스마다 존재합니다.
  • 프로세스의 가상 주소 공간 중 특정 영역(예: 0x7f00... ~ 0x7f10...)을 정의합니다.
  • 이 VMA는 "이 영역은 libc.so 파일(struct file 포인터)의 (오프셋 0)부터 매핑된 영역이다"라고 파일을 가리킵니다.

요약

프로세스 B가 mmap을 호출하면, 커널은 VMA를 생성하고 이 VMA가 가리키는 파일(libc.so)의 address_space(페이지 캐시)를 확인합니다. 그리고 그곳에 이미 캐시된 물리 프레임 P가 있다면, 프로세스 B의 페이지 테이블에 P를 매핑해주는 것입니다.

결론적으로, 운영체제는 파일(vnode/inode)을 중심으로 물리 메모리 점유 상태(페이지 캐시)를 관리하며, 각 프로세스는 자신의 가상 주소 영역(VMA)을 통해 이 공유되는 캐시에 연결(매핑)됩니다.

Q. 프로그램이 실행되어서 프로세스가 되면 자연스럽게 mmap()으로 libc.so를 로드하는건가?

정확히는 프로세스의 main 함수가 실행되기 전에, "동적 링커(Dynamic Linker)"라는 특별한 프로그램이 먼저 실행되어 libc.sommap()으로 로드합니다.

프로그램이 "자연스럽게" 로드하는 것처럼 보이지만, 실제로는 커널과 이 동적 링커(ld.so)가 협력하여 main 함수가 시작할 수 있도록 환경을 먼저 구축하는 것입니다.


1. 커널의 역할: 링커 로드하기

  1. 사용자가 셸에서 ./my_program을 실행하면, 커널에 execve 시스템 콜이 전달됩니다.
  2. my_program"동적 연결(Dynamically Linked)" 실행 파일입니다. 이 파일의 ELF 헤더에는 .interp (interpreter)라는 특별한 섹션이 있습니다.
  3. .interp 섹션에는 "나를 실행하기 전에, 먼저 /lib64/ld-linux-x86-64.so.2 (이하 ld.so)라는 프로그램을 실행시켜라"고 적혀있습니다.
  4. 커널은 이 정보를 보고, my_programmain 함수를 바로 실행하지 않습니다. 대신, 커널은 새 프로세스의 메모리 공간에 my_program의 코드/데이터와 함께 ld.so (동적 링커)를 함께 로드(매핑)합니다.
  5. 커널은 CPU의 실행 포인터를 my_programmain이 아닌 ld.so의 시작점으로 넘깁니다.

2. 동적 링커(ld.so)의 역할: libc.so 로드하기 (이때 mmap 발생)

이제부터는 커널이 아닌, ld.so 프로그램이 해당 프로세스 내(유저 레벨)에서 실행됩니다.

  1. ld.somy_program의 ELF 헤더를 다시 읽어, "이 프로그램이 의존하는(필요로 하는) 공유 라이브러리 목록" (예: libc.so, libpthread.so 등)을 확인합니다.
  2. ld.so는 이 목록을 순회하며 libc.so를 찾습니다.
  3. ld.so직접 open("/usr/lib/libc.so") 시스템 콜을 호출하여 파일 디스크립터(fd)를 얻습니다.
  4. ld.so직접 mmap(..., fd, ...) 시스템 콜을 호출하여, libc.so의 코드와 데이터 세그먼트를 프로세스의 가상 메모리 공간에 "예약(VMA 생성)"합니다.
  5. 모든 의존성 라이브러리(mmap)와 심볼 재배치(relocation) 작업이 완료되면, ld.so는 비로소 my_program의 원래 시작점(결국 main 함수)으로 점프하여 실행을 넘깁니다.

요약

libc.so는 프로세스가 "자연스럽게" 로드하는 것이 아니라, 커널이 ld.so를 먼저 실행시키고,
ld.somain 함수가 실행되기 전에 필요한 libc.sommap()으로 명시적으로 로드하는 것입니다.

Q. 프로세스 A가 libc.so와 같은 공유 라이브러리를 보통 mmap() 시스템 콜을 통해 로드한다고 하는데 정확히 어떤식으로 동작하는걸까?

mmap() 시스템 콜은 파일을 메모리에 직접 로드하는 것이 아니라, "메모리 예약"을 하고 실제 로드는 뒤로 미루는 방식으로 동작합니다.

핵심은 VMA(Virtual Memory Area) 생성페이지 폴트(Page Fault) 핸들링입니다.


1. mmap() 호출 시점: "가상 주소 예약"

프로세스 A(의 동적 링커)가 libc.so를 로드하기 위해 mmap()을 호출하면, 커널은 다음 단계만 수행합니다.

  1. VMA(Virtual Memory Area) 탐색: 커널은 프로세스 A의 가상 주소 공간(mm_struct)을 살펴보고, 요청받은 크기(예: libc.so의 코드 세그먼트 크기)만큼의 비어있는 영역을 찾습니다. 이 시작 주소를 VA_start라고 하겠습니다.
  2. VMA 객체 생성: 커널은 이 VA_start부터 끝(VA_end)까지의 영역을 관리하는 vm_area_struct (VMA)라는 자료구조를 생성합니다.
  3. VMA 정보 기록: 이 VMA 객체에는 다음과 같은 "예약 정보"가 기록됩니다.
    • vm_start, vm_end: VA_start ~ VA_end
    • vm_prot: 메모리 접근 권한 (예: 읽기/실행, PROT_READ | PROT_EXEC)
    • vm_flags: 매핑 속성 (예: 공유, VM_SHARED)
    • vm_file: 매핑할 파일 포인터 (즉, libc.so 파일 객체)
    • vm_pgoff: 파일 내 오프셋 (예: 0, 파일의 시작부터)
  4. 페이지 테이블은 건드리지 않음: 이것이 핵심입니다. mmap()은 이 VMA "예약"만 생성할 뿐, 물리 메모리를 할당하거나 프로세스의 페이지 테이블(PTE)을 수정하지 않습니다.
  5. 반환: 커널은 "예약된" 가상 주소 VA_start를 프로세스에게 반환하고 사용자 모드로 복귀합니다.

결과: mmap() 호출은 매우 빠릅니다. 실제 I/O 작업이 전혀 없습니다. 프로세스는 VA_start라는 주소를 얻었지만, 이 주소는 아직 어떤 물리 메모리와도 연결되지 않은, 말 그대로 "텅 빈" 가상 주소입니다.


2. 최초 접근 시점: "페이지 폴트"

mmap()이 반환된 후, 프로세스가 실제로 VA_start에 있는 코드를 실행하려는 순간(예: libc.so 안의 printf 함수 호출)에 모든 일이 벌어집니다.

  1. MMU의 주소 변환 시도: CPU의 MMU가 VA_start를 물리 주소로 변환하려고 프로세스의 페이지 테이블을 조회합니다.
  2. 페이지 폴트 발생: mmap() 시점에서는 페이지 테이블을 수정하지 않았으므로, 해당 주소에 대한 PTE(Page Table Entry)는 "Invalid" (또는 "Present" 비트가 0)입니다. MMU는 주소 변환에 실패하고 페이지 폴트(Page Fault) 예외를 발생시킵니다.
  3. 커널의 페이지 폴트 핸들러 개입: CPU는 즉시 커널 모드로 전환되고, 커널의 페이지 폴트 핸들러가 실행됩니다.
  4. VMA 확인: 핸들러는 폴트가 발생한 주소 VA_start가 어떤 VMA에 속하는지 확인합니다. (1단계에서 생성한 libc.so VMA를 찾습니다.)
  5. 페이지 캐시 확인: 커널은 VMA에 연결된 libc.so 파일(vm_file)의 페이지 캐시(address_space)를 확인합니다. "이 파일의 이 오프셋(0번)에 해당하는 데이터가 이미 물리 메모리에 올라와 있는가?"
    • Case A: Cache Hit (공유 발생!)
      • 다른 프로세스가 이미 libc.so를 로드해서 물리 프레임 P에 해당 내용이 있습니다.
      • 커널은 즉시 이 P의 주소를 가져옵니다.
    • Case B: Cache Miss (최초 로드!)
      • 커널은 비어있는 물리 프레임 P를 새로 할당받습니다.
      • 디스크에서 libc.so의 해당 부분을 읽어 P에 채웁니다. (I/O 발생)
      • (파일, 오프셋) -> P 매핑을 페이지 캐시에 등록합니다. (다음 요청을 위해)
  6. 페이지 테이블 매핑: 커널은 Case A이든 B이든, 확보한 물리 프레임 P의 주소를 프로세스 A의 페이지 테이블에 기록합니다. (VA_start의 PTE) -> (물리 프레임 P)로 설정하고, "Present" 비트를 1로 켭니다.
  7. 작업 복귀: 커널은 페이지 폴트 처리를 완료하고 사용자 모드로 복귀합니다. CPU는 폴트를 일으켰던 바로 그 명령어를 다시 실행합니다.
  8. 성공: 이번에는 MMU가 페이지 테이블을 조회하고, VA_start -> P라는 유효한 매핑을 찾아 주소 변환에 성공합니다. libc.so의 코드가 비로소 실행됩니다.

이처럼 mmap()은 파일과 가상 주소 공간의 연결(reservation)만 설정하고, 실제 데이터 로드는 페이지 폴트(Page Fault)가 발생했을 때 필요한 페이지만큼만 처리(Demand Paging)합니다. 이것이 mmap()이 효율적으로 동작하고 메모리 공유를 구현하는 핵심 원리입니다.

Q. 페이지 캐시는 어디에 있는걸까?

페이지 캐시는 크게 두 부분으로 나뉘어 존재합니다.

  1. 캐시 데이터 (Data): 실제 파일 내용(예: libc.so의 코드)이 저장되는 곳.
  2. 캐시 메타데이터 (Metadata): 이 데이터가 "어떤 파일의 몇 번째 블록인지"를 관리하는 장부.

질문하신 "어디에 있는가"에 대한 답은 다음과 같습니다.

  • 데이터는 물리 메모리(RAM)의 페이지 프레임에 흩어져 있습니다.
  • 메타데이터(관리 장부)는 커널 메모리(Kernel Space) 내부에 있습니다.

1. 캐시 데이터: 물리 메모리 (RAM)

  • 페이지 캐시 "데이터"는 특정 격리된 공간에 따로 모여있지 않습니다.
  • 커널은 디스크에서 읽어온 파일 내용(libc.so의 코드 블록)을 일반 물리 메모리 페이지 프레임(Page Frame)에 저장합니다.
  • 이 페이지 프레임들은 프로세스 A의 스택, 프로세스 B의 힙 등이 사용하는 페이지 프레임들과 동일한 "물리 RAM" 공간을 공유합니다.
  • 즉, 페이지 캐시는 "커널이 관리하는, 파일 내용을 담고 있는 물리 페이지들의 집합"을 의미하는 개념적인 용어입니다.

2. 메타데이터: 커널 메모리의 inodeaddress_space

이것이 사용자가 질문한 address_space의 정체이며, "관리 장부"의 역할을 합니다.

  1. inode (in Kernel Memory):
    • 커널은 현재 시스템에서 접근 중인 모든 파일(및 디렉터리)에 대해 inode 구조체를 커널 메모리 내에 생성하여 유지합니다. (libc.so 파일도 마찬가지입니다.)
    • inode는 해당 파일의 커널 내 "대표 객체"입니다.
  2. struct address_space (in Kernel Memory):
    • 모든 inode 구조체는 i_mapping이라는 필드를 통해 struct address_space라는 또 다른 커널 자료구조를 가리킵니다.
    • address_space가 바로 해당 파일의 페이지 캐시를 관리하는 "본체(장부)"입니다.
  3. 관리 방식 (Radix Tree / XArray):
    • address_space 구조체는 내부적으로 래딕스 트리(Radix Tree) (최근 커널에서는 XArray)라는 효율적인 검색 트리를 사용합니다.
    • 이 트리는 (파일 내 오프셋, 즉 페이지 번호) \rightarrow (해당 데이터가 저장된 struct page 포인터)의 매핑을 저장합니다.
    • (struct page는 모든 물리 페이지 프레임을 1:1로 관리하는 또 다른 커널 메타데이터입니다.)

요약: 페이지 폴트 시 커널의 동작

페이지 폴트가 발생했을 때, 커널이 VMA를 통해 "페이지 캐시를 확인한다"는 것은 실제로는 다음 과정을 의미합니다.

  1. 폴트가 난 가상 주소가 속한 VMA를 찾습니다.
  2. VMA에 연결된 vm_file 포인터를 따라가 file 구조체를 찾습니다.
  3. file 구조체를 통해 이 파일의 inode (커널 메모리)를 찾습니다.
  4. inodei_mapping 포인터를 따라가 address_space (커널 메모리)를 찾습니다.
  5. address_space래딕스 트리 (커널 메모리)를 탐색합니다. (Key: VMAvm_pgoff + 폴트난 주소의 오프셋)
  6. (Cache Hit) 트리에서 struct page 포인터를 찾으면, 이 struct page가 가리키는 물리 페이지 프레임 주소를 가져와 PTE에 매핑합니다.
  7. (Cache Miss) 트리에서 못 찾으면, 새 물리 프레임을 할당하고, 디스크에서 읽어온 뒤, 이 정보를 트리에 등록하고 PTE에 매핑합니다.

Q. Radix Tree / XArray 란?

래딕스 트리(Radix Tree)와 XArray는 모두 매우 큰(희소한) 배열메모리 효율적으로 구현하기 위한 트리 기반의 자료구조입니다.

Linux 커널의 페이지 캐시(address_space)에서는 "파일의 nn번째 페이지(Key)"가 "어떤 물리 메모리 프레임(Value)"에 있는지를 매핑하는 데 사용됩니다.


1. 래딕스 트리 (Radix Tree)

래딕스 트리는 "기수(radix) 트리"라고도 불리며, 키(key)를 비트(bit) 조각으로 나누어 트리를 탐색하는 자료구조입니다. (일반적인 Trie의 일종입니다.)

핵심 개념 (페이지 캐시 예시)

  • Key: 파일 오프셋(페이지 인덱스, pgoff_t), 예: 0, 1, 1000, 100000
  • Value: struct page* 포인터 (해당 물리 페이지 프레임의 주소)

a.out 파일의 10,000번째 페이지를 찾는다고 가정해 봅시다. 10000이라는 정수(Key)를 2진수로 변환합니다 (예: 0b...0010 0111 0001 0000).

  1. 이 64비트(또는 32비트) 키를 일정한 크기의 비트 조각(예: 6비트씩)으로 나눕니다.
  2. 트리의 루트(Root)에서 시작하여, 첫 번째 비트 조각(예: 상위 6비트)을 배열의 인덱스로 사용해 다음 노드를 찾습니다.
  3. 그다음 노드에서 두 번째 비트 조각을 인덱스로 사용해 다음 노드를 찾습니다.
  4. 이 과정을 키의 끝까지 반복하여 최종적으로 struct page* 포인터(Value)가 저장된 리프(Leaf) 노드에 도달합니다.

주요 장점

  • 메모리 효율성 (Sparse Data): 이것이 가장 큰 이유입니다. 만약 10GB 파일이 있고, 그중 0번째 페이지와 1,000,000번째 페이지만 메모리에 올라와 있다면, 일반 배열은 1,000,001개의 포인터 공간이 필요합니다. 래딕스 트리는 단지 이 2개의 항목을 위한 경로(path)에 해당하는 노드들만 생성하므로 메모리 낭비가 없습니다.
  • 빠른 탐색: 탐색 시간은 트리에 저장된 항목 수(nn)가 아니라 키의 길이(kk)에 비례합니다 (O(k)O(k)). 64비트 시스템에서는 탐색 깊이가 일정하게 제한되므로 매우 빠릅니다.

2. XArray (eXtensible Array)

XArray는 래딕스 트리의 현대적인 대체재입니다. Linux 커널 내부에서 기존의 래딕스 트리가 가진 한계를 극복하기 위해 개발되었습니다.

본질적으로 XArray도 래딕스 트리입니다. 하지만 커널 사용에 맞게 고도로 최적화되고 기능이 확장되었습니다.

래딕스 트리의 한계 (XArray가 해결한 문제)

  1. NULL 값 저장 불가: 기존 래딕스 트리는 void* (포인터)를 저장했는데, NULL 포인터는 "슬롯이 비어있음"을 의미했습니다. 따라서 NULL 자체를 유효한 값으로 저장할 수 없었습니다.
  2. "특수 엔트리" 저장 불가: "이 페이지는 현재 스왑(Swap) 공간에 있다" 또는 "이 페이지는 할당되지 않았다" 등, 일반 포인터가 아닌 특수한 상태값을 저장하기가 까다로웠습니다.
  3. 복잡한 API와 동시성(Concurrency): 락(Lock) 관리가 복잡하고 사용하기 어려운 부분이 있었습니다.

XArray의 개선점

  1. 모든 값 저장 가능: NULL을 포함한 모든 포인터 값을 저장할 수 있습니다.
  2. 특수 엔트리 (Special Entries): 포인터는 보통 메모리 정렬(alignment) 때문에 하위 2~3비트가 항상 0입니다 (예: 0x...000). XArray는 이 사용되지 않는 하위 비트를 "태그(Tag)"처럼 활용합니다.
    • 예: 하위 비트가 001이면 "이것은 스왑 엔트리다"라고 구분하고, 나머지 상위 비트는 스왑 위치 정보를 담는 식입니다.
  3. API 단순화 및 동시성 강화: 락(Lock) 메커니즘이 자료구조 내부에 통합되어 더 안전하고 사용하기 쉽게 개선되었습니다.
  4. eXtensible (확장성): 이름처럼, 사용자는 거대한 배열의 특정 인덱스(Key)에 값을 저장하는 것처럼(xa_store(key, value)) 간단히 사용할 수 있으며, 내부적으로는 트리가 알아서 확장(extend)됩니다.

요약

특징래딕스 트리 (Radix Tree)XArray (eXtensible Array)
기본 구조키(정수)를 비트 조각으로 나눠 탐색하는 트리래딕스 트리를 기반으로 고도로 최적화/확장
주요 용도희소(Sparse)한 인덱스 \rightarrow 값 매핑래딕스 트리의 모든 용도 + @
저장 값void* (포인터), NULL은 "비어있음"모든 값 (포인터, NULL, 특수 엔트리)
특징메모리 효율성, 빠른 탐색특수 엔트리(태깅), 단순한 API, 향상된 동시성
관계구형(Legacy)신형(Modern) (래딕스 트리를 대체 중)

결론적으로, 페이지 캐시에서 address_space가 사용하는 래딕스 트리/XArray는 "파일의 nn번째 페이지는 PP라는 물리 주소에 있다"라는 매핑 정보를, 수백만 개의 빈 슬롯을 낭비하지 않고 효율적으로 저장/검색하는 "희소 배열(Sparse Array)" 자료구조입니다.

profile
멈추지 않기

0개의 댓글