26. 동시 처리 한도 제한기의 허가 반납 누락 (permit leak → throttle 고갈)
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 동시 처리 한도 제한기의 허가 반납 누락 (permit leak → throttle 고갈)
난이도: 중
요약
WaitAsync 로 허가를 얻은 뒤 Release 가 정상 경로(D)에서 한 번만 호출된다. 그런데 (B) 사전 점검 실패의 조기 반환과 (C) op() 의 예외는 (D)를 건너뛴다 → 허가가 반납되지 않는다(permit leak). 거절/실패가 반복되면 내부 카운트가 0까지 줄어들고, 이후 WaitAsync 는 영원히 깨어나지 못해 throttle 전체가 멈춘다(자원 고갈로 인한 사실상 데드락).
문제점
1. (자원 누수) 조기 반환이 허가를 반납하지 않음 — (A)(B)
- 증상:
IsHealthy()==false마다 허가 1개가 영구 소실. - 재현조건: 외부 시스템이 잠시 불안정해 (B)가 자주 실패 → 매번 1개씩 누수 → N번이면 전부 소진.
2. (자원 누수) 예외가 허가를 반납하지 않음 — (C)(D)
- 증상:
op()가 던지면 스택이 (D)를 지나치며 풀려 허가가 새고, 예외는 호출자에게 전파된다. - 근본원인: 획득과 반납이
try/finally로 짝지어지지 않았다. "성공했을 때만 반납"은 자원 관리의 전형적 오류.
3. (잠재) 과다 반납 위험
- 누수를 막겠다고 여러 경로에
Release를 흩뿌리면 이번엔 이중 반납으로 카운트가 max 를 넘겨SemaphoreFullException이 나거나 동시 한도가 깨진다. 반납은 정확히 한 번이어야 한다.
수정안
획득 직후 try 에 들어가고 반납은 finally 에서 딱 한 번 한다.
public async Task<Outcome> RunAsync(Func<Task> op)
{
await _sem.WaitAsync(); // 획득은 try 밖(획득 실패 시 finally 가 반납하면 안 되므로)
try
{
if (!IsHealthy())
return Outcome.Rejected; // finally 가 반납 → 누수 없음
await op(); // 예외가 나도 finally 가 반납
return Outcome.Ok;
}
finally
{
_sem.Release(); // 모든 경로에서 정확히 한 번
}
}
- 취소(
CancellationToken)를 쓰면WaitAsync(ct)가 던질 수 있는데, 이때는 허가를 얻지 못한 상태이므로 위처럼 획득을try밖에 두는 것이 중요하다(얻지도 않은 허가를 반납하면 과다 반납). - 더 안전하게는 "획득→반납"을 IDisposable 핸들로 감싸
using으로 강제한다:
public async Task<IDisposable> AcquireAsync(CancellationToken ct = default)
{
await _sem.WaitAsync(ct);
return new Releaser(_sem); // Dispose() 에서 Release() 1회
}
// 사용: using (await throttle.AcquireAsync()) { await op(); }
더 나은 설계
- 자원의 수명을 스코프에 묶는다:
try/finally또는using(IDisposable Releaser)으로 "얻으면 반드시 돌려준다"를 컴파일러/구문이 보장하게 한다. - 획득 자체에 타임아웃을 둬(
WaitAsync(timeout)) 고갈 시 무한 대기 대신 빠른 실패·관측이 되게 한다. - 한도를 넘는 부하는 거절/대기열 정책을 명시(예: 큐 길이 상한)해 백프레셔를 설계.
면접 포인트
- 락/세마포어/커넥션 같은 자원은 모든 종료 경로(정상·조기반환·예외)에서 반납돼야 한다 →
try/finally·using. - 획득과 반납은 짝이며 반납은 정확히 한 번 — 누수도 과다 반납도 모두 버그.
- 획득 실패(예외/취소) 시엔 반납하면 안 되므로, 획득을
try밖에 두는 패턴을 안다.
해설 · C++
해설 — 동시 처리 한도 제한기의 허가 반납 누락 (RAII 부재)
난이도: 중
요약
sem_.acquire() 로 허가를 얻은 뒤 sem_.release() 가 정상 경로(D)에서만 실행된다. (B) 사전 점검 실패의 조기 반환과 (C) op() 의 예외는 (D)를 지나친다 — 허가가 반납되지 않는다(permit leak). 거절/예외가 반복되면 세마포어 카운트가 0이 되고, 이후 acquire() 는 영원히 블록돼 throttle 이 멈춘다. 근본 원인은 자원 반납을 RAII(스코프 종료 시 자동 해제)로 묶지 않은 것이다.
문제점
1. (자원 누수) 조기 반환 — (A)(B)
isHealthy()==false시 허가를 쥔 채return→ 영구 누수.
2. (예외 안전성) 예외가 release 를 건너뜀 — (C)(D)
op()가 던지면 스택 되감기(stack unwinding)가 일어나 함수가 (D)를 실행하지 않고 빠져나간다 → 허가 누수. 수동release()호출은 예외 경로를 보장하지 못한다.
3. (잠재) 과다 반납은 또 다른 버그
- 누수를 막으려 여러 곳에
release()를 두면 이중 반납이 생길 수 있다. 그러면 카운트가 초기 max 를 넘겨 동시 실행 한도라는 불변식이 깨진다(N 개 제한이 N+1, N+2…로 새는 것). 반납은 정확히 한 번이어야 한다. (참고: C++20std::counting_semaphore::release는count + n <= max()가 사전조건이라 이를 넘기면 정의되지 않은 동작이다.)
수정안
"획득 시 잠그고, 스코프를 벗어나면 자동 반납"하는 RAII 가드를 둔다.
class SemGuard {
public:
explicit SemGuard(Semaphore& s) : sem_(&s) { sem_->acquire(); }
~SemGuard() { if (sem_) sem_->release(); } // 모든 경로(예외 포함)에서 1회
SemGuard(SemGuard&& o) noexcept : sem_(o.sem_) { o.sem_ = nullptr; }
SemGuard& operator=(SemGuard&&) = delete;
SemGuard(const SemGuard&) = delete;
SemGuard& operator=(const SemGuard&) = delete;
private:
Semaphore* sem_;
};
Outcome RequestThrottle::run(const std::function<void()>& op) {
SemGuard guard(sem_); // 획득
if (!isHealthy())
return Outcome::Rejected; // 소멸자가 반납
op(); // 예외가 나도 소멸자가 반납
return Outcome::Ok;
} // 정상 종료도 소멸자가 반납 — 정확히 한 번
- 가드는 이동만 허용(복사 금지)하고 이동된 원본은 nullptr 로 만들어 이중 반납을 구조적으로 차단한다.
- 표준
std::counting_semaphore(C++20)에는 lock_guard 류 어댑터가 없으므로 위처럼 직접 만들거나,acquire/release인터페이스를 가진 타입에 일반화한 가드를 둔다.
더 나은 설계
- 모든 자원(락·세마포어·소켓·버퍼)은 소유 객체의 수명 = 자원 점유 구간이 되도록 RAII 로 감싼다. 수동 해제 호출은 예외·조기반환에서 새기 마련이다.
- 고갈을 관측·완화하려면
try_acquire_for(timeout)류로 무한 대기를 끊고 거절/지표화.
면접 포인트
- C++ 자원 관리의 기본은 RAII — 예외 안전성(exception safety)은 "수동 해제"가 아니라 "소멸자가 해제"로 달성한다.
- 반납은 정확히 한 번 — 누수도 과다 반납(동시 한도 불변식 붕괴)도 모두 버그.
- 가드를 move-only + 이동 후 무력화로 만들어 이중 해제를 타입 수준에서 막는 설계 감각.