Memlite 모듈은 참조 카운팅을 갖춘 커스텀 메모리 풀 시스템을 제공합니다. 궁극적인 목적은, byeol managed 환경에서의 경량화된 메모리 관리 구축에 있어요. 따라서 GC 등 추가적인 메모리 관리가 필요로 해지며, 자체 메모리 풀이 존재하며, 인스턴스 라이프사이클을 추적/관리 합니다.
다음과 같은 기능을 제공합니다:
주의: 아직 GC를 제공하진 않기에, 특별한 케이스에서는 메모리 누수가 발생합니다.
memlite 모듈의 주요 클래스:
binder 클래스는 범용 바인딩 클래스로 instance 클래스를 상속한 클래스로부터 생성된 모든 객체를 바인딩할 수 있습니다. 표준 라이브러리에 잘 정의된 std::weak_ptr과 같은 기능을 tweak 가, std::shared_ptr은 tstr 이 각 담당합니다.
shared_ptr를 이미 잘 알고 있다면 아래와 같이 사용할 수 있다는 걸 쉽게 이해할 수 있을 거예요.
객체를 바인딩하는 bind()와 isBind(), get()을 주로 사용하게 될 것입니다.
위는 아주 기본적인 API만 사용한 지나치게 정석적인 예제예요. 실제로는 이보다는 더 간략하게 쓰는 편입니다.
binder는 크게 tstr과 tweak 2개를 제공합니다. tstr은 강한참조를 갖는 바인더를, tweak는 약한 참조를 갖는 바인더 입니다.
tstr과 tweak 강한/약한 참조 예제
이쯤되면, 아마도 왜 shared_ptr를 사용하지 않고 굳이 tstr을 만들었는가에 대해 의문을 가질 것입니다. shared_ptr이 제공하지 못하는 몇가지 장점이 있기 때문입니다.
shared_ptr은 생성시 내부적으로 reference counting을 위한 Control block이라는 걸 heap에 만들어서 관리한다는 건 이미 잘 알고 있을 것입니다. 그래서 shared_ptr 사용시 다음과 같은 사용은 매우 위험합니다.
그리고 이 문제는 바로 프로그램이 종료하지 않기 때문에 디버깅이 아주 어렵습니다.
byeol에서는 reference counting을 위한 클래스를 life 라고 하며, 이는 watcher 에 의해 인스턴스마다 별도로 제공됩니다. watcher 는 내부에 life 객체들을 배열로 미리 대량 할당해둔 풀(pool)을 관리합니다. 새로운 인스턴스가 바인딩될 때 사용 가능한 life 를 할당하고, 인스턴스가 소멸되면 해당 life 를 사용 가능(available) 상태로 표시하여 나중에 재사용합니다. 동일한 인스턴스에 대해서는 항상 같은 life 가 할당되므로 이중 해제 문제가 발생하지 않습니다.
tstr 과 tweak 는 같은 binder 기반클래스를 갖기 때문에 binder 타입으로 범용적인 로직을 구현할 수 있습니다.
binder 는 abstract class 이므로 binder 객체 생성이 불가능합니다. tstr 이나 tweak 로 이미 생성된 바인더들을 범용적인 로직을 작성할때만 의의를 갖습니다.
binder 는 ADT이며 클래스 템플릿 조차 아닙니다. 따라서 binder::bind() 함수는 parameter가 instance 타입으로 되어있습니다. 이 말은 tstr라고 할지라도 tstr<A>::bind(new B()); 코드에서 컴파일 에러가 발생하지 않는다는 걸 의미합니다.
bind() 안쪽에서 Meta 모듈을 사용하여 동적으로 타입을 검사해서 올바른 경우만 인스턴스가 바인딩 됩니다. 타입이 일치하지 않을 경우, bind()는 false를 반환하고 바인딩을 수행하지 않습니다. 따라서 타입 안정성이 엄격하게 필요한 경우라면 bind()의 반환값을 반드시 확인해야 합니다.
인스턴스 할당시 모든 작업은 instancer 를 시작으로 이뤄집니다. 자체 메모리 풀을 사용함으로써 heap보다 빠른 할당/해제가 가능합니다. 자체 메모리 풀에 대해서는 밑에서 지겹도록 다뤄볼겁니다.
shared_ptr의 알고리즘은 같은 shared_ptr 끼리 공유되는 reference counting 정보를 heap에 보관하고 공유하는 것입니다. heap보다 빠른 자체 메모리 풀을 사용하고, 바인딩 속도를 조금이나마 최적화한다면 속도를 개선할 여지가 있습니다.
참고로, binding은 byeol에서 가장 많은 퍼포먼스 비용을 차지하는 핫스팟중에 하나입니다.
shared_ptr은 heap에 reference counting 정보를 보관하는 객체를 생성하고 이를 공유합니다. 반면 Memlite 모듈은 watcher 클래스를 통해서 이미 메모리는 할당된, 빈 life 하나를 내어주고, 그곳을 해당 instance 의 reference counting 공간으로 활용합니다.
만약 이후, GC와 같은 기능이 추가되면 인스턴스마다 추가적으로 생명주기와 관련된 정보를 필요로 할 여지가 있습니다. shared_ptr와 달리 각 인스턴스의 생명주기 정보 또한 자체적으로 관리하고 있기 때문에 그런 요구사항에도 적절하게 대응할 수 있습니다.
Memlite 의 메모리 관리는 여러 클래스로 구성되어 있습니다. 각 클래스는 하나의 역할을 담당하며, 하위 계층의 클래스부터 이해하는 것이 전체 구조를 파악하는데 도움이 됩니다.
메모리 풀은 구조는 크게 2개의 가지로 분류되는데,
입니다. 일단 메모리 관리 컴포넌트 부터 살펴보죠.
메모리 관리 컴포넌트의 pool, chunks, chunk 각 클래스 역할이 뭔지를 파악하는게 코드 이해에 아주 중요합니다. chunks는 여러개의 chunk를 관리하고, pool은 여러개의 chunks를 관리합니다.
chunk 클래스는 Memlite 에서 유일하게 직접적으로 메모리를 실제로 할당 가능한 최소 단위 클래스입니다. 모든 메모리 관리는 chunk 들을 엮어서 수행합니다.
chunk 는 메모리가 flexible하게 늘어나도록 하는 _resize() 함수가 있지만, Memlite 의 컨셉상 이를 public으로 공개하지 않습니다. 결과적으로 chunk 의 메모리는 객체 생성시 고정되며, 추가 메모리가 필요하다면 chunk 객체를 더 생성해서 운영해야 합니다.
chunk 기본 사용 예제
Block size
chunk 는 생성시 block size와 size 2개를 입력받습니다. blockSize는 메모리에 인스턴스 하나가 차지하게 될 최소 단위 크기입니다. 반면 size는 그러한 인스턴스가 몇개 까지 들어갈 지를 정합니다.
예를들어 만약 int64만 100개 담는 chunk를 만든다고 한다면, 다음과 같이 됩니다.
real block size
실제 메모리 할당시에는 block size 대신 real block size를 사용하는데, 이는 최적화에 따른 것입니다. CPU 연산시 1이나 2바이트 등 작은 단위로 메모리 할당해서 계산하는 것보다 CPU 아키텍처에 맞게 정렬(padding)하는 것이 더 효율적입니다. 예를 들어, 64비트 CPU에서는 8바이트 단위로 정렬되며, 3바이트를 요청해도 실제로는 8바이트가 할당됩니다. 이는 메모리 접근 속도를 최적화하기 위한 것입니다.
ArrayList 구현
chunk 는 배열 기반 리스트(ArrayList)로 직접 구현되어 있습니다. 크기가 고정되어 있지만 크기 내에서는 List처럼 추가 삭제가 자유로우며 임의접근 속도는 Array처럼 빠릅니다. 내부적으로 Free List 알고리즘을 사용하여 사용 가능한 메모리 블록을 추적합니다. 각 비어있는 블록은 다음 빈 블록의 인덱스를 저장하는 intrusive linked list 구조로 연결됩니다.
알고리즘은 다음과 같습니다:
예: size=4의 경우, [1, 2, 3, 4]
예: new1() 경우, _head는 이제부터 _heap[0]에 담긴
1값이 할당됩니다. 이는 다음 new1()을 했을때 _heap[_head]인 _heap[1]를 할당가능한 유력한 빈 원소로 간주한다는 얘기입니다.
_head = 1, [사용중, 2, 3, 4]
예: del(used); // 이때 used = _heap[0]의 주소
*used = _head // [1, 2, 3, 4]
예: _head = (used - _heap) / blockSize 위 상황에서는 used가 _heap[0]의 주소이므로, (used - _heap) / blockSize = 0 _head = 0, [1, 2, 3, 4]
위의 내용을 다이어그램으로 정리해볼꼐요:
chunks 객체는 여러개의 chunk 의 인스턴스 관리를 담당합니다. chunk 는 생성시 고정된 크기만 메모리를 활용하기 때문에 chunks 가 여러개의 chunk 를 추가/삭제 함으로써 유동적으로 메모리를 관리합니다.
chunks 역시 고정된 메모리만 제공한다
chunks 는 chunk 들을 추가하거나 삭제하므로, chunk 가 각 셀마다 고정된 크기만을 사용하기 때문에 chunks 또한 고정된 크기의 메모리만 할당할 수 있습니다. 만약 length를 넘게되면 resize() 를 자동 수행합니다.
가용 chunk 검색 알고리즘
가장 최근에 메모리를 할당한 chunk 가 추가로 할당 할 가능성이 가장 높습니다. 멤버변수 _s는 바로 최근에 할당한 chunk 의 인덱스를 가지고 있습니다.
만약 _chunks[_s]에 가용 메모리가 없을 경우 _s를 ++ 합니다. 이후 마치 원형배열처럼, _chunks의 끝은 처음과 이어져 있다고 보면 됩니다. 그래서 다시 _s가 순회직전의 _s로 값이 같아질 때까지도 가용 메모리가 없다면, chunks 전체에 가용 메모리가 없는 상태이므로 resize()에 들어갑니다.
vector를 쓰면 안된다
당연한 건데, vector는 heap으로 관리되므로 자체 메모리 풀을 만든다면서 vector를 사용해서는 안됩니다. 차후 수정 예정입니다.
pool 클래스는 외부로부터 메모리 할당 요청시 가장 최초로 처리하는 클래스입니다. 내부적으로 chunks 에 대한 배열을 가지고 있으며, chunks 는 chunk 를 가지고 있으므로, 사실상 로우레벨의 메모리 관련 클래스를 모두 관리하는 셈입니다.
pool은 할당 가능한 size 별로 lazy하게 chunks를 가진다
자체 메모리 풀을 만들때 중요한 포인트는, 같은 사이즈의 메모리를 한 곳에 나열함으로써 속도를 높이는 것입니다. chunks 는 블록이라는 개념이 있어서 각 블록은 미리 지정된 크기의 메모리만 할당/해제 될 수 있습니다.
pool 은 chunks 를 만들때 블록의 크기를 고정해서 생성하며, 외부에 의해서 특정 사이즈의 메모리 할당을 요청받으면, 해당 크기의 블록을 담당하는 chunks 를 찾습니다. 없을 경우 lazy 하게 생성합니다.
watcher와 life 클래스가 서로 연계하면서 객체의 라이프사이클을 추적, 관리합니다. 여기에는 id 라는 개체 식별값을 어떻게 정의하고 부여하는지가 매우 중요합니다.
id 클래스는 64bit integer로 되어있는 instance 식별자입니다. tagN은 life 를 식별하며, chkN은 몇번째 chunk 인지를 나타내며 serial은 객체 검증에 사용됩니다.
serial은 프로세스 실행 도중 instance 객체의 생성횟수
pool 과 chunk 를 봤다면 알겠지만, 자체 메모리 풀을 사용하기 때문에 메모리가 해제 될때는 소멸자만 호출할 뿐, 모든 메모리를 초기화 하지 않습니다.
그러니 이전에 할당해서 사용후 소멸된 데이터가 그대로 남아있으며, 심지어 이 데이터에 접근도 가능합니다. (이미 사용한 데이터에 접근시 exception이나 UB가 된다면 weak pointer나 strong pointer를 구현한 binder 를 구현할 수 없었을 것입니다)
binder 에서는 이렇게 해서 가져온 데이터가 정말로 유효한 데이터인지 구분하기 위해서 serial을 추가로 비교합니다.
id 구성 요소 확인 예제
tagN은 life 객체에 접근할때 사용한다
watcher 는 자신의 배열에서 tagN 번째 life 객체를 가져올때 이 값을 사용합니다.
chkN은 chunk 객체를 가져올 때 사용한다
pool 은 먼저 id와 매핑된 instance 의 size를 계산해 chunks 를 가져옵니다. 그리고 chunks 는 자신의 chkN 번째 원소인 메모리블록을 반환합니다. 외부에서는 전달 받은 메모리 주소와 serial 값을 비교해서 같은 인스턴스인지를 검증합니다.
pool 클래스가 로우레벨 관점에서 블록 단위로 메모리를 관리하는 클래스라면, watcher 컴포넌트는 각 블록의 정보를 유기적으로 관리하는 클래스입니다.
life 는 pool 에 할당되어있는 주소값(_pt)와 reference counting을 위한 값들을 갖습니다. _strong은 reference counting을 위한 값이며, _pt는 pool 에 할당받은 인스턴스를 직접 가리킵니다. _id는 객체를 식별하기 위한 값으로 자세한 내용은 id 를 참고하세요.
watcher 클래스는 메모리 관리의 한 축을 담당하는 클래스로, 생성된 객체의 라이프사이클을 관리합니다. instance 가 생성될때마다 watcher 는 life 객체를 추가로 할당해 reference counting으로 객체의 소멸시점을 판별합니다.
reference counting
binder 에 의해서 instance 가 바인딩 될때마다 life 가 count 하는 strong 값을 1 증가시킵니다. binder 가 instance 를 rel() 할때 count를 1 감소하며, 0이 되는 순간 delete로 메모리에서 해제합니다. instance 는 operator delete()를 통해 instancer 에게 메모리 해제 작업을 실행하도록 합니다.
watcher와 life 사용 예제
instance 클래스는 Memlite 모듈의 자체 memory pool에 의해서 관리되는 객체의 기반 클래스입니다. instance 클래스를 상속해야만 binder 를 통해 weak pointer나 strong pointer로 참조 할 수 있습니다. instance 의 식별은 id를 통해서 이뤄집니다.
id 부여 알고리즘
Memlite 에서 가장 취약한 부분을 고르라면 바로 이 id 부여 알고리즘입니다. 인스턴스 생성은 memory pool을 관리하는 instancer 에 의해서 이뤄집니다. 이때 instancer 는 vault라고 하는 instance 내부의 클래스에 instance 주소와 id를 map에 push 합니다. instance::operator new()가 불리면 안쪽에서는 vault에게 map[this]와 같은 코드로 id값을 가져오는 방식입니다.
얼핏 괜찮아 보이지만 단점이 많습니다:
최초 구현은 vector로만 되어있었으며 FIFO로 관리했었으나, 생성자 안에서 다른 객체를 생성하는 경우에는 추가되는 id의 순서가 FIFO가 아니게 되면서 ID가 꼬이는 문제가 있었습니다.
속도에 있어서 instance 클래스의 중요성
byeol에서 가장 빈번히 하는 작업은 객체를 생성하면서 id를 부여하거나 binding을 하는 작업입니다. 이 부분은 개선 예정이며, 더 나은 알고리즘에 대한 아이디어를 환영합니다.
instancer 클래스는 low level로 메모리를 관리하는 pool 클래스와, instance 들의 라이프사이클을 관리하는 watcher 를 가지고 있습니다. Facade 패턴을 사용하여 복잡한 메모리 관리 서브 패키지에 대한 단순화된 인터페이스를 제공합니다.
이 둘을 잘 제어해서 인스턴스의 생명 관리(할당/소멸)를 하는 것이 목적입니다. 사실상 Memlite 에서 핵심 작업을 수행하기 위해 각 제어클래스들에게 작업을 분배하거나 명령을 내리는 진입점을 담당합니다.
이 인터페이스들은 Interface Segregation Principle을 따라 설계되었습니다. 클라이언트가 사용하지 않는 메서드에 의존하지 않도록, 메모리 조회 기능(memoryHaver)과 메모리 할당 기능(allocator)을 별도의 인터페이스로 분리했습니다.
memoryHaver 클래스는 memory pool에서 일정 메모리를 직접 혹은 간접적으로 소유하고 있으며, 그 메모리를 READ 가능한 클래스들의 기본 인터페이스를 정의합니다. 그래서 메모리의 크기나, 상태 등을 알 수 있는 인터페이스로 정의되어 있습니다.
간접적으로 소유하다?
해당 객체가 직접 메모리를 할당받아 사용하는 것이 아니라, 내부에 멤버변수로 있는 다른 객체들이 담당하는 경우가 있습니다. 그리고 메모리의 할당은 내부 멤버변수들을 통해 직접해야 한다면, 그 클래스는 memoryHaver 만 상속받아야 합니다. 만약 할당도 가능하다면 allocator 를 상속하면 됩니다.
len과 size
할당 가능한 메모리의 크기는 size로 표현하며, 그 중에서 할당한 메모리는 len으로 표현됩니다. void* 및 byte 단위로만 제어하는 것을 전제로 합니다.
memoryHaver 의 파생클래스들은 자신들이 담당하는 메모리의 사이즈가 제각기 다르다는 것에 주의하세요.
allocator 클래스는 memoryHaver 를 상속하고 있다는 점에서 알다시피 관리하는 메모리의 상태나 크기를 측정할 수 있으면서, 추가적으로 메모리를 할당/소멸 할 수 있는 클래스입니다.
모든 메모리는 void* 및 바이트 관점에서만 바라본다는 Memlite 컨셉에 맞게, new(), del()의 파라메터는 void*만 제공합니다.
memlite 전용의 공통 인터페이스의 네이밍 컨벤션
할당은 new1() (new one 이라는 뜻입니다.), 해제는 del()를 사용합니다. 이 네이밍은 Memlite 뿐만 아니라 byeol 프로젝트 내부에서 자주 사용됩니다.
끝으로, 여기까지 설명했던 각 클래스/컴포넌트 간의 흐름을 정리하겠습니다.
다음 문서: stela 모듈 - 경량 설정 언어