OSTEP 23장 Complete Virtual Memory Systems는 앞 장들에서 배운 메커니즘이 실제 운영체제 안에서 어떻게 합쳐지는지 보여 줍니다. 첫 사례는 DEC 가 1970년대 후반에 내놓은 VAX-11 미니컴퓨터의 운영체제 VAX/VMS 입니다. 처음에는 50년 가까이 된 운영체제의 구현이 JVM 애플리케이션 운영과 무슨 관계가 있을지 잘 떠오르지 않았습니다.
그런데 VMS 절 끝의 “Other Neat Tricks”에 나오는 demand zeroing 과 copy-on-write(COW) 는 지금 Linux 에서도 그대로 쓰입니다. 힙 512MB 를 확보한 JVM 의 RSS 가 훨씬 작게 시작하는 이유와, 1GB 를 사용하는 프로세스가 fork() 를 호출해도 물리 메모리가 1GB 늘지 않는 이유가 이 두 기법에 있습니다. 23장을 읽으며 다음 질문이 생겼습니다.
운영체제는 왜 메모리 작업을 요청받은 순간에 하지 않고 미룰까요? 미룬 작업의 비용은 언제, 어디서 치르게 될까요?
OSTEP 과 Linux 커널 문서, man page, Oracle Java 문서를 읽고 정리했습니다. 페이지 크기는 x86-64 Linux 의 기본값인 4KB 를 기준으로 합니다.
1. 작업을 미루는 것이 왜 최적화일까요?
OSTEP 은 demand zeroing 과 COW 를 “lazy optimization”이라고 부릅니다. 두 기법은 작업을 빨리 끝내는 대신 작업을 할 시점을 늦춥니다. 23장의 TIP: BE LAZY 에 따르면 작업을 미루면 지금 처리 중인 요청의 지연이 줄고 미룬 작업을 끝내 하지 않아도 될 때도 있습니다. OSTEP 은 뒤쪽을 더 큰 이점으로 꼽습니다.
프로그램이 100MB 를 요청하고 실제로는 20MB 만 사용한다고 가정하면 차이는 이렇습니다.
| 방식 | 요청한 순간 | 처음 접근하는 순간 | 끝까지 접근하지 않은 80MB |
|---|---|---|---|
| eager | 물리 페이지 100MB 를 할당하고 0으로 채운 뒤 매핑합니다 | 추가 작업이 없습니다 | 할당하고 채운 작업이 낭비됩니다 |
| lazy | 가상 주소 영역만 만듭니다 | 그 페이지만 할당하고 0으로 채운 뒤 매핑합니다 | 아무 작업도 하지 않습니다 |
대신 lazy 방식에서는 처음 접근하는 페이지마다 운영체제가 개입하는 비용이 생깁니다.
2. 새 페이지는 왜 0으로 채워야 할까요?
프로세스가 힙을 늘리면 운영체제는 가상 주소 공간에 새 영역을 추가합니다. 주소 공간에 영역을 추가하는 일은 가상 메모리 크기(VSZ)에 드러나고, 프레임을 할당해 매핑하는 일은 RSS 에 드러납니다. 두 일이 같은 순간에 일어날 필요는 없습니다.
새 프레임은 0으로 채워서 줘야 합니다. 물리 프레임은 여러 프로세스가 돌려 가며 쓰므로, 프로세스 A 가 세션 토큰을 담았던 프레임을 그대로 프로세스 B 에 주면 B 가 그 내용을 읽을 수 있습니다. OSTEP 도 0으로 채우는 이유를 보안에서 찾습니다. 페이지를 추가할 때마다 빈 프레임을 찾아 0으로 채우고 매핑하면, 프로세스가 한 번도 사용하지 않은 페이지에서는 이 작업이 모두 낭비됩니다.
이 글에서 다루는 메모리는 파일과 연결되지 않은 anonymous memory 입니다. 힙, 스택, MAP_ANONYMOUS 로 만든 private mmap 영역이 여기에 속합니다.
3. demand zeroing 은 어떻게 동작할까요?
VMS 는 페이지를 추가할 때 페이지 테이블에 엔트리를 넣고 접근할 수 없는 상태로 표시하기만 합니다. 이 엔트리가 demand-zero 페이지라는 사실은 보통 PTE 에서 운영체제용으로 남겨 둔 비트에 기록합니다. 프로세스가 이 페이지에 접근하면 하드웨어가 운영체제로 트랩하고, 운영체제는 그 비트를 확인한 뒤 빈 프레임을 0으로 채워 매핑합니다. 트랩에서 돌아온 프로세스는 같은 명령을 다시 실행해 이번에는 성공합니다. 끝내 접근하지 않은 페이지는 프레임 할당과 0 채우기를 모두 건너뜁니다.
Linux 에서는 어떻게 다를까요?
OSTEP 23장의 Linux 절에도 Linux 가 페이지를 demand zero 한다는 내용이 짧게 나옵니다. 다만 Linux 는 PTE 에 표시해 두지 않습니다. 커널은 프로세스의 메모리 영역 목록(VMA)에 범위만 기록하고 PTE 는 비워 둡니다. page fault 가 나면 PTE 가 비어 있고 그 주소를 덮는 VMA 가 있는 것을 보고 demand zero 로 처리합니다. 커널의 anonymous page fault 처리(mm/memory.c)는 읽기와 쓰기를 다르게 다룹니다.
| 첫 접근 | Linux 의 처리 |
|---|---|
| 읽기 | 0으로 채워진 공유 zero page 를 읽기 전용으로 매핑합니다. 새 프레임은 할당하지 않습니다 |
| 쓰기 | 새 프레임을 할당하고 0으로 채워 쓰기 가능하게 매핑합니다 |
| zero page 를 매핑한 뒤 쓰기 | 쓰기 보호 때문에 다시 page fault 가 나고, 그때 새 프레임을 할당합니다 |
zero page 는 시스템 전체가 하나를 같이 쓰고 RSS 에 들어가지 않습니다. 그래서 mmap 으로 받은 큰 영역을 읽기만 하면 RSS 가 거의 늘지 않습니다. Java 의 new byte[] 나 Python 의 bytearray(n) 처럼 런타임이 할당하면서 직접 0을 쓰는 경우에는 할당하는 순간 모든 페이지에 쓰기가 일어납니다.
어느 접근에서 프레임이 생길까요?
프로세스가 힙에 페이지 네 개를 추가했습니다. 주소 공간은 16 kB 늘었지만 RSS 는 0 kB 입니다. p1 과 p2 를 읽거나 써 보고, 어느 접근에서 프레임이 매핑되고 RSS 가 늘어나는지 확인하세요.
4 kB 페이지 네 개로 줄인 모형이고, 주소 공간과 RSS 는 이 네 페이지만 셌습니다. 읽기는 zero page 를, 쓰기는 새 프레임을 매핑하는 Linux 의 private anonymous memory 처리를 따랐습니다. OSTEP 이 설명하는 VMS 는 읽기에서도 프레임을 0으로 채워 매핑합니다. 빈 프레임 네 개에는 다른 프로세스가 쓰던 내용이 남아 있다고 가정했습니다. 커널이 빈 프레임을 고르는 방법, TLB, transparent huge page(2MB 크기의 큰 페이지)는 그리지 않았습니다. zero page 는 실제 Linux 처럼 RSS 에 넣지 않았습니다. write, read 표시가 움직이는 시간은 실제 page fault 처리 시간과 관계없습니다.
4. -Xmx2g 로 실행하면 RAM 2GB 를 바로 쓸까요?
Oracle java 명령 문서에 따르면 -Xms 는 힙의 최소 크기이자 초기 크기이고 -Xmx 는 최대 크기입니다. 처음에는 -Xms512m -Xmx2g 면 JVM 이 시작하자마자 RAM 을 512MB 쓴다고 생각했습니다. 실제로는 시작 직후 RSS 가 512MB 에도 미치지 않을 수 있습니다. HotSpot 이 힙 메모리를 세 단계로 다루기 때문입니다.
| 단계 | HotSpot 이 하는 일 | -Xms512m -Xmx2g 일 때 | 드러나는 지표 |
|---|---|---|---|
| reserved | -Xmx 만큼의 범위를 접근할 수 없는 상태로 주소 공간에 넣어 둡니다 | 2GB | VSZ |
| committed | 그중 실제로 쓸 범위를 읽고 쓸 수 있게 바꿉니다 | 시작할 때 512MB, GC 가 늘리거나 줄입니다 | NMT(Native Memory Tracking)의 committed, G1 에서는 Runtime.totalMemory() |
| RSS | committed 범위 중 실제로 접근해 프레임이 매핑된 부분 | 접근한 페이지만큼 | RSS |
commit 하지 않은 1.5GB 는 접근 자체가 막혀 있어서 demand zeroing 과는 관계가 없습니다. demand zeroing 은 committed 와 RSS 의 차이를 설명합니다. JVM 이 512MB 를 읽고 쓸 수 있게 바꿔도 Linux 는 프레임을 바로 주지 않고 JVM 이 각 페이지에 처음 쓸 때 준비합니다. 힙에서는 RSS 가 committed 범위 안에 있고 둘의 차이는 힙을 얼마나 채웠는지에 따라 달라집니다. 프로세스 전체 RSS 에는 Metaspace, thread stack, code cache 같은 힙 밖 메모리도 더해집니다.
이 차이를 일부러 없애는 옵션도 있습니다. Oracle 문서에 따르면 -XX:+AlwaysPreTouch 는 JVM 이 운영체제에서 힙을 받은 뒤, 애플리케이션에 메모리를 내주기 전에 힙의 모든 페이지에 접근하게 합니다. 대상은 그 시점에 commit 한 힙이고 G1 은 나중에 힙을 늘릴 때 새로 commit 한 부분에도 미리 접근합니다. 켜면 JVM 시작은 늦어지지만 힙을 처음 쓸 때 생기는 page fault 와 0 채우기를 시작 단계로 옮길 수 있습니다.
5. fork() 는 부모 메모리를 전부 복사할까요?
fork() 로 만든 자식은 부모와 똑같은 주소 공간을 받습니다. 그렇다고 부모가 사용하던 1GB 를 그대로 복사하면 시간도 오래 걸리고 RAM 도 두 배로 필요합니다. 양쪽이 읽기만 한다면 그 사본은 원본과 똑같은 내용으로 남습니다.
OSTEP 은 이 문제를 copy-on-write 로 풉니다. 이 아이디어는 적어도 TENEX 운영체제까지 거슬러 올라갑니다. 운영체제는 페이지를 복사하는 대신 같은 물리 페이지를 두 주소 공간에 매핑하고 양쪽 모두 읽기 전용으로 표시합니다.
fork() 직후
Parent: Virtual Page A ---+
+---> Physical Frame 10 [ Hello ] (read-only)
Child: Virtual Page A ---+
자식이 data[0] = 'Y' 를 실행한 뒤
Parent: Virtual Page A ---> Physical Frame 10 [ Hello ]
Child: Virtual Page A ---> Physical Frame 20 [ Yello ]
자식이 이 페이지에 쓰면 page fault 가 납니다. 커널은 PTE 는 쓰기를 막았지만 VMA 는 쓰기를 허용하는 것을 보고 COW 페이지로 처리합니다. 새 프레임에 내용을 복사해 자식의 페이지만 그쪽으로 매핑하면, 자식은 쓰기를 다시 실행해 자기 프레임에 씁니다. 1GB 중 자식이 4KB 페이지 열 개에 썼다면 복사되는 양은 40KB 입니다. 프레임 10 은 이제 부모만 매핑하므로, 다른 참조가 없으면 Linux 는 다음에 부모가 쓸 때 복사 없이 쓰기 권한만 되돌려 줍니다.
UNIX 계열에서 COW 가 특히 중요한 이유로 OSTEP 은 fork() 직후 exec() 를 호출하는 패턴을 꼽습니다. exec() 는 받은 주소 공간을 버리고 새 프로그램의 주소 공간을 만드므로, 복사했다면 그 데이터는 쓰이지 않은 채 버려집니다. COW 덕분에 복사되는 것은 exec() 전까지 부모나 자식이 쓴 몇 페이지 정도입니다. 그래도 비용이 0은 아닙니다. fork(2) man page에 따르면 Linux 의 fork() 는 부모의 페이지 테이블을 복제하고 자식의 task 구조를 만드는 비용을 치릅니다.
6. page fault 는 항상 디스크를 읽을까요?
swap 을 다룬 글에서는 page fault 가 나면 handler 가 swap space 에서 페이지를 읽어 온다고 정리했습니다. demand zeroing 과 COW 도 page fault 로 동작하지만 디스크를 읽지 않습니다.
| 상황 | page fault 가 나는 이유 | 운영체제의 처리 | 디스크 I/O |
|---|---|---|---|
| swap 된 페이지 접근 | 페이지가 RAM 에 없습니다 | swap space 에서 읽어 옵니다 | 있음 |
| demand-zero 페이지 첫 접근 | 아직 프레임이 매핑되지 않았습니다 | 0으로 채운 프레임이나 zero page 를 매핑합니다 | 없음 |
| COW 페이지에 쓰기 | 공유 중인 페이지라 쓰기가 막혀 있습니다 | 내용을 복사한 새 프레임을 매핑합니다 | 없음 |
| 주소 공간 밖 접근 | 프로세스가 쓸 수 없는 주소입니다 | SIGSEGV 를 보냅니다(기본 동작은 종료) | 없음 |
Linux 는 디스크 I/O 없이 처리한 page fault 를 minor fault(min_flt), I/O 가 필요했던 page fault 를 major fault(maj_flt)로 따로 셉니다. JVM 이 힙을 처음 쓰는 구간에서 page fault 가 많이 늘어도 대부분 min_flt 쪽이라면 swap 이나 파일 I/O 와는 상관없습니다.
7. fork() 이후 메모리 지표는 어떻게 바뀔까요?
부모가 100MB 배열에 값을 써 둔 뒤 fork() 를 호출하고 자식이 그 배열의 모든 페이지에 쓴다고 가정하겠습니다. 이 변화는 프로세스의 메모리 통계 파일 /proc/<PID>/smaps_rollup 의 세 값으로 볼 수 있습니다.
- Rss: 이 프로세스에 매핑되어 RAM 에 올라와 있는 크기입니다. 공유 페이지도 그대로 들어가므로 두 프로세스의 Rss 를 더하면 공유 페이지를 두 번 셉니다.
- Pss: 공유 페이지를 공유하는 프로세스 수로 나눠 센 크기입니다. 커널 문서의 예로, 혼자 쓰는 페이지 1,000개와 다른 프로세스 하나와 공유하는 페이지 1,000개가 있으면 PSS 는 1,500 페이지입니다.
- Private_Dirty: 이 프로세스만 매핑하고 있고 수정된 페이지의 크기입니다. 여러 프로세스가 매핑한 수정된 페이지는 Shared_Dirty 로 셉니다.
자식이 썼는데 부모의 Private_Dirty 가 왜 늘까요?
부모가 4 kB 페이지 네 개에 1을 써 둔 상태입니다. fork() 를 호출한 뒤 자식과 부모가 차례로 써 보고, 프레임과 아래 표의 값이 어떻게 바뀌는지 확인하세요.
| Rss | Pss | Private_Dirty | |
|---|---|---|---|
| 부모 | |||
| 자식 | |||
| 합계 |
실제로 쓰는 RAM 프레임 복사한 페이지
7절의 100MB 배열을 4 kB 페이지 네 개로 줄인 모형이고, 표에는 이 네 페이지만 셌습니다. 스택, 공유 라이브러리처럼 실제 smaps_rollup 에 함께 잡히는 메모리는 넣지 않았습니다.
배열 페이지는 fork() 전에 부모가 썼으므로 모두 dirty 로 셌습니다. 혼자 남은 프레임에는 다른 참조가 없다고 가정해 복사 없이 쓰기 권한만 되돌렸습니다. 페이지 테이블 복제, TLB, transparent huge page 는 그리지 않았고, 새 프레임은 원본 바로 옆 빈 칸에 생기는 것으로 그렸습니다.
자식이 쓰기 전에는 배열 100MB 가 두 프로세스에서 모두 Shared_Dirty 로 세어지고 Pss 에는 각각 절반이 들어갑니다. 자식이 모든 페이지에 쓰면 자식에게는 복사한 100MB 가 Private_Dirty 로 생깁니다. 원래 프레임은 이제 부모만 매핑하므로, 아무것도 쓰지 않은 부모의 Private_Dirty 도 100MB 늘어납니다. 두 프로세스의 Rss 는 처음부터 100MB 를 포함하고 있었으므로 크게 변하지 않습니다. 메모리가 실제로 두 배가 된 변화는 Pss 합계와 Private_Dirty 에서 드러나고, Rss 합계에는 드러나지 않습니다.
실제 프로세스에서는 배열 밖의 메모리도 함께 세어지므로 정확히 100MB 만큼 변하지는 않습니다. transparent huge page(2MB 크기의 큰 페이지)를 쓰는 시스템에서는 커널 설정에 따라 혼자 남은 페이지를 shared 로 셀 수도 있습니다.
8. 처음 질문으로 돌아가면
운영체제는 왜 메모리 작업을 요청받은 순간에 하지 않고 미룰까요? 미룬 작업의 비용은 언제, 어디서 치르게 될까요?
| 구분 | demand zeroing | copy-on-write |
|---|---|---|
| 미루는 작업 | 프레임 할당과 0 채우기 | 공유 중인 페이지의 복사 |
| 미루는 동안의 상태 | VMS 는 PTE 에 접근할 수 없다고 표시하고, Linux 는 VMA 에 범위만 기록하고 PTE 를 비워 둡니다 | 두 주소 공간이 같은 프레임을 읽기 전용으로 매핑합니다 |
| 작업을 하는 시점 | 그 페이지에 처음 접근할 때 | 그 페이지에 처음 쓸 때 |
| 끝내 하지 않아도 되는 경우 | 할당만 하고 접근하지 않은 페이지 | 읽기만 한 페이지, exec() 로 버려진 주소 공간 |
| Linux 와 JVM 에서 만나는 곳 | committed 힙과 RSS 의 차이, AlwaysPreTouch | fork() 비용, RSS 합계와 PSS 합계의 차이 |
프로세스가 가상 주소로만 메모리를 쓰기 때문에 운영체제는 작업을 미룰 수 있습니다. 프로세스는 주소 공간에 페이지가 있다는 것만 알고 그 뒤에 프레임이 있는지, 공유 중인지는 모릅니다. 운영체제는 첫 접근이나 첫 쓰기를 page fault 로 받아서 그때 필요한 작업만 합니다.
미룬 작업의 비용은 첫 접근의 지연으로 돌아옵니다. 힙을 결국 다 채우는 서비스라면 lazy 방식은 비용을 없애지 못하고 요청 처리 중으로 옮길 뿐이고, 이럴 때는 AlwaysPreTouch 로 비용을 시작 단계에 몰아서 낼 수 있습니다. 어느 쪽이 나은지는 서비스가 시작 시간과 첫 요청의 지연 중 무엇을 더 중요하게 여기는지에 따라 다릅니다.
학습 자료: Remzi H. Arpaci-Dusseau, Andrea C. Arpaci-Dusseau, Operating Systems: Three Easy Pieces, Chapter 23 Complete Virtual Memory Systems. Linux Kernel Documentation, The /proc Filesystem. Linux kernel source, mm/memory.c. Linux man-pages, fork(2). Oracle, The java Command (JDK 21).