LODY/정리

운영체제 / 메모리 가상화와 페이징

메모리 가상화와 페이징

🟣 모두가 0번지에서 시작한다는 거짓말

프로그램을 컴파일해 실행 파일을 뜯어보면, 코드가 특정 주소에 놓인다고 못박혀 있다. 그런데 프로세스를 여럿 띄워 각자의 주소를 찍어 보면 모두 같은 주소를 가리킨다. 물리 메모리가 하나뿐인데 여러 프로그램이 같은 주소를 쓴다는 건 말이 안 된다. 그런데도 아무 충돌 없이 돌아간다.

비밀은 프로그램이 보는 주소가 진짜 물리 주소가 아니라는 데 있다. 운영체제는 각 프로세스에게 자기만의 넓고 연속된 메모리를 통째로 가진 것 같은 착시를 준다. 이것이 가상 주소 공간이다. 프로세스가 다루는 모든 주소는 가상 주소이고, 실제 물리 메모리의 어디에 앉을지는 운영체제와 하드웨어가 뒤에서 정한다. 이 착시 덕분에 프로그래머는 다른 프로세스를 신경 쓸 필요가 없고, 프로세스끼리 메모리가 격리되어 안전해진다.

문제는 이 착시를 유지하려면 가상 주소를 물리 주소로 바꾸는 변환이 매 메모리 접근마다 일어나야 한다는 것이다. 이 변환을 어떻게 빠르고 유연하게 할 것인가가 이 장의 전부다.


🟣 통째로 옮기던 시절: 베이스와 바운드, 그리고 세그먼트

가장 단순한 변환은 주소 공간 전체를 물리 메모리의 어느 한 구간에 통째로 올리는 것이다. 하드웨어에 레지스터 두 개를 둔다. 이 프로세스가 물리 메모리 어디서 시작하는지 담은 베이스(base), 그리고 얼마나 큰지 담은 바운드(bound)다. 가상 주소에 베이스를 더해 물리 주소를 얻고, 바운드를 넘으면 잘못된 접근으로 막는다. 변환이 덧셈 한 번이라 빠르다.

문제는 낭비다. 주소 공간에는 코드, 힙, 스택이 있고 그 사이는 대부분 비어 있다. 그런데 베이스와 바운드는 이 빈 공간까지 통째로 물리 메모리에 올린다. 안 쓰는 공간이 물리 메모리를 잡아먹는다. 이를 줄이려 코드·힙·스택을 각각 따로 배치하는 세그먼테이션이 나왔다. 세그먼트마다 베이스와 바운드를 두어 빈 공간을 물리 메모리에 올리지 않는다.

세그먼테이션은 낭비를 줄였지만 새 골칫거리를 낳았다. 세그먼트 크기가 제각각이라 물리 메모리에 넣고 빼기를 반복하면 여기저기 자잘한 빈틈이 생긴다. 이 빈틈들을 합치면 충분한데 흩어져 있어 큰 요청을 못 받는 외부 단편화(external fragmentation)다. 크기가 들쭉날쭉한 게 문제라면, 크기를 똑같이 고정해 버리면 어떨까. 이 발상이 페이징이다.


🟣 페이징: 같은 크기로 잘라 흩어 놓는다

페이징은 가상 주소 공간을 같은 크기의 조각으로 자른다. 이 조각이 페이지(page)이고, 보통 4KB다. 물리 메모리도 같은 크기로 자르는데 이 조각은 프레임(frame)이라 부른다. 이제 어떤 가상 페이지든 어떤 물리 프레임에든 들어갈 수 있다. 크기가 다 같으니 넣고 빼도 빈틈의 크기가 일정해서 외부 단편화가 사라진다.

대신 어떤 페이지가 어떤 프레임에 들어갔는지 기록할 표가 필요하다. 이것이 페이지 테이블(page table)이고, 프로세스마다 하나씩 있다. 가상 주소는 두 부분으로 나뉜다. 어느 페이지인지 가리키는 가상 페이지 번호(VPN)와, 그 페이지 안에서 몇 번째 바이트인지 가리키는 오프셋(offset)이다. VPN으로 페이지 테이블을 찾아 물리 프레임 번호(PFN)를 얻고, 거기에 오프셋을 붙이면 물리 주소가 나온다.

가상 주소[ VPN | 오프셋 ]페이지 테이블VPN → PFN물리 주소[ PFN | 오프셋 ]VPN으로 조회PFN + 오프셋

페이징은 단편화를 없앴지만 두 가지 새 비용을 치른다. 하나는 속도다. 이제 데이터를 한 번 읽으려면 페이지 테이블을 먼저 읽어야 하니 메모리 접근이 두 번으로 늘었다. 다른 하나는 공간이다. 페이지 테이블 자체가 크다. 32비트 주소에 4KB 페이지면 프로세스마다 백만 개 항목이 필요하고, 그게 프로세스 수만큼 쌓인다. 이 두 문제를 각각 다른 도구로 푼다.


🟣 TLB: 변환을 캐시한다

속도 문제부터 본다. 매 접근마다 페이지 테이블을 다시 읽는 게 낭비인 이유는, 프로그램이 방금 쓴 페이지를 곧바로 또 쓰기 때문이다. 배열을 순회하면 같은 페이지 안의 주소를 연달아 접근한다. 그렇다면 최근에 한 변환 결과를 어딘가 가까이 저장해 두고 재사용하면 된다. 이 캐시가 TLB(Translation Lookaside Buffer)이고, CPU 안(MMU)에 있는 아주 빠른 하드웨어 캐시다.

메모리에 접근할 때 하드웨어는 먼저 TLB를 뒤진다. 원하는 변환이 있으면(TLB 히트) 페이지 테이블을 거치지 않고 바로 물리 주소를 얻는다. 없으면(TLB 미스) 그제야 페이지 테이블을 읽어 변환하고, 그 결과를 TLB에 채워 넣는다. TLB가 효과적인 이유는 프로그램의 접근이 지역성(locality)을 띠기 때문이다. 같은 페이지를 반복해서 쓰고(시간 지역성), 인접한 주소를 이어서 쓴다(공간 지역성). 한 페이지에 4KB가 들어가니 그 안을 순회하는 동안은 계속 TLB 히트가 난다.

여기서 앞 장의 문맥 교환 비용이 되살아난다. 프로세스가 바뀌면 이전 프로세스의 TLB 항목이 새 프로세스에는 틀린 변환이 된다. 그래서 문맥 교환 때 TLB를 비우거나(flush) 항목에 프로세스 식별자를 붙여 구분해야 한다. 앞서 문맥 교환의 숨은 비용으로 TLB 무효화를 꼽았던 게 바로 이 지점이다.


🟣 다단계 페이지 테이블: 안 쓰는 공간은 표도 만들지 않는다

이번엔 공간 문제다. 페이지 테이블이 큰 진짜 이유는, 주소 공간 대부분이 비어 있는데도 그 빈 페이지들의 항목까지 표에 다 만들어 두기 때문이다. 힙과 스택 사이의 광대한 빈 공간에 대응하는 페이지 테이블 항목이 전부 자리를 차지한다.

해법은 페이지 테이블을 여러 단계로 쪼개서, 실제로 쓰는 영역에 해당하는 부분만 만드는 것이다. 다단계 페이지 테이블에서는 상위 단계 표가 하위 단계 표를 가리키고, 쓰이지 않는 영역의 하위 표는 아예 할당하지 않는다. 상위 표에 "이 영역은 비었음"이라고만 표시해 두면 그 아래 백만 개 항목을 만들 필요가 없다. 표를 찾는 단계가 늘어 미스 때 접근 횟수는 많아지지만, 대신 표가 차지하는 공간을 크게 줄인다. 공간을 아끼려 시간을 조금 내주는 거래다. 이 미스 비용은 앞서 본 TLB가 대부분 흡수한다.


🟣 물리 메모리보다 큰 주소 공간: 스와핑과 페이지 폴트

지금까지는 모든 페이지가 물리 메모리에 있다고 가정했다. 하지만 실행 중인 프로그램 전부의 페이지를 물리 메모리에 다 올리기엔 메모리가 부족하다. 그래서 운영체제는 당장 안 쓰는 페이지를 디스크의 스왑 공간으로 내려 두고, 필요할 때 다시 올린다. 물리 메모리를 실제보다 커 보이게 만드는 이 기법이 스와핑이다.

페이지 테이블 항목에는 이 페이지가 지금 물리 메모리에 있는지 표시하는 존재 비트(present bit)가 있다. 접근한 페이지의 존재 비트가 꺼져 있으면, 그 페이지는 디스크에 있다는 뜻이다. 이때 하드웨어가 페이지 폴트(page fault)를 일으켜 커널로 넘긴다. 커널은 디스크에서 그 페이지를 물리 프레임으로 읽어 올리고, 페이지 테이블을 고친 뒤, 멈췄던 명령을 다시 실행한다. 프로그램은 자기가 접근한 데이터가 디스크에 있었다는 사실조차 모른다.

물리 메모리가 꽉 찼다면 새 페이지를 올리기 전에 기존 페이지 하나를 디스크로 내보내야 한다. 누구를 내보낼지 정하는 것이 페이지 교체 정책이고, 앞으로 가장 오래 안 쓸 페이지를 내보내는 게 이상적이지만 미래를 모르니 과거를 근사한다. 오래 안 쓰인 페이지를 내보내는 LRU 근사가 대표적이다. 여기서 스케줄링 장에서 본 논리가 똑같이 되풀이된다. 미래를 알 수 없으니 과거 행동으로 짐작한다.

페이지 접근존재 비트 = 0?페이지 폴트디스크에서 로드물리 프레임 사용예 (디스크에 있음)아니오 (메모리에 있음)로드 후 재실행

🟣 모바일에는 스왑이 없다: Jetsam

이 스와핑 이론이 iOS를 이해하는 열쇠가 된다. 데스크톱 운영체제는 메모리가 부족하면 페이지를 디스크로 스왑해 버티지만, iOS는 앱 메모리를 디스크로 스왑하지 않는다. 플래시 저장소의 수명과 성능 때문이다. 스왑이라는 안전판이 없으니, 메모리가 부족해지면 운영체제가 택할 수 있는 길은 하나뿐이다. 메모리를 많이 쓰는 앱을 그냥 강제 종료하는 것이다. 이 담당 메커니즘이 Jetsam이다.

그래서 iOS 앱은 백그라운드로 내려가면 언제든 종료될 수 있고, 개발자는 didReceiveMemoryWarning 같은 경고를 받으면 캐시를 비워 메모리를 미리 반납해야 한다. 스왑이 없다는 이 한 가지 차이가, 왜 모바일에서 메모리 관리가 데스크톱보다 훨씬 공격적이어야 하는지를 설명한다. 페이징과 스와핑 이론을 알면 이 제약이 그냥 "메모리를 아껴 써라"가 아니라, 스왑 부재라는 구조적 이유에서 나온 결과임이 보인다. mmap으로 파일을 메모리에 올리는 발상은 리눅스 시스템 프로그래밍 쪽에서 손으로 다뤘는데, 그 매핑된 페이지가 어떻게 물리 프레임과 이어지는지가 바로 이 장의 내용이다.


🟣 정리

  • 가상 메모리는 각 프로세스에게 넓고 연속된 자기만의 주소 공간이라는 착시를 준다. 모든 주소는 가상 주소이고 매 접근마다 물리 주소로 변환된다.
  • 베이스·바운드는 빠르지만 빈 공간을 낭비하고, 세그먼테이션은 낭비를 줄이지만 외부 단편화를 부른다. 페이징은 같은 크기 조각으로 잘라 단편화를 없앤다.
  • 페이징은 속도(접근 두 번)와 공간(큰 페이지 테이블)이라는 비용을 낳는다. TLB가 속도를, 다단계 페이지 테이블이 공간을 해결한다.
  • TLB는 최근 변환을 캐시하고 지역성 덕에 효과를 낸다. 문맥 교환 때 TLB를 비워야 하는 것이 앞 장에서 본 숨은 비용이다.
  • 스와핑은 안 쓰는 페이지를 디스크로 내려 물리 메모리를 커 보이게 한다. 존재 비트가 꺼진 페이지 접근은 페이지 폴트를 일으킨다.

면접에서 "iOS는 왜 메모리 경고를 주고 앱을 죽이나"를 물으면, 스왑이 없어 Jetsam으로 회수한다는 구조적 이유까지 답할 수 있다. 여기까지가 CPU와 메모리를 나눠 쓰는 가상화였다. 그런데 자원을 나눠 쓰는 순간 반드시 경쟁이 따라온다. 여러 실행 흐름이 같은 데이터를 동시에 건드리면 무슨 일이 생기는지, 마지막으로 동시성과 락을 다룬다.