WinCNT

Memory and Object Pool 본문

게임 프로그래밍(학습 내용 정리)/컴퓨터 개론

Memory and Object Pool

WinCNT_SSS 2022. 1. 10. 12:44

제프리 리처의 Windows via C/C++(복간판)을 참고하며 정리한 문서입니다.

 

메모리는 모든 영역 전반에 걸쳐서 있다

네트워크 또한 시스템의 하부로 볼 수도 있지? 않을까?

 

Object Pool

오브젝트 = 객체, 실제로 메모리에 올라와 있는(올라와야 하는) 것들

 

메모리

누가 관리하나? - OS

    관리하는 기법으로는 페이징이 있다

    하지만 OS마다 구현은 다를 것

누가 사용하나? - 프로세서(CPU)

 

메모리는 가상주소공간(논리적 주소)와 물리적 주소로 나눌 수 있다

논리적 주소를 통해 물리적 주소를 찾아서 데이터를 가지고

CPU 안에 있는 메모리(즉, 레지스터)에 넣는다

 

메모리 구조

코드 영역(text) = 실행할 프로그램 코드(기계어)

데이터 영역

  • 전역 변수와 정적 변수
  • initialized data, uninitialized data

스택 영역 = 함수 호출(스택 프레임), 지역변수와 매개 변수

  • 컴파일 시에 메모리의 크기가 결정된다(= 정적 할당)
    컴파일되서 나온 exe파일은 이미 스택 메모리 사이즈가 결정되어 있다

힙 영역

  • 사용자에 의해 동적으로 할당되고 해제(= 동적 할당)
    new나 malloc 등이 실행될 때 할당된다
    (미리 알 수 없기 때문에 메모리의 크기를 알 수 없음)
  • 런타임 시에 크기가 결정된다
  • 프로세스마다 힙 영역은 존재

힙은 low address에서 high로

스택은 high address에서 low address로

둘이 만나면 메모리 full이 되어 버린다

 

여기서 드는 의문이 하나 있을 수 있다

new/delete를 할 때마다 OS가 주소를 할당하는 걸까?

사실 그러한 방식은 효율이 많이 떨어지기 때문에 다른 방식을 사용한다

그럼 실제로는 어떠한 방식으로 구현되어 있는 걸까?

 

힙 메모리 할당

new/delete를 할 때마다 OS가 주소를 할당하는 것은 효율이 떨어진다

그리고 new/delete 키워드 자체가 OS에서 주소 할당을 요청하는 API인 것도 아니다

new/delete를 사용하면 힙 할당 라이브러리를 통해 OS에 주소를 할당하는 API가 불리게 된다

다음은 대표적인 라이브러리인 CRT Heap을 가져왔다

CRT Heap

힙 영역을 사용하기 위해서는 결국 커널 영역(결국은 OS지만)을 요청(C++의 new와 같은 키워드)해야 한다

new와 같은 키워드도 사실은 커널 영역에 힙 영역을 요청하기 위한 일종의 API이다

그러한 API는 여러 종류가 있지만 우선은 CRT Lip(C/C++에서 사용하는 라이브러리)에 대해서 알아보자

 

C - malloc / free 

C++ - new / delete

위의 함수나 키워드를 쓰면 OS에게 힙 메모리 할당을 요청한다(Application층)

그 다음으로는 CRT(C Runtime Lib)의 API 함수들을 실행함

 

HeapCreate / HeapDestroy 

  • Allocations of small & large memory blocks
  • 크고 작은 메모리 블록의 할당(메모리는 블럭 단위로 제공함)
  • 메모리 청크에서 일부분을 블록으로 제공함
    (Context switch를 자주할 필요가 없어진다)
  • 프로세스가 기동되었을 때 디폴트 힙 공간을 만들기 위해 수행됨

VirtualAlloc / VirtualFree

  • Allocations of aligned blocks of granular-size(large) memory chunks only
  • 세분화된 사이즈(대형) 메모리 청크의 정렬된 블록 할당
  • 메모리를 청크(대형) 단위로 가져오는 것은 Context switch를 줄이기 위해서이다
  • 기본으로 제공하는 CRT Heap 영역이 부족하면 다시 커널 영역에 새로운 영역을 요청한다

Kernel

  • Actual memory manager for the OS
  • OS용 실제 메모리 관리자
  • NTT.dll(? 정확한 이름은 기억 안 난다...)라는 특정 dll에 힙 영역 할당을 요청
  • 그 외에도 커널이 관리하는 것에는 파일 열고 닫기, 프로세스 정보 획득 등이 있음

 

User Mode - 어플리케이션 단에서만 동작하는 코드들, 우리들이 짠 코드

Kernel Mode(OS가 담당) - 물론 커널도 코드는 있다(실제 실행하는 기계어 코드)

 

즉, 동적 할당은 User Mode와 Kernel Mode를 왔다 갔다한다는 것을 이해해야 한다

이것을 Context Switch(맥락 전환)라고 한다

 

동적 할당은 커널 코드에서 관리하는 커널 오브젝트(의 핸들, 혹은 포인터)을 가져다가 쓰는 것

커널 영역에 있는 함수를 실행하려고 하면 시스템 콜(!!)이 발생한다

즉 내 코드가 실행되는 것이 아니라 결국에는 커널 영역에 있는 코드가 실행되는 것이다!!

 

Context Switch(맥락 전환)은 자주 발생하지 않는 것이 좋다

왜냐하면 비용도 들고 복잡해지니까

그래서 파일을 열고 닫을 때 한 번에 가져오거나 해서 횟수를 줄이는 것이

처음의 비용은 크더라도 전체적으로는 비용을 줄일 수 있다

 

우리가 아무것도 안 해도 OS 기본적으로 제공하는 CRT Heap를 가져다가 쓸 수 있다(제공은 커널)

(컴파일 시에 new 등이 있으면 우리는 기본적으로 제공하는 CRT Heap만 쓰게 된다)

즉, new할 때마다 HeapCreate, VirtualAlloc이 실행되는 것이 아니라

기본적으로 1번 실행해서 new가 실행될 때마다 기본적으로 제공하는 CRT Heap에서

메모리 블록(메모리 청크)를 가져와서 쓰는 것

 

반대로 CRT Lib에 있는 함수(HeapCreate 등)을 사용하면 1개 이상의 Heap 영역을 만들 수 있다

 

CRT Heap의 문제

메모리 할당 과 해제에 대한 비용 발생이 발생한다
단편화 현상이 발생한다

 

단편화 현상

외부 단편화

조각 모음이 필요한 상태

 

내부 단편화(할당 해제 단편화)

new / delete를 자주했을 때 발생하는 단편화

메모리 영역이 애매하게 남음

(메모리가 block단위이기 때문에 발생)

 

블록을 크게 만들면 남은 영역도 커지고

블록을 작게 만들면 Context Switch가 자주 발생한다

 

단편화 현상을 해결하기 위해 여러 기법이 개발되고 있다

구글 메모리, LFH(내부 단편화가 낮은 힙을 만드는 API)

 

페이징 기법

외부 단편화 해결, 내부 단편화 존재

가상메모리를 같은 크기의 블록으로 나눈 것을 페이지라고 하고

RAM을 페이지와 같은 크기로 나눈 것을 프레임이라고 할 때,

페이징 기법이란 사용하지 않는 프레임을 페이지에 옮기고,

필요한 메모리를 페이지 단위로 프레임에 옮기는 기법

 

페이지와 프레임을 대응시키기 위해 page mapping과정이 필요해서

paging table이 필요하다

페이지가 필요할 때 매핑을 바꿔준다(삭제하는 건 아님)

 

VirtualAlloc으로 커널 영역에서 힙 영역을 불러올 때의 메모리 청크는 페이징 기법으로 가져온다

 

페이지 단위를 작게 하면 내부 단편화가 줄어들지만

page mapping 과정이 많아져서 오히려 효율이 떨어질 수 있다

 

단, 프로세스의 커널 영역(파티션)인 경우는 페이징을 하지 않는다!

 

세그멘테이션 기법

내부 단편화 해결, 외부 단편화 존재

 

서로 크기가 다른 논리적 단위인 세그먼트로 분할해서 메모리를 할당하여

실제 메모리 주소로 변환하므로 각 세그먼트는 연속적인 공간에 저장됨

(완전한 가변은 아님)

 

new로 memory block에서 메모리를 받을 때 세그멘테이션 기법을 사용한다

 

프로세스가 필요한 메모리 만큼 할당해주기 때문에 내부단편화는 일어나지 않으나

여전히 중간에 프로세스가 메모리를 해제하면 생기는 구멍, 즉 외부 단편화 문제는 여전히 존재

 

메모리 풀(Memory Pool)

필요한 메모리 공간을 필요한 크기, 개수 만큼 사용자가 직접 지정하여

미리 할당 받아 놓고 필요할 때마다 사용하고 반납하는 기법

 

new / delete의 비용을 줄이기 위해 Application 레벨에서 새로 만든 메모리 영역

(new를 재정의하거나 GetMomory를 하거나)

 

결국 같은 거 아닌가? 하지만 다른 것이 하나 있다

delete를 하지 않는다는 것

(반납할 때는 내가 사용하는 Memory Pool에 반납한다)

 

구동할 때는 시간이 걸리겠지만

런타임 시에 new / delete를 안 하기 때문에 시스템 호출(시스템 콜) 횟수를 줄여서

속도가 향상될 수 있다

 

메모리의 할당, 해제가 잦은 경우에 메모리 풀을 쓰면 효과적이다
메모리 누수에 유의

구현을 잘못하면 메모리 누수가 발생하는 것이 확정임

왜냐하면 필요한 크기를 예상하기 어렵기 때문

예) 패킷 풀
패킷을 받을 때마다 패킷 풀이라는 곳에 저장한다고 하자
앞의 2바이트를 읽어서 패킷의 크기를 안 다음에, 메모리 풀(세그먼트 방식)에 저장한다
(그러면 패킷마다 new/delete를 할 필요가 없음)

하지만 패킷 풀의 크기를 넓게 잡아도 패킷이 적게 들어오면 메모리가 낭비되는 공간이 크다
이것도 메모리 누수라고 함

그리고 단편화가 발생하는 것은 똑같음

어떻게 구현할 것인가가 관건

 

Object Pool 패턴

게임에서는 오브젝트의 크기가 일정하기 때문에 세그멘테이션이 필요 없는 경우가 많다

(오브젝트 내부의 단편화는 발생할 수는 있지만 그것은 다른 이야기)

 

그래서 게임에서는 Object Pool 패턴이 유용한 경우가 많다

즉, 블록을 만들 필요가 없음

물론 여전히 메모리 누수는 발생한다

질럿, 저글링 등의 오브젝트는 각각의 크기가 같기 때문에 블록을 생각할 필요가 없음
하지만 질럿 오브젝트 풀을 적당히 잡았다 하더라도
실제 생성한 질럿의 수가 적으면 남는 부분이 낭비(메모리 누수)된다

 

구현

생각해 볼 거리

  1. Array냐 list냐
  2. 주어진 크기를 다 사용한 상태에서, 메모리를 더 요청할 때는 어떻게 할 것인가?
예시
Object Pool에는 사용 상태 플래그가 보관
ManagedObject 상속

사용 상태만 바꾸고 해제하는 등등
가비지 콜렉터(...)의 방식...

참조 카운터
참조는 여러 곳에서 할 수 있다
그럴 때 원래의 주소를 해제하면 에러가 난다
그럴 때는 객체의 대입 연산자(=)를 재정의하고
처리에 참조 카운터라는 변수의 수를 조정하게 해두면
참조 카운터가 0일 때 Pool에 반납했다고 할 수 있을 것이다
이것이 Smart Point(...)의 시작

 

가비지 콜렉터의 단점은 무엇일까?

가비지 콜렉터는 주기적으로 안 쓰는

Pool를 반납하거나 하는 처리를 한꺼번에 할 필요가 있다

즉, 메모리를 반납할 때 어쩔 수 없이 느려질 수 있다

그리고 메모리를 직접적으로 사용할 수 없다

(C/C++에서 결국에 안 들어간 이유)

현업에서 Pool은 안 쓰더라도 직접 new/delete를 하지 않고 ManagedObj 등의 상속(래핑)을 통해서
어떠한 오브젝트가 생성, 소멸되는지 추적할 수 있도록 하는 곳이 많다
(그러면 최소한 추적을 할 수 있으니까)

 

SSS