가상 메모리 — 모두에게 "네 것"이라고 속이다
02편에서 각 프로세스는 0번지부터 시작하는 자기만의 깨끗한 메모리가 있는 줄 안다고 했다. 그런데 물리 RAM은 하나이고, 수십 개 프로세스가 모두 0번지를 쓴다면 충돌해야 정상이다. 충돌이 안 나는 비결이 가상 메모리다 — OS가 각 프로세스의 가상 주소를 서로 다른 물리 주소로 몰래 번역해, 모두에게 "이 메모리는 통째로 네 것"이라고 속인다. 이 속임수가 프로세스 격리의 실체이자, 02·03편에서 컨텍스트 스위치가 비쌌던 진짜 이유다.
페이지와 프레임 — 메모리를 조각으로
OS는 메모리를 통째로가 아니라 고정 크기 조각으로 다룬다 — 가상 메모리의 조각을 페이지(page), 물리 메모리의 조각을 **프레임(frame)**이라 부른다(보통 4KB). 그리고 어느 페이지가 어느 프레임에 들어 있나를 **페이지 테이블**이 기록한다. 즉 프로세스마다 자기 페이지 테이블이 있어 — 같은 "5번 페이지"라도 프로세스 A는 100번 프레임, B는 300번 프레임으로 번역된다. 이게 02편 격리의 비밀이다.
매번 표를 뒤지면 느리다 — TLB
문제는 — 모든 메모리 접근마다 페이지 테이블을 뒤져야 한다는 것이다. 그런데 페이지 테이블도 메모리에 있어서, 번역 한 번에 메모리 접근이 또 필요하다(게다가 x86-64는 4단계 페이지 테이블이라 한 번 번역에 메모리를 네 번 본다). 그래서 하드웨어가 **TLB(Translation Lookaside Buffer)**라는 작은 초고속 캐시에 최근 번역 결과를 저장한다. CPU는 번역할 때마다 TLB를 먼저 보고 — 있으면(TLB 히트) 즉시, 없으면(TLB 미스) 페이지 테이블을 walk해 채운다. (출처: TLB.)
여기서 02·03편의 수수께끼가 풀린다 — 프로세스를 전환하면 페이지 테이블이 통째로 바뀌므로 TLB를 비워야(flush) 하고, 그러면 한동안 TLB 미스로 느려진다. 반면 같은 프로세스의 스레드끼리는 페이지 테이블이 같아 TLB를 안 비워도 되니 전환이 싸다 — 03편에서 본 그 차이의 하드웨어적 뿌리가 바로 이 TLB다.
필요할 때만 가져온다 — 페이지 폴트와 demand paging
가상 메모리의 또 다른 마법은 — 프로세스의 모든 페이지가 물리 메모리에 다 올라와 있을 필요가 없다는 것이다. 페이지 테이블 항목에 "이 페이지는 아직 메모리에 없음" 표시를 둬, 프로그램이 그 페이지에 처음 접근하면 — CPU가 **페이지 폴트(page fault)**를 일으키고, OS가 그때서야 디스크에서 그 페이지를 메모리로 가져온다. 이 "실제로 쓸 때까지 미루는" 게 **demand paging(요구 페이징)**이다(필요할 때까지 미루는 지연 평가의 발상이다). 덕분에 물리 메모리보다 큰 프로그램도 돌릴 수 있고(자주 쓰는 페이지만 올려), 기동도 빨라진다.
실제로 눈으로 보기
지금까지가 개념이라면, 이제 돌아가는 리눅스에서 그것을 직접 확인해 본다. 개념이 추상적으로 남지 않으려면 어디를 보면 나오는지를 알아야 한다.
페이지 크기와 주소 공간
getconf PAGE_SIZE # 4096 — 이 시스템의 페이지 크기
cat /proc/self/maps | head -5 # 지금 이 프로세스의 가상 주소 지도
55a4c2e00000-55a4c2e02000 r--p 00000000 08:01 1049 /usr/bin/cat
55a4c2e02000-55a4c2e06000 r-xp 00002000 08:01 1049 /usr/bin/cat
│ │
│ └ 권한 — r 읽기 · x 실행 · p private(COW)
└ 가상 주소 구간. 프로세스마다 자기 번호를 갖는다
/proc/<pid>/maps가 페이지 테이블이 무엇을 담고 있는지를 사람이 읽을 수 있게 보여 준다. 다른 프로세스에서 같은 명령을 실행해 보면 — 같은 주소가 다른 프로세스에 각각 있다는 것이 바로 확인된다. 이것이 격리의 실체다.
가상 크기와 실제 사용량은 다르다
ps -o pid,vsz,rss,comm -p 1234
# PID VSZ RSS COMMAND
# 1234 421024 8532 myapp
# │ └ RSS — *실제 물리 메모리*에 올라와 있는 크기 (KB)
# └ VSZ — *가상 주소 공간* 크기. 아직 안 올라온 페이지도 포함
VSZ가 4GB인데 RSS가 8MB인 상황이 정상이다 — demand paging 때문이다. 주소 공간은 크게 잡아 뒀지만 실제로 건드린 페이지만 물리 메모리에 있다. 그래서 "메모리를 얼마나 쓰나"를 볼 때는 VSZ가 아니라 RSS를 본다.
페이지 폴트는 두 종류다
ps -o pid,min_flt,maj_flt,comm -p 1234
# PID MINFL MAJFL COMMAND
# 1234 45231 12 myapp
- minor fault — 페이지가 이미 메모리 어딘가에 있는데 이 프로세스의 페이지 테이블에만 없다. 표만 고치면 끝나 마이크로초 단위다
- major fault — 페이지가 디스크에 있어 읽어와야 한다. 밀리초 단위 — 자릿수가 세 개 차이 난다
진단할 때 보는 것은 major fault다. minor는 수십만 건이어도 정상이지만, major가 꾸준히 오르면 메모리가 부족해 디스크를 오가고 있다는 뜻이다.
TLB 미스를 직접 재 본다
perf stat -e dTLB-loads,dTLB-load-misses,page-faults ./myapp
# 1,204,331,289 dTLB-loads
# 18,442,109 dTLB-load-misses # 1.53% of all dTLB cache accesses
# 4,102 page-faults
TLB 미스율이 몇 퍼센트만 돼도 성능에 보인다. 한 번 미스마다 페이지 테이블을 네 단계 걸어야 하기 때문이다.
이 값을 줄이는 정석이 거대 페이지(huge page) 다 — 4KB 대신 2MB 페이지를 쓰면, 같은 메모리를 덮는 데 필요한 TLB 항목이 500분의 1로 준다.
grep Huge /proc/meminfo
# AnonHugePages: 262144 kB ← 투명 거대 페이지로 잡힌 양
# HugePages_Total: 0
메모리를 많이 쓰면서 접근이 흩어지는 프로그램(대용량 인메모리 처리, DB)에서 특히 효과가 크다.
왜 이게 캐시인가 — TLB는 주소 번역의 캐시이고, 페이지 폴트는 메모리의 캐시 미스다 — 즉 가상 메모리도 *캐시 계층(빠른 곳↔느린 곳)*의 한 층이다. "가까운 빠른 곳에 자주 쓰는 것을, 멀고 느린 곳에 나머지를"라는 캐시 원리가 — TLB↔페이지 테이블, *RAM↔디스크(swap)*로 그대로 반복된다. 그리고 프로그램이 다루는 메모리 주소는 모두 가상 주소라, 실제 물리 위치는 OS가 페이지 테이블로 관리한다 — 그래서 OS가 페이지를 물리적으로 옮겨도 프로그램에는 가상 주소가 일관되게 보인다.
정리하면, 가상 메모리는 각 프로세스에게 "메모리 전체가 네 것"이라고 속이는 추상이다 — 메모리를 페이지/프레임 조각으로 나누고 페이지 테이블로 가상→물리 번역해, 서로 다른 프로세스를 같은 가상 주소로도 격리한다(02편 격리의 실체). 번역이 느리니 TLB로 캐시하고(프로세스 전환 시 flush가 비용의 뿌리), demand paging으로 필요한 페이지만 그때그때 디스크에서 가져온다. 그런데 물리 메모리는 유한해서 — 가득 차면 무엇을 내보내고, 너무 부족하면 무슨 일이 나는지가 다음 편, 메모리 관리다.