← 문제로

29. 침투적 참조 카운트의 비원자성 (버퍼 풀의 이중 반납·반납 후 사용)

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — 침투적 참조 카운트의 비원자성 (버퍼 풀의 이중 반납·반납 후 사용)

난이도: 최상

요약

AddRef_refs++ (A)Release--_refs (B)비원자적 read-modify-write다. 여러 스레드가 동시에 호출하면 증감이 유실(torn)되어 카운트가 실제 참조 수와 어긋난다. 그 결과 ① 카운트가 너무 일찍 0이 되어 아직 쓰는 중인 버퍼를 풀로 반납(다른 소비자에게 재대여 → 데이터 오염, "반납 후 사용"), ② 0을 두 스레드가 동시에 관측해 이중 반납, ③ 거꾸로 0에 영영 못 닿아 미반납(누수) 이 발생한다.

문제점

  • [비원자적 read-modify-write / 데이터 레이스]
    • 증상: 풀이 같은 버퍼를 두 번 나눠주거나(상태 오염·크래시), 카운트가 음수로 내려가거나(이중 반납), 버퍼가 풀로 돌아오지 않는다(누수).
    • 재현 조건(유실 증가): 현재 _refs=2. 두 스레드가 동시에 AddRef. 둘 다 _refs를 2로 읽고 3을 쓴다 → 결과 3(원래 4여야 함). 증가 하나 유실 → 나중에 0에 너무 일찍 도달 → 아직 참조가 남았는데 반납.
    • 재현 조건(이중 반납): _refs=1에서 두 스레드가 거의 동시에 Release. --_refs가 비원자라 둘 다 "0"을 관측 → 둘 다 _pool.Return(this) 실행.
    • 근본 원인 (A)(B): x++/--x는 "읽기 → 증감 → 쓰기"의 세 단계이며, 그 사이 다른 스레드가 끼어들 수 있다. 참조 카운트는 본질적으로 여러 스레드가 동시에 갱신하므로 단일 원자 연산이어야 한다.

수정안

Interlocked로 증감을 원자화하고, "정확히 0을 만든 스레드"만 반납한다.

public void AddRef()
{
    // 이미 살아있는 참조를 쥔 상태에서만 호출돼야 한다(0에서 되살리기 금지).
    Interlocked.Increment(ref _refs);
}

public void Release()
{
    int now = Interlocked.Decrement(ref _refs);   // 원자적 감소 + "감소 후 값" 반환
    if (now == 0)
        _pool.Return(this);                       // 0을 만든 스레드 단 하나만 반납
    // 방어: now < 0 이면 이미 누군가 0 이하로 내렸다는 뜻 → 로직 버그(이중 Release)
}

핵심:

  • Interlocked.Decrement원자적이고 반환값이 "감소 후의 값" 이라, 정확히 한 스레드만 now == 0을 보게 된다 → 이중 반납·미반납이 사라진다.
  • Interlocked 연산은 .NET에서 전체 메모리 배리어다. 그래서 0을 만든 스레드는 다른 스레드가 감소 직전까지 버퍼에 쓴 내용을 관측한다(C++에서 별도 acquire 펜스가 필요한 것과 달리, 여기선 배리어가 내장).

더 나은 설계

  • 0에서 되살리기(resurrection) 금지: AddRef는 이미 유효한 참조를 가진 코드만 호출해야 한다. 약한 참조(weak)에서 강한 참조를 얻는 경로가 있다면, "0이면 실패"하는 CAS 루프가 필요하다:
    public bool TryAddRef()
    {
        int c;
        do { c = Volatile.Read(ref _refs); if (c == 0) return false; }
        while (Interlocked.CompareExchange(ref _refs, c + 1, c) != c);
        return true;
    }
    
  • 소유권을 타입으로 표현: 수동 AddRef/Release는 누락/중복이 쉽다. using/IDisposable 핸들이나 소유권 래퍼(빌릴 때 +1, Dispose 때 -1)로 짝을 강제하면 사람 실수를 줄인다.
  • 반납 후 사용 차단: 반납 시 버퍼를 "사용 불가" 상태로 표시(세대 토큰)하고, 사용 시 토큰을 검증하면 use-after-return을 빨리 잡는다.
  • 트레이드오프: 원자 연산은 경합 시 캐시라인 핑퐁 비용이 있다. 핫패스라면 스레드 로컬 배칭이나, 애초에 버퍼를 단일 소유(한 스레드가 끝까지 소유)로 설계해 공유 카운트를 없애는 편이 더 빠르다.

면접 포인트

  • x++가 원자적이지 않은 이유(read-modify-write 3단계)와, 참조 카운트가 왜 반드시 원자 증감이어야 하는지.
  • Interlocked.Decrement의 반환값(감소 후 값)으로 "정확히 한 번 반납"을 보장하는 패턴.
  • .NET Interlocked가 전체 배리어라는 점, 그리고 0에서의 되살리기(weak→strong) 문제와 CAS 해법.