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 가 늘어나는지 확인하세요.

주소 공간 16 kB RSS 0 kB page fault 0 0으로 채운 프레임 0

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 만큼의 범위를 접근할 수 없는 상태로 주소 공간에 넣어 둡니다2GBVSZ
committed그중 실제로 쓸 범위를 읽고 쓸 수 있게 바꿉니다시작할 때 512MB, GC 가 늘리거나 줄입니다NMT(Native Memory Tracking)의 committed, G1 에서는 Runtime.totalMemory()
RSScommitted 범위 중 실제로 접근해 프레임이 매핑된 부분접근한 페이지만큼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() 를 호출한 뒤 자식과 부모가 차례로 써 보고, 프레임과 아래 표의 값이 어떻게 바뀌는지 확인하세요.

RssPssPrivate_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 zeroingcopy-on-write
미루는 작업프레임 할당과 0 채우기공유 중인 페이지의 복사
미루는 동안의 상태VMS 는 PTE 에 접근할 수 없다고 표시하고, Linux 는 VMA 에 범위만 기록하고 PTE 를 비워 둡니다두 주소 공간이 같은 프레임을 읽기 전용으로 매핑합니다
작업을 하는 시점그 페이지에 처음 접근할 때그 페이지에 처음 쓸 때
끝내 하지 않아도 되는 경우할당만 하고 접근하지 않은 페이지읽기만 한 페이지, exec() 로 버려진 주소 공간
Linux 와 JVM 에서 만나는 곳committed 힙과 RSS 의 차이, AlwaysPreTouchfork() 비용, 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).