18. GCHandle/fixed 핀닝과 GC 압축(compaction)의 상호작용
난이도 상내 리뷰 · C#
해설 · C#
해설 — GCHandle/fixed 핀닝과 GC 압축(compaction)의 상호작용
난이도: 상
요약
.NET GC 는 수집 시 살아있는 객체를 이동(compaction) 시켜 단편화를 줄인다. 관리 객체를 네이티브 포인터로 다루는 동안 GC 가 그 객체를 옮기면 포인터가 대롱거리므로, fixed(짧게)·GCHandle(Pinned)(오래)로 이동을 금지(pin) 한다. 핀 고정은 포인터를 안정시키지만 압축을 방해해 단편화를 유발한다(특히 gen0/LOH). 관리 배열과 포인터는 같은 저장소라 한쪽 쓰기가 양쪽에 보인다.
예측 출력
True
10 40
True
99
핵심 개념 · 단계별
줄1: fixed 안의 포인터 안정성 — True
fixed (byte* p = buf)은buf를 블록 동안 핀 고정하고, 배열 첫 원소 주소를p로 준다. 블록 내에서 GC 가buf를 이동할 수 없으므로p는 유효·불변.q = p후p == q= True.- 핵심 제약: 이 안정성은
fixed블록 안에서만 보장. 블록을 벗어나면 핀이 풀려 다음 GC 가buf를 이동할 수 있고, 저장해 둔 포인터는 무효(대롱). 그래서fixed밖으로 포인터를 새어 나가게 하면 안 된다.
줄2: 포인터 쓰기가 managed 뷰에 반영 — 10 40
p[0]=10; p[3]=40;은buf와 동일한 힙 저장소를 직접 쓴 것. 관리 인덱서buf[0],buf[3]로 읽으면 같은 바이트라 10 40. (별도 복사가 아니라 같은 메모리의 두 뷰.)
줄3: GCHandle(Pinned) 은 GC 후에도 주소 불변 — True
GCHandle.Alloc(buf, Pinned)은fixed보다 오래(핸들을Free()할 때까지)buf를 고정한다.AddrOfPinnedObject()는 고정된 첫 원소 주소.GC.Collect()로 강제 수집해도 핀된 객체는 이동 대상에서 제외되므로addr1 == addr2= True. 이 안정성 덕에 비동기 소켓 I/O·P/Invoke 가 진행되는 동안 커널/네이티브가 그 버퍼를 안전하게 참조한다.
줄4: 핸들 통해 쓴 값도 managed 뷰에 — 99
Marshal.WriteByte(addr1, 1, 99)는buf[1]저장소에 99 를 씀 → 관리 뷰buf[1]= 99. (핸들Free()는 핀만 해제하지 데이터를 지우지 않는다.)
흔한 오해·함정
- 핀은 공짜가 아니다: 압축을 막으므로 gen0/gen1 에 핀된 객체가 흩어지면 단편화로 힙이 커지고 수집 효율이 떨어진다. 오래·다수 핀은 특히 나쁘다.
- 수명 규칙:
fixed는 스코프 기반(짧게), 비동기/장기 interop 은GCHandle(Pinned)또는 POH(Pinned Object Heap, .NET 5+) 에 버퍼를 두어 단편화를 국소화. 소켓 라이브러리는 흔히 큰 버퍼를 미리 핀해 재사용(핀 횟수·단편화 최소화). GCHandle.Free()누락 = 영구 핀 + 핸들 누수: 반드시try/finally(위 코드) 또는SafeHandle. (csharp_internals/problem8IDisposable/Dispose 패턴과 같은 결정적 해제 원칙.)fixed밖으로 포인터 반출 금지: 블록을 벗어나면 이동 가능 → 대롱 포인터.Span<T>(스택 전용,csharp_internals/problem5)나Memory<T>.Pin()이 더 안전한 현대적 대안.stackalloc/Span은 애초에 이동하지 않는 스택 메모리라 핀이 불필요 — 짧은 버퍼엔 이쪽이 낫다.
면접 포인트
- GC 압축이 왜 포인터를 위협하고,
fixed(스코프)와GCHandle(Pinned)(장기)의 차이·용도. - 핀닝이 단편화를 만드는 메커니즘과 완화책(버퍼 재사용·POH·Span/Memory.Pin).
- 관리 배열과 네이티브 포인터가 같은 저장소를 공유하는 점, 그리고
fixed밖으로 포인터를 내보내면 안 되는 이유.