8. ReaderWriterLockSlim 쓰기 기아 + 재진입 + 예외 누수
난이도 상해설 — 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) 같은 스레드의 읽기 락 재진입 (분류: 정확성/동시성)
- 증상:
FindAndCheckParty→HasPartyMember에서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 참조를 교체한다.
더 나은 설계
-
읽기 ≫ 쓰기면 copy-on-write 스냅샷(권장):
ImmutableDictionary또는 "새Dictionary를 만들어 통째 교체"로 읽기를 완전 무락화. 트레이드오프: 쓰기 비용이 O(변경) ~ O(n)으로 커지고 GC 할당이 생김. 쓰기가 드물면 최적. -
ConcurrentDictionary: 세밀 락/락프리 버킷으로 읽기·쓰기 모두 확장. 단일 키 연산이 원자적이면 충분할 때 가장 단순. 트레이드오프: "여러 키에 걸친 일관 스냅샷"은 보장 못 함(필요하면 1번). -
RWLS를 유지해야 한다면: 모든 획득을
try/finally또는 RAII 구조체(using가능한 락 핸들)로 감싸 예외 누수 차단, 재진입은 코드 구조로 제거. 쓰기 기아는 "쓰기 대기 시 신규 읽기를 잠깐 막는 게이트"를 직접 구현해 완화할 수 있으나 복잡하고 버그 유발 — 가급적 1·2번으로 회피. -
락 보유 시간 최소화: 락 안에서
Validate같은 사용자 코드(예외/콜백/IO)를 호출하지 말 것. 락은 자료구조 접근만 감싸고, 검증·로깅은 락 밖에서.
면접 포인트
ReaderWriterLockSlim에서 writer starvation이 왜 발생하나? reader-preference/writer-preference 정책 차이와, .NET RWLS가 어느 쪽을 보장(혹은 미보장)하는지 설명하라.- 락 획득 후 예외가 나면 무슨 일이 생기나?
try/finally또는 RAII로 해제를 보장하는 패턴과, 락 안에서 사용자 코드를 호출하면 안 되는 이유는? - 읽기가 압도적인 워크로드에서 RWLS,
ConcurrentDictionary, copy-on-write 스냅샷 중 무엇을 택하고 트레이드오프는?LockRecursionPolicy를SupportsRecursion으로 바꾸는 게 왜 보통 나쁜 선택인가?
해설 — shared_mutex 쓰기 기아 + 재진입 데드락 + 예외 누수
난이도: 상
요약
세 가지 결함이 겹친다. (A) std::shared_mutex의 공정성 정책: 읽기가 압도적으로 많은 워크로드에서, 새 읽기 요청이 끊임없이 들어오면 대기 중인 쓰기가 무한정 밀려난다(writer starvation). 표준은 reader/writer 우선순위를 보장하지 않으며 구현(glibc/MSVC)마다 다르다. (B) 락을 raw lock_shared()/unlock_shared()로 직접 잡았는데, 그 사이 Validate가 예외를 던지면 unlock_shared가 실행되지 않아 락이 영구 미해제 → 이후 모든 쓰기가 영구 블록(데드락). (C) 같은 스레드가 공유 락을 잡은 채 다시 lock_shared()를 시도하는 재진입인데, std::shared_mutex는 재귀를 지원하지 않아 동일 스레드 재잠금은 정의되지 않은 동작(UB)/데드락이다. 해법: RAII 락 가드(std::shared_lock/std::unique_lock)로 해제 보장, 재진입 제거, 그리고 쓰기 기아를 구조적으로 완화.
문제점
(A) 쓰기 기아 (writer starvation) (분류: 동시성/정책, 핵심)
- 증상: 읽기 트래픽이 포화되면
Add/Remove(접속/해제)가 수 초 지연 또는 사실상 멈춤. - 재현조건: 읽기 스레드가 충분히 많아 "공유 락 보유자 수가 0으로 떨어지는 순간"이 거의 안 생길 때.
- 근본원인:
std::shared_mutex는 쓰기 우선(writer-preference)을 표준으로 보장하지 않는다. 구현에 따라 reader-preference면, 쓰기가 대기 중이어도 새 읽기들이 계속 들어와 공유 카운트가 0으로 안 떨어지면 쓰기는 무한정 대기한다. 읽기 빈도 ≫ 쓰기 빈도일수록 심하다. (glibc의 기본 rwlock은 reader-preference 경향, MSVC SRWLOCK은 다름 — 정책에 의존하면 안 됨.)
(B) Find의 예외 시 락 누수 (분류: 정확성/동시성, 매우 심각)
- 증상:
Validate(s)가 throw하면lock_.unlock_shared()가 실행되지 않는다. 공유 카운트가 영영 안 줄어 → 이후 어떤 쓰기도 못 잡고 전체 레지스트리 영구 블록. - 재현조건: 검증/후처리 경로에서 예외. 한 번이면 충분히 치명적.
- 근본원인: 락 획득과 해제가 RAII로 묶여 있지 않다. C++ 예외 안전(exception-safety)의 기본을 어김. 락은 반드시 RAII 가드(
std::shared_lock/std::lock_guard/std::unique_lock)로 잡아 스택 언와인딩 시 소멸자가 자동 해제하게 해야 한다.
(C) 같은 스레드의 공유 락 재진입 (분류: 정확성/동시성)
- 증상:
FindAndCheckParty→HasPartyMember에서lock_shared()를 또 호출 →std::shared_mutex는 재귀 비지원이라 데드락 또는 UB. 그로 인해 (B)처럼 바깥 락도 누수. - 재현조건: 공유 락 보유 중 같은 mutex를 다시 잡는 코드경로 진입.
- 근본원인:
std::shared_mutex는 동일 스레드가 이미 공유로 잡은 상태에서 다시 잠그는 것을 허용하지 않는다(표준상 미정의).std::recursive_mutex는 있어도 재귀 공유 mutex는 표준에 없다. 진짜 문제는 "락을 잡은 채 다시 락을 요구하는 호출 구조"다 — 내부 헬퍼는 락 없이(이미 보유 가정) 동작하도록 분리해야 한다.
수정안
(B) 예외 안전: RAII 락 가드
Session* Find(int id)
{
std::shared_lock<std::shared_mutex> lk(lock_); // 소멸 시 자동 unlock_shared
Session* s = nullptr;
if (auto it = sessions_.find(id); it != sessions_.end())
s = &it->second;
Validate(s); // 예외가 나도 lk 소멸자가 락을 푼다
return s;
}
Add/Remove도 std::unique_lock<std::shared_mutex>(또는 std::lock_guard)로 감싼다.
void Add(const Session& s)
{
std::unique_lock<std::shared_mutex> lk(lock_);
sessions_[s.Id] = s;
}
(C) 재진입 제거: "락 보유 가정" 내부 메서드로 분리
bool FindAndCheckParty(int id)
{
std::shared_lock<std::shared_mutex> lk(lock_);
if (auto it = sessions_.find(id); it != sessions_.end())
return HasPartyMemberNoLock(it->second.Id); // 락 재진입 안 함
return false;
}
// 호출자가 공유 락을 이미 보유한다고 가정(스스로 락을 잡지 않음)
bool HasPartyMemberNoLock(int id) const
{
return sessions_.find(id) != sessions_.end();
}
이미 락을 잡고 있으므로 내부 헬퍼는 락을 다시 잡지 않는다. 공개 API가 필요하면 별도로 "락 잡는" 버전을 두되 서로 호출하지 않게 한다.
(A) 쓰기 기아 완화
std::shared_mutex엔 표준 writer-preference 옵션이 없으므로 정책·자료구조로 푼다. 읽기가 압도적이면 불변 스냅샷 + 원자 교체(copy-on-write) 가 가장 깔끔하다.
#include <atomic>
#include <memory>
#include <mutex>
class SessionRegistry
{
public:
using Map = std::unordered_map<int, Session>;
Session Find(int id) const
{
// 읽기는 락/대기 전혀 없음. shared_ptr 스냅샷만 원자적으로 집는다.
std::shared_ptr<const Map> snap = std::atomic_load(&map_);
auto it = snap->find(id);
return it != snap->end() ? it->second : Session{};
// Validate는 락/스냅샷 밖에서 (예외 누수 원천 차단)
}
void Add(const Session& s)
{
// 쓰기끼리만 직렬화하면 충분(읽기를 막지 않음 → 기아 없음)
std::lock_guard<std::mutex> lk(writeGate_);
auto next = std::make_shared<Map>(*std::atomic_load(&map_)); // copy
(*next)[s.Id] = s;
std::atomic_store(&map_, std::shared_ptr<const Map>(next)); // 원자 교체
}
void Remove(int id)
{
std::lock_guard<std::mutex> lk(writeGate_);
auto next = std::make_shared<Map>(*std::atomic_load(&map_));
next->erase(id);
std::atomic_store(&map_, std::shared_ptr<const Map>(next));
}
private:
std::shared_ptr<const Map> map_ = std::make_shared<const Map>();
std::mutex writeGate_;
};
읽기가 락을 전혀 잡지 않으므로 쓰기가 굶을 일이 없다. 쓰기는 작은 임계구역에서 새 불변 맵을 만들어 포인터를 원자 교체한다. (C++20이면 std::atomic<std::shared_ptr<const Map>>로 더 깔끔하게.)
더 나은 설계
-
읽기 ≫ 쓰기면 copy-on-write 스냅샷(권장): 새 맵을 만들어 통째 원자 교체. 읽기를 완전 무락화. 트레이드오프: 쓰기 비용 O(n) 복사 + 할당. 쓰기가 드물면 최적.
-
샤딩된 락 / concurrent map:
tbb::concurrent_hash_map, follyConcurrentHashMap, 또는 키 해시로 N개 샤드 + 샤드별 mutex. 읽기·쓰기 모두 확장. 트레이드오프: "여러 키에 걸친 일관 스냅샷"은 보장 못 함(필요하면 1번). -
std::shared_mutex를 유지해야 한다면: 모든 획득을 RAII 가드로 감싸 예외 누수 차단, 재진입은 코드 구조로 제거. 쓰기 기아는 직접 writer-preference 래퍼(쓰기 대기 시 신규 reader를 잠깐 막는 게이트)를 구현해 완화할 수 있으나 복잡하고 버그 유발 — 가급적 1·2번으로 회피. -
락 보유 시간 최소화: 락 안에서
Validate같은 사용자 코드(예외/콜백/IO)를 호출하지 말 것. 락은 자료구조 접근만 감싸고, 검증·로깅은 락 밖에서. (락 잡은 채 예외 던지는 코드를 호출하지 않는 것이 (B)의 근본 예방.)
면접 포인트
std::shared_mutex에서 writer starvation이 왜 발생하나? reader-preference/writer-preference 정책 차이와, 표준이 어느 쪽도 보장하지 않는다는 점을 설명하라. glibc/MSVC 구현 차이는?- 락 획득 후 예외가 나면 무슨 일이 생기나? RAII 가드(
std::shared_lock/std::unique_lock)가 스택 언와인딩으로 해제를 보장하는 원리와, 락 안에서 사용자 코드를 호출하면 안 되는 이유는? - 읽기가 압도적인 워크로드에서
std::shared_mutex, concurrent map, copy-on-write 스냅샷 중 무엇을 택하고 트레이드오프는?std::shared_mutex가 재귀를 지원하지 않는데 재진입하면 왜 UB/데드락인가?