그림으로 배우는 Linux 구조 - 4 / 메모리 관리 시스템


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를 뺀 값

linux4_2

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 값이 어떻게 변하는지 확인해 봅시다.

  1. free 명령어 실행
  2. 1GiB 파일 작성
  3. free 명령어 실행
  4. 파일 삭제
  5. 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 메모리가 줄어듭니다.

linux4_3

이럴 때 커널의 메모리 관리 시스템은 재활용 가능한 메모리 영역을 해제 해서 free 값을 늘립니다.

linux4_4

예를 들어 재활용 가능한 메모리에는 디스크에서 데이터를 읽어서 아직 변경되지 않은 페이지 캐시가 있습니다. 이런 페이지 캐시는 동일한 데이터가 디스크에 존재하므로 메모리를 해제해도 문제가 없습니다.


프로세스 삭제와 메모리 강제 해제

재활용 가능한 메모리를 해제해도 메모리 부족이 해결되지 않으면 시스템은 메모리가 부족해서 어쩔 도리가 없는 Out Of Memory(OOM) 상태가 됩니다.

linux4_5

이런 상황에 빠지면 메모리 관리 시스템이 적당히 프로세스를 골라서 강제 종료시키고 메모리에 빈 공간을 만드는 OOM Killer라고 하는 기능이 동작합니다.

linux4_6

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
......


가상 메모리

가상 메모리는 하드웨어와 소프트웨어(커널)를 연동해서 구현합니다. 가상 메모리는 무적 복잡한 기능이므로 다음 순서로 설명합니다.

  1. 가상 메모리가 없을 때 생기는 문제점
  2. 가상 메모리 기능
  3. 가상 메모리로 문제점 해결


가상 메모리가 없을 때 생기는 문제점
  • 메모리 단편화
  • 멀티 프로세스 구현이 어려움
  • 비정상적인 메모리 접근


메모리 단편화
프로세스를 생성하고 메모리 확보와 해제 작업을 반복하다 보면 메모리 단편화(Fragmentation of memory)문제가 발생합니다. 예를 들어 아래에서 비어 있는 메모리는 합치면 300바이트지만 각자 따로따로 100바이트씩 3개의 영역으로 나눠진 상태이므로 100바이트보다 큰 영역을 확보하려면 실패합니다.

linux4_7

비어 있는 영역 3개를 하나로 묶어서 다루면 어떻게 될 것 같겠지만 다음과 같은 이유로 불가능합니다.

  • 프로그램이 메모리를 확보할 때마다 확보한 메모리가 몇 개의 영역으로 쪼개져 있는지 일일이 관리해야 하므로 무척 불편합니다.
  • 크기가 100바이트보다 큰 연속된 데이터 묶음. 예를 들어 300바이트짜리 배열을 작성하려는 용도로 사용할 수 없습니다.


멀티 프로세스 구현이 어려움
프로레스 A를 실행했을 때 코드 영역이 주소 300부터 400이고, 데이터가 주소 400부터 500으로 매핑된 상태를 생각해 봅시다.

linux4_8

이 다음에 동일한 실행 파일을 사용해서 별도의 프로세스 B를 실행했다고 합시다. 하지만 이건 불가능합니다. 그 이유는 이 프로그램은 주소 300부터 500에 매핑된다고 전제하는데, 이미 해당 영역은 프로세스 A가 사용하고 있기 때문 입니다. 억지로 다른 장소(예를 들어 500부터 700)로 매핑해서 동작시켜도 명령어나 데이터가 가리키는 메모리 주소가 달라서 올바르게 동작하지 않습니다.

다른 프로그램을 실행할 때도 마찬가지입니다. 어떤 프로그램 A와 B가 있고 각각 동일한 메모리 영역에 매핑되도록 만들었다면 A와 B는 동시에 실행 불가능합니다. 결국 동시에 여러 프로그램을 실행하려면, 사용자가 모든 프로그램의 배치 장소가 겹치지 않도록 의식해서 관리해야 한다는 말이 됩니다.


비정상적인 메모리 접근
커널이나 수많은 프로세스가 메모리에 존재할 때, 어떤 프로세스가 커널이나 다른 프로세스에 할당된 메모리 주소를 지정하면 자신이 사용하는 영역이 아님에도 불구하고 접근할 수 있는 문제가 생깁니다.

linux4_9

따라서 이렇게 아무나 접근 가능하다면 데이터 누출 또는 데이터 손상의 위험이 있습니다.


가상 메모리 기능

가상 메모리(Virtual memory)는 프로세스가 메모리에 접근할 때 시스템에 설치된 메모리에 직접 접근 하는 대신에 가상 주소(Virtual address)를 사용해서 간접적으로 접근하는 기능입니다. 가상 주소에 대비되는 시스템에 설치된 메모리의 실제 주소를 물리 주소(Physical address)라고 하며, 이런 주소를 사용해서 접근 가능한 범위를 주소 공간(Address space)라고 합니다.

linux4_10

위 상태에서 프로세스 주소 100에 접근하면, 실제로 메모리에서는 주소 600에 존재하는 데이터에 접근합니다.

linux4_11

2장에서 readelf 명령어나 cat /proc//maps 출력 결과에 나오는 주소는 실제로는 모두 가상 주소 입니다. 프로세스에서 실제 메모리에 직접 접근하는 방법, 다시 말하면 물리 주소를 직접 지정하는 방법은 없습니다.

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바이트로 정합니다.

linux4_12

페이지 테이블은 커널이 작성합니다. 커널은 프로세스 생성 시 프로세스 메모리를 확보하고 확보한 메모리에 실행 파일 내용을 복사한다고 설명했습니다. 이때 동시에 프로세스용 페이지 테이블도 작성합니다. 하지만 프로세스가 가상 주소에 접근할 때 물리 주소로 변환하는 건 CPU가 하는 작업입니다.

linux4_13

주소 300~500에 프로세스가 접근하면 CPU에서 페이지 폴트(Page fault)라고 하는 예외(Exception)가 발생합니다. 예외는 실행 중인 코드 중간에 끼어 들어서 별도의 처리를 실행할 수 있도록 CPU가 제공하는 기능을 이용하는 동작 구조 방식입니다. 이런 페이지 폴트 예외가 발생하면 CPU에서 실행 중인 명령이 중단되고, 커널 메모리에 배치된 페이지 폴트 핸들러(Page fault handler)처리가 실행됩니다.

linux4_14

커널은 페이지 폴트 핸드러를 이용해서 프로세스가 비정상적인 메모리 접근을 일으켰다는 걸 감지합니다. 그런 이후에 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가 발생할 수도 있습니다.


가상 메모리로 문제 해결하기

메모리 단편화
프로세스의 페이지 테이블을 잘 설정하면 물리 메모리의 단편화된 영역이라도 프로세스 가상 주소 공간에서는 커다란 하나의 영역으로 다룰 수 있습니다. 이렇게 하면 단편화 문제가 해결됩니다.

linux4_15


멀티 프로세스 구현이 어려움
가상 주소 공간은 프로세스마다 만들어집니다. 따라서 멀티 프로세스 환경에서 각자의 프로그램이 다른 프로그램과 주소가 중복되는 걸 피할 수 있습니다.

linux4_16


비 정상적인 메모리 접근
프로세스마다 가상 주소 공간이 있다는 말은 애초에 다른 프로세스의 메모리가 어떻게 되어 있는지 알 수가 없기 때문에 접근할 수 없다는 뜻 입니다.

linux4_17

커널 메모리도 보통 프로세스 가상 주소 공간에 매핑되지 않으므로 비정상적인 접근을 할 수 없습니다.


프로세스에 새로운 메모리 할당하기

메모리는 확보한 순간 당장 사용하기보다는 조금 시간이 지난 후에 사용하는 일이 많아서 리눅스는 메모리를 확보하는 절차를 두 단계로 나눕니다.

  1. 메모리 영역 할당: 가상 주소 공간에 새롭게 접근 가능한 메모리 영역을 매핑합니다.
  2. 메모리 할당: 확보한 메모리 영역에 물리 메모리를 할당합니다.
메모리 영역 할당: 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() 시스템 콜을 호출한 직후에는 페이지 테이블 엔트리가 만들어지지만, 해당하는 페이지에 물리 메모리는 할당되어 있지 않습니다.

linux4_18

이제 해당하는 페이지에 접근하면 다음과 같은 절차로 메모리를 확보합니다.

  1. 프로세스가 페이지에 접근합니다.
  2. 페이지 폴트가 발생합니다.
  3. 커널의 페이지 폴트 핸들러가 동작해서 페이지에 대응하는 물리 메모리를 할당합니다.

linux4_19

페이지 폴트 핸들러는 페이지 테이블 엔트리가 존재하지 않는 페이지에 접근하면 프로세스에 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)라는 엄청난 메모리가 필요하다는 계산이 됩니다.

따라서 평탄한 페이지 테이블을 계층형 테이블 구조로 사용합니다.

linux4_20

linux4_21

이렇게 하면 페에지 테이블 전체 엔트리 개수가 16개에서 8개로 줄어듭니다. 만약 사용하는 가상 메모리 용량이 커진다면 아래와 같이 페이지 테이블 사용량이 늘어납니다.

linux4_22

가상 메모리 용량이 어느 크기 이상이 되면 계층형 페이지 테이블 쪽이 평탄한 테이블보다 메모리 사용량이 많아집니다. 하지만 그런 경우는 무척 드물어서 모든 프로세스의 페이지 테이블에 필요한 합계 메모리 용량은 평탄한 페이지 테이블보다 계층형 페이지 테이블 쪽이 작은 경우가 대부분입니다. 실제 하드웨어에서 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

Tag: [ book  linux  memory  ]