신입 게임 개발자의 프로그래밍 일기

[C++ 입문자에서 벗어나기]Chapter_4: 메모리(Memory)#1 본문

Language/C++

[C++ 입문자에서 벗어나기]Chapter_4: 메모리(Memory)#1

KFGD 2017. 12. 11. 17:49

[C++ 입문자에서 벗어나기]Chapter_4: 메모리(Memory)#1


C++을 배우면서 메모리에 대해 이야기하지 않을 수 없겠죠?

이번 포스팅에서 이야기의 주제는 아래 3가지입니다.


-물리적 메모리(Physical Memory)와 가상 메모리(Virtual Memory)

-기본적인 메모리 관리 함수: malloc과 free

-클래스를 위한 메모리 관리 함수: new와 delete


[물리적 메모리(Physical Memory)와 가상 메모리(Virtual Memory))


우리는 물리적 메모리가 뭔지를 알고 있습니다. 컴퓨터 구입 및 조립 시, 포함되는 RAM이라는 메모리가 그것입니다.

그런데 왜 지금 이 포스팅에서는 메모리를 물리적 메모리라는 다른 용어로 표현하고 있는 걸까요?

그 이유는 프로그래밍에서 이야기하는 메모리에는 두가지 종류가 있기 때문입니다.

그것들은 각각 물리적 메모리와 가상 메모리입니다.

물리적 메모리는 CPU가 읽고 쓰는 물리적인 개체를 뜻하며 가상 메모리는 프로그래밍에서 흔히 말하는 메모리인 논리적인 개체입니다.

이제부터는 우리는 왜 가상 메모리라는 개념이 필요한지에 대해 이야기해보려고 합니다.


우리는 컴퓨터로 프로그램을 실행시키고 프로세스가 물리적 메모리(RAM)에 적재(load)되어 실행되는 것을 알고 있습니다.

그런데 현재 우리의 메인 메모리에는 여러 개의 프로세스들이 동시 점유하고 있는데요.

사실 메모리의 적재되어 있는 프로세스의 전체 크기는 물리 메모리의 크기를 넘어섭니다.(매우 큰 물리 메모리가 있다면 다르겠지만...)

이것이 가능한 이유는 운영체제가 프로그램 실행 시, 현재 바로 필요한 최소한의 정보(Working Set)만을 메인 메모리인 RAM에 적재하고 

나머지는 하드디스크과 같은 보조기억장치에 저장하기에 가능합니다. 

이 기법을 페이징(Paging)기법이라고 말합니다. (정확하게는 가상메모리 + 페이징이라고 하는게 맞을 거 같네요!)

또한 우리가 프로그래밍시 고려하는 메모리는 사실 메인 메모리가 아닌 가상 메모리입니다.

가상 메모리는 앞서 말한 메인 메모리와 하드디스크를 같이 프로세스의 메모리로서 사용할 수 있도록 만든 기술입니다.

여전히 CPU가 메인 메모리에 읽고 쓰기를 작업한다는 사실은 변함이 없지만 프로그래머가 코드 작성 시, 실제 메모리의 상태 및 크기를

고려하지 않고 프로세스마다 별도의 0부터 시작하는 동일한 크기의 연속된 메모리 공간에서 작업을 할 수 있게 함으로서 부담감을 완화시켜줍니다.

물론, 가상 메모리상의 주소를 실제 물리 메모리의 주소로 연결시키는 책임은 알아서 처리되는 것이 아닌 를 OS가 맡게 됩니다.

(이해에 도움이 되는 자료: Virtual Address Space Wiki)

프로세스마다 할당된 공간은 다른 프로세스가 침범이 불가능하므로 안정성 향상이라는 장점도 얻게 되며

가상 메모리의 크기가 물리 메모리보다 클 경우 물리 메모리보다 큰 프로세스를 돌릴 수도 있습니다.


*가상 메모리의 크기는 OS의 종류에 따라 달라집니다. 

x86 Windows의 경우는 4GB(kernel 2GB, application 2GB) 공간을 x64의 경우 보통 16TB(kernel 8TB, application 8TB)의 공간이 할당됩니다.

참고: Windows Dev Center: Memory Limits for Windows and Windows Server Releases의 Memory and Address Space Limits부분



[기본적인 메모리 관리 함수: malloc 과 free]


프로그래머가 코딩시 고려하는 메모리는 가상 메모리라고 했으니 malloc과 free 함수 또한 가상 메모리와 연관이 있다는 것은 

쉽게 추측이 가능하실 겁니다. 그 추측은 어느정도는 옳았고 이번에 단순히 메모리를 할당 해제 한다는 

이 두개의 함수를 좀 더 자세하게 살펴보려고 합니다.

x86 Windows 기준 할당되는 프로세스마다 할당되는 가상 메모리의 영역은 4GB입니다.

이 중 절반인 2GB는 OS(Kernel)에 의해 사용되고 프로세스가 온전히 사용하는 영역은 사실 2GB입니다.

이 2GB라는 영역에 C++에서 사용하는 메모리 구조(Code, Data, BSS, Heap, Stack)들이 전부 적재되게 됩니다.

우리가 아는 malloc은 heap 영역에 속한 메모리를 할당하는 함수인데 사실 이 함수가 호출 될 때마다 

OS가 메모리를 프로세스에 할당해주는 것이 아닙니다.

우리는 앞서 가상 메모리에 대해서 이야기 해보았습니다. 

다시 위에 이야기 중 일부를 언급하자면 가상 메모리는 페이지 단위로 나뉘어져있고 페이지는 4KB의 크기를 가지고 있습니다.

그럼, 여기서 과연 우리는 흔히 malloc을 이용할 때 KB단위로 heap 메모리를 요청할까요?

아닙니다. 보통 Byte단위의 메모리 할당을 요청합니다.

추가적으로 Heap이라는 개념은 가상 메모리에서 사용하는 개념이 아닙니다.

C++에서 사용하는 메모리 구조(Memory Layout)를 이용할 때 사용하는 용어이며 

가상 메모리를 Heap이라는 목적에 맞게 사용하게 된다는 식으로 이야기하는 것이 옳을 것입니다.


우선 제가 이야기하려는 큰 틀부터 이야기해드리겠습니다.

Windows는 단순히 malloc과 같은 Heap Memory만을 관리하는 API만을 제공하고 있지 않습니다.

Windows 는 프로그래머들이 메모리 관리를 보다 편하게 할 수 있도록 가상 메모리 관련 함수들을 세가지 방법으로 제공하고 있습니다.

(아래 글은 제프리 리처의 Windows VIA C/C++ 15장 애플리케이션에서 가상 메모리 사용 방법의 지문 중 일부분을 축약해 놓은 겁니다.)


Microsoft Windows는 메모리를 사용할 때 사용 목적에 따라 세 가지 서로 다른 방법을 제공합니다.

1) 가상메모리: 크기가 큰 객체나 구조체의 배열을 관리하는 데 최적의 방법

2) 메모리 맵 파일: 크기가 큰 데이터(일반적으로 파일에 저장되어 있는) 스트림을 관리하거나 

   단일 머신에서 수행 중인 다수의 프로세스 사이에서 데이터를 공유하고자 할 때 사용되는 체적의 방법

3) 힙: 크기는 작지만 개수가 많은 객체를 관리하는 데 최적의 방법




위의 이미지는 MSDN의 Managing Virtual Memory라는 게시글에서 Introduction 부분에서 삽입된 것을 가져온 것입니다.)

위 API 계층도를 보면 알 수 있겠지만 malloc과 free와 같은 Heap Memory API는 Virtual Memory API의 위에서 제작된 API입니다.

Windows 운영체제는 가상 메모리의 페이지들을 Free, Reserved, Committed 상태로 구분하는데 이것들은 각각 아래와 같은 가상 메모리 상태를 뜻합니다.


Free 상태는 최초 가상 메모리 생성시 모든 가상 메모리가 갖게 되는 상태로 물리 메모리와 비맵핑 상태이며 어느 애플리케이션에서도

사용하겠다고 예약하지 않은 상태입니다. 물리 메모리와 비맵핑상태임으로 당연히 읽기 쓰기가 불가능하고요.


Reserved 상태는 특정 애플리케이션에서 곧 사용하기 위해 예약한 상태입니다.  다른 애플리케이션에서 사용이 불가능합니다.

예약만 됬을 뿐 Free 상태와 마찬가지로 물리 메모리와 비맵핑 상태임으로 읽기 쓰기가 불가능합니다.


Committed상태는 애플리케이션이 가상 메모리와 물리 메모리를 맵핑시킨 상태입니다. 읽기 쓰기가 가능합니다. 

위 상태들은 Virtual Memory API(ex. VirtualAlloc VirtualFree)들로 상태를 변경시킬 수 있는데 

Virtula Memory API는 가상 메모리를 이용하기 위해서 가상 메모리의 상태를 제어하면서 메모리를 사용해야 합니다.

(Virtual Memory API 사용 예시: Reserving and Committing Memory)

메모리 할당이 필요하면 단순히 원하는 메모리 크기만 요청하고 해제하려면 메모리 주소만 필요하는 

malloc과 free에 비하면 까다롭습니다.

Heap Memory API는 페이지 단위의 메모리 할당(Reserved, Committed)작업들이 불필요하고 

힙 메모리 영역이 부족할 시 알아서 힙 메모리 영역의 크기가 자동으로 증가한다는 장점을 갖고 있습니다.

이런 것들이 가능한 이유는 OS의 힙 관리자(Heap Manager)의 존재로 가능합니다.

힙 관리자에 대해 자세히 이야기하고 싶지만 제프리리처의 Windows Via C/C++에서의 지문을 빌려서 이야기하자면...


"마이크로소프는 힙 매니저가 물리적 저장소를 헌제 커밋하고 디커밋하는지를 정확하게 문서화해두지 않았다.

 마이크로소프트는 지속적으로 힙에 대한 성능 테스트를 진행하고 있으며, 가장 안전하면서도 최적화된

 방법을 찾기 위해 다양한 시나리오를 테스트하고 있다. 애플리케이션과 하드웨어가 바뀌어감에 따라 최적화된

 방법들도 바뀌게 될 것이다."(제프리 리처의 Windows Via C/C++ 18장 힙(Heap) 지문의 일부분...)


Heap Memory API는 사용이 간단하다는 장점이 있지만 단점도 존재합니다.

우선 속도가 느리며 힙 관리자 메모리 영역을 관리함으로 프로그래머는 직접적으로 메모리 영역 관리가 불가능합니다.

추가적으로 Heap은 크기가 변하는 메모리 구조로 다른 메모리 영역을 침법할 수 있다는 불안 요소가 존재합니다.

이정도로 Windows에서의 가상 메모리에 대한 이야기를 마치고 다시 malloc과 free의 기능으로 넘어가겠습니다.


Visual Studio의 경우 프로세스에 default설정으로 할당되는 최초 Heap Size는 1MB입니다.(참고: Microsoft Docs: -Heap)

이와 같이 Process는 실행 시 OS로부터 많은 양의 메모리를 먼저 할당받은 후에 프로그래머가 힙 메모리 관련 함수를 호출 할 때 

힙 관리자를 통해서 원하는 크기 만큼의 메모리를 미리 할당받은 메모리에서 할당받거나 혹은 남은 메모리가 부족할 경우에는

추가적으로 힙관리자는 가상 메모리 덩어리(Memory Chunk)를 가져와서 그 중 일부분을 할당해줍니다.

반대로 해제시에 힙 관리자는 단순히 메모리 반환 뿐만이 아니라 해당 반환하는 메모리에 인접하는 곳에 미할당(혹은 해제된) 메모리 영역이

존재할 경우 이 두 메모리 블럭을 병합하는 과정도 수행합니다.

추가적으로 메모리 해제시 메모리 주소만 알려주는 걸로 반환이 가능한 이유는 할당시 메모리 블록의 크기 정보가 힙 관리자에 저장되기 때문입니다.

이 정보는 해당 메모리 블록 해제시에 사용되며 메모리 주소만으로도 메모리 블록 해제가 가능한 비밀의 열쇠입니다.


malloc

-힙 관리자에게 메모리 블럭 요청

-메모리 부족시 힙 관리자는 추가적으로 미할당 가상 메모리를 할당 받음

-할당된 메모리 블록 정보 힙관리자에 기록


free

-힙 관리자에 저장된 정보를 활용하여 메모리 블럭 반환

-반환한 메모리 블럭 근처에 미할당 메모리 영역이 존재 시, 해당 메모리 블록과 병합하여 하나의 메모리 블럭으로 구성



[클래스를 위한 메모리 관리 함수: new와 delete]


malloc과 free가 있는데 왜 new와 delete가 필요한걸까요?

파트 제목에서 추측할 수 있듯이 클래스를 통해 생성할 객체를 위한 것 입니다.

즉, 메모리 할당 해제와 더불어 생성자와 소멸자를 호출하기 위한 함수라는 겁니다.

추가적으로 malloc은 void*형태로 메모리 주소를 넘겨주어서 추가적인 타입 캐스팅 작업이 필요하지만

new는 타입 캐스팅 작업이 필요하지 않습니다.


그럼, 하나 더 생각해봅시다.

new[]와 delete[]는 어떨까요? 이것들의 생성자와 소멸자 호출은 어떻게 처리될까요?

new[]는 상관이 없습니다. []안에 몇개의 객체를 생성할 지를 숫자로 적으니 그 숫자만큼의 생성자를 호출해서 처리하면 되니까요.

그런데 delete[]는 어떻습니까?

delete[]는 단순히 메모리의 시작주소를 가리키는 포인터만을 놓을 뿐 몇 개의 소멸자를 호출해야 하는지는 코드 상에서는 알 수 가 없습니다.

그러면 어떻게 처리될까요?

인터넷의 바다를 해맨 결과 ISO CPP의 Wiki: MemoryManagement에서 답을 찾을 수 있었습니다.

현재 상업용 컴파일러들이 사용하는 방식은 "over-allocation"과 "associative array" 총 2가지입니다.

이 두가지는 모두 절충안이면 어느 하나도 완벽하지는 않습니다.


over-allocation 방식은 생성하려는 객체 배열이 필요로 하는 메모리에 4 또는 8 만큼의 추가 메모리를 더 할당합니다.

(참고적으로 4인지 8인지는 sizeof(size_t) 기계 종속 상수에 의해 결정된다고 합니다.)

그리고 추가적으로 할당된 메모리 공간에 생성하려는 객체 배열의 개수를 저장시키는 것입니다.(이 메모리 공간을 head segment라고도 합니다.)

new[]로 반환되는 주소값은 객체 배열의 개수를 저장시킨 메모리 다음번 즉, 객체 배열의 첫번째 주소값입니다.

이 방식은 associative array technique보다 더 빠르지만 delete[] 대신 delete를 사용하는 문제에 매우 민감합니다.

객체 배열을 매모리 해제 시, 실수로 delete[] 대신 delete를 사용할 경우 유효한 주소 공간이 아니여서 Heap영역을 손상시킬 것입니다.


associative array 방식은 전역으로 연관 배열 객체를 생성해서 객체 배열의 생성 정보(개수)를 저장시키는 방식으로

이 방식은 over-allocation technique보다는 느리지만 delete[] 대신 delete를 사용하는 문제에 대해 덜 민감합니다.

delete를 사용해서 프로그램 에러를 만들더라도 배열 상에서 첫번째 객체만 파괴될 뿐 Heap에는 영향이 가지 않습니다.


컴파일러가 어떤 방식으로 delete[]를 구현하던 간에 new[]로 생성한 것은 delete[]로 해제하기만 한다면 문제는 발생하지 않습니다.


(over-allocation과  associative array에 관한 보다 자세한 정보 혹은 원문이 보고싶다면 각각 ISO CPP의 wiki에서 Memory Managet섹션에 위치한

How do compilers use "over-allocation" to remember the number of elements in an allocated array?와

How do compilers use "associative array"to remember the number of elements in an allocated array?를 참고하시기 바랍니다.)


추가적으로 Effective C++에 소개된 코딩 규약 하나를 덧붙이는 것으로 이번 포스팅을 마치도록 하겠습니다.

항목 16: new및 delete를 사용할 때는 형태를 반드시 맞추자!(Effective C++)



Comments