← 문제로

7. ThreadLocal 버퍼 누수와 레지스트리 동시성

난이도 중
내 리뷰 · C#
해설 · C#

해설 — ThreadLocal 버퍼 누수와 레지스트리 동시성

난이도: 중

요약

SerializeBuffer가 생성될 때 전역 BufferRegistrythis를 등록하는데 해제(Deregister)가 전혀 없다. 게다가 정적 BufferRegistry가 모든 버퍼를 강한 참조 리스트로 붙잡으므로, 스레드가 끝나 ThreadLocal의 슬롯이 정리돼도 버퍼 객체는 레지스트리 때문에 GC되지 않는다(GC 루트인 정적 필드에서 도달 가능). 그래서 단기 스레드를 반복 생성/종료하면 64KB짜리 버퍼가 영구 누적된다(GetTotalBytes() 과대). 또한 ThreadLocal<T>Dispose하지 않고, 등록한 버퍼를 스레드 종료 시 떼어내지 않아 누수가 굳는다. 마지막으로 _buffers.Add/순회가 락 없이 여러 스레드에서 호출되어 List<T>가 손상되거나 집계 중 예외가 난다. 해법: 등록/해제 대칭 + 약한 참조 또는 명시적 정리 + 레지스트리 동시성 보호.

C++판은 thread_local T* = new T;delete 누락(객체 누수) + 댕글링 이 핵심이다. C#은 GC가 있으므로 "delete 누락"은 없지만, 정적 레지스트리가 객체를 GC 루트로 붙잡아 회수 자체를 막는 것이 같은 누수의 C# 버전이다.

문제점

(A) ThreadLocal<SerializeBuffer> — 정리/Dispose 부재 (분류: 메모리, 핵심)

  • 증상: 스레드를 반복 생성/종료할수록 64KB씩 누적. 메모리 단조 증가.
  • 재현조건: 단기 스레드 churn(매치메이킹 잡, IO 풀 확장/축소). 스레드가 죽어도 버퍼가 안 죽는다.
  • 근본원인: ThreadLocal<T> 자체는 스레드 종료 시 그 스레드의 슬롯 값을 더 이상 붙잡지 않지만, 여기선 같은 버퍼를 전역 레지스트리가 강하게 참조한다(아래 B). 그래서 ThreadLocal이 놓아도 레지스트리가 잡고 있어 회수 불가. 또한 ThreadLocal<T>IDisposable인데 Dispose하지 않아 내부 추적 자료구조도 정리되지 않는다(앱 수명 내내 누적). thread_local 객체의 "자동 회수" 혜택을 레지스트리 강참조가 무력화한 셈.

(B) BufferRegistry에 Register만 있고 Deregister 없음 — 강참조 누수/과대집계 (분류: 메모리/정확성)

  • 증상: 레지스트리 등록 수가 "현재 스레드 수"를 초과해 계속 증가. GetTotalBytes()가 실제보다 과대.
  • 근본원인: 등록/해제 수명 관리가 비대칭. 정적 Instance.​_buffers는 GC 루트라, 여기에 담긴 모든 버퍼는 영원히 도달 가능해 GC 대상에서 제외된다. 등록만 있는 레지스트리는 "살아있는 것"이 아니라 "한 번이라도 만들어진 것"을 추적하게 되고, 그게 곧 강참조 누수다.

(C) _buffers.Add(...) / 순회 — 레지스트리 락 부재 (분류: 동시성)

  • 증상: 여러 스레드가 동시에 버퍼를 처음 만들 때 Add가 동시 호출 → List<T>의 내부 배열 재할당/_size 갱신이 레이스 → 원소 유실, IndexOutOfRange/NullReference, 집계 중 컬렉션 변경 예외.
  • 근본원인: List<T>는 동시 쓰기에 안전하지 않다. Add는 capacity 초과 시 재할당+복사+크기 갱신이라는 비원자 시퀀스다. 등록/해제/순회 모두 락(또는 동시성 컬렉션)으로 보호해야 한다.

수정안

원칙: 등록/해제 대칭, 레지스트리는 약한 참조로 붙잡거나 명시적 정리, 동시성 보호. 가장 단순·견고한 길은 "총 바이트만 필요하면 개별 객체를 모으지 말고 원자 카운터"다(아래 더 나은 설계 2번). 개별 추적이 필요하면 아래처럼.

using System.Collections.Generic;
using System.Threading;

public sealed class BufferRegistry
{
    public static readonly BufferRegistry Instance = new BufferRegistry();
    private readonly object _gate = new();
    // (B) 약한 참조로 보관 → 레지스트리가 GC를 막지 않음
    private readonly HashSet<WeakReference<SerializeBuffer>> _buffers = new();

    public void Register(SerializeBuffer b)
    {
        lock (_gate)                                  // (C) 락
            _buffers.Add(new WeakReference<SerializeBuffer>(b));
    }

    public void Deregister(SerializeBuffer b)          // (B) 명시 해제도 제공
    {
        lock (_gate)
            _buffers.RemoveWhere(w => !w.TryGetTarget(out var t) || ReferenceEquals(t, b));
    }

    public long GetTotalBytes()
    {
        long total = 0;
        lock (_gate)                                  // (C) 락
        {
            _buffers.RemoveWhere(w => !w.TryGetTarget(out _)); // 죽은 항목 청소
            foreach (var w in _buffers)
                if (w.TryGetTarget(out var b)) total += b.Capacity;
        }
        return total;
    }
}
public sealed class SerializeBuffer
{
    private readonly byte[] _data;
    public SerializeBuffer()
    {
        _data = new byte[64 * 1024];
        BufferRegistry.Instance.Register(this);
    }
    public int Capacity => _data.Length;
    public byte[] Data => _data;
}

public static class Serializer
{
    // (A) trackAllValues:false + 스레드 종료 시 정리. ThreadLocal은 Dispose 대상.
    private static readonly ThreadLocal<SerializeBuffer> _tls =
        new ThreadLocal<SerializeBuffer>(() => new SerializeBuffer());

    public static void SerializePacket(int payloadByte)
        => _tls.Value.Data[0] = (byte)payloadByte;

    // 앱 종료/모듈 언로드 시
    public static void Shutdown() => _tls.Dispose();
}

핵심 변경:

  • (B) 레지스트리가 WeakReference로 버퍼를 잡아 GC 루트가 되지 않게 한다. 스레드가 끝나 ThreadLocal이 슬롯을 놓으면 버퍼는 더 이상 강참조가 없어 GC 회수되고, 집계 시 죽은 약참조를 청소한다(lazy cleanup). 결정적 정리가 필요하면 SerializeBuffer : IDisposable로 만들고 스레드 종료 시 Deregister.
  • (C) 모든 레지스트리 접근을 lock으로 보호. (ConcurrentBag/ConcurrentDictionary로 대체도 가능.)
  • (A) ThreadLocal<T>를 적절히 Dispose하고, 강참조 누수 원인을 (B)에서 제거.

더 나은 설계

  1. 명시적 수명 관리(권장): SerializeBuffer : IDisposable로 두고, 스레드 작업 완료 시 using으로 회수 + Deregister. 약참조 청소에 의존하지 않아 결정적. 게임서버처럼 수명이 명확한 곳에 적합.

  2. 레지스트리 대신 원자 카운터: "총 바이트"만 알면 되면 개별 객체를 모을 필요 없이, 생성 시 Interlocked.Add(ref _total, cap), 해제(Dispose/finalizer) 시 Interlocked.Add(ref _total, -cap) 하는 long 하나로 충분하다. 락·리스트·누수 위험이 전부 사라진다. 트레이드오프: 개별 버퍼 열람 불가.

  3. 스레드 풀 + 고정 수명: 단기 스레드 churn 자체가 ThreadLocal 슬롯 생성/정리 비용을 키운다. ThreadPool/고정 워커 풀을 재사용하면 ThreadLocal이 풀 수명만큼만 살아 누수 표면적이 준다. (또는 아예 ArrayPool<byte>.Shared로 버퍼를 빌려 ThreadLocal 보유 자체를 없앤다.)

  4. ArrayPool<byte>로 대체: 스레드별 버퍼 대신 호출마다 ArrayPool<byte>.Shared.Rent/Return을 쓰면 ThreadLocal·레지스트리·누수가 모두 사라진다. 풀이 내부적으로 버킷을 재사용한다. 트레이드오프: Rent/Return 짝을 반드시 맞춰야(빌린 걸 안 돌려주면 그게 또 누수).

면접 포인트

  1. C#에서 정적 컬렉션이 객체를 강하게 붙잡으면 왜 누수가 되나? GC 루트와 도달 가능성으로 설명하라. C++의 thread_local T* = new T; delete 누락과 무엇이 같고 다른가?
  2. ThreadLocal<T>의 수명/정리는? Dispose를 안 하면 무엇이 누적되나? trackAllValues의 의미와 비용은?
  3. 생성 시 전역 레지스트리에 등록하는 객체에서 등록/해제 대칭(또는 약한 참조)이 없으면 어떤 문제가 연쇄되는가? 동시 Add되는 List<T>가 왜 깨지는지, 어떤 동시성 컬렉션/락으로 고치는지 설명하라.