← 문제로

8. ReaderWriterLockSlim 쓰기 기아 + 재진입 + 예외 누수

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

해설 — ReaderWriterLockSlim 쓰기 기아 + 재진입 + 예외 누수

난이도: 상

요약

세 가지 결함이 겹친다. (A) ReaderWriterLockSlim공정성 정책: 읽기가 압도적으로 많은 워크로드에서, 새 읽기 요청이 끊임없이 들어오면 대기 중인 쓰기가 무한정 밀려난다(writer starvation). .NET의 RWLS는 쓰기 우선 보장이 없어 읽기 폭주 시 쓰기가 굶는다. (B) 락 획득 후 ExitReadLock 사이에서 Validate예외를 던지면 락이 영구 미해제 → 이후 모든 접근이 영구 블록(데드락). (C) 같은 스레드가 읽기 락을 잡은 채 다시 읽기 락을 시도하는 재진입인데, RWLS 기본은 LockRecursionPolicy.NoRecursion이라 LockRecursionException. 해법: try/finally로 해제 보장, 재진입 제거(또는 정책 변경은 비권장), 그리고 쓰기 기아를 구조적으로 완화.

문제점

(A) 쓰기 기아 (writer starvation) (분류: 동시성/정책, 핵심)

  • 증상: 읽기 트래픽이 포화되면 Add/Remove(접속/해제)가 수 초 지연 또는 사실상 멈춤.
  • 재현조건: 읽기 스레드가 충분히 많아 "읽기 락이 0으로 떨어지는 순간"이 거의 안 생길 때. 읽기들이 겹쳐 들어오면 쓰기는 빈 틈을 못 잡는다.
  • 근본원인: ReaderWriterLockSlim쓰기 우선(writer-preference)을 보장하지 않는다. 쓰기가 대기 중이어도 새 읽기들이 계속 들어와 활성 읽기 카운트가 0으로 안 떨어지면, 쓰기는 무한정 대기한다. 읽기 빈도 ≫ 쓰기 빈도일수록 심하다. (참고: .NET RWLS는 일부 버전에서 약한 writer-bias가 있으나 진정한 기아 방지는 아님 — 정책으로 의존하면 안 됨.)

(B) Find의 예외 시 락 누수 (분류: 정확성/동시성, 매우 심각)

  • 증상: Validate(s)가 throw하면 _lock.ExitReadLock()이 실행되지 않는다. 읽기 카운트가 영영 안 줄어 → 이후 어떤 쓰기도 못 잡고 전체 레지스트리 영구 블록.
  • 재현조건: 검증/후처리 경로에서 예외. 한 번이면 충분히 치명적.
  • 근본원인: 락 획득과 해제가 try/finally로 묶여 있지 않다. 예외 안전(exception-safety)의 기본을 어김. 락은 반드시 finally(또는 RAII 래퍼)에서 풀어야 한다.

(C) 같은 스레드의 읽기 락 재진입 (분류: 정확성/동시성)

  • 증상: FindAndCheckPartyHasPartyMember에서 EnterReadLock을 또 호출 → LockRecursionException. 그 예외로 (B)처럼 바깥 락도 누수.
  • 재현조건: 읽기 락 보유 중 같은 락을 다시 잡는 코드경로 진입.
  • 근본원인: RWLS 기본 정책이 LockRecursionPolicy.NoRecursion. 재진입을 허용하려면 SupportsRecursion이 필요한데, 이는 데드락·성능 함정이 많아 권장되지 않는다. 진짜 문제는 "락을 잡은 채 다시 락을 요구하는 호출 구조"다 — 내부 헬퍼는 락 없이(이미 보유 가정) 동작하도록 분리해야 한다.

수정안

(B) 예외 안전: try/finally

public Session Find(int id)
{
    _lock.EnterReadLock();
    try
    {
        _sessions.TryGetValue(id, out var s);
        Validate(s);
        return s;
    }
    finally
    {
        _lock.ExitReadLock();   // 예외가 나도 반드시 해제
    }
}

Add/Remove도 동일하게 try/finally로 감싼다.

(C) 재진입 제거: "락 보유 가정" 내부 메서드로 분리

public bool FindAndCheckParty(int id)
{
    _lock.EnterReadLock();
    try
    {
        if (_sessions.TryGetValue(id, out var s) && s != null)
            return HasPartyMemberNoLock(s.Id);   // 락 재진입 안 함
        return false;
    }
    finally { _lock.ExitReadLock(); }
}

// 호출자가 읽기 락을 이미 보유한다고 가정 (스스로 락을 잡지 않음)
private bool HasPartyMemberNoLock(int id) => _sessions.ContainsKey(id);

이미 락을 잡고 있으므로 내부 헬퍼는 락을 다시 잡지 않는다. 공개 API가 필요하면 별도로 "락 잡는" 버전을 두되, 서로 호출하지 않게 한다.

(A) 쓰기 기아 완화

RWLS 자체는 강한 writer-preference 옵션이 없으므로, 정책·자료구조로 푼다. 읽기가 압도적이면 불변 스냅샷 + 원자 교체(copy-on-write) 가 가장 깔끔하다.

using System.Collections.Immutable;

public sealed class SessionRegistry
{
    // 읽기는 락/대기 전혀 없음. volatile 참조만 읽는다.
    private volatile ImmutableDictionary<int, Session> _map =
        ImmutableDictionary<int, Session>.Empty;

    public Session Find(int id)
    {
        var snap = _map;            // 락 없는 읽기
        snap.TryGetValue(id, out var s);
        return s;                   // Validate는 락 밖에서 (예외 누수 원천 차단)
    }

    public void Add(Session s)
    {
        // 쓰기끼리만 직렬화하면 충분 (읽기를 막지 않음 → 기아 없음)
        lock (_writeGate)
            _map = _map.SetItem(s.Id, s);
    }

    public void Remove(int id)
    {
        lock (_writeGate)
            _map = _map.Remove(id);
    }

    private readonly object _writeGate = new();
}

읽기가 락을 전혀 잡지 않으므로 쓰기가 굶을 일이 없다. 쓰기는 작은 임계구역에서 새 불변 맵을 만들어 volatile 참조를 교체한다.

더 나은 설계

  1. 읽기 ≫ 쓰기면 copy-on-write 스냅샷(권장): ImmutableDictionary 또는 "새 Dictionary를 만들어 통째 교체"로 읽기를 완전 무락화. 트레이드오프: 쓰기 비용이 O(변경) ~ O(n)으로 커지고 GC 할당이 생김. 쓰기가 드물면 최적.

  2. ConcurrentDictionary: 세밀 락/락프리 버킷으로 읽기·쓰기 모두 확장. 단일 키 연산이 원자적이면 충분할 때 가장 단순. 트레이드오프: "여러 키에 걸친 일관 스냅샷"은 보장 못 함(필요하면 1번).

  3. RWLS를 유지해야 한다면: 모든 획득을 try/finally 또는 RAII 구조체(using 가능한 락 핸들)로 감싸 예외 누수 차단, 재진입은 코드 구조로 제거. 쓰기 기아는 "쓰기 대기 시 신규 읽기를 잠깐 막는 게이트"를 직접 구현해 완화할 수 있으나 복잡하고 버그 유발 — 가급적 1·2번으로 회피.

  4. 락 보유 시간 최소화: 락 안에서 Validate 같은 사용자 코드(예외/콜백/IO)를 호출하지 말 것. 락은 자료구조 접근만 감싸고, 검증·로깅은 락 밖에서.

면접 포인트

  1. ReaderWriterLockSlim에서 writer starvation이 왜 발생하나? reader-preference/writer-preference 정책 차이와, .NET RWLS가 어느 쪽을 보장(혹은 미보장)하는지 설명하라.
  2. 락 획득 후 예외가 나면 무슨 일이 생기나? try/finally 또는 RAII로 해제를 보장하는 패턴과, 락 안에서 사용자 코드를 호출하면 안 되는 이유는?
  3. 읽기가 압도적인 워크로드에서 RWLS, ConcurrentDictionary, copy-on-write 스냅샷 중 무엇을 택하고 트레이드오프는? LockRecursionPolicySupportsRecursion으로 바꾸는 게 왜 보통 나쁜 선택인가?