7. ThreadLocal 버퍼 누수와 레지스트리 동시성
난이도 중해설 — ThreadLocal 버퍼 누수와 레지스트리 동시성
난이도: 중
요약
SerializeBuffer가 생성될 때 전역 BufferRegistry에 this를 등록하는데 해제(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)에서 제거.
더 나은 설계
-
명시적 수명 관리(권장):
SerializeBuffer : IDisposable로 두고, 스레드 작업 완료 시using으로 회수 +Deregister. 약참조 청소에 의존하지 않아 결정적. 게임서버처럼 수명이 명확한 곳에 적합. -
레지스트리 대신 원자 카운터: "총 바이트"만 알면 되면 개별 객체를 모을 필요 없이, 생성 시
Interlocked.Add(ref _total, cap), 해제(Dispose/finalizer) 시Interlocked.Add(ref _total, -cap)하는long하나로 충분하다. 락·리스트·누수 위험이 전부 사라진다. 트레이드오프: 개별 버퍼 열람 불가. -
스레드 풀 + 고정 수명: 단기 스레드 churn 자체가
ThreadLocal슬롯 생성/정리 비용을 키운다.ThreadPool/고정 워커 풀을 재사용하면 ThreadLocal이 풀 수명만큼만 살아 누수 표면적이 준다. (또는 아예ArrayPool<byte>.Shared로 버퍼를 빌려 ThreadLocal 보유 자체를 없앤다.) -
ArrayPool<byte>로 대체: 스레드별 버퍼 대신 호출마다ArrayPool<byte>.Shared.Rent/Return을 쓰면 ThreadLocal·레지스트리·누수가 모두 사라진다. 풀이 내부적으로 버킷을 재사용한다. 트레이드오프: Rent/Return 짝을 반드시 맞춰야(빌린 걸 안 돌려주면 그게 또 누수).
면접 포인트
- C#에서 정적 컬렉션이 객체를 강하게 붙잡으면 왜 누수가 되나? GC 루트와 도달 가능성으로 설명하라. C++의
thread_local T* = new T;delete 누락과 무엇이 같고 다른가? ThreadLocal<T>의 수명/정리는?Dispose를 안 하면 무엇이 누적되나?trackAllValues의 의미와 비용은?- 생성 시 전역 레지스트리에 등록하는 객체에서 등록/해제 대칭(또는 약한 참조)이 없으면 어떤 문제가 연쇄되는가? 동시
Add되는List<T>가 왜 깨지는지, 어떤 동시성 컬렉션/락으로 고치는지 설명하라.
해설 — thread_local 버퍼 누수와 레지스트리 동시성
난이도: 중
요약
thread_local SerializeBuffer* buf = new SerializeBuffer() 는 포인터를 thread_local로 두고 new로 할당했지만 어디서도 delete하지 않는다. thread_local 변수 자체(포인터)는 스레드 종료 시 사라지지만 그것이 가리키던 힙 객체는 누수된다. 게다가 객체가 전역 BufferRegistry에 this를 등록하는데 해제(deregister)가 전혀 없어 종료된 스레드의 버퍼 포인터가 레지스트리에 영구 잔류 → GetTotalBytes()가 과대해지고, 그 댕글링 포인터를 역참조하면 use-after-free다. 추가로 레지스트리 push_back이 락 없이 여러 스레드에서 호출되어 벡터가 깨진다. 해법: thread_local로 객체 자체를 두거나(자동 소멸), 소멸자에서 deregister + 레지스트리 락.
문제점
(A) thread_local SerializeBuffer* buf = new SerializeBuffer(); — 객체 누수 (분류: 메모리, 핵심)
- 증상: 스레드를 반복 생성/종료할수록 64KB씩 영구 누수. 메모리 단조 증가.
- 재현조건: 단기 스레드 churn(매치메이킹 잡, IO 풀 확장/축소). 스레드가 죽어도 버퍼 객체는 안 죽는다.
- 근본원인: thread_local 포인터는 스레드 종료 시 포인터 변수만 파괴된다.
new로 만든 객체는delete가 없으면 회수되지 않는다. thread_local의 자동 소멸 혜택을 누리려면 객체 자체를 thread_local로 둬야 한다(thread_local SerializeBuffer buf;). 포인터+new는 그 혜택을 스스로 버린 셈.
(B) BufferRegistry에 Register만 있고 deregister 없음 — 댕글링/과대집계 (분류: 메모리/정확성)
- 증상: 레지스트리 등록 수가 "현재 스레드 수"를 초과해 계속 증가.
GetTotalBytes()가 실제보다 과대. (A)를 고쳐 객체가 소멸돼도, 레지스트리에 남은 포인터는 이미 파괴된 객체를 가리키는 댕글링이 되어GetTotalBytes()의b->Capacity()가 use-after-free. - 근본원인: 등록/해제 수명 관리가 비대칭. 객체 생성 시 등록했으면 소멸 시 반드시 해제해야 한다(RAII 짝). 등록만 있는 레지스트리는 "살아있는 것"이 아니라 "한 번이라도 만들어진 것"을 추적하게 된다.
(C) buffers_.push_back(b) — 레지스트리 락 부재 (분류: 동시성)
- 증상: 여러 스레드가 동시에 버퍼를 처음 만들 때
push_back이 동시 호출 →std::vector의 재할당/크기 갱신이 레이스 → 원소 유실, 힙 손상, 크래시. - 근본원인:
std::vector는 동시 쓰기에 안전하지 않다.push_back은 capacity 초과 시 재할당+복사+포인터 교체라는 비원자 시퀀스다. 등록/해제 모두 뮤텍스로 보호해야 한다.
수정안
원칙: 객체 자체를 thread_local로 두어 자동 소멸시키고, 소멸자에서 deregister, 레지스트리는 뮤텍스로 보호.
#include <mutex>
#include <unordered_set>
#include <vector>
class SerializeBuffer;
class BufferRegistry
{
public:
static BufferRegistry& Get() { static BufferRegistry inst; return inst; }
void Register(SerializeBuffer* b)
{
std::lock_guard<std::mutex> lk(mtx_); // (C) 락
buffers_.insert(b);
}
void Deregister(SerializeBuffer* b) // (B) 해제 추가
{
std::lock_guard<std::mutex> lk(mtx_);
buffers_.erase(b);
}
std::size_t GetTotalBytes() const;
private:
mutable std::mutex mtx_;
std::unordered_set<SerializeBuffer*> buffers_;
};
class SerializeBuffer
{
public:
SerializeBuffer() : data_(64 * 1024)
{
BufferRegistry::Get().Register(this);
}
~SerializeBuffer() // (B) 소멸 시 해제(RAII 짝)
{
BufferRegistry::Get().Deregister(this);
}
std::size_t Capacity() const { return data_.size(); }
std::uint8_t* Data() { return data_.data(); }
private:
std::vector<std::uint8_t> data_;
};
// (A) 객체 자체를 thread_local로 → 스레드 종료 시 ~SerializeBuffer 자동 호출
static SerializeBuffer& TlsBuffer()
{
thread_local SerializeBuffer buf; // new/delete 불필요
return buf;
}
std::size_t BufferRegistry::GetTotalBytes() const
{
std::lock_guard<std::mutex> lk(mtx_);
std::size_t total = 0;
for (auto* b : buffers_) total += b->Capacity();
return total;
}
핵심 변경:
- (A)
thread_local SerializeBuffer buf;— 포인터/new제거. 스레드 종료 시 thread_local 객체의 소멸자가 자동 호출된다(C++11 보장). - (B) 소멸자에서
Deregister(this)— 등록/해제 대칭. 댕글링·과대집계 제거. - (C) 레지스트리 모든 접근을 뮤텍스로 보호.
unordered_set으로 O(1) 삽입/삭제.
만약 어떤 이유로 포인터+
new를 유지해야 한다면, 소멸 책임을 스레드 종료에 묶는 트릭이 필요하다: thread_local로std::unique_ptr<SerializeBuffer>를 두면 스레드 종료 시unique_ptr소멸자가delete를 호출한다. 그래도 (B)(C)는 그대로 필요.
더 나은 설계
-
thread_local 객체 직접 보유(권장): 위 수정처럼 포인터 대신 객체를 두면 수명이 스레드에 자동 묶인다. 동적 다형성이 필요해 포인터가 불가피하면
thread_local std::unique_ptr<T>. -
레지스트리 대신 원자 카운터: "총 바이트"만 알면 되면 개별 포인터를 모을 필요 없이, 생성자에서
total_.fetch_add(cap), 소멸자에서fetch_sub(cap)하는std::atomic<size_t>하나로 충분하다. 락·벡터·댕글링 위험이 전부 사라진다. 트레이드오프: 개별 버퍼 열람은 불가. -
스레드 풀 + 고정 수명: 단기 스레드 churn 자체가 thread_local 비용(생성/소멸, TLS 슬롯)을 키운다. 고정 크기 워커 풀을 재사용하면 thread_local이 풀 수명만큼만 살아 누수 표면적이 준다.
-
수명 추적 도구: 포인터 레지스트리를 유지해야 한다면
weak_ptr(객체를shared_ptr로 관리) 기반으로 두어,lock()실패 시 죽은 항목으로 간주하고 청소(lazy cleanup)하는 방식이 댕글링을 구조적으로 막는다.
면접 포인트
thread_local T* p = new T;와thread_local T t;의 수명 차이는? 전자가 왜 누수가 되는가? thread_local 객체의 소멸 시점/소멸자 호출 보장은?- 생성 시 전역 레지스트리에 등록하는 객체에서 RAII 짝(소멸 시 해제)이 없으면 어떤 문제가 연쇄되는가? (누수 → 과대집계 → 댕글링/use-after-free)
- 단기 스레드를 대량 생성/종료하는 워크로드에서 thread_local과 스레드 풀 중 무엇을 택하고 왜인가? TLS 슬롯·소멸 비용 관점에서 설명하라.