22. SemaphoreSlim 의 내부 동작과 async 친화적 대기(WaitAsync)
난이도 상내 리뷰 · C#
해설 · C#
해설 — SemaphoreSlim 의 내부 동작과 async 친화적 대기(WaitAsync)
난이도: 상
답변 프레임워크: 요약 → 핵심 개념 → 단계별 내부 동작 → 흔한 오해·함정 → 면접 포인트
요약
SemaphoreSlim 은 lock/Monitor 와 달리 "소유 스레드"라는 개념이 없는 카운팅 세마포어다.
Wait() 는 카운트가 0이면 호출 스레드를 블로킹(스핀 후 커널 대기)하지만, WaitAsync()
는 카운트가 0일 때 Task 를 반환하고 스레드를 즉시 반환한다 — 내부적으로 대기자를
큐(대기열)에 넣어두고, 다른 스레드가 Release() 를 호출하면 그 큐에서 대기자를 하나 골라
연결된 TaskCompletionSource 를 완료시켜 이어서 실행되게 한다. Wait/Release 를
같은 스레드가 짝을 이룰 필요가 전혀 없다(lock 의 재진입/소유권 개념 자체가 없다) —
그래서 await 로 진입해서 다른(스레드풀) 스레드에서 Release 를 호출해도 아무 문제가
없다. lock(obj) { await ...; } 가 금지되는 이유는 Monitor.Enter/Exit 가 스레드
소유권 기반이라 await 이후 재개되는 스레드가 다를 수 있는 비동기 코드와 근본적으로
맞지 않기 때문이다.
예측
s_gate초기 카운트 2 → 처음 두 개의CallExternalApiAsync호출은WaitAsync()가 즉시(동기적으로) 완료되어 게이트에 진입한다.- 세 번째 이상의 호출은 카운트가 0이 되어
WaitAsync()가 완료되지 않은 Task 를 반환한다 → 호출자의await지점에서 메서드가 일시 중단되고, 스레드풀 스레드는 즉시 반환되어 다른 작업을 처리할 수 있다(블로킹 없음). - 앞선 두 요청 중 하나가
finally에서Release()를 호출하면, 대기 큐의 다음 요청 하나가 깨어나(스레드풀 워커에 큐잉되어)WaitAsync()이후 코드를 재개한다. - 만약
Wait()(동기 버전)를 썼다면, 대기하는 3번째 호출의 스레드는 커널 대기 상태로 블로킹되어 그 스레드풀 스레드를 그동안 다른 일에 쓸 수 없다(스레드풀 고갈 위험).
핵심 개념
- 카운팅 세마포어 vs 상호 배제 락.
Monitor(lock)는 "한 스레드가 소유"하는 배타 락이고 재진입이 허용된다.SemaphoreSlim은 카운트만큼 동시 진입을 허용하는 자원 카운터이며 소유 스레드 개념이 없다 — 누구든Wait하고 누구든Release할 수 있다. Wait()는 블로킹,WaitAsync()는 완료 기반(Task) 대기.WaitAsync()는 카운트가 즉시 확보 가능하면 완료된 Task(동기 경로)를 반환하고, 아니면 내부 대기자 큐에 자신을 등록하고 미완료 Task 를 반환한다. 이 Task 가 완료되는 시점은 어떤 스레드의 코드 실행이 아니라 다른 스레드가 Release 를 호출해 그 Task 를 완료시키는 콜백이다.- 비동기 대기의 재개는 스레드풀에서.
WaitAsync()뒤에 이어지는await대기는 캡처된SynchronizationContext/TaskScheduler가 있으면 그곳으로, 없으면(ASP.NET Core, 콘솔 앱) 스레드풀에서 재개된다 —async/await의 일반적인 이어달리기 규칙을 그대로 따른다.
단계별 내부 동작
SemaphoreSlim내부에는 현재 사용 가능한 카운트(정수)와, 대기 중인 요청들을 담는 자료구조(대기자 큐 — 실제로는WaitAsync요청마다 만들어지는TaskCompletionSource류의 객체들의 연결 리스트/큐)가 있다.WaitAsync()호출: 락(내부적으로 짧은 스핀락/모니터)을 잡고 카운트를 확인한다.- 카운트 > 0 이면 카운트를 감소시키고 이미 완료된 Task 를 즉시 반환(동기 완료, 추가 할당·컨텍스트 전환 없음).
- 카운트 == 0 이면 새 대기 등록(TCS 생성)을 큐에 넣고, 그 TCS 의
Task를 반환한다. 이 시점에 호출 스레드는 블로킹되지 않고 메서드에서 빠져나간다(async 메서드라면 상태 머신이 이 Task 에 콜백을 걸고 호출 스레드를 반환).
Release()호출: 락을 잡고 카운트를 증가시키되, 대기 큐에 대기자가 있으면 카운트를 다시 그 대기자에게 넘기는 형태로 큐의 맨 앞(또는 구현 세부에 따른 순서) 대기자의 TCS 를 완료시킨다 — 즉 "카운트를 늘리고 누군가 다시 폴링"하는 게 아니라, 깨울 대상을 확정해서 직접 완료시키는 핸드오프 방식에 가깝다.- TCS 가 완료되면, 그 Task 를
await하고 있던 async 상태 머신의 이어달리기 콜백이 스레드풀(또는 캡처된 컨텍스트)에서 실행되어CallExternalApiAsync의 나머지 코드가 재개된다. - 동기
Wait()를 썼다면 3단계 이전에, 대기 스레드는 짧은 스핀 이후 커널 동기화 객체 (예: 이벤트)에서 진짜로 블로킹되어 운영체제 스케줄러가 깨울 때까지 그 스레드를 전혀 쓸 수 없다 —WaitAsync()와의 근본적 차이는 여기서 스레드 리소스를 점유하느냐다.
흔한 오해·함정
- "SemaphoreSlim(1,1) 은 lock 과 똑같다" ✗ — 이진 세마포어처럼 보이지만 재진입이
안 된다(같은 스레드가 두 번
Wait하면 스스로 데드락) 그리고 Wait/Release 를 다른 스레드가 해도 무방하다. lock 은 재진입 허용, 소유 스레드만 해제 가능이라 의미가 다르다. - "lock 블록 안에 await 를 넣으면 컴파일러가 알아서 처리해준다" ✗ — C# 컴파일러는
lock블록 내await를 컴파일 에러로 막는다(async 메서드 안의 lock 문 안에 await 불가). 이유:lock은Monitor.Enter/Exit쌍으로 컴파일되는데, await 이후 재개되는 스레드가 진입했던 스레드와 다를 수 있어Monitor.Exit가 소유하지 않은 락을 풀려는 예외를 던지거나 의미가 깨진다. - "WaitAsync 도 결국 스레드 하나를 잡아먹는다" ✗ — 대기 중에는 어떤 스레드도 점유하지
않는다. 콜백이 등록되어 있을 뿐이고, Release 가 호출되는 순간에만 스레드풀에서 짧게
재개 코드가 실행된다. 그래서 수천 개의 동시 대기자가 있어도 스레드풀 고갈이 일어나지
않는다(반대로
Wait()를 대량으로 쓰면 스레드풀이 고갈될 수 있다). - "Release 는 대기자 전체를 깨운다" ✗ —
Release(int releaseCount = 1)는 기본적으로 한 번에 하나(또는 releaseCount 개) 의 카운트만 반환하며, 그만큼의 대기자만 깨어난다. 나머지는 계속 대기한다.
면접 포인트 (예상 질문)
Wait()와WaitAsync()의 차이를 스레드 점유 관점에서 설명하라.lock(obj) { await ...; }가 왜 문제이며 SemaphoreSlim.WaitAsync() 는 이를 어떻게 회피하는가?- SemaphoreSlim 에는 왜 "소유 스레드" 개념이 없는가? 이것이 실무 사용 패턴(다른 스레드에서 Release)에 어떤 이점을 주는가?
- 동시 요청 수를 제한해야 하는 게임서버의 외부 API 호출 로직에서, lock 대신 SemaphoreSlim 을 써야 하는 이유를 스레드풀 고갈 관점에서 설명하라.