본문 바로가기

Computer Science

[CS] OS(운영체제)의 메모리 관리에 대해 파헤쳐보자

반응형

이번 포스팅은 CS 스터디를 하기 위해 진행해오고 있던 '한 권으로 읽는 컴퓨터 구조와 프로그래밍' 의 메모리 파트를 읽다가 도저히(?) 이해가 안 가서.. 개별적으로 설명이 매우 잘 된 블로그를 통해서 얻게 된 내용을 나만의 방법으로 정리해 포스팅하려고 한다. 본문 내용의 출처는 이 블로그임을 밝힌다.

 

운영체재는 메모리 관리를 어떻게 해주고 있을까?


1. 운영체제(OS)의 역할

본론으로 메모리 관리라는 개념에 들어가기에 앞서 우리는 운영체제 즉, OS의 역할을 먼저 짚고 넘어가야 한다. 운영체제의 역할은 다음과 같이 정의할 수 있다. CPU, 메모리, I/O 장치 등 복잡한 컴퓨터 하드웨어를 잘 몰라도 사람이 쉽게 프로그램(또는 어플리에키션)을 만들 수 있게끔 일련의 '공통' 기능을 지원한다. 그래서 운영체제의 기능을 크게 3가지로 분류하면 CPU 관리, 메모리 관리, I/O 장치관리를 들을 수 있다. 우리는 이번 포스팅에서는 메모리 관리에 대해 초점을 맞춰 설명하려고 한다.

2. 운영체제의 '메모리 관리' 기능

'메모리 관리' 라고 한다면 크게 3가지 일을 한다고 할 수 있다. 첫 번째, 사용자들이 프로그래밍을 하면서 쉽게 메모리를 사용할 수 있도록 해준다. 우리가 파이썬과 같은 프로그래밍 언어를 활용해서 코드를 작성할 때 내부적으로는 OS가 메모리를 관리해주고 있다는 말이다. 두 번째, 방금 OS가 메모리를 관리해준다고 했다. 그런데 OS는 이 메모리를 효율적으로 관리해준다. 마지막 세번째, 메모리를 보호(Memory protection)해준다. 메모리 보호란 예를 들어, A라는 프로세스가 있는데 B라는 프로세스가 동시에 실행되고 있다. 그런데, B라는 프로세스가 A 프로세스의 메모리를 침범한다면 끔찍한 일이 발생할 것이다. 이러한 문제를 막는 메모리 보호의 기능도 OS가 해주고 있다.

 

이제 운영체제가 메모리 관리를 해준다는 것이 구체적으로 어떤 일을 한다는 것인지도 알아보았다. 이제 본격적으로 메모리 관리에 대해 배울텐데, 읽어나가면서 몇 가지 숙지해야 할 사전 개념들이 존재한다. 등장하는 개념 순서대로 읽고 이해해야 연쇄적으로 내용이 이해가 가기 때문에 중간 내용을 건너띄지 말고 하나씩 천천히 살펴보도록하자.

3. 링킹(Linking) 이란?

'링크'라고 읽는 Link의 사전적 의미는 무엇을 연결하다라는 의미정도로 쓰일 것이다. 컴퓨터에서도 이러한 링킹이라는 것이 존재하는데, 우선 아래 그림부터 살펴보자.

 

출처: 내용 원본 블로그

 

위 그림은 대표적인 컴파일 언어인 C++ 코드가 동작하는 과정을 도식화한 그림이다. 그림에서 Linking 이라고 적혀있는 부분이 우리가 지금 배울 '링킹'이라는 개념이다. 이제 개념을 하나씩 살펴보자. 

 

가장 먼저 인간이 이해할 수 있는 언어라고 할 수 있는 즉, 우리가 '코딩한다'라고 말하는 소스 코드(Source Code)가 가장 먼저 존재한다. 그리고 이를 컴퓨터가 이해할 수 있는 언어(기계어, Object Code)로 변환하는 과정을 컴파일링이라고 한다. 그리고 마지막으로 이 기계어를 실행가능한 파일로 변환하는 것을 링킹이라고 한다. 여기서 실행가능한 파일이라 함은 예를 들어, 윈도우로 따지면 우리가 파일 확장자 명이 .exe 인 것들을 생각하면 된다.

 

우리가 주목할 부분은 기계어에서 실행파일로 변환시키는 과정인 링킹이다. 그런데 위 그림에서는 나와있지 않지만, 링킹을 구체적으로 수행하는 과정은 아래와 같다.

 

출처: 원본 내용 블로그

 

바로 라이브러리 파일들이라는 것이 존재한다. 여기서 라이브러리 파일이라고 한다면, C++에서는 표준 입,출력 라이브러리인 cout 같은 것들을 의미한다. 따라서, 소스코드를 기계여로 변환시킨 Object 파일들과 라이브러리 파일들을 연결(링킹)하여 만든 것이 바로 실행가능한 파일(Executable File)이다. 

 

그런데 링킹에는 두 가지 종류가 존재한다. 바로 정적(Static) 링킹과 동적(Dynamic) 링킹이다. 방금 우리가 위에서 알아본 링킹 방법은 정적 링킹에 해당한다. 이런 정적 링킹은 어떤 장,단점이 있을까? 위에서 본 것처럼 라이브러리 파일들이 링킹 과정에서 이미 포함되기 때문에 컴파일하는 시간(여기서 컴파일이라는 용어는 '컴파일링 + 링킹' 과정을 모두 합쳐 부른다. 즉, 컴파일과 컴파일링은 약간 상/하위 개념이다)이 단축되며, 해당 코드의 유출(기술 유출이라고도 할 수 있음)을 막기도 한다.

 

하지만 단점도 존재한다. 만약 위와 같은 컴파일 과정을 거칠 여러 개의 C++ 코드가 있다고 가정해보자. 그런데 이 여러 개의 C++ 코드에 분명히 공통적으로 사용되는 라이브러리들이 있을 것이다. 특히, 표준 입/출력이나 로깅 라이브러리같은 경우들은 모든 C++ 코드에 공통적으로 사용될 것이다. 그런데 정적 링킹은 이런 공통 라이브러리들을 하나의 개별 C++ 코드 컴파일 과정을 수행할 때마다 포함시켜줘야 되고 이는 결과적으로 메모리를 엄청나게 차지하는 문제가 발생한다.

 

그래서 "공통적으로 사용하는 라이브러리들을 하나에 한 번 올려놓고, 매번 왔다갔다 하면서 사용하자!" 라는 아이디어에 착안해 나온 것이 바로 동적 링킹이다. 이 때, 공통적으로 사용하는 라이브러리들 즉, 동적으로 링킹하는 라이브러리라고 하여 DLL(Dynamic-Link Library)라고 한다.

(이 개념을 배우면서 예전에, 메이플스토리라는 게임을 뭣모르고 실행시키려다가 에러 창이 뜨면서 'DLL'이라는 키워드가 뜨는 것을 본적이 있는데, 이게 그것이었다.. 약 15년 만에 알아버린 유레카..)

 

정적 링킹의 단점을 보완하는 동적 링킹인 만큼, 동적 링킹의 장점은 분명하다. 메모리를 적게 사용한다는 것. 하지만 단점도 존재한다. 컴파일 성능 오버헤드가 발생하는데, 성능 오버헤드란 쉽게 말해서 컴파일 성능이 느려지고 저하됨을 의미한다.

4. 로딩(Loading) 개념과 로딩의 종류

다음은 로딩이라는 개념에 대해 배워보자. 예를 들어, A.exe라는 이름의 프로그램을 실행시킬때, A.exe에 있는 파일이 메모리에 올라가야 실행이 된다. 이 때, 메모리에 파일(데이터)을 적재하는 것을 바로 로딩이라고 한다.

 

그런데, 이 로딩에도 대표적으로 두 가지 종류가 존재한다. 첫 번째는 동적 적재(Dynamic Loading)이다. 동적 적재는 메모리 크기가 프로세스 크기보다 클 때 사용하는 방식이다. 또 필요한 시점에만 루틴(일종의 함수)을 호출해 메모리에 적재하고 이후에 메모리에서 빼내고, 이런 방식으로 동작한다. 

 

두 번째는 오버레이(Overlays) 적재 방식이 있다. 오버레이 적재 방식은 프로세스 크기가 메모리 크기보다 클 때 사용한다. 이렇게만 말하면 이해가 잘 안가는데, 우선 아래 그림을 보자.

 

오버레이 적재 방식

 

현재 pass1, pass2 프로그램 크기를 합친 것이 현재 여유분의 메모리(흰 색 공간)보다 크다는 것을 알 수 있다. 즉, 프로세스 크기가 메모리보다 큰 상태이다. 그런데 우리는  pass1 이라는 프로그램만 메모리에 올린 후, pass1이 끝나면 pass2 라는 프로그램을 메모리에 올리는 즉, 오버레이 방법을 취함으로써 해결할 수 있다. 위 그림에서 Overlay driver 라는 메모리를 작게 차지하는 드라이버를 볼 수 있다. 이 드라이버가 상황에 맞춰서 pass1 또는 pass2 프로그램을 메모리에 올렸다가 내렸다가 하는 중재자 역할을 한다.

 

하지만 이러한 오버레이 적재 방식은 사용자(프로그래머)가 직접 구현을 해주어야 한다. 다시 말해, 운영체제가 관리해주는 것이 아닌 사람이 직접 관리해주어야 한다는 것이다. 그리고 이 오버레이를 사람이 직접 관리한다는 것은 매우 난해하고 복잡한 작업이었다고 한다.

 

그래서 현재는 오버레이 적재 방식은 사용되지 않고 있다. 이를 대체하는 방법으로 Paging 기법, VMM(Virtual Machine Monitor, 가상 메모리 관리자)을 요즘에 가장 많이 사용한다. 왜냐하면 이것들은 OS가 사람 대신 관리해주기 때문에 매우 편하기 때문이다.

5. 스와핑(Swaping)과 VMM

(* 참고로 여기서 '메모리'란, 메인 메모리인 RAM을 의미)

VMM을 배우기 앞서 스와핑이라는 개념부터 알아보자. 예를 들어, 메모리에 최대 10개의 프로세스를 올릴 수 있다고 해보자. 현재 10개의 프로세스가 메모리에 올라온 상태인데, 갑자기 또 추가로 실행할 프로그램이 생겨서 11번째의 프로세스를 실행시켰다. 그러면 현재 메모리는 가득 찬 상태인데 어떻게 될까?

 

이 때, 11번째 프로세스를 위해 이미 올라가 있는 10개의 프로세스 중 하나만 잠깐 내리고 11번째 프로세스를 올리자! 라는 아이디어에서 스와핑이라는 개념이 등장한다. 그런데 방금 우리가 빼낸 프로세스 1개를 다시 메모리에 올려놓을 때까지는 11번째 프로세스가 종료되거나 하는 이벤트가 발생해야 가능할 것이다. 그런데 운이 나쁘게(?)도 11번째 프로세스가 앞으로 적어도 2시간 이상은 메모리에 올라가 있어야 한다면 메모리에서 빼낸 프로세스 1개는 그럼 그냥 버려야 할까? 아니다. 이 빼낸 프로세스 1개도 다른 메모리에 저장을 시켜놓아야 한다. 이 때 사용하는 것이 바로 보조기억장치인 하드디스크나 SSD에 저장해놓고 빼낸 프로세스 1개도 여전히 실행상태가 유지되도록 해주어야 한다.

 

이렇게 프로세스 단위로 메모리로부터 쫓아낸다(?)라는 개념이 바로 Swap Out 이라고 한다. 그리고 새롭게 메모리에 올려놓은 11번째 프로세스가 마침내 종료되었을 때, 보조기억장치에 저장해놓았던 프로세스 1개를 다시 메모리에 올려놓아야 한다. 이 때, 다시 넣어놓는 것을 Swap in 이라고 한다.

 

그렇다면 VMM은 스와핑과 무슨 관련이 있을까? VMM은 가상 메모리라는 개념을 사용하는데, 여기서 가상 메모리란 예를 들어, 메인 메모리는 8기가 인데 10기가짜리 프로그램을 실행시키는 것을 가능하게 해주는 개념이다. 그런데 이러한 일을 가능하게 해줄 때 8기가만 우선 메인 메모리에 올려놓고 나머지 2기가는 보조기억장치에 저장하고 있다가 필요하면 부분적으로 바꾸어주는 일종의 스와핑을 수행해준다. 그런데 방금 위에서 스와핑은 '프로세스 단위'로 해준다고 배웠다. 하지만 VMM은 프로세스보다 더 작은 단위인 Paging 단위로 스와핑을 해주는 것이 차이점이다.

6. 메모리 주소 할당(바인딩)

다음으로 배울 개념은 메모리 주소를 할당하는 방법이다. 어떤 데이터를 메모리에 저장시킬 때, 그 데이터에는 "이 데이터가 메모리의 어느 번지수에 저장되어 있어요!" 라고 명시해주는 메모리 주소를 할당해주어야 한다. 마치 우리가 사는 집주소처럼 말이다. 그런데 이 때, 데이터에 메모리 주소를 '언제' 할당하느냐에 따라 주소 할당 방법이 3가지로 분류된다.

 

먼저 컴파일 시 메모리 주소를 할당해주는 컴파일 타임 주소 할당이라는 방법이 있다. 이는 물리적 주소만을 사용하는데, 물리적 주소란, 프로그램 내부에서 사용하는(CPU에 의해 생성되는) 주소와 물리적인 주소가 동일하다는 것을 의미한다. 그런데 이러한 물리적 주소만을 사용하는 것은 문제가 있다.

 

예를 들어, A라는 프로그램을 메모리 1번지에다가 올려! 라고 명령했는데, 실제로 메모리를 까보니 1번지에는 이미 다른 B라는 프로그램이 수행되고 있다면 문제가 발생한다. 다른 예시로, 메모리 주소가 1번~1,000번지까지 있다면 1번부터 100번 주소까지는 A라는 프로그램을 올리고, 101번 ~ 200번까지는 B라는 프로그램 올려! 라고 메모리 번지수에 있어서 일종의 강제 규칙을 설정하는 것이다. 그런데, 사람들이 갖고 있는 컴퓨터들의 메모리에는 모두 동일한 메모리 주소에 동일한 프로그램이 수행되고 있을 리가 없다는 것이 문제다. 

 

그래서 위와 같은 주소 할당 방법의 문제점을 확인하고 이를 해결하기 위해 논리적 주소라는 것을 사용한다. 논리적 주소는 가상 주소라고도 말한다. 물리적 주소와 논리적 주소를 어떤 방식으로 잘 매핑하는 것이다. 이 때 매핑하는 작업은 CPU 내에 존재하는 MMU(Memory Management Unit, 메모리 관리 장치)가 처리해준다.

(참고로, 컴파일 타임 주소 할당에도 논리적 주소가 있다고 할 수 있는데 단, 이 방법에서는 논리적 주소가 물리적 주소라 동일하다)

 

이렇게 가상 주소인 논리적 주소를 사용하는 방법 중 하나가 로드 타임 주소 할당 방식이다. 말 그대로 메모리를 데이터에 적재(로딩)할 때 주소를 할당하는 방식이다. 이 말은 곧 프로그램 내부에서 사용하는 주소와 물리적 주소가 다름을 의미한다. 아래 그림을 보자.

 

왼쪽은 논리적 주소, 오른쪽은 물리적 주소(출처: 원본 블로그)

 

우선 논리적 주소에서 0번 ~ 98,000번이 data에 할당된 메모리 주소이다. 이는 프로그램 내부에서 사용하는 주소(논리적 주소)이며 0번지부터 해서 상대 주소라는 것을 사용한다. 그리고 이 data가 메모리(RAM)에다가 로딩을 시킬 때, 어디 번지에 로딩할 건지에 따라 물리적 주소가 바뀌게 된다.

 

예를 들어, 위 그림에서는 물리적 주소 10만번지에다가 로딩한다고 가정했으므로, 논리적 주소에서의 0번~98,000번이 물리적 주소에서는 10만(메모리에 data를 로딩할 주소)을 더해서 100,000 ~ 198,000번 주소에 저장이 되는 것이다. 이렇게 되면 data를 물리적 주소에 로딩하려는 주소에 따라 어디에나 올려놓을 수 있게 된다. 예를 들어, 논리적 주소(0번~98,000번)를 물리적 주소인 10만~19만8천 이어도 되고 5만~14만8천 이어도 되고 20만~39만8천 이어도 된다. 결국, 이를 통해 멀티프로그래밍이 가능하게 해준다는 것을 알 수 있다.

 

그런데 이러한 로드 타임 주소 할당 방식은 문제점이 존재한다. 로드 타임 방식은 메모리 로딩할 때 위 [논리적주소 ➜ 물리적주소] 변환 작업을 매번 해주어야 되고 이렇게 되면 메모리 로딩할 때 시간이 엄청 오래걸리는 문제가 발생한다.(왜냐하면 주소를 변환한 후 메모리에 올려지니까)

 

그래서 위 문제를 해결하기 위한 방식으로 실행 타임 주소 할당 방식이 등장한다. 방금 위에서 로드 타임의 병목 현상은 [논리적주소 ➜ 물리적주소] 변환 작업을 로딩할 때 매번 해줘야 한다는 단점이었다. 그래서 이 변환 작업을 '실행 시간'에 바꾸자라는 것이 바로 실행 타임 주소 할당 방식이다. 다시 그림을 보자.

 

왼쪽은 논리적 주소, 오른쪽은 물리적 주소(출처: 원본 블로그)

 

98,000번 주소를 198,000번 주소로 바꾸는 작업을 명령어 실행 시 해주어야 한다. 잘 보면 [0번~98,000번 ➜ 100,000번~198,000번] 으로 바꾸어 줄때 각각 10만을 더해주면 되는 것을 알 수 있다. 그런데 10만을 더해준다는 '연산'을 또 실행해주어야 한다. 그렇다면 이 10만을 더하는 연산을 실행시켜주는 것은 누가 할까? 바로 하드웨어가 해주는데, 그 중에서도 CPU 내에 존재하는 MMU가 수행해주는 것이다. 참고로 위 예시에서 더해주는 '10만'을 base address 라고한다.

 

정리하자면, 로드 타임 방식은 [논리적 주소 ➜ 물리적 주소] 변환 작업을 메모리에 로딩할 때 한꺼번에 미리 다 해놓지만, 실행 타임 방식은 이 변환을 그때 그때 처리하는 방식이다. 이것만 보면 한번에 다 미리 해놓은 로드 타임이 더 효율적이다라고 생각할 수 있지만 현대에 들어와서 하드웨어가 매우 좋아져(base address 연산을 하드웨어가 하니깐!) 실행 타임 방식을 적용해소 성능 상에 문제가 없다고 한다. 

 

현대 컴퓨터에서는 로드 타임 방식이 여전히 메모리에 로딩할 때 성능 오버헤드가 크기 때문에 실행 타임 방식을 대부분 적용하고 있다고 한다.

7. MMU를 통한 연속 메모리 할당(Contiguous Allocation)

먼저 메모리가 어떻게 분할되는지부터 알아보자. 메모리에는 다양한 프로그램이 올라가기도 하지만 OS도 올라간다. 그래서 메모리는 OS를 위한 파티션과 다양한 프로그램(유저 프로그램)을 위한 파티션이 존재한다. 

 

6번 목차에서 알아봤던 실행 타임 주소 할당 방식에서 MMU를 활용해 간단하게 더하기 연산만을 해서 물리적 주소도 논리적 주소처럼 연속적으로 메모리를 할당(Contiguous Allocation)하는 것을 가능하게 했다고 배웠다.

 

그리고 MMU는 Memory Protection 이라는 기능도 존재한다. 예를 들어, 메모리 주소가 0~1,000번까지 존재한다고 가정하자. 이 때 Base address가 400번이고, offset이 700이다.(offset이 700이라는 것은 한 데이터를 저장할때 메모리 범위가 700이라는 뜻 0번~700번 처럼!) 그런데, 이 때 400 + 700 = 1,100 이되고 결국 메모리 가용 범위를 넘어가게 된다. 이렇게 잘못된 메모리 주소를 참조하지 않도록 막아주는 역할을 한다. 그러면 구체적으로 어떻게 막아주는 걸까?

 

MMU는 참조할 수 있는 범위 여부를 판단하는 추가 레지스터를 둔다. 이를 Limit(상한) 레지스터라고 부르며 이 레지스터에 있는 값보다 가장 주소값이 크다면 Memory protection fault를 발생시키게 된다. 여기서 'fault'란 것은 MMU가 위차하고 있는 CPU가 자기 자신한테 인터럽트를 걸어서 운영체제한테 처리해달라고 요청해주는 것을 의미한다.

 

이렇게 MMU를 통해 연속 메모리 할당하는 방법은 단순히 MMU가 더하기 연산을 한다는 점, 상한 레지스터를 두어서 가용범위를 넘어가는지 여부만 체크하는 점 이 2가지만 두면 할 수 있는 간단하다는 특징이 있다. 하지만 아쉽게도(?) 이 방법도 단점이 존재한다. 바로 External Fragmentation이 발생하게 되며 결국에 이 문제점 때문에 현대 컴퓨터에서는 MMU를 통한 연속 메모리 할당을 선택해 쓰고 있지 않다.

8. Fragmentation이 뭔데?

MMU를 통한 연속 메모리 할당은 메모리 주소를 '연속적'으로 사용하기 때문에 하나의 프로세스를 두 가지로 분할해서 메모리에 올리는 것이 불가능하다. 아래 사진처럼 말이다.

 

연속 메모리 할당은 프로세스를 나누어서 메모리에 올리는 것이 불가능

 

그리고 메모리에는 수많은 프로그램들이 실행되고 종료되고 또 새로운 프로그램들이 실행되고 한다. 그러면 분명히 아래와 같은 문제가 발생할 수 있다. 

 

새로운 Process6이 메모리에 들어가야 하지만 메모리에 공간이 없다

 

그림의 왼쪽이 메모리이다. 메모리에 가용공간이 존재하는데 이를 Hole이라고 한다. 그리고 그러한 가용공간이 존재할 때 현재 새로운 PROCESS 6이 새롭게 메모리에 올라가려고 한다. 그러던 중 PROCESS 5 또는 PROCESS 1 둘 다 종료되서 공간이 생겼다. 그런데 문제가 발생한다. 종료된 프로세스 2개 덕분에 생긴 Hole이 생기긴 했는데 이게 파편적으로 생겨버려서 PROCESS 6이 들어갈 하나의 공간은 부족하다는 것이다. 아래처럼 말이다.

 

공간이 생겼지만 부족한 아이러니한 문제..

 

이렇게 총 Hole 공간을 계산했을 때, 새롭게 들어가려고 하는 프로세스 크기를 포용할 수는 있지만, 그 가능한 공간들이 연속적이지 않아서 새로운 프로세스를 메모리에 올리지 못하는 문제를 External Fragmentation 이라고 부른다.

 

물론 이 문제를 해결하기 위해서 Compaction 이라는 방법도 제안되었다. Compaction이란, 가능한 Hole을 연속적인 공간으로 모아주기 위해서 메모리에 올라가 있는 프로세스들을 양쪽으로 밀어버리는 것이다.

 

COMPACTION을 통해 Hole 모으기

 

하지만 이 Compaction 방법은 밀어낸 프로세스들 위 그림상으로는 PROCESS 2,3,4를 다른 메모리에다가 잠깐 복사해놓았다가 나중에 다시 메모리에 붙여넣기해서 올려야 하는데, 이 때 사용할 수 있는 다른 메모리는 보조기억장치(하드디스트, SSD)가 있다. 그런데 알다시피 보조기억장치는 속도가 매우 느리다. 그래서 결국 I/O 문제가 발생할 수 밖에 없게 된다. 이런 문제는 하단에서 배울 Paging 개념 등장의 배경이 된다.

9. 프로세스를 메모리의 빈 공간 중 어디에 넣을까?

아래처럼 새로운 PROCESS를 메모리에 올리려고 하는데 빈 공간(Hole)이 A,B가 있다. A,B 공간 중 어디에 집어넣어야 할까?

 

출처: 원본 내용 블로그

 

위 판단을 하기 위한 3가지 기준이 존재한다. 메모리를 앞에서부터 탐색하다가 최초로 발견되는 빈 공간에 할당하는 First-fit, 존재하는 모든 공간을 한 번씩 다 탐색하고 가장 잘 맞는 곳에 넣는 Best-fit, 그리고 가장 큰 공간에 넣는 Worst-fit이 있다. 여기서 "왜 가장 큰 공간에 넣지?" 할 수 있을텐데, 이는 관점을 좀 다르게 한 접근이다. 가장 큰 공간에 새로운 프로세스를 넣는게 불필요한 조그마한 자투리 공간을 남기지 않을 것이라는 아이디어에서 기인했다.

 

결과적으로 이 3가지 기법을 시뮬레이션 한 결과, Worst 기법이 성능이 가장 낮았다. 그리고 First, Best 기법 중에서 메모리 효율적인 면에서 차이가 없다고 나왔다고 한다. 그래서 연산 복잡도를 생각했을 때, 두 기법 중 First 기법이 연산이 더 적게 들기 때문에 First-fit 기법의 성능이 가장 우수하다고 판단했다. 하지만 가장 우수한 First-fit 기법을 활용한다고 해도 여전히 External Fragmentation 문제가 존재한다.

 

지금까지는 메모리에 프로세스 크기 별로 메모리를 할당해왔다. 할당한 프로세스 크기가 개별적으로 다 다르므로 '가변 분할'이라고도 한다. 하지만 External Fragmentation 문제를 해결하기 위해서 메모리를 고정된 크기로 분할한다. 즉, Paging 이라는 단위를 통해 메모리를 고정적인 크기로 분할하게 된다. 그래서 이를 '고정 분할'이라고도 부른다.

(참고로 Internal Fragmentation 이라는 개념도 있는데, 이는 고정 분할 기법에서 발생하는 문제를 뜻한다)

10. Paging을 활용한 고정 분할, 그리고 Internal Fragmentation

가변 분할의 문제점인 External Fragmentation 문제를 해결할 수 있는 Paging에 대해서 알아보도록 하자. 페이징(Paging)은 프로세스를 고정된 크기로 쪼개는 하나의 '단위'를 의미한다. 그리고 이렇게 하나의 프로세스마다 작은 페이징 단위로 쪼갠 후 메인 메모리에 적재하게 된다. 이게 무슨 의미인지는 아래 그림을 살펴보자.

 

출처: 내용 원본 블로그

 

그림의 왼쪽은 논리주소, 오른쪽은 물리 주소이다. 우리는 현재 A, B라는 2개의 프로세스를 실행시켜야 한다. 이 때, 각 프로세스마다 페이징 단위로 쪼갠다. 그리고 아직은 배우지 않았지만 추후에 배울 Page Mapping 테이블이라는 것을 통해서 물리적 주소에 각 페이지를 프레임(Frame)인 물리 주소로 변환시켜 메인 메모리에 적재시킨다. 참고로 페이지, 프레임의 차이는 단순히 그 작은 단위가 올라가 있는 곳이 논리주소면 페이지, 물리주소면 프레임이라고 부르는 것 외에 차이가 없다. 이러한 페이징을 활용한 기법은 현대에도 인텔 프로세서에도 사용되고 있다고 한다.

 

하지만 이런 페이징 기법을 활용한 고정 분할 방법은 내부 단편화(Internal Fragmentation)라는 문제가 발생한다. 내부 단편화 문제는 아래와 같다.

 

출처 : 내용 원본 블로그

 

그림의 왼쪽은 A라는 프로세스가 총 6개의 페이지로 딱 나누어진다. 하지만 B라는 프로세스는 A 프로세스 보다 크기가 약간 크다. 그리고 A 프로세스를 분할할 때 사용한 크기의 페이징으로 B 프로세스를 나누려고 하니(이 때 동일한 크기의 페이징으로 분할하는 이유는 '고정 분할'이기 때문) B 프로세스에서는 딱 맞아 떨어지지 않게 쪼개지는 것을 볼 수 있다. 이렇게 페이징이 프로세스를 딱 맞아떨어지지 않게 쪼개지지 않는 문제를 내부 단편화 문제라고 한다. 

 

그렇다면 이런 내부 단편화 문제가 있는 페이징 단위로 쪼개는 방법을 왜 사용할까? 위 외부 단편화 문제가 발생했을 때보다 낭비되는 메모리 공간(Hole)이 상대적으로 매우 적기 때문이다.

11. Paging Mapping Table의 등장

자, 이제 다시 10번 목차에서 봤던 그림을 다시 보자.

 

출처: 내용 원본 블로그

 

우리는 앞서 페이징 단위로 프로세스를 쪼개서 [논리 주소 ➜ 물리 주소] 변환을 수행하면 메모리 공간 낭비가 적다는 것을 알게되었다. 그런데 한 가지 드는 의문이 있다. Physical memory(물리 주소)라고 되어있는 부분을 보면 똑같은 색깔들의 프레임들이 연속적으로 붙어있지도 않고 심지어 프레임 번호들이 뒤바뀌고 중구난방으로 되어 있는 것을 볼 수 있다.

 

Logical Memory(논리 주소) 부분에 적혀있는 각 프로세스 별로 페이징들이 위에서 아래 번호(내림차순 순서로)대로 순차적으로 실행되어야 프로그램이 정상적으로 실행될텐데, 중구 난방으로 되어 있는 Physical memory에서 저 그대로 프레임을 실행시키면 프로그램이 정상적으로 실행될까? 절대 그럴리가 없다.

 

이를 해결하기 위해 페이지 매핑 테이블(Page Mapping Table)이 등장한다. 페이지 매핑 테이블은 크게 2가지 역할을 한다. 첫 번째, 각 대응되는 Page 와 Frame을 매핑시켜준다. 추측할 수 있다시피 [Page ↔️ Frame] 대응되는 표현을 페이지 매핑 테이블이 담고 있다. 두 번째, 변환된 프레임들을 동일한 색깔 즉, 동일한 프로세스들끼리 연속적으로 연결해줘서 순차적으로 프레임들이 실행될수 있도록 해준다. 

 

그럼 이제 페이지 매핑 테이블이 어떻게 Page 와 Frame을 매핑시켜주고 연결시켜 잘 실행시킬 수 있는지 살펴보도록 하자. 아래 그림을 보고 페이징 매핑 테이블 개념을 익혀보자.

 

위와 같은 방법으로 페이징을 활용해 논리주소를 표현

 

먼저 3가지 개념이 존재한다. 위 그림에서 페이지 번호, 페이지 크기, 페이지 오프셋(Offset) 용어를 살펴보자. 먼저 페이지 번호는 하나의 프로세스를 고정된 페이징 크기로 여러 개 분할 했을 때, 그 여러 개로 분할된 페이지에 붙는 일종의 순번이다. 그리고 페이지 크기는 페이지 하나 당 논리 주소를 저장할 수 있는 크기(범위)를 의미한다. 마지막으로 페이지 오프셋은 페이지 크기 내에 들어가는 주소인데, 이것이 바로 페이지 내에서 표현할 수 있는 주소 위치를 의미한다.

 

그래서 페이지 매핑 테이블을 활용해서 논리 주소를 물리 주소로 변환하는 과정을 도식화하면 아래와 같다. 아래 그림의 $p$는 페이지 번호, $d$는 페이지 오프셋을 의미, $f$는 프레임 번호를 의미한다.(프레임 번호는 페이지 번호랑 의미하는 바가 동일. 단순히 용어가 페이지에서 프레임으로 바뀐 것 뿐)

 

출처 : 내용 원본 블로그

 

그런데 아까 예시에서 운영체제가 32비트 프로세서라고 가정했을 때, 하나의 프로세스 마다 총 $2^{20}$ 비트의 페이지가 존재한다. 그런데 $2^{20}$ 비트라면 이는 곧 1 메가 바이트이다. 그리고 페이지 테이블은 숫자(integer)를 저장하고 있으니 4 byte를 곱하면 4 메가 바이트가 된다. 이렇게 되면 만약에 하나의 컴퓨터에 20개의 프로세스가 운용되고 있다고 한다면 총 80 메가바이트가 된다. 즉, 프로세스가 점점 많아지면 용량이 엄청나게 차지해 성능의 문제가 발생하게 된다.

12. 자주 사용되는 페이지들은 기억해놓자, TLB(Translation Lookaside Buffer)

위에서 프로세스 개수가 많아짐에 따라 페이지 매핑 테이블의 용량이 기하급수적으로 증가하고 결국 성능의 문제가 발생한다고 했다. 그런데 왜 성능의 문제가 발생하는 걸까? 이유는 페이지 매핑 테이블이 바로 CPU의 MMU(메모리 관리 장치)에다가 저장해야 하기 때문이다. MMU에 넣는 이유는 CPU가 속도가 빠르기 때문이다. 그런데 페이지 매핑 테이블 용량이 커지면 당연히 CPU에 과부하가 걸린다. 

 

그러면 페이지 매핑 테이블을 보조기억장치인 메인 메모리(RAM)에 넣으면 되지 않을까? 이렇게 되면 페이지 매핑 테이블, 메인 메모리 두 번을 참조해야 하는 비효율적인 문제가 발생한다. 그래서 뛰어난 개발자들은 바로 '캐싱 메모리'를 이용해서 성능을 저해하지 않으면서 MMU 안에 페이지 매핑 테이블을 적절하게 넣을 수 있었다. 캐시 메모리의 역할은 다음과 같다.

 

"CPU가 주기억장치에서 저장된 데이터를 읽어올 때, 자주 사용하는 데이터를 캐시 메모리에 저장한 뒤, 다음에 이용할 때 주기억장치가 아닌 캐시 메모리에서 먼저 가져오면서 속도를 향상시킨다."

 

캐싱이라는 것은 보통 자주 사용되는 데이터를 메인 메모리가 아닌 캐시 메모리라는 자그마한 다른 곳에 미리 저장해두는 것을 의미한다. 그리고 자주 사용되는 만큼 그것들이 필요할 때 메인 메모리까지 가지 않고 캐시 메모리에만 잠깐 들려서 빼오면 되기 때문에 성능 개선에 핵심이 되는 기법으로 알려져 있다. 이러한 캐싱 메모리의 아이디어를 페이징 매핑 테이블에도 적용시켜낸 것이다.

 

공통적으로(자주) 사용되는 페이지들을 MMU에 미리 저장해놓자

 

위 그림을 보면 4개의 프로세스 마다 서로 다른 크기로 되어있는 것을 볼 수 있다. 그리고 각 프로세스마다 고정된 페이징 크기 단위로 잘 분할되어 있다. 그런데 잘 보면 위 4개의 프로세스들이 비록 크기는 서로 다르지만 서로 공통적으로 사용되는 페이지들이 빨간색 네모칸처럼 존재한다. 이렇게 공통적으로 사용된다는 것은 그만큼 자주 사용되는 페이지들일 것이고 이를 미리 MMU라는 일종의 캐싱 메모리에다가 저장해놓는다면 이를 활용해서 페이징 매핑 테이블의 속도와 성능을 개선시키게 된다.

 

원본 블로그에서는 TLB를 더 깊게 파헤쳐보는 포스팅이 연이어 존재한다. 하지만 원래 스터디를 진행하던 책 내용을 읽어나가기 위해서는 이정도까지의 개념만 알아가도 괜찮을 것 같아서 TLB에 대해서는 이정도만 포스팅하고 마무리하려고 한다.

 

반응형