가상 메모리
가상 메모리란 사용 가능한 메모리가 실제로 존재하는 메모리보다 큰 것처럼 보이게 하는 기술이다. 프로세스 자체가 차지하는 메모리는 그렇게 크지 않지만, 그 프로세스가 만들어 낼 메모리의 양은 생각보다 거대할 수 있다. RAM이 가진 메모리의 양이 4GB인데, 프로세스에서 8GB만큼의 데이터를 만들어버린다면 할당량을 넘어서기 때문에 제약이 발생하고 만다
제약을 피해서 최대한으로 확보하면? 다른 프로세스들이 메모리를 확보할 수 없게 된다.
그럼 프로세스마다 메모리 할당량을 제한하면? 대규모 데이터나 복잡한 프로그램은 만들 수 없게 된다. 게다가 메모리 할당량을 제한한다는 것은 곧 RAM에 해당 메모리만큼의 용량을 항상 확보해둬야 한다는 것인데, 이는 메모리를 낭비하는 꼴이 된다.
생각해보자, 프로세스에서 8GB 수준의 데이터를 연산 한 번으로 처리할 확률이 얼마나 될까? 대부분의 데이터는 필요할 때만 잠깐씩 쓰이지, 모든 데이터를 한 번에 쓰는 경우는 거의 없다. 그렇다. 한 번에 쓸 경우가 없으니, 데이터를 좀 늦게 불러와도 괜찮을 것이다. 그렇다. 굳이 큰 데이터는 RAM에 저장할 필요가 없는 것이다. 용량이 수십배 많은 SSD나 HDD에 저장한다면, 어떤 거대한 프로세스도 구동할 수 있게 된다! 이것을 가상 메모리 시스템이라 말하며, RAM이 아닌 공간을 RAM인 것처럼 속임으로써 메모리가 커지는 것이다.
작동 방식
프로세스가 메모리의 특정 공간에 접근하려면 '주소'가 필요하다 주소에 있는 거 주소~ 엌ㅋㅋ. 주소는 당연히 메모리의 직접적인 좌표를 가리켜야 하나(이를 물리 주소(Physical Address)라고 한다), 정말로 그렇게 된다면 나쁜 경우 프로세스가 다른 프로세스, 혹은 커널의 데이터를 조작해버릴 수 있게 된다. 그렇기에 기본적으로는 가상 주소(Virtual Address)를 통해 데이터를 읽고 쓰게 된다.
중고 거래를 위해 거래인(Process)이 직접 우리집(Physical Address)에 올 수도 있겠지만, 요즘같이 흉흉한 세상에선 함부로 다른 사람을 집에 들일 수 없는 노릇이다. 그렇기에 우리는 전철역 등 중간에서 만날만한 곳(Virtual Address)을 제시해 거래인을 만나고, 거래한다. 거래인은 우리집까지 오지 않아도 물건을 살 수 있고, 나도 모르는 사람을 집에 들이지 않으므로 서로서로 좋은 일이다.
이런 식으로 물리 주소와 가상 주소는 긴밀하게 연결되어있는데, 이를 변환해주는 장치를 MMU(Memory Management Unit, 메모리 관리 장치)라고 한다. 하드웨어로 존재하는 이유는, 너무나도 중요하고 너무나도 자주 쓰이기 때문이다. 위 예시에서는 '거래 할 물건'이 데이터의 역할을, 집과 전철역을 오가는 '나'가 MMU의 역할을 한다고 볼 수 있다.
페이징
페이징이란 가상 메모리 알고리즘 중 하나로, 동일한 크기의 페이지를 여러 개 만들어 가상 주소 공간과 이에 맞는 물리 주소 공간을 관리하는 기법이다. 하드웨어 단위의 지원이 필요하며, OS에서는 지원되는 크기 중에 원하는 크기로 제한하여 페이징을 실시한다. 예를 들어 인텔 x86 CPU에서는 4kb, 2mb, 1gb의 페이징을 지원하나, 리눅스에서는 이 중 4kb를 사용하므로 모든 페이지가 4kb의 크기를 갖게 된다.
페이지가 수도 없이 많이 있기 때문에, 이 페이지들을 관리할 공간도 필요하다. 이것을 페이지 테이블이라고 하며, 페이지 테이블에는 가상 주소, 물리 주소, Valid-Invalid Bit(해당 장소에 데이터가 저장되어 있는지를 알려주는 비트. v, i로 구분한다)가 마치 표처럼 정리된다. 프로세스가 주소를 제시하면, MMU가 페이지 테이블에서 가상 주소와 대응하는 물리 주소를 찾아내고, 해당 주소에 있는 데이터를 프로세스에게 전달해주는 것이다.
페이지의 가상 주소는 (p, d)의 형태로 저장된다. 여기서 p는 어느 페이지를 가리키는지, d는 페이지 내에서 몇번째를 가리켜야하는지를 뜻한다. 페이지 크기가 4KB라면, 0~11번째까지 12개의 비트를 d, 12~번째까지 20개의 비트를 p라고 할 수 있다. 즉, 최대로 만들 수 있는 페이지는 2^20개가 된다.
이렇게 많은 페이지를 한 번에 관리하기란 쉽기 않기에, 리눅스 같은 OS에서는 페이지를 3~4개 정도의 계층으로 나누어 관리한다. 이를 다중 단계 페이징(Multi-Level Paging)이라고 한다.
x86, x64
32비트 OS, 64비트 OS를 구분하는것도 이러한 메모리와 연관이 있다. 앞에 붙은 32, 64비트는 데이터 버스, 즉 한 번에 처리 가능한 데이터의 크기를 반영하는 말이다. 데이터 버스가 커지면 '주소'라는 데이터가 가질 수 있는 크기도 커지게 되는데, 이게 왜 중요하냐면 32비트에서는 주소 길이가 짧아 서울 일부 지역만 가리킬 수 있었다면, 64비트부터는 주소 길이가 길어져 전세계 어디든지 가리킬 수 있게 되었기 때문이다. 즉, 32비트 OS에서는 모든 주소가 32비트의 크기를 가지므로 프로세스가 최대 4GB (2^32)의 메모리를 지원하지만, 64비트 OS는 훨씬 증가된 16EB의 메모리를 지원할 수 있다. 16EB가 얼마냐면... 171억 7986만 9184GB다. 지원이 된다고 했을 뿐, 실제로 이렇게 많이 쓰진 않는다!
TLB(Translation Lookaside Buffer)
프로세스를 구동하던 CPU가 가상 주소를 들이밀며 데이터를 달라고 하면, MMU에서는 메모리 내의 페이지 테이블을 뒤져가며 물리 주소를 찾게 된다. 그리고 해당 물리 주소를 가지고 다시 메모리에 접근해 데이터를 가지고 와 프로세스에게 전달하는데, MMU가 두 번 이동하는 동안 CPU는? 그냥 손가락만 빨고 기다리고 만다. 이렇게 기다리는 시간을 최소화하기 위해 자주 쓰이는 페이지 정보는 임시로 기록해두기로 하는데(이를 캐싱(Caching)이라고 한다), 이 기록소의 역할을 TLB가 해준다. 그렇기에 MMU는 메모리에 접근하기 전, TLB에서 기록해놓은 것이 있는지 먼저 확인하고, 만약 있다면 메모리에서 바로 데이터를 가져와 CPU에게 전달한다. 이렇게 TLB를 사용하면 사이클이 단축되므로 이득을 볼 수 있다.
요구 페이징(Demand Paging)
프로세스의 모든 데이터를 메모리에 넣지 않고, 실행 중에 필요할 때에만 메모리에 넣는 것을 요구 페이징 기법이라고 한다. 반대되는 개념으로는 선행 페이징(Anticipatory Paging, Prepaging, 프로세스와 관련한 모든 데이터를 미리 메모리에 넣어둔다)이 있다. 요구 페이징 기법을 활용하면 메모리를 더더욱 압축할 수 있다.
그럼 그 필요할 때라는 건 어떻게 알 수 있을까. 이는 오류를 활용하여 알아낼 수 있다.
페이지 폴트(Page Fault)
CPU가 가상 주소를 통해 특정 페이지의 특정 좌표를 가리켰는데, MMU에서 그걸 못 찾는 경우가 있다. TLB를 확인해봤는데 없고, 메모리를 확인해봤는데 그래도 없는 것이다. 페이지가 있지만 특정 좌표의 데이터는 없는 경우가 아니라, 그냥 페이지 자체가 없는 상황이다.
이러면 MMU는 위기에 봉착하게 된다. 상관이 데이터를 가져오라 했는데 면전에 대고 '없는데요?' 라고 할 수도 없는 일이다. 이런 상황을 인터럽트(Interupt)가 발생했다고 말하며, 이 때는 다른 작업을 미루고 해당 인터럽트를 해결하는 것에 집중한다. (물론 이런 것들만 전문적으로 해결해주는 담당 부서가 있는데, 이를 ISR(Interupt Service Routine)이라고 한다.)
페이지 폴트라는 인터럽트가 발생하면, OS는 해당 인터럽트에 대한 코드를 실행시켜 SSD/HDD 등에 저장해뒀던 페이지를 꺼내다가 메모리로 갖다 넣는다. 메모리에 새로 등록된 페이지는 새로운 주소를 할당받게 되며, 이를 알게 된 MMU는 CPU에게 주소가 바꼈다고 전하게 되고, CPU는 바뀐 가상 주소를 다시 제시하여 데이터를 받을 수 있게 된다.
다만 이렇게 되면, 페이지 폴트가 일어날 때마다 데이터를 받는데 적지 않은 시간을 쓰게 된다. 그렇다면 쓸 것 같은 페이지를 미리미리 메모리에 올려서 페이지 폴트를 피해야하는데, 이건 불가능한 일이다. 프로세스 스케줄링 기법처럼, 페이지도 교체하는 기법들이 있다. 이를 페이지 교체 정책(Page Replacement Policy)라고 한다.
스레싱(Thrashing)
스레드와는 아무 관련없다. 위의 페이지 폴트가 너무 자주 일어나다보면, 되려 프로세스의 연산 시간보다 페이지 폴트를 처리하는 시간이 더 많아지는 주객전도가 발생하게 된다. 절정에 다다르면 연산을 거의 하지 못하게 될 정도가 되는데, 이것을 스레싱이라고 한다.
인터럽트
인터럽트는 페이지 폴트 외에도 여러 상황에서 발생할 수 있다. 모든 인터럽트가 각자의 번호와 실행할 코드의 주소를 가진채로 저장되는데, 이것들이 저장되는 곳을 IDT(Interrupt Descriptor Table)라고 한다.
IDT는 컴퓨터를 부팅할 때마다 OS가 만들게 되며, 실행할 코드도 OS가 가지고 있다.
참고로 시스템 콜도 인터럽트 명령이다. 0x80라는 번호를 가지고 있어서, IDT에 0x80이라는 인터럽트를 때려박아도 시스템 콜을 작동시킬 수 있다(가능하다면 말이다!).
페이지 교체 정책(Page Replacement Policy)
페이지 폴트로 인해 OS가 페이지를 메모리에 올리려는데, 메모리가 꽉 차서 페이지를 교체할 수 없다면? 이미 올라가 있는 페이지를 끌어내리고 새로운 페이지를 올려야 한다. 페이지 교체 정책은 끌어내릴 페이지를 결정하는 방법을 뜻한다.
FIFO(First In, First Out) : 어디서든 빠지지 않고 나오는 선입선출 방식이다. 단순하게 오래된 페이지부터 내려서 순환시키는 방법이다.
OPT(Optimal Replacement Algorithm) : 제일 안 쓸 것 같은 페이지를 내리는 방식이다. OS 단위에서는 불가능한, 그야말로 꿈의 알고리즘.
LRU(Least Recently Used) : 가장 오랫동안 접근하지 않은 페이지를 내리는 방식이다. 오랫동안 사용하지 않는다 -> 제일 안 쓸 것 같은 페이지일 것 같기에 이런 방식이 나왔다.
NUR(Not Used Recently) : LRU와 같이, 최근에 사용되지 않은 페이지를 내린다. 조금 다른 점은, NUR은 페이지마다 참조 비트와 수정 비트를 둬서 그것을 기반으로 사용 여부를 판가름한다는 것이다.