.NET GC 동작을 관찰 가능하게 만들기: GCExperiment를 구축하면서 배운 점
I’m happy to translate the article for you, but I’ll need the full text you’d like translated. Could you please paste the content (excluding the source line you already provided) here? Once I have the text, I’ll translate it into Korean while preserving the original formatting, markdown, and any code blocks or URLs.
왜 실험을 하는가?
문서에서는 GC가 무엇을 하는지 알려줍니다. 코드를 실행하면 언제 그리고 왜 하는지 알 수 있습니다. 실제 시스템에서 할당 압력을 파악하려 할 때 이 차이는 중요합니다.
프로젝트 이름은 GCExperiment이며 — 네 개의 독립된 실험으로 구성되어 각각 하나의 GC 메커니즘을 목표로 하고, 모두 실제 스냅샷으로 계측됩니다.
.NET 10 / C# 14 기반이며, x64 전용입니다. x86에서는 헤더 크기와 정렬이 다르므로 현실적인 LOH 동작을 위해서는 64‑bit가 필요합니다.
실험 1 — LOH 배치
내가 알게 된 가장 놀라운 점: 단순히 85,000바이트 임계값이 아니라는 것.
GC는 전체 객체 비용 — 헤더, 페이로드, 그리고 정렬을 측정합니다. byte[84_999]에 대한 실제 계산은 다음과 같습니다:
// Full size calculation
24 // array header
+ 84_999 // payload
= 85_023 → aligned to 85_024 → LOH threshold hit
따라서 byte[84_999]는 Large Object Heap에 배정됩니다 — 페이로드가 85 KB를 초과했기 때문이 아니라 정렬된 전체 객체가 초과했기 때문입니다.
실험이 보여주는 내용
- 서로 다른 크기의 배열이 실제로 어디에 배정되는지 (SOH vs LOH)
GCInfo.EstimateSizeByFactory를 사용해 전체 객체 크기를 측정하는 방법- 필요할 때 LOH 압축을 강제하는 방법:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
실험 2 — 세대 승격
객체는 Gen0에 영원히 존재하지 않습니다. 살아남은 객체는 승격되며, 이 실험은 이를 실시간으로 확인할 수 있게 합니다.
| 세대 | 의미 |
|---|---|
0 | 최근에 할당된, 일시적인 |
1 | Gen0 수집을 한 번 살아남음 |
2 | 오래 살아남음, 혹은 LOH에 있음 (LOH 객체는 즉시 Gen2에서 시작) |
예상치 못한 점 하나: GC.KeepAlive는 승격을 관찰할 때 실제로 결과를 바꿉니다. 이를 사용하지 않으면 JIT가 참조를 최적화해 버려 객체가 세대를 검사하기 전에 수집됩니다.
실험 3 — 할당 압력
두 패턴을 나란히 비교합니다:
- 단명하는 작은 객체 — 할당, 폐기, 반복 → Gen0을 강타
- 오래 지속되는 큰 객체 — Gen2/LOH 압력을 증가
출력은 실험이 진행되는 동안 실시간 컬렉션 카운트를 표시합니다. 샘플 크기를 변경하고 Gen0와 Gen2 카운트가 어떻게 달라지는지 관찰하는 것은 직관을 기르는 데 유용합니다.
실험 4 — LOH 단편화
큰 할당을 교대로 수행하면 — A를 할당하고, B를 할당하고, A를 해제한 뒤 C를 할당하려고 할 때 — A가 남긴 구멍에 C가 들어가지 않을 수 있습니다. LOH는 기본적으로 압축되지 않으므로 이러한 구멍이 누적됩니다.
[ A ][ B ][ hole ][ B ][ hole ]
↑
C doesn't fit here
이것이 LOH 압축 및 버퍼 풀링(ArrayPool)이 존재하는 이유입니다. 이 실험은 할당 실패와 GC 스냅샷 비교를 통해 단편화를 가시화합니다.
진단 및 도우미
이 저장소에는 두 개의 계측 도우미가 포함되어 있습니다:
GCMonitor (GCMastery.Diagnostics에 있음)
GCMonitor.PrintSnapshot("after allocation");
GCMonitor.PrintObjectGeneration(myObj, nameof(myObj));
GCMonitor.Reset("clean baseline");
GCInfo (GCExperiment에 있음)
var state = GCInfo.GetCurrentState(forceFullCollectionFirst: true);
var gen = GCInfo.GetGeneration(myObj);
var size = GCInfo.EstimateSizeByFactory(() => new byte[84_999], sampleCount: 1000);
모든 실행
GenerationExperiment.Run();
LOHExperiment.Run();
FragmentationExperiment.Run();
PressureExperiment.Run();
측정하기 전에 한 가지 팁 — 항상 깨끗하게 시작하세요
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
GC.WaitForPendingFinalizers();
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
그렇지 않으면 이전 실행에서 남은 상태가 결과를 오염시킵니다.
아직 파악 중인 내용
- 서버 GC와 워크스테이션 GC 동작 차이
GCSettings.LatencyMode가 승격 타이밍에 미치는 영향- 시간에 따른 단편화를 시각화하는 더 나은 방법 (현재는 콘솔 전용)
이와 관련해서 작업해 본 경험이 있거나 이상한 점을 발견했다면 — 혹은 추가할 만한 실험 아이디어가 있다면 — 댓글로 피드백을 남겨 주세요.
레포
👉
.NET 10 · C# 14 · x64 필요