34. ArrayPool<T> · Memory<T> · IMemoryOwner<T> 버퍼 풀링의 내부 동작
난이도 중내 리뷰 · C#
해설 · C#
해설 — ArrayPool · Memory · IMemoryOwner 버퍼 풀링의 내부 동작
난이도: 중상
요약
ArrayPool<T>.Rent(n)은 "n 이상" 크기의 배열을 돌려준다(정확히 n이 아니다).
풀은 크기를 2의 거듭제곱 버킷으로 관리하므로 buffer.Length가 요청보다 클 수 있고,
게다가 이전 사용자의 데이터가 남아 있을 수 있다. 따라서 반드시 (a) 실제 유효
길이만큼만 쓰고, (b) 반납 후에는 배열을 만지지 않으며, (c) 민감 데이터는
clearArray:true로 반납해야 한다. async 경계를 넘겨 버퍼를 들고 가야 하면
Span<T>(ref struct, 스택 전용)가 아니라 Memory<T>/IMemoryOwner<T>가 필요하다.
핵심 개념 1: Rent 는 "at least" 계약
requested 100 -> buffer.Length 128 // 다음 버킷
requested 1000 -> buffer.Length 1024
requested 4096 -> buffer.Length 4096 // 정확히 버킷 경계
requested 5000 -> buffer.Length 8192 // 다음 버킷으로 올림
(정확한 값은 런타임/버전마다 다를 수 있으나 항상 >= 요청, 대개 2의 거듭제곱 버킷.)
- 공유 풀(
ArrayPool<T>.Shared)은TlsOverPerCoreLockedStacksArrayPool로 구현되며, 길이를 버킷(16, 32, 64 … 2^k) 으로 나눠 스레드 로컬 + per-core 락 스택에 캐시한다.Rent는 요청을 담을 수 있는 최소 버킷을 찾아 그 크기의 배열을 준다. - 그래서
buffer.Length를 "내 데이터 길이"로 쓰면 안 된다. 유효 길이는 내가 따로 관리해야 한다(예:payloadLen).
핵심 개념 2: ProcessPacket 의 세 함정
- (A) 정확한 길이 오용: 체크섬을
buf.Length(=버킷 크기, 예 8192)까지 순회한다. 실제 유효 바이트는payloadLen뿐이고, 나머지는 직전 대여자가 남긴 쓰레기(또는 민감 데이터). 체크섬이 오염되고, 최악의 경우 다른 세션의 잔여 데이터를 읽는 정보 노출이 된다. →for (i < payloadLen). - (B)(C) 반납 후 사용(use-after-return):
Return(buf)직후buf[0]를 읽는다(C). 반납된 배열은 즉시 다른 스레드가Rent해 갈 수 있으므로, 그 내용을 읽는 것은 다른 사용자와의 데이터 레이스이자 논리적 use-after-free다. 반납 후 지역 참조는null로 만들어 재사용을 원천 차단하는 게 안전하다. - 민감 데이터 클리어 누락: 세션 키·개인정보를 담았던 버퍼를 그냥 반납하면 다음
대여자가 그 잔여를 본다.
Return(buf, clearArray: true)로 지워서 반납한다(성능 트레이드오프 있음).
올바른 형태
public static void ProcessPacket(int payloadLen)
{
byte[] buf = ArrayPool<byte>.Shared.Rent(payloadLen);
try
{
FillFromSocket(buf, payloadLen);
long sum = 0;
for (int i = 0; i < payloadLen; i++) // 유효 길이만
sum += buf[i];
Console.WriteLine($"checksum={sum}"); // 반납 전에 사용
}
finally
{
ArrayPool<byte>.Shared.Return(buf, clearArray: true); // 예외에도 반드시 반납
}
}
try/finally로 예외 경로에서도 반드시 반납한다(안 하면 풀에서 배열이 영영 빠져
사실상 누수). 반납은 항상 함수의 마지막에, 그 후 배열 접근 없음.
핵심 개념 3: Span vs Memory (async 경계)
Span<T>는ref struct라서 힙에 올라갈 수 없다. 즉 필드로 저장 불가,await를 가로질러 살아남을 수 없고, 람다/이터레이터에 캡처 불가. 상태 머신은 지역 변수를 힙 객체 필드로 승격하는데ref struct는 그럴 수 없기 때문이다.- 따라서
await socket.ReadAsync(...)처럼 비동기 경계를 넘겨 버퍼를 들고 가야 하면Memory<T>(일반 struct, 힙 가능)를 쓴다. 풀에서 받으려면MemoryPool<T>.Shared.Rent(n)이 주는IMemoryOwner<T>를using으로 잡아 수명을 관리하고,owner.Memory.Slice(0, payloadLen)로 유효 구간만 넘긴다. - 동기 핫패스의 짧은 구간 처리에는
Span<T>가 가장 빠르고(경계검사 제거·스택 전용), 경계를 넘기거나 저장해야 할 때만Memory<T>로 승격한다.
흔한 오해·함정
- "Rent(1000)이면 길이 1000짜리가 온다" → 아니다.
>= 1000. 유효 길이는 직접 관리. - "풀 배열은 0으로 초기화돼 온다" → 아니다.
clearArray:true로 반납하지 않는 한 이전 데이터가 남는다.Rent는 초기화를 보장하지 않는다. - "반납은 대충 안 해도 GC가 처리" → 반납 안 하면 그 배열은 풀에 못 돌아와 풀링 효과가 사라진다(성능 저하). 이중 반납은 더 위험(같은 배열이 두 대여자에게).
- "
new byte[n]대비 항상 빠르다" → 작은 버퍼나 짧은 수명은 GC가 더 쌀 수 있다. 풀은 크고/빈번하고/수명이 명확한 버퍼에서 이득. 프로파일링으로 판단.
면접 포인트
- "ArrayPool.Rent 의 계약은?" → "요청 이상" 크기, 내용 미초기화. 유효 길이 직접 관리, 민감 데이터는 clearArray 반납, try/finally 로 확실히 반납.
- "왜 async 메서드에서 Span 을 못 들고 가나?" →
Span<T>는 ref struct라 await를 넘는 상태 머신 필드가 될 수 없다. 경계를 넘기려면Memory<T>/IMemoryOwner<T>. - "풀링이 GC 압박을 줄이는 원리?" → 할당/수집 대신 재사용해 Gen0 할당률과 LOH 단편화를 낮춘다. 단 수명·반납 규칙을 어기면 데이터 노출·레이스로 되돌아온다.