← 문제로

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 의 일반적인 이어달리기 규칙을 그대로 따른다.

단계별 내부 동작

  1. SemaphoreSlim 내부에는 현재 사용 가능한 카운트(정수)와, 대기 중인 요청들을 담는 자료구조(대기자 큐 — 실제로는 WaitAsync 요청마다 만들어지는 TaskCompletionSource 류의 객체들의 연결 리스트/큐)가 있다.
  2. WaitAsync() 호출: 락(내부적으로 짧은 스핀락/모니터)을 잡고 카운트를 확인한다.
    • 카운트 > 0 이면 카운트를 감소시키고 이미 완료된 Task 를 즉시 반환(동기 완료, 추가 할당·컨텍스트 전환 없음).
    • 카운트 == 0 이면 새 대기 등록(TCS 생성)을 큐에 넣고, 그 TCS 의 Task 를 반환한다. 이 시점에 호출 스레드는 블로킹되지 않고 메서드에서 빠져나간다(async 메서드라면 상태 머신이 이 Task 에 콜백을 걸고 호출 스레드를 반환).
  3. Release() 호출: 락을 잡고 카운트를 증가시키되, 대기 큐에 대기자가 있으면 카운트를 다시 그 대기자에게 넘기는 형태로 큐의 맨 앞(또는 구현 세부에 따른 순서) 대기자의 TCS 를 완료시킨다 — 즉 "카운트를 늘리고 누군가 다시 폴링"하는 게 아니라, 깨울 대상을 확정해서 직접 완료시키는 핸드오프 방식에 가깝다.
  4. TCS 가 완료되면, 그 Task 를 await 하고 있던 async 상태 머신의 이어달리기 콜백이 스레드풀(또는 캡처된 컨텍스트)에서 실행되어 CallExternalApiAsync 의 나머지 코드가 재개된다.
  5. 동기 Wait() 를 썼다면 3단계 이전에, 대기 스레드는 짧은 스핀 이후 커널 동기화 객체 (예: 이벤트)에서 진짜로 블로킹되어 운영체제 스케줄러가 깨울 때까지 그 스레드를 전혀 쓸 수 없다 — WaitAsync() 와의 근본적 차이는 여기서 스레드 리소스를 점유하느냐다.

흔한 오해·함정

  • "SemaphoreSlim(1,1) 은 lock 과 똑같다" ✗ — 이진 세마포어처럼 보이지만 재진입이 안 된다(같은 스레드가 두 번 Wait 하면 스스로 데드락) 그리고 Wait/Release 를 다른 스레드가 해도 무방하다. lock 은 재진입 허용, 소유 스레드만 해제 가능이라 의미가 다르다.
  • "lock 블록 안에 await 를 넣으면 컴파일러가 알아서 처리해준다" ✗ — C# 컴파일러는 lock 블록 내 await컴파일 에러로 막는다(async 메서드 안의 lock 문 안에 await 불가). 이유: lockMonitor.Enter/Exit 쌍으로 컴파일되는데, await 이후 재개되는 스레드가 진입했던 스레드와 다를 수 있어 Monitor.Exit 가 소유하지 않은 락을 풀려는 예외를 던지거나 의미가 깨진다.
  • "WaitAsync 도 결국 스레드 하나를 잡아먹는다" ✗ — 대기 중에는 어떤 스레드도 점유하지 않는다. 콜백이 등록되어 있을 뿐이고, Release 가 호출되는 순간에만 스레드풀에서 짧게 재개 코드가 실행된다. 그래서 수천 개의 동시 대기자가 있어도 스레드풀 고갈이 일어나지 않는다(반대로 Wait() 를 대량으로 쓰면 스레드풀이 고갈될 수 있다).
  • "Release 는 대기자 전체를 깨운다" ✗ — Release(int releaseCount = 1) 는 기본적으로 한 번에 하나(또는 releaseCount 개) 의 카운트만 반환하며, 그만큼의 대기자만 깨어난다. 나머지는 계속 대기한다.

면접 포인트 (예상 질문)

  1. Wait()WaitAsync() 의 차이를 스레드 점유 관점에서 설명하라.
  2. lock(obj) { await ...; } 가 왜 문제이며 SemaphoreSlim.WaitAsync() 는 이를 어떻게 회피하는가?
  3. SemaphoreSlim 에는 왜 "소유 스레드" 개념이 없는가? 이것이 실무 사용 패턴(다른 스레드에서 Release)에 어떤 이점을 주는가?
  4. 동시 요청 수를 제한해야 하는 게임서버의 외부 API 호출 로직에서, lock 대신 SemaphoreSlim 을 써야 하는 이유를 스레드풀 고갈 관점에서 설명하라.