티스토리 뷰

https://www.yes24.com/Product/Goods/122109062

 

그림으로 배우는 리눅스 구조 - 예스24

선배가 옆에서 하나하나 알려주듯 친절히 설명해주는실습과 그림으로 배우는 리눅스 지식의 모든 것 * Go 언어와 Python, Bash 스크립트 실습 코드 제공* 이 도서는 『실습과 그림으로 배우는 리눅

www.yes24.com

 

1. 가상 메모리의 등장 배경

 멀쩡히 물리적인 메모리가 컴퓨터에 있는데 왜 굳이 "가상" 메모리라는 골치아픈 개념을 도입한 걸까? 이를 알기 위해서는 가상 메모리를 사용하지 "않을" 때 어떤 문제가 발생하는지를 알아보는 것이 더 와닿을 것이다. 가상 메모리를 도입하지 않았을 때의 문제점은 다음과 같다.

  • 메모리 단편화
  • 멀티 프로세스 구현의 어려움
  • 비정상적인 메모리 접근

  (1) 메모리 단편화

  프로세스를 생성하고 메모리 확보와 해제 작업을 반복하다 보면 메모리 단편화(fragmentation of memory)가 발생한다. 예를 들면 다음 그림과 같다.

 

 프로세스를 새로 배정할 연속된 메모리 공간이 없어지기 때문에 결론적으로 나머지 프로세스가 끝나고 메모리를 반납하기 전까지 메모리 공간을 이용할 수 없다.

 

 (2) 멀티 프로세스 구현의 어려움

 메모리에 서로 다른 두 개의 프로그램을 올린다고 생각하면 두 프로그램이 다르므로 두 개의 매핑이 일어나 멀티 프로세스를 이용할 수 있다. 하지만 같은 프로그램을 멀티 프로세스로 만든다고 하면 구현이 복잡해진다. 원칙적으로 한 메모리 영역에 동시에 두 프로세스가 접근할 수 없기 때문이다. 그렇다면 메모리를 복제해서 새로운 영역에 매핑할 수도 있겠지만, 그렇다면 코드는 복제가 되지만 두 프로세스가 데이터를 공유하지는 못하는 점에서 또 다시 문제가 발생한다. 은행 계좌에서 입출금을 하는 프로그램을 생각해보자. 멀티 프로세스로 구현한다면 당연히 같은 통장 데이터에 접근해야 할 것이다.

 

 (3) 비정상적인 메모리 접근

 만약 커널 영역의 메모리의 주소로 잘못 지정해서 접근한다면 커널 데이터 손상의 위험성이 있다.

 

 이러한 문제를 해결하기 위해 가상 메모리라는 개념이 등장하였다.

 

2. 가상 메모리 기능

 가상 메모리는 메모리의 물리 주소(physical address)에 접근하는 대신에 가상 주소(virtual address)를 사용해서 간접적으로 접근하는 기능이다. 즉 물리적 주소는 물리 주소 공간에, 가상 주소는 가상 주소 공간을 가진다.

 

 예를 들어 프로세스 A의 1번에 접근한다면, 이는 물리적 주소 1로 변환이 되어서 데이터를 접근하게 된다. 이쯤에서 리눅스 프로세스 address의 진실을 알 수 있다. readelf나 cat 명령을 통해 가져온 메모리 주소는 모두 가상 주소를 의미한다.(그렇지 않다면 모든 프로세스가 0x0000~으로 시작하기 때문에 뭔가 문제가 있는 상황이었다!) 즉 우리는 컴퓨터의 주인이지만, 프로세스의 진짜 물리 주소를 알 방법이 없다. 오직 커널만이 그 주소를 알고 있는 것이다. 

 

 3. 페이지 테이블

 그렇다면 어떻게 가상 주소를 물리 주소로 변환할 수 있을까? 변환하기에 앞서 몇 가지 알아두어야 할 것들이 있다. 우선 다음 그림을 보자.

 

 보면 논리 주소가 page table이라는 걸 통해서 물리주소로 변환됨을 알 수 있다. 그런데 여기에서 p, f, d는 각각 무엇일까?  

  • p : page number - 논리 주소를 쪼개는 기본적인 단위. x86_64 아키텍처에서는 4KiB
  • f : frame number - 물리 주소를 쪼개는 기본적인 단위. 마찬가지로 4KiB
  • d : page/frame offset - 한 페이지/frame 내에서 몇 번째인지를 표시하는 offset(보통 몇 번째 byte인지를 나타낸다.)

 이해가 좀 어려울 수도 있는데, 그림으로 표현해 보면 다음과 같다.

 

 따라서 offset은 동일하고, paging이라는 알고리즘은 결국 page number를 frame number로 바꿔주는 자료구조를 필요로 한다는 것을 알 수 있다! 이 자료구조에 해당하는 것이 바로 page table이다. 그렇다면 page table은 위 그림과 같이 page number를 기반으로 인덱스를 탐색해서, entry에서 frame number를 추출해서 리턴해주는 역할을 한다. 

 그렇다면 페이지 테이블에서 가상 주소에 해당하는 물리 주소를 찾지 "못했다면" 어떻게 될까? 이렇게 되면 CPU에서는 페이지 폴트(Page fault)라는 exception을 발생시키고, 커널 메모리에 배치된 페이지 폴트 핸들러가 실행되어서 다음과 같은 과정을 거친다.

  1. 페이지 폴트 핸들러가 해당 페이지가 디스크(HDD,SSD)에 있는지 또는 접근이 잘못된 것인지를 확인한다.
  2. 디스크에 있다면, 이를 메모리에 로드하고, exception이 일어나기 직전 상황으로 되돌린다. 따라서 다시 프로세스가 시작하고, 이 경우에 프로세스는 자신이 page fault를 발생시켰다는 사실을 망각하고 페이지 테이블에서 해당 프레임 넘버를 찾고 정상적으로 물리 주소에 접근할 수 있다.
  3. 접근이 잘못되었다면, SIGSEGV 시그널을 프로세스에 송신한다. 이 시그널을 받은 프로세스는 강제 종료된다.

 그러면 3번 과정을 실제로 실습해보자. 이 go 코드에서는 반드시 접근이 실패하는 nil 주소에 접근함으로써 SIGSEGV 시그널을 핸들러로부터 수신받고 종료될 것이다.

package main

import "fmt"

func main() {
	// nil은 반드시 접근에 실패해서 페이지 폴트가 발생하는 특수한 메모리 접근
	var p *int = nil
	fmt.Println("비정상 메모리 접근 전")
	*p = 0
	fmt.Println("비정상 메모리 접근 후")
}

 

 실행 결과 *p에 접근하는 순간 exception이 발생하고 SIGSEGV 시그널을 수신받고 종료됨을 알 수 있다. 이 에러가 알고리즘을 하면서 많이 당할 수 있는 유명한 segmentation fault 에러이다.

 

 이렇게 가상 메모리를 도입한다면 앞서 언급한 세 가지 문제점을 해결할 수 있다.

  1. 메모리 단편화 : 프로세스의 페이지 테이블을 설정한다면, 물리 메모리에서 단편화된 프레임들을 하나로 모아 가상 주소에 하나의 영역으로 다룰 수 있다.
  2. 멀티 프로세스 구현의 어려움 : 가상 주소는 프로세스마다 하나씩 만들어지기 때문에, 다른 프로그램과 주소가 겹치는 걸 방지할 수 있다.
  3. 비정상적인 메모리 접근 : 프로세스들이 물리 주소를 알지 못하고 가상 주소만 알고 있으므로, 페이지 테이블을 설정해놓는다면 비정상적인 메모리 접근을 방지할 수 있다. 

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

리눅스에서 프로세스가 메모리를 확보하는 절차는 다음 두 단계로 나뉜다.

  1. 메모리 영역 할당 : 가상 주소 공간에 새롭게 접근 가능한 메모리 영역을 매핑한다.
  2. 메모리 할당 : 확보한 메모리 영역에 물리 메모리를 할당한다.

 프로세스에서는 메모리를 확보한 후 당장 사용하지 않고 조금 시간이 지난 후 사용하기 때문에 위 두 가지 동작으로 나뉜다. 예를 들어 mmap() 시스템 콜에서는 새로운 메모리 영역을 할당해주도록 커널에 요청한다. 이를 실습으로 확인해 보자. 할당된 메모리 매핑 정보는 /proc/<pid>/maps 에 저장되어 있으므로, cat 커맨드로 이를 출력 후, mmap()으로 새 메모리를 할당받고 다시 cat 커맨드를 입력하는 방식이다.

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 실행에 실패했습니다")
	}
}
*** 새로운 메모리 영역 확보 전 메모리 맵핑 ***
...
7fd1e3096000-7fd1e5447000 rw-p 00000000 00:00 0 
7fd1e5447000-7fd1f55c7000 ---p 00000000 00:00 0 
...

*** 새로운 메모리 영역: 주소 = 0x7fd1a3096000, 크기 = 0x40000000 ***

*** 새로운 메모리 영역 확보 후 메모리 매핑 ***
...
7fd1a3096000-7fd1e5447000 rw-p 00000000 00:00 0 
7fd1e5447000-7fd1f55c7000 ---p 00000000 00:00 0 
...

 새로운 메모리 확보 결과 시작 주소가 7fd1e3096000 에서 7fd1a3096000 이 되어서, 0x40000000 Byte가 확보됨을 알 수 있다. 16진수이므로 이는 4 x 16^7 = 2^30 이므로 1GiB 크기이다. 따라서 1GiB를 확보했음을 알 수 있고, 이는 Go 코드에서 설계했던 것과 일치한다. (ALLOC_SIZE = 1024 x 1024 x 1024 = 2^30)

 한편 mmap() 시스템 콜을 호출한 직후에는 아직 물리 메모리를 할당하지 않은 상태이다. 아직 프로세스에서 사용하지 않았다면 그대로 놔두고 다음 명령을 처리하는 것이 경제적이기 때문이다. 따라서 해당하는 논리 주소에 접근했을때, 물리 주소를 페이지 테이블에서 알 수 없게 되고, 이때 Page fault가 발생한다. 이 경우에는 비정상적인 접근이 아니므로, 페이지 폴트 핸들러가 물리 메모리를 할당해준다.(이때는 디스크에 있는 내용을 로드하는 것이 아니므로 단순히 물리 메모리만 할당해서 페이지 테이블 엔트리를 채워준다.)

 그럼 이 과정을 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()

 

 이때 실습 과정에서 다른 쉘을 켜놓고 여기에서 sar -r 1 명령을 통해 실시간으로 메모리 현황을 체크해보자.

 

 

 kbmemused에 주목하자. 실제로 메모리 접근이 시작되는 14:36:07 이전까지는 kbmemused가 그대로이다. 그리고, 14:36:07부터 1초마다 10MiB가 확보되는데, 이에 따라 약 10MiB씩 늘어나는 kbmemused를 확인할 수 있고, 14:36:16 이후로 프로세스가 종료되면 메모리를 반납해서 100MiB가 한꺼번에 줄어드는 것을 알 수 있다.

 이번에는 시스템 전체가 아니라, demand-paging.py 프로세스 자체의 정보를 확인해 보자. 다음 명령으로 이를 확인할 수 있다.

$ ps -o vsz rss maj_flt min_flt -p {pid}

 

 maj_flt(메이저 폴트), min_flt(마이너 폴트)가 페이지 폴트 횟수를 의미한다. 이 두 값의 합이 페이지 폴트 횟수이다. vsz는 확보한 메모리 영역 크기, rss는 확보한 물리 메모리 크기를 의미한다. 다음 capture.sh 프로그램을 사용한다.

#!/bin/bash

<<COMMENT
demand-paging.py 프로세스에 대해 1초 간격으로 메모리 관련 정보를 출력합니다.
각 줄 처음에는 정보를 수집한 시각을 표시합니다. 이후 필드의 의미는 다음과 같습니다.
   1번 필드: 확보한 메모리 영역 크기
   2번 필드: 확보한 물리 메모리 크기
   3번 필드: 메이저 폴트 횟수
   4번 필드: 마이너 폴트 횟수
COMMENT

PID=$(pgrep -f "demand-paging\.py")

if [ -z "${PID}" ]; then
    echo "demand-paging.py 프로세스가 존재하지 않습니다. $0 실행 전에 실행하기 바랍니다." >&2
    exit 1
fi

while true; do
    DATE=$(date | tr -d '\n')
    # -h는 헤더를 출력하지 않는 옵션
    INFO=$(ps -h -o vsz,rss,maj_flt,min_flt -p ${PID})
    if [ $? -ne 0 ]; then
        echo "$DATE: demand-paging.py 프로세스가 종료했습니다." >&2
        exit 1
    fi
    echo "${DATE}: ${INFO}"
    sleep 1
done

 

 메모리 영역을 확보한 이후에 첫 번째 필트(VSZ)가 100000KiB(= 100MiB) 늘어나고, 메모리 영역에 접근하는 순간부터 두 번째 필드(RSS)가 10000KiB( = 10MiB) 씩 늘어나고 있고, 페이지 폴트(마이너 폴트) 가 증가하는 것을 알 수 있다.

 

4. 페이지 테이블 계층화

 페이지 테이블의 문제점 중 하나는, 페이지 테이블 자체가 메모리를 낭비한다는 점이다. 왜냐하면, 페이지 테이블조차 '페이지'로 구성되었기 때문이다! 사실 OS에서는 초심자 입장에서 이해하기 어려운 파트 중 하나이다. 구체적으로 다음과 같은 상황이라고 가정해보자.

  • 4KiB (2^12) page size
  • 페이지의 주소 공간은 32bit
  • Page Table Entry는 4byte

 우선 가장 먼저 정해야 할 것은, virtual address 32bit를 vitrual page number와 offset으로 나누는 것이다. 페이지 크기가 4KiB라는 것은 offset이 4K개라는 것이다!(보통 byte 단위이므로..) 따라서 virtual page number가 매핑하는 범위는

2^32 / 2^12 = 2^20 이므로 virtual page number는 20bit이다. 따라서 페이지 테이블의 크기는 2^20 x 4B(entry 크기) = 4MB이다. 페이지 테이블 크기가 상당히 큰데, 이를 줄일 방법이 있을까?

 정답은 바로 multi-level page table을 이용하는 것이다. 지금까지는 1개의 virtual page number를 1개의 physical frame number로 변환하였으나, frame number가 아니라, 또 다른 페이지 테이블을 "가리키도록" 설정한다면 어떨까? 즉 그림과 같이 페이지 테이블을 계층적으로 설계하자는 것이다.

 

 이때 페이지 테이블의 매칭과정은 다음 그림을 참고해서, virtual page number의 앞 10비트는 outer page table, 뒤 10비트는 page table에 할당해주면 된다.

 이렇게 설계한 후 메모리를 계산해 보자. outer page table은 페이지 1개, page table은 2^10개 이므로, 총 (2^10 + 1) x 4KiB = 4.004MB이다. 엥 더 크지않나? 라고 당연히 의심을 가져야 한다. 그러나 앞서 말했듯, 메모리에 실제로 접근하기 전까지는 물리 주소를 할당하지 않기 때문에 실제로는 훨씬 적은 메모리를 사용한다.

 

 페이지 테이블이 점유하는 메모리 크기는  sar -r ALL 명령에서 kbpgtbl 필드로 알 수 있다.

 

5. Huge Page

 앞서 보았듯, 프로세스가 메모리 접근을 과도하게 넓은 범위에서 한다면, 결국 페이지 테이블의 크기가 증가할 수밖에 없다. 이를 해결하기 위해 Huge Page를 도입하였는데, 말 그대로 페이지의 크기를 늘려서 페이지 테이블에 필요한 용량을 줄일 수 있다. 페이지의 크기를 늘리면 더 적은 virtual page number를 관리하기 때문에 page table의 크기가 줄어든다. ( page table 크기 = virtual page number 공간 크기 x page table entry의 크기 이므로!)

 Huge Page는 mmap() 함수의 flags 인수에 MAP_HUGETLB 플래그를 지정해서 사용할 수 있다.

 

6. Transparent Huge Page(THP)

 THP는 리눅스에서 연속된 4KiB 페이지가 존재할 때 이를 자동으로 Huge Page로 바꾸어 주는 기능이다.

 

다음 포스트에서는 4장의 지식을 기반으로 프로세스 관리에 대한 심화 내용을 다루어보겠다.

 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함