C++: 자원 관리(Resource Management)
C++: 자원 관리(Resource Management)
2-1 자원 관리(Resource Management)
- 프로그램이 실행되는 과정에서 연산되고 참조되는 데이터는 모두 메모리 상에 존재.
- 데이터는 생성 시점이나 방식에 따라 운영체제의 관리 하에 수명에 따라 특정 메모리 영역(Segment)에 할당 되며, 크게 4개로 구별된다. 주소값이 낮은 곳 부터 차례대로 코드, 데이터, 힙, 스택으로 나뉜다.
코드(Code)
- 텍스트(Text) 세그먼트, 코드(Code) 세그먼트로도 불린다.
- 기계어로 컴파일된 명령어들이 저장되는 영역으로 프로그램의 작성 코드들이 보관된다.
- 안정성을 위해 보통은 읽기 전용(Read-Only)로 설정된다.
- 프로그램 시작시에 후술할 스택, 힙에 덮어씌워지는걸 방지하고자 낮은 주소값에 배치된다.
데이터(Data)
- 전역변수(Global Variable)과 정적변수(Static Variable)이 저장된다.
- 초기값이 있는 데이터영역(Initialized Data Segment)과 초기값이 없는 영역(Block Started By Symbol, BSS)로 나뉘며, 전자가 더 낮은 주소값에 위치한다. BSS에 있는 변수들은 처음 시작시 0으로 초기화된다.
1 2 3 4 5 6 7 8 9
//Initialized Data에 저장 int GlobalNum = 10; static int Num = 15; //BSS, Uninitialized Data에 저장 char Globalchar; static bool Boolean; int main(){ int a = GlobalNum; }
힙 메모리(Heap Memory)
- 실행 도중 입력되거나 생성되는 데이터가 저장되는 메모리 공간.
- new 키워드를 이용한 할당이나 malloc, calloc등 메모리 할당 함수를 통해 생성되는 데이터들이 저장된다.
- 필요에 따라 사이즈가 더 커질 수 있으며, 기본적으로 스택보다 많은 영역을 할당받는다.
- 매우 큰 데이터를 다루거나 할당해야 하는 경우, 스택 메모리에서는 불가능해도 힙 메모리에선 가능한 경우가 있다.
1 2 3 4 5 6 7 8
int main(){ //힙에 생성 int* a = new int; int* b = (int*)malloc(sizeof(int)); //힙에서 삭제 delete a; free(b); }
- 매우 큰 데이터를 다루거나 할당해야 하는 경우, 스택 메모리에서는 불가능해도 힙 메모리에선 가능한 경우가 있다.
스택 메모리(Stack)
- 컴파일 과정에서 이미 생성되는 데이터들이 저장되는 메모리 공간.
- 상수, 리터럴, 함수등 변하지 않을 값들이 배치된다.
- 함수가 호출되거나 종료됨에 따라 자동적으로 생성 및 삭제되며, 힙 메모리보다 빠르게 동작한다.
- 힙이 증가할때는 주소값이 높아지는 영역으로 증가하며, 스택이 증가할 때는 주소값이 낮아지는 영역으로 커진다. 일정량 이상 사용하여 사용 가능한 공간이 남지 않으면 힙과 스택의 영역이 겹쳐 충돌하게 된다.
- 이는 스택 오버플로우(Stack Overflow)를 발생시키며, 함수의 비정상적인 작동을 야기할 수 있다.
- 이를 방지하기 위해 운영체제는 힙과 스택의 충돌 전에 감지할 수 있는 기법들을 운영체제가 운용하며, 프로그램을 강제 종료할 권한을 가진다.
1 2 3 4 5 6 7 8
void Temp(){ int a = 1; } int main(){ //함수 호출과 함께 a 스택에 생성 Temp(); //함수 종료와 함께 스택에서 삭제 }
- 언리얼 엔진에서는 프로그래밍 할때 여러 이유로 힙 메모리를 주로 사용하게 된다.
- 함수의 수명을 넘어 프레임간의 유지되는 수명의 유연한 관리
- 후술할 리플렉션 시스템의 반영
- 독자적인 가비지 컬렉션(Garbage Collection)
스마트 포인터(Smart Pointer)
- 기존 포인터에는 소유권 개념이 없어 메모리가 실제 사용중인지 아닌지 추적하는데 설계와 노력을 필요로 한다.
- 여러 포인터가 가리키는 메모리 주소가 어떤 한 포인터에 의해서 해지되었을 때, 다른 포인터들은 해당 주소에 있는 정상적이지 않은 값을 읽게 될 수 있으며, 이렇게 정상적이지 않은 주소를 가진 포인터를 허상 포인터(Dangling Pointer)라고 한다.
1 2 3 4 5 6 7 8 9 10 11 12 13
int main(){ int* a = (int*)malloc(sizeof(int)); int* b = a; *a = 10; //둘다 10 출력 cout << *a << endl; cout << *b << endl; free(a); //이상한 값 출력 cout << *b << endl; }
- 또한 사용하지 않는데 정상적으로 해지되지 않는 메모리가 계속 쌓이게 되면, 사용가능한 메모리 용량이 줄어들며 성능을 잡아먹고 메모리 누수(Memory Leak)가 일어난다.
- C++에서는 메모리 공간을 보다 안정적으로 관리하고, new/delete를 사용하지 않는 자동적인 방식을 구현하기 위해 스마트 포인터(Smart Pointer)의 개념을 도입했다. 이 3가지 종류가 있으며, memory 라이브러리에 저장되어 있다.
유니크 포인터(Unique Pointer, unique_ptr)
- 오직 유니크 포인터 자신만 소유할 수 있다. 다른 유니크 포인터가 소유하려고 한다면 컴파일 에러를 보인다..
- 복사가 불가능하며, std::move를 이용해 소유권을 다른 유니크 포인터로 이동할 수 있다.
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
#include <iostream> #include <memory> //스마트 포인터 라이브러리 using namespace std; class X{ public: X(int val = 0){ cout << "created!" << endl; num = val; } ~X(){ cout << "destroyed!" << endl; } void ShowNum(){ cout << num << endl; } private: int num; }; int main(){ unique_ptr<X> A = make_unique<X>(15); //Unique pointer 선언, created! 출력 A->ShowNum(); //15 출력 unique_ptr<X> B; //B = A; 오류! 유니크 포인터는 복사 불가능 B = move(A); //A에서 B로 소유권 이동, A는 nullptr로 바; B->ShowNum(); //15 출력 return 0; //범위 이탈로 B 자동으로 소멸, 관리하던 객체 X도 소멸로 destroyed! 출력 //일반포인터라면 자동 해제되지 않고 delete 로 직접 삭제해줘야함. }
공유 포인터(Shared Pointer, shared_ptr)
- 소유권 대신 메모리가 참조되고 있는 횟수인 레퍼런스 카운트를 추적한다.
- 레퍼런스 카운트가 0으로 떨어지면, 아무도 참조하지 않으면 자연스럽게 메모리 해제.
- use_count()를 사용해 레퍼런스 카운트를 확인.
- reset()으로 객체 소유를 해제하거나 다른 객체로 변경.
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
#include <iostream> #include <memory> using namespace std; class X{ public: X(int val = 0){ cout << "created!" << endl; num = val; } ~X(){ cout << "destroyed!" << endl; } void ShowNum(){ cout << num << endl; } private: int num; }; int main(){ shared_ptr<X> A = make_shared<X>(15); //shared pointer 선언, created! 출력, 레퍼런스 카운트 1 shared_ptr<X> B = A; //shared pointer는 unique랑 다르게 복사 가능, 레퍼런스 카운트 2 A->ShowNum(); //15 출력 B->ShowNum(); //똑같이 15 출력 cout << A.use_count() << endl; //2 A.reset(); cout << B.use_count() << endl; //1 B.reset();//레퍼런스 카운트 0, 객체 소멸, destroyed! 출력 return 0; }
약한 포인터(Weak Pointer, weak_ptr)
- 공유포인터는 서로 참조하게 되었을 때 순환 참조로 인한 메모리 누수, 오류를 발생 시킬 수 있음.
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 32 33 34 35 36
#include <iostream> #include <memory> using namespace std; class Y; class X{ public: X(){ cout << "X created!" << endl; } ~X(){ cout << "X destroyed!" << endl; } shared_ptr<Y> ptr; }; class Y{ public: Y(){ cout << "Y created!" << endl; } ~Y(){ cout << "Y destroyed!" << endl; } shared_ptr<X> ptr; }; int main(){ //포인터 2개 생성, X created!, Y created! 출력 shared_ptr<X> A = make_shared<X>(); shared_ptr<Y> B = make_shared<Y>(); //순환참조 A->ptr = B; B->ptr = A; cout << A.use_count() << endl; //레퍼런스 카운트 2 return 0; //서로 참조중이여서 메모리 사용으로 인식, 객체 소멸 안됨. Destroyed! 출력 없음. }
- 이를 해결하기 위해, 공유포인터처럼 참조는 하지만, 레퍼런스 카운터를 증가시키지 않는 포인터인 약한 포인터를 사용.
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 32 33 34 35
#include <iostream> #include <memory> using namespace std; class Y; class X{ public: X(){ cout << "X created!" << endl; } ~X(){ cout << "X destroyed!" << endl; } shared_ptr<Y> ptr; }; class Y{ public: Y(){ cout << "Y created!" << endl; } ~Y(){ cout << "Y destroyed!" << endl; } weak_ptr<X> ptr; //약한 포인터로 교체 }; int main(){ //포인터 2개 생성, X created!, Y created! 출력 shared_ptr<X> A = make_shared<X>(); shared_ptr<Y> B = make_shared<Y>(); //순환참조 A->ptr = B; B->ptr = A; cout << A.use_count() << endl; //레퍼런스 카운트 1 return 0; //순환 참조 없음. 객체 삭제, Destroyed! 출력 }
- 공유포인터는 서로 참조하게 되었을 때 순환 참조로 인한 메모리 누수, 오류를 발생 시킬 수 있음.
- 간단한 로그 출력 프로그램 예시
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 32 33 34 35 36 37 38 39 40
#include <iostream> #include <memory> using namespace std; class Logger{ private: int logCount; public: Logger(){ logCount = 0; } ~Logger(){ cout << "Logger instance destroyed" << endl; } void logInfo(string message){ cout << "[info] " << message << endl; logCount++; } void logWarning(string message){ cout << "[Warning] " << message << endl; logCount++; } void logError(string message){ cout << "[Error] " << message << endl; logCount++; } void showTotalLogs(){ cout << "Total logs: " << logCount << endl; } }; int main(){ //로그 생성기 생성 unique_ptr<Logger> logger = make_unique<Logger>(); //로그 출력 logger->logInfo("Actor Spawned"); logger->logWarning("Accessing potentially uninitialized object"); logger->logError("Index out of bound"); //로그 횟수 출력, 3 logger->showTotalLogs(); return 0; //소멸자 호출, Logger instance destroyed 출력 }
얕은 복사(Sallow Copy)
- 포인터의 주소값만 복사하는 방식. 참조 형식의 전달과 유사하며, 복사 비용이 적다.
- 스마트 포인터 시작 부분에서 언급했듯이, 포인터의 주소값으로 가리키는 위치의 객체가 원본 객체에 의해서 소멸되면 유효하지 않은 값을 가리키는 허상 포인터가 될 수 있음.
깊은 복사(Deep Copy)
- 포인터가 가리키는 클래스 객체의 값들을 새로운 메모리 영역에 완전히 복제하는 방식.
- 복사 이후에는 완전히 다른 객체인 상태라, 원본의 삭제 여부와 상관없이 객체를 유지할 수 있지만, 한 객체에서의 수정 사항은 다른 쪽에 전달되지 않음.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
#include <iostream> using namespace std; int main(){ int* a = new int(3); int* b = new int(*a); *a = 10; cout << *a << endl; //10 cout << *b << endl; //3 delete a; //a 객체 삭제 cout << *b << endl; //3 delete b; //b 객체 삭제 }
가비지컬렉션(Garbage Collection)
- 메모리 사용을 주기적으로 체크하여 사용되지 않는 데이터, 메모리를 자동으로 해제해주는 기능으로, Java, Python같은 다른 언어에서는 내장되어있는 기능이다.
- C++에는 이 기능이 내장되어있지 않으며, 동적 할당된 메모리의 수동 해제, 스마트 포인터 사용, GC의 개별적인 구현 등을 통해 메모리를 관리한다. GC 구현에 쓸 수 있는 알고리즘은 여러가지가 있다. 장단점과 상황에 따라 다양한 GC 방식을 구현/사용할 수 있다.
- 참조 카운팅(Reference Counting): 공유 포인터에서 쓰인 기능과 동일한 방식이다. 참조된 횟수를 추적하여 참조되지 않을 때 메모리를 해제한다.
- 마크 앤 스윕(Mark & Sweep)
- 객체들을 훑어 사용되지 않는 객체들을 표시(Mark)한 후 해당 객체들을 청소, 쓸어버리는 방식(Sweep)으로 작동한다.
- 속도는 다른 알고리즘에 비해 느린편이나 순환 참조 문제가 없고 비교적 간단하며 보편적인 방식이다.
- 마크 앤 컴팩트(Mark & Compact)
- 기존 마크앤 스윕에서 스윕 단계 대신 계속 쓰는 객체들의 주소를 옮겨서 정리하는 방식이다.
- 메모리 파편화(Fragmentation) 문제가 없으나, 주소를 옮기는데 비용이들어 마크 앤 스윕보다 느릴 수 있다.
- 복사 GC(Copying GC)
- 2개의 힙 영역으로 나누고, 살아있는 객체들을 골라 다른 쪽 힙 영역으로 복사한 후 기존 힙 영역을 통째로 초기화하는 방식.
- 작동 속도가 빠른 편이지만, 이 경우 힙 영역을 절반밖에 쓰지 못한다.
- 이 외에도
- 언리얼 엔진에서는 독자적인 GC 기능이 구현되어 있으며 이는 마크 앤 스윕 메커니즘으로 구동한다.
- 루트 셋(Root Set)에 포함된 객체들을 확인한다.
- 루트 셋에서 직간접적으로 도달 가능한 객체들을 마크해놓는다. (그래프 탐색방법과 같다.)
- 마크되지 않은 개체들의 메모리를 회수한다.
- 언리얼 엔진에서 인식하는 객체인 UObject에는 GC의 동작을 제어하기 위해 플래그(Flag)를 사용할 수 있다.
- RF_RootSet: 해당 UObject가 루트셋의 일부로 관리됨을 알리는 플래그. AddToRoot()/RemoveFromRoot() 함수로 설정/해제.
- RF_BeginDestroyed: 메모리에서 실제로 해제되기 전까지의 작업을 담당하는 함수인 BeginDestroy()의 호출을 나타내는 플래그.
- RF_FinishedDestroyed: 메모리가 해제됬음을 알리는 FinishDestroy() 함수의 호출을 알리는 플래그.
언리얼 리플렉션(Unreal Reflection)
- 리플렉션(Reflection): 컴파일 시점에 정해진 정보가 아닌, 실행중에 동적으로 프로그램 스스로의 정보를 조사하고 처리할 수 있는 기능, 코드를 코드를 통해 다루는 기능
- 자신의 구조에 대한 조사. (클래스, 필드, 메서드 등)
- 프로그램 구조 자체를 데이터처럼 다루어 반영.
- C++에는 리플렉션 기능이 따로 존재하지 않으며, 언리얼 엔진은 독립적인 리플렉션 시스템을 구축하여 사용함.
- 스코프 단위가 아닌 프레임 단위의 데이터 보존/관리.
- 블루프린트, 에디터등 작업 환경의 통합.
Unreal Header Tool(UHT)
- 작성된 소스코드를 컴파일 하기 전에 UObject 형태로 먼저 변환해주는 툴.
- 리플렉션 메크로를 클래스, 변수, 함수 등의 선언중 특정한 위치에 놓음으로서 UHT에서 인식하고 .generated 파일을 생성하도록 알림.
- UCLASS()
- 클래스 정의 앞에 위치
- 해당 클래스를 리플렉션에 등록.
- UPROPERTY():
- 맴버 변수 선언 앞에 위치.
- 변수를 리플렉션에 노출.
- UFUNCTION()
- 맴버 함수 선언 앞에 위치.
- 함수를 리플렉션에 노출.
- USTRUCT()
- 구조체 정의 앞에 위치.
- 구조체를 리플렉션에 등록.
- GENERATED_BODY()
- 클래스나 구조체 첫줄에 위치
- UHT에게 필요한 코드를 삽입할 위치를 알림.
- UCLASS()
- 액터 클래스의 헤더파일 예시
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 32
// Fill out your copyright notice in the Description page of Project Settings. #pragma once #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "MyActor.generated.h" UCLASS() class MYPROJECT2_API AMyActor : public AActor { GENERATED_BODY() public: // Sets default values for this actor's properties AMyActor(); UFUNCTION(BlueprintCallable) void NewFunction(int Val); UPROPERTY(BlueprintReadOnly, Category = MyActor) int Num; protected: // Called when the game starts or when spawned virtual void BeginPlay() override; public: // Called every frame virtual void Tick(float DeltaTime) override; };
- 리플렉션(Reflection): 컴파일 시점에 정해진 정보가 아닌, 실행중에 동적으로 프로그램 스스로의 정보를 조사하고 처리할 수 있는 기능, 코드를 코드를 통해 다루는 기능
This post is licensed under CC BY 4.0 by the author.