Memory Pool & GC
Unreal Engine의 Garbage Collector를 참고한 Mark-and-Sweep 방식이고, Cycle이 중간에 멈췄다가 이어서 재개될 수 있도록 시간 예산을 두었음.
목차
- 01핵심 내용
- 02상세 내용 & 다이어그램
- 03개발 히스토리
- 04Compaction을 하지 않은 이유
- 05이슈
- 06버그
01 / INTRO
핵심 내용
왜 이 방식: GC가 한 프레임 안에 통째로 끝나면 Frame-time Spike로 이어짐 — Mark/Sweep은 그대로 두고,
객체 단위 편차가 가장 큰 Purge 단계만 시간 예산을 둬서 여러 프레임에 나눠 실행하도록 설계.
상태 하나로 도는 전체 Cycle
eIdle → eMarking → eSweeping → ePurging → eIdle, eStatus
하나가 전부 결정. 각 단계가 빨리 끝나면 Collect()
한 번 호출로 여러 단계를 연속으로 통과할 수 있음.Purge 단계에만 걸린 시간 예산
Mark와 Sweep은 항상 한 번에 끝까지 돎. 죽은 객체를 파괴하고 슬롯을 반환하는
Purge()만 객체 단위로 시간을 체크 — 부족하면 중간에 멈췄다가 다음
Collect()에서 그 지점부터 이어감.의도적으로 뺀 Compaction
Accessor(객체 메타데이터)가 별도 Handle Table이 아니라 각 Instance 자신의 헤더 바로
앞에 있음 — 객체를 옮기면 그 객체를 가리키는 모든 Pointer를 찾아 다시 써야 함. 대신 버킷 점유율
기반 할당 정책으로 단편화를 관리.
특징
시간 예산 안에서 멈췄다 재개하는 Cycle
Purge가 객체 단위로 시간을 체크 — 큰 GC Cycle이 한 프레임에 몰려 Spike를
만드는 걸 구조적으로 막음.
Compaction 없는 단편화 관리문서화된 설계 근거
Accessor가 인라인이라 Pointer 하나로 메타데이터에 접근하는 대신 이동이 불가능 —
그 트레이드오프를 버킷 점유율 기반 할당 정책(eSparse~eFull)으로 보완한다는 설계 근거가 문서화되어
있음.
Thread 안전한 Root set
AddRoot/RemoveRoot는 Mutex로
보호되어 있음 — 최소한 "이 객체가 GC 대상에서 제외됐는지"는 여러 Thread에서 안전하게 확인
가능.주요 내용
root set까지만인 Thread 안전성
Accessor의 마킹 상태와 각 Pool의 List는 Mutex 보호 밖(이슈 #16) — Collector
Thread와 mutator Thread가 경합하면 Crash로 이어질 수 있음.
조건부로 스킵되던 종료 시점 정리수정됨 · #17
PoolBudget::~PoolBudget()이 버킷 하나만 비어 있어도
Release() 전체를 건너뛰어서, 대부분의 종료 상황에서 살아있는
Pool과 arena가 그냥 새어나갔음 → empty 플래그 초기값이 반대로 되어 있던 것을 바로잡음.write barrier 부재
새 객체가 "흰색"으로 태어나는데 이미 마킹을 마친 부모 Graph에 연결됐을 때 강제로
살아있다고 표시할 장치가 없음 — 이론상 생성된 바로 그 Cycle에 수거될 수 있는 경로가 존재함.
02 / MODULES
상세 내용 & 다이어그램
Collector, Storage, Smart Pointer클래스 다이어그램 — Collector, Storage, Smart Pointer
시퀀스 다이어그램 — Collect() 사이클 한 번
ObjectPtr vs. RootPtr
Root set의 내용물"Pointer를 가리키는 Pointer"
RootPtr의 생성자는 AddRoot(this)를
호출 — 관리 대상 객체의 주소가 아니라 RootPtr Wrapper 자신의 주소를 root
set에 넣음. Mark()는 이 Wrapper를 거쳐 실제 Instance에 도달함.비활성 Handle인 ObjectPtr
레이아웃은 같지만 생성자/소멸자 어디서도 Collector와 대화하지 않는 평범한 Handle임.
RootPtr로 감싸야만 GC root가 됨.사용 예시
#include "Memory.h"
using namespace Memory;
class MyClass
{
GENERATE(MyClass); // Reflection 시스템과 함께 쓰면 GC 순회 대상이 됨
public:
int value = 42;
};
int main()
{
Init(1024 * 1024, 1000000); // Pool 크기 1MB, Sweep 시간 예산 1ms
ObjectPtr<MyClass> obj = MakePtr<MyClass>(); // 비활성 Handle — GC root 아님
RootPtr<MyClass> root = MakePtr<MyClass>(); // 생성자가 AddRoot(this) 호출 -> GC root
RootPtr<int[]> arr = MakeArray<int>(5);
obj = ObjectPtr<MyClass>(); // 참조 해제
Collect(); // obj가 가리키던 메모리는 회수 대상, root/arr는 보호됨
Release();
}
03 / HISTORY
2023년 5~10월의 1기 · 2025년 11월부터 GC로 다시 짠 2기
개발 히스토리
GitHub 커밋 기준2023년 5~10월의 1기 · 2025년 11월부터 GC로 다시 짠 2기
커밋 기록을 불러오는 중…
- 1기ECS용 Memory Pool → MemoryManager2023.05 — 2023.10
태그 v1.0 → v4.x - 2023.05.20MemoryECS용 MemoryPool 첫 버전엔진에서 겪은 Component Pointer 무효화 문제를 풀려고 만든 고정 크기 풀 — 같은 날 Scene · Node 기반 ECS용으로 맞춤(v1.0.0).
- 2023.05.26MemoryMemoryManager · OnlyMemoryPool · Demo로 프로젝트 분리 (v2.x)
- 2023.07.03MemoryMemoryPtr 도입 (v4.0)"생성 · 소멸 권한은 MemoryManager가 쥐어야 한다"는 이유로 weak_ptr 비슷한 Pointer를 만듦 — 커밋 메시지에 남긴 이유 그대로.
- 2023.10.02MemoryPool 전체 크기를 객체 크기의 배수로 맞춰 남는 공간 제거
- 2기Accessor · Collector 구조의 GC로 재작성2025.11 — 현재
2년 넘는 공백 뒤 재시작 - 2025.11.30Memory구조를 갈아엎은 재시작옛
MemoryManager/,OnlyMemoryPool/,MemoryPtr/,ExternalProject_Add기반 빌드를 전부 걷어내고 지금의Accessor/Collector/Pointer/Storage구조와 git submodule 기반 빌드로 다시 짬 — 그 사이 Reflection · Container를 만들며 익힌 설계를 그대로 가져옴. - 2025.12.12MemoryContainer 라이브러리로 일원화GC 실동작 확인부터Memory 안에 따로 있던 List · Arena를 지우고 Container를 submodule로 연결. 데모에
GCExample()을 넣어DynamicArray/HashMap에 담긴ObjectPtr까지Collect()가 실제로 훑고 회수하는지 확인. - 2025.12.15Memory순환 참조 마킹 무한 루프직접 겪고 고친 문제커밋 메시지 그대로 — 서로 연결된 값들을 GC에 등록하면 순환이 생겨 마킹 로직이 끝나지 않음. 스택으로 그래프를 도는
GarbageCollector.cpp루프에IsMarked()가드를 넣어 이미 마킹된 노드를 다시 쌓지 않게 고침. - 2026.02.22MemoryRefPtr 설계 전환침투적 → 비침투적관리 대상이
RefCounted를 상속해야만 쓸 수 있던 구조가 너무 불편해서, 대상 타입을 감싸는RefData<T>를 두는 std::shared_ptr 비슷한 방식으로 바꿈. 같은 커밋에서ObjectPtr기본 생성자가 Pointer를 nullptr로 초기화하지 않아 나던 런타임 에러도 잡음. - 2기코드 리뷰 → 수정2026.08
- 2026.08.06Memory리뷰로 찾은 버그 4건 수정 + GC Prepare 단계 분리#17 PoolBudget 소멸자, #18 Purge 시간 예산, #19 미초기화 메모리 접근, #20 RefPtr 메모리 순서를 한 커밋에서 고치고, Sweep 전에 마킹 상태를 초기화하는 Prepare 단계를 따로 뗌 — Unreal의 reset-before-mark 방식 (PR #21).
04 / DESIGN
Compaction을 하지 않은 이유
빠진 기능이 아니라 의도적인 트레이드오프
Compaction을 배제한 이유:
Accessor는 별도의 안정된 Handle Table이 아니라
각 Instance 헤더에 인라인으로 존재함. Compaction 중 객체를 옮기면 그 Accessor도
함께 옮겨지므로, 이미 그 Instance를 가리키고 있던 모든 ObjectPtr/RootPtr
을 찾아서 다시 써야 함. 참조자를 추적하는 목록이 따로 없다면, 이건 사실상 GC를 한 번 더 도는 것과 같은 비용임.
대신 할당 시점에서 단편화를 관리함: 각
PoolBudget은 버킷을 점유율로
분류하고(eSparse / eModerate / eDense / eFull), 새 할당은 가장 꽉 찬
비어있지 않은 버킷부터 찾음 — 어느 한 버킷이 eFull까지 차서 새 백킹 할당을
강제하는 상황을 줄이고, sparse 버킷은 완전히 비워져 회수될 여지를 남겨둠.
Sequence 다이어그램 — PoolBudget의 버킷 탐색과 밀도 재분류
GetPool()은 Dense부터 Sparse 순으로 역순 탐색해서 첫 번째로 여유 있는 버킷을
바로 씀 — eFull 버킷은 애초에 탐색 대상에서 빠짐. Update()는 Pool마다
GetUsedRatio()를 다시 재서 밀도가 바뀌었으면 List::Splice로
해당 버킷으로 옮김(포인터/Iterator 재계산 없이 노드만 다른 리스트로 이동).
05 / ISSUES
이슈
- 원인:
- 개선 방향: 기존 root set Mutex 범위를 Accessor 상태와 pool List 변경까지 확장하거나, 상태 필드를 atomic으로 바꾸는 방향.
AddRoot/RemoveRoot는 Mutex로 보호되지만,
Accessor의 마킹 상태와 각 Pool의 used/free/dead List는 그렇지 않음 — mutator Thread가
ObjectPtr을 통해 Accessor 상태를 읽는 동안 Collector Thread가 같은 Accessor를
건드리면 경합이 발생할 수 있음.- 개선 방향: 기존 root set Mutex 범위를 Accessor 상태와 pool List 변경까지 확장하거나, 상태 필드를 atomic으로 바꾸는 방향.
06 / BUGS
8건 중 4건 수정 · 상태는 실시간 동기화
버그
2026.08 코드 리뷰에서 직접 찾아 등록한 이슈8건 중 4건 수정 · 상태는 실시간 동기화
실제 버그 8건 — 4건 수정 완료 클릭해서 펼치기
-
- 수정(2026.08.06): empty 플래그의 초기값이 반대로 되어 있던 것을 바로잡아, 버킷이 남아 있으면
eSparse..eFull 네 버킷을 순회하다 하나라도 비면 empty = true;
break;로 즉시 탈출하고, Release()는 모든 버킷이 비어있지 않을
때만 호출됨. 종료 시점에 버킷 하나(특히 eSparse)가 비어있는 게 흔한 상황이라, 살아있는
Pool<T>와 그 arena가 대부분의 경우 그냥 새어나감.- 수정(2026.08.06): empty 플래그의 초기값이 반대로 되어 있던 것을 바로잡아, 버킷이 남아 있으면
Release()가 불리도록 고침.
include/Storage/Pool.h:161-166, 203-205
대량 소멸 시 O(n²)인 Sweepfree-list가 아닌 DynamicArray
성능
-
m_usedList가 Intrusive List가 아니라 평범한
DynamicArray<uint8_t*>라서, Sweep()이 죽은
객체마다 부르는 Erase(itr)가 매번 뒤 원소를 전부 당기는 O(n) 연산임.
한 Cycle에 여럿이 동시에 죽으면 비용이 누적됨.
-
- 영향:
- 수정(2026.08.06):
Acquire()는 사용하기 전에 m_used를
확인한 뒤 Accessor<T>를 placement-new함. 그런데
Clear()/Sweep()은 이 체크 없이
m_memory를 곧바로 static_cast<IAccessor*>해서
가상 함수(Destruct(), GetStatus())를 호출함.- 영향:
Init()만 되고 Acquire()는 한 번도 안 된
Array<T>(즉 vtable이 아직 안 만들어진 날 것의 메모리)에 대해
Clear()/Sweep()이 불리면, 초기화되지 않은
메모리를 살아있는 다형성 객체처럼 다루는 정의되지 않은 동작임.- 수정(2026.08.06):
m_status(eUsed)를 확인한 뒤에만 Accessor로 다루도록 Sweep · Clear · Purge · Acquire를 고침.
include/Accessor/Accessor.h:19, src/Collector/GarbageCollector.cpp:82-126
"흰색"으로 태어나는 새 객체write barrier 부재
설계 노트
-
Accessor의 기본 상태는 eUnreachable임. Mark는
Collect() 한 번 안에서 동기적으로 끝까지 도는데, 이미 마킹을 마친 부모
Graph에 그 이후 연결된 객체를 강제로 살아있다고 표시할 장치(write barrier)가 없음 — 생성된 바로 그
Cycle에 수거될 수 있는 경로가 이론상 존재함(#16의 Thread 경합과는 별개 문제).
src/Storage/ArrayBudget.cpp:45-52,130-138
배열을 재사용하지 않는 ArrayBudget매번 새로 생성
성능
-
GetArray()는 free 목록을 확인하는 Pool<T>과
달리 항상 CreateArray()로 새 Instance를 만듦. Array<T>
자체도 bool m_used 하나로 지키는 슬롯 1개짜리 구조라, 배열 Storage는
커지기만 하고 Remove()가 빈 항목을 목록에서 걷어내는 것에만 의존함.
-
- 수정(2026.08.06):
AddRef/Release 모두 memory_order_release로만
증감하고, 마지막 Release()가 delete this를 호출하기
전에 acquire Fence가 없음. 약한 메모리 모델 하드웨어에서는 다른 Thread가 막판에 쓴 내용이 소멸자
실행 시점에 보이지 않을 수 있음.- 수정(2026.08.06):
GetRefCount()를 acquire로 바꿔, AddRef(relaxed) · Release(acq_rel)와 함께 delete 전 acquire 순서를 보장.
src/TimeLimit.cpp:32-55, GarbageCollector.cpp:141
10번에 한 번만 체크하는 Purge 시간 예산
설계 노트
-
TimeLimit::HasTime()은 m_interval번마다 한 번씩만
시계를 샘플링하는데, Purge()는 SetInterval(10)으로
설정해서, 최대 9번의 destruction만큼 예산을 초과할 수 있음.
#18 · src/Storage/StorageManager.cpp:65-96
Purge()의 조기 반환Pool 버킷 하나의 시간 초과로 배열 버킷 · Clear() 전부 생략
해결
-
- 수정(2026.08.06): 배열 버킷에도 Pool 버킷과 같은 시간 예산을 적용.
m_poolMap을 순회하다 어떤 버킷의 Purge(timeLimit)이
false를 반환하면 그 자리에서 return false — 이후 배열 버킷 루프와
Clear()가 통째로 스킵됨. 게다가 배열 버킷 루프는
timeLimit을 아예 체크하지 않아서(ArrayBudget에는
시간 제한을 받는 Purge Overload 자체가 없음), 도달하면 항상 전체를 끝까지
처리함 — 시간 예산 시스템이 배열 쪽에는 사실상 적용되지 않음.- 수정(2026.08.06): 배열 버킷에도 Pool 버킷과 같은 시간 예산을 적용.
GitHub에 지금 열려 있는 이슈 (실시간)
불러오는 중…