30. Reader-Writer 락 오용: 읽기 락 보유 중 쓰기 승급과 순회 중 변경 (C#)
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — Reader-Writer 락 오용: 읽기 락 보유 중 쓰기 승급과 순회 중 변경 (C#)
난이도: 중상
요약
ScanAndCleanup이 읽기 락을 쥔 채 같은 락의 쓰기 락을 추가로 잡으려다 비재귀 ReaderWriterLockSlim에서 즉시 예외/교착에 빠지고, 순회 중 컬렉션을 변경해 또 예외가 난다. Despawn은 try/finally 없이 수동 Enter/Exit를 써, 예외가 나면 쓰기 락이 영구히 점유되어 존 전체가 멈춘다.
문제점
- [락 승급(read→write) + 순회 중 변경] (A) —
EnterReadLock()보유 상태에서EnterWriteLock()을 호출한다.ReaderWriterLockSlim의 기본 정책은LockRecursionPolicy.NoRecursion이라, 같은 스레드가 읽기 락을 든 채 쓰기 락을 요청하면 **LockRecursionException**이 던져진다(승급 미지원).- 설령 재귀를 허용해도, 읽기→쓰기 직접 승급은 다른 리더가 있으면 교착이다(쓰기는 모든 리더가 빠질 때까지 대기하는데, 자기 자신이 리더라 영원히 못 빠짐). 이게 바로
EnterUpgradeableReadLock이 존재하는 이유다. - 추가로
foreach로_entities를 순회하면서Remove하면InvalidOperationException(컬렉션 변경)이 난다.
- [락 해제 비예외안전] (B) —
Despawn은EnterWriteLock()후_entities[id](키 없으면KeyNotFoundException)·OnDespawn(콜백 예외)을 거쳐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. 예외로 락을 흘리면 그 자원은 영구 동결된다(데드락보다 진단이 어려운 "조용한 멈춤").
해설 · C++
해설 — Reader-Writer 락 오용: 공유 락 보유 중 독점 락 재요청과 순회 중 반복자 무효화 (C++)
난이도: 중상
요약
scanAndCleanup이 shared_mutex의 공유 락을 쥔 채 같은 뮤텍스의 독점 락을 추가로 잡으려 해 재귀 잠금(UB)·교착에 빠지고, 순회 중 erase로 반복자를 무효화한다. despawn은 RAII 없이 수동 lock()/unlock()을 써, 예외가 나면 독점 락이 영구 점유되어 존이 동결된다.
문제점
- [같은 shared_mutex 재귀 잠금 + 반복자 무효화] (A) —
std::shared_lock(공유) 보유 상태에서std::unique_lock(독점)을 같은mtx_에 건다.std::shared_mutex는 재귀 잠금을 지원하지 않는다. 한 스레드가 공유 락을 든 채 같은 뮤텍스의 독점 락을 요청하는 것은 미정의 동작이며, 실제로는 독점 락이 "모든 공유 보유자(자기 자신 포함)가 빠질 때까지" 대기해 교착한다.- 또한 범위 기반
for로entities_를 순회하면서erase(kv.first)하면 반복자가 무효화되어 이어지는 순회가 UB(크래시/오동작).
- [락 해제 비예외안전] (B) —
despawn은mtx_.lock()후entities_.at(id)(없으면std::out_of_rangethrow)·onDespawn(예외 가능)을 거쳐mtx_.unlock()에 도달한다. 중간 예외 시 스택 되감기로 함수를 빠져나가지만unlock()이 호출되지 않아 독점 락이 영구 점유된다. 이후 모든 공유/독점 락이 무한 대기 → 존 동결. - 근본원인: shared_mutex의 비재귀성을 무시(승급 경로 부재) + 락 해제를 RAII로 보장하지 않음.
수정안
정리는 "읽기로 후보 수집 → 락 해제 → 독점 락으로 일괄 제거"로 승급을 없애고, 락은 항상 RAII(lock_guard/unique_lock)로 잡는다.
// 주의: std::vector 사용 시 <vector> 포함
void scanAndCleanup() {
std::vector<int64_t> dead;
{
std::shared_lock<std::shared_mutex> lk(mtx_);
for (auto& kv : entities_) if (kv.second.dead) dead.push_back(kv.first);
} // 공유 락 해제(순회 중 변경 없음)
if (dead.empty()) return;
std::unique_lock<std::shared_mutex> wl(mtx_);
for (int64_t id : dead) entities_.erase(id); // 독점 락 1회로 일괄 제거
}
void despawn(int64_t id) {
Entity copy;
{
std::unique_lock<std::shared_mutex> lk(mtx_); // RAII: 예외에도 자동 해제
auto it = entities_.find(id);
if (it == entities_.end()) return;
copy = it->second;
entities_.erase(it);
} // 락 해제 후 콜백 호출
onDespawn(copy); // 락 밖에서 — 예외/블로킹 격리
}
std::shared_mutex는 승급 락이 없다. "읽다가 조건부 쓰기"가 정말 필요하면 후보 수집-재잠금 패턴이나boost::upgrade_mutex류를 쓴다(표준에는 없음).- 콜백(
onDespawn)은 락 밖에서 호출해 락 보유 시간을 줄이고 재진입/예외 위험을 격리한다.
더 나은 설계
- 불변 스냅샷 + 원자 교체: 읽기가 압도적이면 맵을
shared_ptr<const Map>으로 두고, 변경 시 새 맵을 만들어std::atomic_store로 교체. 리더는atomic_load로 락 없이 현재 스냅샷을 읽는다.- 트레이드오프: 쓰기마다 복사 비용 → 쓰기가 잦으면 부적합. 읽기:쓰기 비율이 클 때만 유리.
- find의 참조 탈출 주의: 락 안에서 얻은 원소의 포인터/참조를 락 밖으로 내보내면 동시
erase에 의해 dangling이 된다. 본 수정안처럼 복사 반환하거나, 처리까지 락 안에서 끝낸다.
면접 포인트
std::shared_mutex는 재귀/승급 불가. 공유 락 보유 중 같은 뮤텍스 독점 락 요청은 UB이자 교착. 승급이 필요하면 수집-재잠금 패턴.- RAII가 곧 예외 안전이다. 수동
lock()/unlock()은 예외 한 번에 락을 흘려 "조용한 동결"을 만든다.lock_guard/unique_lock이 기본.