실제로 os는 메모리를 페이지 맵핑을 하기 때문에 프로세스에서 관리하는 논리적 주소와 실제 물리적 주소는 다르다. 운영체제에게 메모리를 요청하면 최소 페이지 사이즈(4kb)로 할당 해준다. 프로세스에서 접근하는 메모리 주소는 맵핑된 주소기 때문에 절대 다른 프로세스와 중복되지 않으니 걱정하지 않고 써도 된다.
실제로 프로세스 메모리를 조작하기 위해서는 프로세스 메모리가 linear 한 상태가 되어야 한다.
그리고 물리적 메모리도 linear하게 매핑해서 쓰다보니 메모리 할당 반납하는 과정에서 메모리 파편화가 일어난다. 물리적 주소의 메모리 파편화를 external 파편화라고 한다. 그래서 중간에 페이지 맵핑 테이블을 두고 프레임(물리적 주소에서는 페이징을 프레임이라고 함)단위로 아무대나 할당하고 맵핑 테이블을 이용해서 프로세스에서는 linear한 생태로 사용하겠금한다.
반대로 이렇게 하면 논리적 메모리는 linear를 유지하기 떄문에 이쪽에도 파편화가 생길 수 있는데 이를 internal fragment라고 하고 이는 external fragment 보다 작기 때문에 페이징 방식을 쓴다.
결론
이후 단계를 보기 위해 이해해야 하는 내용은 다음과 같다.
- 논리주소를 liner한 상태로 유지하고 물리주소의 external 파편화를 줄이기 위해 페이징 단위로 테이블 참고하여 맵핑 하는 과정을 거친다.
- 뿐만 아니라 페이지 단위로 메모리에 속성을 부여하기도 한다(ex. 읽기용, 쓰기용 등) 왜냐하면 1바이트 단위로 메모리 속성을 지정하는 것은 그 속성 정보를 저장하기 위해서 그만큼의 메모리가 또 필요하기 때문에 비효율적.
전역 오버로딩의 경우 모든 new, delete 연산자에 적용 되므로 위험한 방식 입니다. 그렇다면 메모리 관리를 하기 위해서는 클래스별 오버로딩을 쓰면 될까요? 가능합니다. 하지만 메모리 관리를 좀더 범용적으로 하려면 클래스별로 오버로딩을 작성하는 일도 여간 귀찮은 작업일 것 입니다. 매크로 등을 이용해서 조금은 개선 시킬 수 있지만요.
그래서 여기서는 저희만의 new, delete를 정의해서 사용하는 방법을 소개합니다. xnew, xdelete로 구현해 봅시다.
new, delete 동작 방식을 이해하고 있다면 위 코드를 이해하는 큰 어려움이 없습니다. 템플릿 코드로 바꿔준게 전부니까요. 이 코드를 이해 하기 위해서 new, delete 동작 방식 이외에 선수 지식이라면 템플릿 기초 문법, 템플릿 가변 매개변수, 전달참조(+forward) 정도 입니다. 위 코드가 이해되지 않는다면 해당 내용을 먼저 학습하고 위 코드를 보시면 어렵지 않게 이해할 수 있습니다.
본격적으로 메모리풀링을 하기 앞서 new, delete 연산자 흐름 가로채서 custom new, xdelete를 정의 했습니다. 이제 다음 단계에서 위 코드에 보이는 malloc 과 free 부분 역시 저희만의 메모리 할당기/해제기를 만들어 대체 해보도록 합시다.
이 글은 [인프런-게임서버 만들기 by rookiss]를 내용을 토대로 개인적으로 학습한 내용을 종합하여 정리 했습니다.
CustomAllocator를 만들기 앞서 핵심이 되는 new, delete 연산자 오버로딩에 대한 설명이 필요하다.
new, delete 연산자는 메모리를 할당, 해제하는 연산자로 이 연산자를 오버로딩 하여 메모리를 효과적으로 관리할 수 있다.
하지만 다양한 메모리 할당 전략(컴파일러에 의한)을 모른다면 오히려 독이 될 수도 있다. 그러니 다양한 메모리 할당, 해제 원리나 정책을 이해하고 사용 했을 때 new, delete 오버로딩은 빛을 발할 것이다. 하지만 효율적인 메모리 관리 또는 순수한 학습용이 아니라도 디버깅(개발시)에 메모리 이슈를 해결하기 위한 안전 도구를 만드는데도 유용할 것으로 보인다. 실제로 이 글은 메모리를 효과적으로 관리하기 위한 기법과 메모리 이슈를 쉽게 잡아 내기 위한 기법 두가지를 설명하는 것을 목표로 한다.
자, 그럼 new, delete 연산자의 오버로딩에 대해서 알아보자.
new 와 delete의 구체적인 동작 방식
new 표현식
Knight* knight = new Knight();
위 문장을 new-표현식이라고 합니다. 이 문장은 두가지 일을 합니다. 먼저 operator new를 호출하여 Knight 객체에 대한 메모리를 할당하고, 객체의 생성자를 호출합니다. 생성자 실행이 끝나고 나서야 객체에 대한 포인터가 리턴 됩니다.
실제로 어셈블러를 통해서 'operator new 호출 -> 생성자 호출 -> 객체 주소 리턴' 되는 과정을 알 수 있습니다.
흥미로운 점은 operator new가 실제로 void*를 반환하는데 이때 바로 객체의 포인터를 포인터 변수에 대입하지 않고 생성자 부터 호출되네요. 그리고 생성자가 호출되기 전에 cmp 비교를 통해서 분기가 나뉘는 것도 볼 수 있네요... 아직은 제가 이해하기 힘든 영역 입니다...
delete 표현식
delete knight;
위 문장은 delete-표현식이라고 합니다. 이 문장 역시 new-표현식과 마찬가지로 두가지 일을 합니다. 먼저 소멸자를 호출하고 operator delete를 호출해서 메모리르 해제 합니다.
new 표현식과 다르게 'scalar deleting destructor'를 호출 하고 scalar deleting destructor 내부에서 소멸자와 operator delete를 호출하는 것을 확인 할 수 있습니다. scalar deleteting destructor는 컴파일러가 사용하는 함수라고 하는데 왜 new-표현식과 다르게 동작하는지는 잘 모르겠습니다.. 추측하자면 어떤 버전의 operator delete 연산자가 호출 될지는 호출된 operator new 버전에 따라 바뀌기 때문에 어떤 operator delete를 호출해야 할지에 대한 정보가 필요하기 때문에.. 이런 과정을 거치는게 아닐까 생각 해봅니다...
요약하자면,
new-표현식은 ==> '메모리 할당 -> 생성자 호출'
delete-표현식은 ==> '소멸자 -> 메모리 해제'
여기서 또 한가지 중요한 부분은 new, delete 표현식 자체는 오버라이딩 할 수 없습니다. 단지 new, delete 연산자를 오버로딩 하는 것 입니다. 즉 메모리 할당, 해제를 커스터마이징 할 수 있는 것이지요.
new 표현식과 operator new
new-표현식에는 여섯가지 종류가 있고, 각 버전마다 적용되는 operator new가 따로 있다.
그중 2개는 배치 new 연산자로 placement new operator라 부른다. 이 연산자를 사용하면 기존에 확보된 메모리에서 객체를 생성하고(생성자를 호출) 한다. 이는 메모리풀을 만들 때 유용하다. 또한 이 배치 new 연산자는 오버로딩이 금지되어 있다.
delete-표현식은 두가지 종류가 있다. 하지만 operator delete는 operator new와 동일하게 6종류가 있다. 왜 일까?
operator delete는 operator new와 항상 대응되어 호출된다. operator new 종류를 보면 일반버전 2개, 익센션버전2개, 배치버전 2개가 있다. operator new의 익센션 버전이 호출된단다면 operator delete 익센션 버전이 호출된다. 하지만 이는 개발자가 예측하고 처리하는 부분이 아니다. 따라소 익센션 버전 delete-표현식은 존재하지 않는다. 배치버전 delete-표현식 역시 이와 동일한 이유다.
함수는 함수 포인터 변수로만 보관할 수 있고, 함수자(functor)는 객체 타입으로만 보관이 가능합니다.
std::function을 사용하면 모든 collable 들을 객체형태로 보관할 수 있습니다. 심지어 람다식도 보관할 수 있습니다.
여기서 collable은 소괄호를 이용하여 호출 가능한 모든형태를 말합니다.
물론 auto를 통해서도 collable 들을 보관할 수도 있습니다. auto와 std::function의 차이는 뒤에 설명하겠습니다.
일단, 간단한 사용 예시를 봅시다.
간단한 사용 예시
#include <iostream>
#include <functional>
using namespace std;
int func(const string& a)
{
cout << "func1 " << a << endl;
return 0;
}
struct functor
{
void operator() (char c)
{
cout << "functor : " << c << endl;
}
};
int main()
{
function<int(const string&)> f1 = func;
function<void(char c)> f2 = functor();
function<void(void)> f3 = []() { cout << "lamda func" << endl; };
f1("hi");
f2('k');
f3();
}
C에서의 함수 포인터에서도 멤버변수를 보관할 떄 문법이 약간 달라졌던 것 처럼 std::function 을 이용할 때도 동일합니다. 당연히 멤버함수는 객체 의존적인 함수이기 떄문입니다. 멤버함수는 암무적으로 자신을 호출한 객체를 인자로 암묵적으로 받고 있기 때문에 std::functio에 타입에 멤버함수의 클래스타입을 첫번째 인자로 명시해야 합니다.
함수 포인터와 마찬가지로 멤버함수를 보관할 때는 명시적으로 멤버함수 앞에 &를 통해 함수 주소를 반환하고 있습니다. 이게 FM 이죠. 정적, 전역함수의 경우 컴파일러에 의해서 암묵적으로 함수이름이 함수주소를 반환해주고 있었던거니 까요.
멤버함수를 보관하는 std::function 객체
#include <iostream>
#include <functional>
using namespace std;
class Knight
{
public:
void AttackByFist() const
{
cout << "AttackByFist" << endl;
}
void AttackBySword(int addDamage)
{
cout << "AttackBySword" << endl;
}
};
int main()
{
function<void(const Knight&)> attackByFist = &Knight::AttackByFist; // <void(Knight&)> 형태로도 const 멤버 함수를 보관됩니다. 이유는 모르겠습니다.
function<void(Knight&, int)> attackBySword = &Knight::AttackBySword;
Knight k;
attackByFist(k);
attackBySword(k, 100);
}
float ratio = 0.3;;
auto addLamda = [&ratio](int a, int b) -> float {
float ret = (a + b) / ratio;
return ret;
};
float ret = addLamda(3, 4); // 2.3333
[캡쳐](인자) -> 타입 { 구현부 }
캡쳐(capture)
함수 객체 내부에 변수를 저장하는 개념과 유사하다. '구현부'에서 외부 변수를 사용할 때 방식을 지정할 수 있다.
방식에는 복사방식, 참조방식이 있다.
예)
[=] : 모든 값을 구현부에서 복사 방식으로 사용합니다.
[&] : 모든 값을 구현부에서 참조 방식으로 사용합니다.
[a, &b] : b변수를 참조 방식으로 사용합니다. a변수는 복사 방식으로 사용합니다.
인자
함수 매개변수와 동일한 형태로 매개변수를 받습니다.
예) [](int, int)
타입
'-> bool' 과 같은 형태로 지정하지만 실제로 구현부 return 값을 통해 자동 추론 되기 때문에 생략 가능 합니다.
구현부
일반 함수 구현부와 동일하게 작성됩니다.
주의점
MS에서는 캡쳐방식에 [=], [&] 사용을 지양합니다. 이는 명시적인 캡쳐 방식을 지정하지 않을 경우에 문제를 인식하기 쉽지 않기 때문입니다.
문제가 되는 경우를 아래 코드를 통해서 살펴 봅시다.
class Knight
{
public:
auto ResetHpJob()
{
// this 포인터가 복사된 형태
// 캡터 부분만 봐서는 한눈에 알아보기 힘들다.
auto f = [=]()
{
_hp = 200;
};
//auto f = [this]()
//{
// this->_hp = 200;
//};
return f;
}
public:
int _hp = 100;
};
해당 코드는 클래스 내부에서 람다를 정의하고 있습니다. 그리고 ResetHpJob 함수에서는 멤버변수를 접근하는 람다를 반환하고 있습니다. 이 람다를 외부에서 호출하게 되면 문제가 됩니다.
왜냐하면 람다 정의부분은 객체에 의존적인 변수에 접근하고 있고 람다가 호출되는 시점에 객체가 존재한다는 보장 이 없기 때문입니다.
이런 코드 작성을 미리 방지하려면 어떻게 해야 할까요??
클래스 내부이기 때문에 멤버변수를 의심없이 접근하고 있지만 실제로는 'this->멤버변수' 와 같은 형태로 접근 되어야 합니다. 따라서 캡쳐 방식을 지정해야 합니다. 이때 [=] 와 같은 방식으로 지정하게 된다면 '='가 this 포인터를 복사하기 위함인지 한눈에 파악하기 어렵습니다.
이런 이유로 캡쳐 방식을 지정할 때는 변수별로 별도로 지정하는 것이 코드 가독성을 높이고 이는 곧 디버깅을 쉽게 합니다.
- initializer_list<T> 생성자를 이용하여 stl container vector와 같은 방식으로 초기화 할 수 있다. 이때 일반적인 생성자 시그니처와 initailizer 초기화 방식의 시그니처가 동일한 경우 (), {}에 따라 다른 생성자가 호출된다. 이는 초기화 호출방식에 대한 혼란을 야기시킨다.
중괄호 초기화 방식의 축소변환은 확실히 개발단계에서 장점이지만 이고 개인의 취향에 따른 선택사항이지만 중괄호 초기화로 인해 생성자 호출 방식에 있어서 분기점이 생긴다. 이는 팀단위 개발에서는 코드 가독성을 떨어뜨릴 것 같다. {}초기화는 약속된 특정 클래스에서만 합의하에 사용하는게 좋을 것 같다. 애초에 ms도 그렇게 사용하는 것(?) 같다.
#include <iostream>
#include <vector>
using namespace std;
class Knight
{
public:
Knight()
{
cout << "Knight 기본 생성자" << endl;
}
Knight(const Knight& k)
{
cout << "Knight 복사 생성자" << endl;
}
Knight& operator = (const Knight& k)
{
cout << "Knight 대입 연산자" << endl;
hp = k.hp;
return *this;
}
public:
int hp = 100;
};
class Mage
{
public:
Mage()
{
}
Mage(int a, int b)
{
cout << "Mage(int int)" << endl;
}
Mage(initializer_list<int> li)
{
cout << "Mage(initailizer_list)" << endl;
}
};
Knight Func()
{
return Knight();
}
int main()
{
// 다양한 초기 방법
{
// 일반 자료형
int a = 0;
int b(0);
int c = (0);
int d{ 0 };
int e = { 0 };
// 객체 타입
Knight k1; // k1 기본 생성자
Knight k2 = k1; // k2 복사 생성자
Knight k3; // k3 기본 생성자
k3 = k1; // k3 대입 연산자
Knight k4{ k2 }; // k4 복사 생성자.
}
// !주의!
{
Knight k5(); // knight k5; 이 기본생성자를 호출하는 거고 knight k5()는 함수 선언문이다. void형 매개변수에 리턴값이 knight인 k5.
Knight k6{}; // 이건 기본생성자 버전.
}
// {} 초기화 장점 - 축소 변환 방지
{
// 일반 초기화시 축소됨
double x = 1.34;
int y = x; // 컴파일에서 경고
int z = { x }; // 컴파일에서 에러
}
// {} 초기화 장점 - 배열과 같은 방식을 객체 초기화 가능.
{
// 번거로운 초기화 방식
vector<int> v1;
v1.push_back(1);
v1.push_back(2);
v1.push_back(3);
// {}를 이용한 초기화 방식. 마치 배열 초기화 방식과 유사
int arr[] = { 1,2,3,4,5 };
v1 = { 1,2,3,4 };
}
// {} 문제점 - 아래 두 초기화 방식은 다른 생성자를 호출한다. - why?
{
vector<int> v1{10 , 1}; // 벡터 크기를 10으로 지정하고 모든 요소를 1로 초기하는 생성자 호출.
vector<int> v2(10, 1); // 벡터에 10, 1 두 값을 초기화하는 생성자 initalizer_list 생성자 호출.
}
// {} 문제점 재현
{
Mage m{ 3,4 }; // initailizer 생성자가 정의되어 있으면 Mage(int a, int b) 생성자는 호출되지 않습니다.
}
return 0;
}