그림으로 배우는 Linux 구조 - 4 / 메모리 관리 시스템
By Bys on January 23, 2024
Memory management system
리눅스는 시스템에 설치된 메모리 전체를 커널의 메모리 관리 시스템(Memory management system) 기능을 사용해서 관리합니다. 메모리는 각 프로세스가 사용할 뿐만 아니라 커널 자체도 사용합니다.
메모리 관련 정보 수집
$ free
total used free shared buff/cache available
Mem: 32495760 1049728 30523080 3444 922952 31004572
Swap: 499996 0 499996
- total
- 시스템에 설치된 전체 메모리 용량
- free
- 명목상 비어 있는 메모리 (자세한 건 available 항목 설명 참고)
- buff/cache
- 버퍼, 캐시, 페이지 캐시가 이용하는 메모리. 시스템의 비어 있는 메모리(free 필드값)가 줄어들면 커널이 해제시킴
- available
- 실제로 사용가능한 메모리. free 필드값과 비어 있는 메모리가 줄어 들었을 때 해제 가능한 커널 내부 메모리 영역(예를 들어 페이지 캐시) 크기를 더한 값
- used
- 시스템이 사용 중인 메모리에서 buff/cached를 뺀 값
used
used 값은 프로세스가 사용하는 메모리와 커널이 사용하는 메모리를 모두 포함합니다. 여기서 커널이 사용하는 메모리 관련 이야기는 생략하고, 프로세스가 사용하는 메모리에 주목합니다. used 값은 프로세스 메모리 사용량에 따라 늘어납니다. 한편, 프로세스가 종료하면 커널은 해당 프로세스의 메모리를 모두 해제합니다.
#!/usr/bin/python3
import subprocess
# 적당한 양의 데이터를 작성해서 메모리를 사용
# 메모리 용량이 작은 시스템이라면 메모리 부족으로 실패할 가능성이 있으므로
# size값을 줄여서 다시 실행
size = 10000000
print("메모리 사용 전의 전체 시스템 메모리 사용량을 표시합니다.")
subprocess.run("free")
array = [0]*size
print("메모리 사용 후의 전체 시스템 메모리 남은 용량을 표시합니다.")
subprocess.run("free")
result
$ ./memuse.py
메모리 사용 전의 전체 시스템 메모리 사용량을 표시합니다.
total used free shared buff/cache available
Mem: 32495764 1121988 29993736 3452 1380040 30927284
Swap: 499996 0 499996
메모리 사용 후의 전체 시스템 메모리 남은 용량을 표시합니다.
total used free shared buff/cache available
Mem: 32495764 1200108 29915616 3452 1380040 30849164
Swap: 499996 0 499996
메모리를 사용하면 used 값이 약 76.2MiB((1200108-1121988)/1024) 증가했습니다. 구체적인 값 자체는 중요하지 않습니다. 프로그램 실행 중에 메모리를 사용하면 시스템 전체 메모리 사용량이 늘어난다는 점만 알면 됩니다.
buff/cache
buff/cache값은 페이지 캐시(Page cache)와 버퍼 캐시(Buffer cache)가 사용하는 메모리 용량을 뜻합니다. 페이지 캐시와 버퍼 캐시는 접근 속도가 느린 저장 장치에 있는 파일 데이터를 접근 속도가 빠른 메모리에 일시적으로 저장해서 접근 속도가 빨라진 것처럼 보이게 하는 커널 기능입니다. 저장 장치에 있는 파일 데이터를 읽어와서 메모리에 데이터를 캐시한다는(임시로 쌓아둔다) 개념만 기억해두면 됩니다.
다음과 같이 동작하는 프로그램으로 페이지 캐시 전후로 buff/cache 값이 어떻게 변하는지 확인해 봅시다.
- free 명령어 실행
- 1GiB 파일 작성
- free 명령어 실행
- 파일 삭제
- free 명령어 실행
buff-cache.sh
#!/bin/bash
echo "파일 작성 전의 시스템 전체 메모리 사용량을 표시합니다."
free
echo "1GB 파일을 새로 작성합니다. 커널은 메모리에 1GB 페이지 캐시 영역을 사용합니다."
dd if=/dev/zero of=testfile bs=1M count=1K # dd: data duplicator if: inputfile, of: outputfile, bs: blocksize
echo "페이지 캐시 사용 후의 시스템 전체 메모리 사용량을 표시합니다."
free
echo "파일 삭제 후, 즉 페이지 캐시 삭제 후의 시스템 전체 메모리 사용량을 표시합니다."
rm testfile
free
result
$ ./buff-cache.sh
파일 작성 전의 시스템 전체 메모리 사용량을 표시합니다.
total used free shared buff/cache available
Mem: 32495764 1113380 29626168 3480 1756216 30902836
Swap: 499996 0 499996
1GB 파일을 새로 작성합니다. 커널은 메모리에 1GB 페이지 캐시 영역을 사용합니다.
1024+0 records in
1024+0 records out
1073741824 bytes (1.1 GB, 1.0 GiB) copied, 7.3063 s, 147 MB/s
페이지 캐시 사용 후의 시스템 전체 메모리 사용량을 표시합니다.
total used free shared buff/cache available
Mem: 32495764 1124872 28538512 3480 2832380 30888344
Swap: 499996 0 499996
파일 삭제 후, 즉 페이지 캐시 삭제 후의 시스템 전체 메모리 사용량을 표시합니다.
total used free shared buff/cache available
Mem: 32495764 1124236 29614000 3480 1757528 30891508
Swap: 499996 0 499996
파일 작성 전후로 buff/cache값이 약 1GiB 정도 늘어났고, 파일을 삭제하니 값이 원래대로 돌아왔습니다.
sar(System Activity Report) 명령어를 사용해서 메모리 관련 정보를 수집하기
sar -r 명령어를 사용하면 두 번째 인수로 지정한 간격으로 메모리 관련 통계 정보를 얻을 수 있습니다. 그러면 5초 동안 1초 간격으로 메모리 정보 데이터를 수집해봅시다.
$ sar -r 1 5
Linux 6.2.0-1017-aws (ip-10-20-10-20) 01/29/24 _x86_64_ (8 CPU)
02:20:25 kbmemfree kbavail kbmemused %memused kbbuffers kbcached kbcommit %commit kbactive kbinact kbdirty
02:20:26 29616776 30894744 1046816 3.22 13336 1550164 3185208 9.65 522960 1922068 232
02:20:27 29616272 30894792 1047060 3.22 13336 1550424 3185208 9.65 523332 1922440 232
02:20:28 29616272 30894792 1047060 3.22 13336 1550424 3185208 9.65 523332 1922440 232
02:20:29 29616272 30894792 1047060 3.22 13336 1550424 3185208 9.65 523332 1922440 232
02:20:30 29616272 30894792 1047060 3.22 13336 1550424 3185208 9.65 523332 1922500 232
Average: 29616373 30894782 1047011 3.22 13336 1550372 3185208 9.65 523258 1922378 232
free 명령어 필드 | sar -r 명령어 필드 |
---|---|
total | 해당 사항 없음 |
free | kbmemfree |
buff/cache | kbuffers + kbcached |
available | 해당 사항 없음 |
sar명령어는 free 명령어와 다르게 한 줄에 필요한 정보가 모두 담겨 있어서 계속해서 정보를 수집할 때 편리합니다.
메모리 재활용 처리
시스템 부하가 높아지면 free 메모리가 줄어듭니다.
이럴 때 커널의 메모리 관리 시스템은 재활용 가능한 메모리 영역을 해제 해서 free 값을 늘립니다.
예를 들어 재활용 가능한 메모리에는 디스크에서 데이터를 읽어서 아직 변경되지 않은 페이지 캐시가 있습니다. 이런 페이지 캐시는 동일한 데이터가 디스크에 존재하므로 메모리를 해제해도 문제가 없습니다.
프로세스 삭제와 메모리 강제 해제
재활용 가능한 메모리를 해제해도 메모리 부족이 해결되지 않으면 시스템은 메모리가 부족해서 어쩔 도리가 없는 Out Of Memory(OOM) 상태가 됩니다.
이런 상황에 빠지면 메모리 관리 시스템이 적당히 프로세스를 골라서 강제 종료시키고 메모리에 빈 공간을 만드는 OOM Killer라고 하는 기능이 동작합니다.
OOM Killer가 동작했을 때 dmesg
명령어로 커널 로그를 확인하면 다음과 같이 출력됩니다.
# 예시
[XXX] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),...
# 실제
[Tue Nov 7 10:10:58 2023] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=cri-containerd-e755b2100de7907d5a06651e946e407e84e1ec27b7fd66a106ae6cb595820837.scope,mems_allowed=0,oom_memcg=/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod4017dabe_7444_42d8_a387_f11427ec90e3.slice/cri-containerd-e755b2100de7907d5a06651e946e407e84e1ec27b7fd66a106ae6cb595820837.scope,task_memcg=/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod4017dabe_7444_42d8_a387_f11427ec90e3.slice/cri-containerd-e755b2100de7907d5a06651e946e407e84e1ec27b7fd66a106ae6cb595820837.scope,task=envoy,pid=4682,uid=1337
[Tue Nov 7 10:10:58 2023] Memory cgroup out of memory: Killed process 4682 (envoy) total-vm:256756kB, anon-rss:60136kB, file-rss:38892kB, shmem-rss:0kB, UID:1337 pgtables:316kB oom_score_adj:999
[Tue Nov 7 10:11:01 2023] oom_reaper: reaped process 4682 (envoy), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB
OOM Killer가 동작하는 시스템이라면 메모리가 충분하지 않은 경우가 많습니다. 동시 실해 ㅇ중인 프로세스 개수를 줄여서 메모리 사용량을 줄이거나 또는 추가로 메모리를 설치하는게 좋습니다. 메모리 용량은 충분한데 OOM Killer가 동작한다면 어떤 프로세스 또는 커널에 메모리 누수(Memory leak)가 일어나고 있을지도 모릅니다. 프로세스 메모리 사용량을 정기적으로 모니터링하면 시스템 부하 여부와 관계없이 시간이 지남에 따라 메모리 사용량이 늘어나는 수상판 프로세스를 찾기 쉽습니다.
제일 간단한 모니터링 방법으로 ps 명령어가 있습니다. ps aux 실행 결과에 표시된 각 프로세스 정보에서 RSS 필드는 프로세스가 사용하는 메모리 용량을 뜻합니다.
# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 2.5 0.0 166552 11104 ? Ss 07:09 0:02 /sbin/init
ubuntu 1379 0.0 0.0 7764 3456 ? Ss 07:09 0:00 bash --noprofile --norc
ubuntu 2905 0.0 0.0 10464 3328 pts/1 R+ 07:11 0:00 ps aux
......
가상 메모리
가상 메모리는 하드웨어와 소프트웨어(커널)를 연동해서 구현합니다. 가상 메모리는 무적 복잡한 기능이므로 다음 순서로 설명합니다.
- 가상 메모리가 없을 때 생기는 문제점
- 가상 메모리 기능
- 가상 메모리로 문제점 해결
가상 메모리가 없을 때 생기는 문제점
- 메모리 단편화
- 멀티 프로세스 구현이 어려움
- 비정상적인 메모리 접근
메모리 단편화
프로세스를 생성하고 메모리 확보와 해제 작업을 반복하다 보면 메모리 단편화(Fragmentation of memory)문제가 발생합니다. 예를 들어 아래에서 비어 있는 메모리는 합치면 300바이트지만 각자 따로따로 100바이트씩 3개의 영역으로 나눠진 상태이므로 100바이트보다 큰 영역을 확보하려면 실패합니다.
비어 있는 영역 3개를 하나로 묶어서 다루면 어떻게 될 것 같겠지만 다음과 같은 이유로 불가능합니다.
- 프로그램이 메모리를 확보할 때마다 확보한 메모리가 몇 개의 영역으로 쪼개져 있는지 일일이 관리해야 하므로 무척 불편합니다.
- 크기가 100바이트보다 큰 연속된 데이터 묶음. 예를 들어 300바이트짜리 배열을 작성하려는 용도로 사용할 수 없습니다.
멀티 프로세스 구현이 어려움
프로레스 A를 실행했을 때 코드 영역이 주소 300부터 400이고, 데이터가 주소 400부터 500으로 매핑된 상태를 생각해 봅시다.
이 다음에 동일한 실행 파일을 사용해서 별도의 프로세스 B를 실행했다고 합시다. 하지만 이건 불가능합니다. 그 이유는 이 프로그램은 주소 300부터 500에 매핑된다고 전제하는데, 이미 해당 영역은 프로세스 A가 사용하고 있기 때문 입니다. 억지로 다른 장소(예를 들어 500부터 700)로 매핑해서 동작시켜도 명령어나 데이터가 가리키는 메모리 주소가 달라서 올바르게 동작하지 않습니다.
다른 프로그램을 실행할 때도 마찬가지입니다. 어떤 프로그램 A와 B가 있고 각각 동일한 메모리 영역에 매핑되도록 만들었다면 A와 B는 동시에 실행 불가능합니다. 결국 동시에 여러 프로그램을 실행하려면, 사용자가 모든 프로그램의 배치 장소가 겹치지 않도록 의식해서 관리해야 한다는 말이 됩니다.
비정상적인 메모리 접근
커널이나 수많은 프로세스가 메모리에 존재할 때, 어떤 프로세스가 커널이나 다른 프로세스에 할당된 메모리 주소를 지정하면 자신이 사용하는 영역이 아님에도 불구하고 접근할 수 있는 문제가 생깁니다.
따라서 이렇게 아무나 접근 가능하다면 데이터 누출 또는 데이터 손상의 위험이 있습니다.
가상 메모리 기능
가상 메모리(Virtual memory)는 프로세스가 메모리에 접근할 때 시스템에 설치된 메모리에 직접 접근 하는 대신에 가상 주소(Virtual address)를 사용해서 간접적으로 접근하는 기능입니다. 가상 주소에 대비되는 시스템에 설치된 메모리의 실제 주소를 물리 주소(Physical address)라고 하며, 이런 주소를 사용해서 접근 가능한 범위를 주소 공간(Address space)라고 합니다.
위 상태에서 프로세스 주소 100에 접근하면, 실제로 메모리에서는 주소 600에 존재하는 데이터에 접근합니다.
2장에서 readelf 명령어나 cat /proc/
cat /proc/9554/maps
55f9ba03b000-55f9ba04a000 r--p 00000000 103:01 521707 /opt/c9/local/bin/tmux
55f9ba04a000-55f9ba0c5000 r-xp 0000f000 103:01 521707 /opt/c9/local/bin/tmux
55f9ba0c5000-55f9ba0f5000 r--p 0008a000 103:01 521707 /opt/c9/local/bin/tmux
55f9ba0f5000-55f9ba0fe000 r--p 000b9000 103:01 521707 /opt/c9/local/bin/tmux
55f9ba0fe000-55f9ba0ff000 rw-p 000c2000 103:01 521707 /opt/c9/local/bin/tmux
55f9ba0ff000-55f9ba106000 rw-p 00000000 00:00 0
55f9bab19000-55f9bab3a000 rw-p 00000000 00:00 0 [heap]
7fece2a00000-7fece2ce9000 r--p 00000000 103:01 2587 /usr/lib/locale/locale-archive
7fece2e00000-7fece2e28000 r--p 00000000 103:01 24632 /usr/lib/x86_64-linux-gnu/libc.so.6
7fece2e28000-7fece2fbd000 r-xp 00028000 103:01 24632 /usr/lib/x86_64-linux-gnu/libc.so.6
7fece2fbd000-7fece3015000 r--p 001bd000 103:01 24632 /usr/lib/x86_64-linux-gnu/libc.so.6
7fece3015000-7fece3016000 ---p 00215000 103:01 24632 /usr/lib/x86_64-linux-gnu/libc.so.6
7fece3016000-7fece301a000 r--p 00215000 103:01 24632 /usr/lib/x86_64-linux-gnu/libc.so.6
7fece301a000-7fece301c000 rw-p 00219000 103:01 24632 /usr/lib/x86_64-linux-gnu/libc.so.6
7fece301c000-7fece3029000 rw-p 00000000 00:00 0
7fece313a000-7fece313d000 rw-p 00000000 00:00 0
7fece313d000-7fece3140000 r--p 00000000 103:01 32339 /usr/lib/x86_64-linux-gnu/libresolv.so.2
7fece3140000-7fece314a000 r-xp 00003000 103:01 32339 /usr/lib/x86_64-linux-gnu/libresolv.so.2
7fece314a000-7fece314d000 r--p 0000d000 103:01 32339 /usr/lib/x86_64-linux-gnu/libresolv.so.2
......
참고
ELF 란?
리눅스에서 실행 가능(Executable)하고 링크 가능(Linkable)한 File의 Format을 ELF(Executable and Linkable Format) 라고 합니다.
페이지 테이블 가상 주소를 물리 주소로 변환하려면 커널 메모리 내부에 저장된 페이지 테이블(Page table)을 사용합니다. CPU는 모든 메모리를 페이지 단위로 쪼개서 관리하는데 주소는 페이지 단위로 변환됩니다. 페이지 테이블에서 한 페이지에 대응하는 데이터를 페이지 테이블 엔트리(Page table entry)라고 부릅니다. 페이지 테이블 엔트리는 가상 주소와 물리 주소 대응 정보를 포함합니다. 페이지 크기는 CPU 아키텍처에 따라 정해져 있스빈다. x86_64 아키텍처는 4KiB입니다. 다만 이 책에서는 설명이 편하도록 페이지 크기를 100바이트로 정합니다.
페이지 테이블은 커널이 작성합니다. 커널은 프로세스 생성 시 프로세스 메모리를 확보하고 확보한 메모리에 실행 파일 내용을 복사한다고 설명했습니다. 이때 동시에 프로세스용 페이지 테이블도 작성합니다. 하지만 프로세스가 가상 주소에 접근할 때 물리 주소로 변환하는 건 CPU가 하는 작업입니다.
주소 300~500에 프로세스가 접근하면 CPU에서 페이지 폴트(Page fault)라고 하는 예외(Exception)가 발생합니다. 예외는 실행 중인 코드 중간에 끼어 들어서 별도의 처리를 실행할 수 있도록 CPU가 제공하는 기능을 이용하는 동작 구조 방식입니다. 이런 페이지 폴트 예외가 발생하면 CPU에서 실행 중인 명령이 중단되고, 커널 메모리에 배치된 페이지 폴트 핸들러(Page fault handler)처리가 실행됩니다.
커널은 페이지 폴트 핸드러를 이용해서 프로세스가 비정상적인 메모리 접근을 일으켰다는 걸 감지합니다. 그런 이후에 SIGSEGV(SIG Segmentation Violation) 시그널을 프로세스에 송신합니다. SIGSEGV 시그널을 받은 프로세스는 보통 강제 종료됩니다.
segv.go
package main
import "fmt"
func main() {
// nil은 반드시 접근에 실패해서 페이지 폴트가 발생하는 특수한 메모리 접근
var p *int = nil
fmt.Println("비정상 메모리 접근 전")
*p = 0
fmt.Println("비정상 메모리 접근 후")
}
go build segv.go
./segv
비정상 메모리 접근 전
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x47df98]
goroutine 1 [running]:
main.main()
/home/ubuntu/environment/workspace/ch04/segv.go:9 +0x58
segv-c.c
#include <stdlib.h>
int main(void) {
int *p = NULL;
*p = 0;
}
make segv-c
./segv-c
Segmentation fault (core dumped)
비정상적인 주소에 접근한 직후에 SIGSEGV 시그널을 수신했는데 이 시그널에 대처하지 못해서 이상 종료된 것을 알 수 있습니다. C, Go 처럼 메모리 주소를 직접 다루는 언어로 작성된 프로그램이라면 SIGSEGV 때문에 프로그램이 강제 종료되는 일이 종종 발생합니다. 한편, 파이썬처럼 메모리 주소를 직접 다루지 않는 언어로 만든 프로그램은 보통 이런 문제가 발생하지 않습니다. 하지만 프로그래밍 언어 처리나 C 언어 등으로 만든 라이브러리에 버그가 있다면 SIGSEGV가 발생할 수도 있습니다.
가상 메모리로 문제 해결하기
메모리 단편화
프로세스의 페이지 테이블을 잘 설정하면 물리 메모리의 단편화된 영역이라도 프로세스 가상 주소 공간에서는 커다란 하나의 영역으로 다룰 수 있습니다. 이렇게 하면 단편화 문제가 해결됩니다.
멀티 프로세스 구현이 어려움
가상 주소 공간은 프로세스마다 만들어집니다. 따라서 멀티 프로세스 환경에서 각자의 프로그램이 다른 프로그램과 주소가 중복되는 걸 피할 수 있습니다.
비 정상적인 메모리 접근
프로세스마다 가상 주소 공간이 있다는 말은 애초에 다른 프로세스의 메모리가 어떻게 되어 있는지 알 수가 없기 때문에 접근할 수 없다는 뜻 입니다.
커널 메모리도 보통 프로세스 가상 주소 공간에 매핑되지 않으므로 비정상적인 접근을 할 수 없습니다.
프로세스에 새로운 메모리 할당하기
메모리는 확보한 순간 당장 사용하기보다는 조금 시간이 지난 후에 사용하는 일이 많아서 리눅스는 메모리를 확보하는 절차를 두 단계로 나눕니다.
- 메모리 영역 할당: 가상 주소 공간에 새롭게 접근 가능한 메모리 영역을 매핑합니다.
- 메모리 할당: 확보한 메모리 영역에 물리 메모리를 할당합니다.
메모리 영역 할당: mmap() 시스템 콜
동작 중인 프로세스에 새로운 메모리 영역을 할당하려면 mmap() 시스템 콜을 사용합니다. mmap() 시스템 콜에는 메모리 영역 크기를 지정하는 인수가 있습니다. 시스템 콜을 호출하면 커널 메모리 관리 시스템이 프로세스의 페이지 테이블을 변경하고, 요청된 크기만큼 영역을 페이지 테이블에 추가로 매핑하고 매핑된 영역의 시작 주소를 프로세스에 돌려줍니다.
package main
import (
"fmt"
"log"
"os"
"os/exec"
"strconv"
"syscall"
)
const (
ALLOC_SIZE = 1024 * 1024 * 1024
)
func main() {
pid := os.Getpid()
fmt.Println("*** 새로운 메모리 영역 확보 전 메모리 맵핑 ***")
command := exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/maps")
command.Stdout = os.Stdout
err := command.Run()
if err != nil {
log.Fatal("cat 실행에 실패했습니다")
}
// mmap() 시스템 콜을 호출해서 1GB 메모리 영역 확보
data, err := syscall.Mmap(-1, 0, ALLOC_SIZE, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_ANON|syscall.MAP_PRIVATE)
if err != nil {
log.Fatal("mmap()에 실패했습니다")
}
fmt.Println("")
fmt.Printf("*** 새로운 메모리 영역: 주소 = %p, 크기 = 0x%x ***\n", &data[0], ALLOC_SIZE)
fmt.Println("")
fmt.Println("*** 새로운 메모리 영역 확보 후 메모리 매핑 ***")
command = exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/maps")
command.Stdout = os.Stdout
err = command.Run()
if err != nil {
log.Fatal("cat 실행에 실패했습니다")
}
}
./mmap
*** 새로운 메모리 영역 확보 전 메모리 맵핑 ***
7efcffc21000-7efd01f92000 rw-p 00000000 00:00 0
*** 새로운 메모리 영역: 주소 = 0x7efcbfc21000, 크기 = 0x40000000 ***
*** 새로운 메모리 영역 확보 후 메모리 매핑 ***
7efcbfc21000-7efd01f92000 rw-p 00000000 00:00 0
메모리 할당: Demand paging
mmap() 시스템 콜을 호출한 직후라면 새로운 메모리 영역에 대응하는 물리 메모리는 아직 존재하지 않습니다. 대신에 새롭게 확보 영역 내부에 있는 각 페이지에 처음으로 접근할 때 물리 메모리를 할당합니다. 이런 방식을 Demand paging이라고 합니다. Demand paging을 구현하기 위해 메모리 관리 시스템이 페이지마다 해당 페이지의 물리 메모리 할당 여부 상태를 관리합니다.
mmap() 시스템 콜로 1페이지의 메모리를 새롭게 확보하는 예제로 Demand paging 구조를 설명합니다. 이때 mmap() 시스템 콜을 호출한 직후에는 페이지 테이블 엔트리가 만들어지지만, 해당하는 페이지에 물리 메모리는 할당되어 있지 않습니다.
이제 해당하는 페이지에 접근하면 다음과 같은 절차로 메모리를 확보합니다.
- 프로세스가 페이지에 접근합니다.
- 페이지 폴트가 발생합니다.
- 커널의 페이지 폴트 핸들러가 동작해서 페이지에 대응하는 물리 메모리를 할당합니다.
페이지 폴트 핸들러는 페이지 테이블 엔트리가 존재하지 않는 페이지에 접근하면 프로세스에 SIGSEGV를 보내지만, 페이지 테이블 엔트리는 존재해도 대응하는 물리 메모리가 할당되지 않는 경우라면 새로운 메모리를 할당하는 처리로 분기합니다.
Demand paging이 발생하는 모습을 확인해 봅시다.
demand-paging.py
#!/usr/bin/python3
import mmap
import time
import datetime
ALLOC_SIZE = 100 * 1024 * 1024
ACCESS_UNIT = 10 * 1024 * 1024
PAGE_SIZE = 4096
def show_message(msg):
print("{}: {}".format(datetime.datetime.now().strftime("%H:%M:%S"), msg))
show_message("새로운 메모리 영역 확보 전. 엔터 키를 누르면 100메가 새로운 메모리 영역을 확보합니다: ")
input()
# mmap() 시스템 콜 호출로 100MiB 메모리 영역 확보
memregion = mmap.mmap(-1, ALLOC_SIZE, flags=mmap.MAP_PRIVATE)
show_message("새로운 메모리 영역을 확보했습니다. 엔터 키를 누르면 1초당 1MiB씩, 합계 100MiB 새로운 메모리 영역에 접근합니다: ")
input()
for i in range(0, ALLOC_SIZE, PAGE_SIZE):
memregion[i] = 0
if i%ACCESS_UNIT == 0 and i != 0:
show_message("{} MiB 진행중".format(i//(1024*1024)))
time.sleep(1)
show_message("새롭게 확보한 메모리 영역에 모두 접근했습니다. 엔터 키를 누르면 종료합니다: ")
input()
./demand-paging.py
08:17:34: 새로운 메모리 영역 확보 전. 엔터 키를 누르면 100메가 새로운 메모리 영역을 확보합니다:
08:17:37: 새로운 메모리 영역을 확보했습니다. 엔터 키를 누르면 1초당 1MiB씩, 합계 100MiB 새로운 메모리 영역에 접근합니다:
08:17:41: 10 MiB 진행중
08:17:42: 20 MiB 진행중
08:17:43: 30 MiB 진행중
08:17:44: 40 MiB 진행중
08:17:45: 50 MiB 진행중
08:17:46: 60 MiB 진행중
08:17:47: 70 MiB 진행중
08:17:48: 80 MiB 진행중
08:17:49: 90 MiB 진행중
08:17:50: 새롭게 확보한 메모리 영역에 모두 접근했습니다. 엔터 키를 누르면 종료합니다:
$ sar -r 1
Linux 6.5.0-1014-aws (ip-10-20-10-20) 03/09/24 _x86_64_ (8 CPU)
08:25:26 kbmemfree kbavail kbmemused %memused kbbuffers kbcached kbcommit %commit kbactive kbinact kbdirty
08:25:23 30562904 31200288 777568 2.39 140180 812604 2682484 8.13 1167764 399444 316
## 새로운 메모리 영역 확보 전
08:25:24 30562904 31200296 777568 2.39 140180 812604 2682484 8.13 1167892 399452 316
08:25:25 30562904 31200296 777568 2.39 140180 812604 2682484 8.13 1167892 399452 196
## 새로운 메모리 영역을 확보해도 해당 영역에 접근하지 않으면 메모리 사용량(kbmemused 필드 값)은 변하지 않습니다.
## 메모리 접근이 시작되면 초당 약 10MiB씩 메모리 사용량이 늘어납니다.
08:25:26 30552572 31189964 787900 2.42 140180 812604 2682616 8.13 1178092 399452 196
08:25:27 30542240 31179632 798232 2.46 140180 812604 2682616 8.13 1188352 399452 196
08:25:28 30532160 31169552 808312 2.49 140180 812604 2682616 8.13 1198612 399452 196
08:25:29 30521828 31159220 818644 2.52 140180 812604 2682616 8.13 1208872 399452 196
08:25:30 30511496 31148888 828976 2.55 140180 812604 2682616 8.13 1219204 399452 196
08:25:31 30501164 31138556 839308 2.58 140180 812604 2682616 8.13 1229464 399452 196
08:25:32 30490832 31128224 849640 2.61 140180 812604 2682616 8.13 1239664 399452 196
08:25:33 30480752 31118144 859720 2.65 140180 812604 2682616 8.13 1249984 399452 196
08:25:34 30470420 31107812 870052 2.68 140180 812604 2682616 8.13 1260308 399452 0
08:25:35 30460088 31097480 880384 2.71 140180 812604 2682616 8.13 1270568 399452 0
08:25:36 30460088 31097480 880384 2.71 140180 812604 2682616 8.13 1270568 399452 0
## 프로세스가 종료되면 메모리 사용량은 프로세스 실행 전 상태로 돌아갑니다.
08:25:37 30564496 31201888 775976 2.39 140180 812604 2574880 7.80 1165156 399452 0
08:25:38 30564540 31201932 775924 2.39 140188 812604 2574880 7.80 1164608 399448 16
08:25:39 30564540 31201932 775924 2.39 140188 812604 2574880 7.80 1164608 399448 16
08:25:40 30564540 31201940 775924 2.39 140188 812604 2574880 7.80 1164660 399456 16
프로그램이 확보한 메모리 영역에 접근했을 때만 초당 페이지 폴트 횟수를 뜻하는 fault/s 필드값이 늘어가는 걸 알 수 있습니다.
$ sar -B 1
08:39:02 pgpgin/s pgpgout/s fault/s majflt/s pgfree/s pgscank/s pgscand/s pgsteal/s %vmeff
08:39:03 0.00 0.00 8.00 0.00 6.00 0.00 0.00 0.00 0.00
## 프로세스 시작 및 메모리 영역 확보
08:39:04 0.00 0.00 1116.00 0.00 57.00 0.00 0.00 0.00 0.00
08:39:05 0.00 0.00 11.00 0.00 12.00 0.00 0.00 0.00 0.00
08:39:06 0.00 0.00 12.00 0.00 10.00 0.00 0.00 0.00 0.00
08:39:07 0.00 420.00 4.00 0.00 6.00 0.00 0.00 0.00 0.00
## 메모리 영역 접근
08:39:08 0.00 0.00 2578.00 0.00 8.00 0.00 0.00 0.00 0.00
08:39:09 0.00 0.00 2569.00 0.00 7.00 0.00 0.00 0.00 0.00
08:39:10 0.00 0.00 2566.00 0.00 6.00 0.00 0.00 0.00 0.00
08:39:11 0.00 0.00 2564.00 0.00 7.00 0.00 0.00 0.00 0.00
08:39:12 0.00 16.00 2566.00 0.00 6.00 0.00 0.00 0.00 0.00
08:39:13 0.00 0.00 2565.00 0.00 12.00 0.00 0.00 0.00 0.00
08:39:14 0.00 0.00 2568.00 0.00 6.00 0.00 0.00 0.00 0.00
08:39:15 0.00 0.00 3944.00 0.00 946.00 0.00 0.00 0.00 0.00
08:39:16 0.00 0.00 2567.00 0.00 7.00 0.00 0.00 0.00 0.00
08:39:17 0.00 0.00 3556.00 0.00 617.00 0.00 0.00 0.00 0.00
## 메모리 접근 완료
08:39:18 0.00 0.00 13.00 0.00 7.00 0.00 0.00 0.00 0.00
08:39:19 0.00 0.00 911.00 0.00 27106.00 0.00 0.00 0.00 0.00
## 프로세스 종료
08:39:20 0.00 0.00 5.00 0.00 7.00 0.00 0.00 0.00 0.00
페이지 폴트 횟수는 maj_flt, min_flt 두 종류가 있는데 이 두 값의 합이 페이지 폴트 총 횟수라는 것만 알면 됩니다.
정보 수집은 capture.sh 프로그램을 사용합니다.
./capture.sh
## 1. 메모리 영역 확보 전
VSZ RSS MAJFL MINFL
Sun Mar 10 04:26:56 UTC 2024: 17792 9472 0 1045
Sun Mar 10 04:26:57 UTC 2024: 17792 9472 0 1045
## 2. 메모리 영역 확보 후
Sun Mar 10 04:26:58 UTC 2024: 120192 9472 0 1045
Sun Mar 10 04:26:59 UTC 2024: 120192 9472 0 1045
Sun Mar 10 04:27:00 UTC 2024: 120192 9472 0 1045
## 3. 메모리 접근 시작
Sun Mar 10 04:27:01 UTC 2024: 120192 19584 0 3606
Sun Mar 10 04:27:02 UTC 2024: 120192 29824 0 6166
Sun Mar 10 04:27:03 UTC 2024: 120192 40064 0 8726
Sun Mar 10 04:27:04 UTC 2024: 120192 50304 0 11286
Sun Mar 10 04:27:05 UTC 2024: 120192 60544 0 13846
Sun Mar 10 04:27:06 UTC 2024: 120192 70784 0 16406
Sun Mar 10 04:27:07 UTC 2024: 120192 81024 0 18966
Sun Mar 10 04:27:08 UTC 2024: 120192 91264 0 21526
Sun Mar 10 04:27:09 UTC 2024: 120192 101504 0 24086
Sun Mar 10 04:27:10 UTC 2024: 120192 111744 0 26645
## 4. 메모리 접근 완료
Sun Mar 10 04:27:11 UTC 2024: 120192 111744 0 26645
Sun Mar 10 04:27:12 UTC 2024: 120192 111744 0 26645
Sun Mar 10 04:27:13 UTC 2024: demand-paging.py 프로세스가 종료했습
- 1~2 메모리 영역을 확보하고 접근하기 전까지 가상 메모리(VSZ) 사용량은 약 100MiB 정도 늘어나지만, 물리 메모리 사용량(RSS)은 늘어나지 않습니다.
- 3~4 메모리 접근 중에 페이지 폴트 횟수가 늘어납니다. 그리고 메모리 접근이 끝나면 물리 메모리 사용량이 메모리 확보 전보다 약 100MiB 정도 커집니다.
페이지 테이블 계층화
페이지 테이블은 얼마나 많은 메모리를 소비할까요? x86_64 아키텍처라면 가상 주소 공간 크기는 128TiB까지, 1페이지 크기는 4KiB, 페이지 테이블 엔트리 크기는 8바이트가 됩니다. 따라서 단순히 계산하면 프로세스 1개당 페이지 테이블에 256GiB(= 8바이트 * 128TiB/4KiB)라는 엄청난 메모리가 필요하다는 계산이 됩니다.
따라서 평탄한 페이지 테이블을 계층형 테이블 구조로 사용합니다.
이렇게 하면 페에지 테이블 전체 엔트리 개수가 16개에서 8개로 줄어듭니다. 만약 사용하는 가상 메모리 용량이 커진다면 아래와 같이 페이지 테이블 사용량이 늘어납니다.
가상 메모리 용량이 어느 크기 이상이 되면 계층형 페이지 테이블 쪽이 평탄한 테이블보다 메모리 사용량이 많아집니다. 하지만 그런 경우는 무척 드물어서 모든 프로세스의 페이지 테이블에 필요한 합계 메모리 용량은 평탄한 페이지 테이블보다 계층형 페이지 테이블 쪽이 작은 경우가 대부분입니다. 실제 하드웨어에서 x86_64 아키텍처라면 4단 구조 페이지 테이블을 사용합니다. 이렇게 해서 페이지 테이블에 필요한 메모리 용량을 크게 줄입니다.
시스템이 사용하는 물리 메모리 중에서 페이지 테이블이 사용하는 메모리는 sar -r ALL 명령어로 kbpgtbl 필드에서 확인 할 수 있습니다.
sar -r ALL 1
Linux 6.5.0-1014-aws (ip-10-20-10-20) 03/10/24 _x86_64_ (8 CPU)
04:50:47 kbmemfree kbavail kbmemused %memused kbbuffers kbcached kbcommit %commit kbactive kbinact kbdirty kbanonpg kbslab kbkstack kbpgtbl kbvmused
04:50:48 30567076 31216072 769120 2.37 96940 874492 2558984 7.76 1162088 415344 116 640800 187688 5376 7756 18928
04:50:49 30567076 31216072 769120 2.37 96940 874492 2558984 7.76 1162276 415352 116 641008 187688 5424 7880 18928
04:50:50 30567076 31216072 769116 2.37 96940 874496 2558984 7.76 1162336 415352 120 641080 187688 5424 7880 18928
Huge page 내용은 생략.
Reference
- 그림으로 배우는 리눅스 구조 (다케우치 사토루)
- E-Book
book
linux
memory
]