← 문제로

30. Reader-Writer 락 오용: 읽기 락 보유 중 쓰기 승급과 순회 중 변경 (C#)

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

해설 — Reader-Writer 락 오용: 읽기 락 보유 중 쓰기 승급과 순회 중 변경 (C#)

난이도: 중상

요약

ScanAndCleanup이 읽기 락을 쥔 채 같은 락의 쓰기 락을 추가로 잡으려다 비재귀 ReaderWriterLockSlim에서 즉시 예외/교착에 빠지고, 순회 중 컬렉션을 변경해 또 예외가 난다. Despawntry/finally 없이 수동 Enter/Exit를 써, 예외가 나면 쓰기 락이 영구히 점유되어 존 전체가 멈춘다.

문제점

  • [락 승급(read→write) + 순회 중 변경] (A)EnterReadLock() 보유 상태에서 EnterWriteLock()을 호출한다.
    • ReaderWriterLockSlim의 기본 정책은 LockRecursionPolicy.NoRecursion이라, 같은 스레드가 읽기 락을 든 채 쓰기 락을 요청하면 **LockRecursionException**이 던져진다(승급 미지원).
    • 설령 재귀를 허용해도, 읽기→쓰기 직접 승급은 다른 리더가 있으면 교착이다(쓰기는 모든 리더가 빠질 때까지 대기하는데, 자기 자신이 리더라 영원히 못 빠짐). 이게 바로 EnterUpgradeableReadLock이 존재하는 이유다.
    • 추가로 foreach_entities를 순회하면서 Remove하면 InvalidOperationException(컬렉션 변경)이 난다.
  • [락 해제 비예외안전] (B)DespawnEnterWriteLock()_entities[id](키 없으면 KeyNotFoundExceptionOnDespawn(콜백 예외)을 거쳐 ExitWriteLock()에 도달한다. 중간에 예외가 나면 Exit가 호출되지 않아 쓰기 락이 영구 점유된다. 이후 모든 읽기/쓰기가 무한 대기 → 존 동결(서버 부분 마비).
  • 근본원인: RW 락의 승급 의미를 무시(전용 승급 락 미사용) + 락 해제를 finally로 보장하지 않음.

수정안

정리(scan)는 EnterUpgradeableReadLock으로 단일 승급 가능 리더를 쓰거나, 후보를 먼저 수집한 뒤 쓰기 락 한 번으로 제거한다. 모든 Enter/Exit는 try/finally로 감싼다.

public void ScanAndCleanup()
{
    var dead = new List<long>();
    _lock.EnterReadLock();
    try { foreach (var kv in _entities) if (kv.Value.IsDead) dead.Add(kv.Key); }
    finally { _lock.ExitReadLock(); }      // 순회 중에는 변경하지 않음

    if (dead.Count == 0) return;
    _lock.EnterWriteLock();
    try { foreach (var id in dead) _entities.Remove(id); }
    finally { _lock.ExitWriteLock(); }
}

public void Despawn(long id)
{
    _lock.EnterWriteLock();
    try
    {
        if (!_entities.TryGetValue(id, out var e)) return;
        OnDespawn(e);                       // 예외가 나도 finally에서 해제됨
        _entities.Remove(id);
    }
    finally { _lock.ExitWriteLock(); }
}
  • 한 메서드 안에서 "읽다가 조건부로 쓰기"가 꼭 필요하면 EnterUpgradeableReadLock()(동시에 한 스레드만) → 필요 시 EnterWriteLock()로 승급하는 패턴을 쓴다. 일반 읽기 락은 승급 불가다.

더 나은 설계

  • 수집-후-변경(snapshot then mutate): 읽기 단계에서 변경 후보만 모으고, 짧은 쓰기 락에서 일괄 적용. 읽기 락 보유 시간을 최소화하고 승급 자체를 없앤다.
  • 불변 스냅샷 + 원자 교체(copy-on-write): 읽기가 극단적으로 많으면 엔티티 맵을 불변으로 두고, 변경 시 새 맵을 만들어 참조를 원자 교체(Volatile.Write/Interlocked.Exchange). 리더는 락 없이 현재 스냅샷을 읽는다.
    • 트레이드오프: 쓰기마다 복사 비용이 들어 쓰기가 잦으면 부적합. 읽기:쓰기 비율이 매우 클 때 유리.
  • 락 보유 중 콜백 금지: OnDespawn 같은 사용자 콜백을 락 안에서 호출하면 예외·재진입·블로킹 위험이 커진다. 락 밖으로 빼서(엔티티만 떼어내고 락 해제 후 콜백) 락 보유 시간을 줄인다.

면접 포인트

  • "읽기 락을 들고 쓰기 락을 잡으면?" — 일반 RW 락은 승급을 지원하지 않는다. ReaderWriterLockSlim은 비재귀라 예외, 또는 교착. 승급이 필요하면 EnterUpgradeableReadLock.
  • 수동 Enter/Exit는 항상 try/finally. 예외로 락을 흘리면 그 자원은 영구 동결된다(데드락보다 진단이 어려운 "조용한 멈춤").