← 문제로

24. .NET ThreadPool 내부 동작(전역/로컬 큐, work-stealing, 스레드 주입)과 동기 블로킹 기아

난이도 상
내 리뷰 · C#
해설 · C#

해설 — .NET ThreadPool 내부 동작(전역/로컬 큐, work-stealing, 스레드 주입)과 동기 블로킹 기아

난이도: 상

요약

.NET ThreadPool은 하나의 전역 큐와 워커 스레드별 로컬 큐(work-stealing queue)를 함께 사용해 작업을 분배한다. 신규 스레드 투입은 "hill-climbing" 휴리스틱 기반으로 처리량을 관찰하며 서서히(초 단위 간격으로) 이루어지므로, 짧은 시간에 대량의 작업이 .Result/.Wait()로 블로킹되면 스레드 주입 속도가 수요를 따라가지 못해 요청들이 큐에서 오래 대기하는 "스레드 기아(thread starvation)"가 발생한다.

핵심 개념

  • 전역 큐(Global Queue): Task.Run으로 큐잉된 최상위 작업들이 기본적으로 쌓이는 곳. 모든 워커 스레드가 여기서 꺼내갈 수 있다.
  • 로컬 큐(Worker-Local Queue): 이미 스레드풀 워커 스레드 안에서 실행 중인 코드가 새 Task를 만들면(중첩 Task), 그 워커의 로컬 큐에 우선 쌓인다. 자신의 로컬 큐는 LIFO로 처리해 캐시 지역성을 높인다.
  • Work-Stealing: 워커 스레드가 자기 로컬 큐를 다 처리하고 나면, 다른 워커의 로컬 큐 뒤쪽(FIFO 방향)에서 작업을 "훔쳐와" 처리한다. 이를 통해 부하가 특정 워커에 쏠리지 않고 자연스럽게 분산된다.
  • 스레드 주입(Thread Injection)과 Hill-Climbing: ThreadPool은 최소 스레드 수(MinThreads)만큼은 즉시 생성하지만, 그 이상은 "현재 처리량이 스레드를 늘리면 개선되는지"를 주기적으로(대략 수백 ms ~ 초 단위 간격) 관찰하며 점진적으로 늘리는 hill-climbing 알고리즘을 사용한다. 급격한 수요 폭증에는 즉각 반응하지 못하고 서서히 스레드를 추가한다.

단계별 내부 동작

  1. Task.Run으로 매치 20개를 동시에 큐잉하면, 이 20개의 델리게이트가 전역 큐에 쌓인다.
  2. MinThreads를 2로 낮춰뒀으므로, 초기에는 워커 스레드 2개만 즉시 사용 가능하다. 이 2개가 큐에서 작업을 꺼내 실행을 시작한다.
  3. 각 워커가 ProcessOneMatch 안에서 FetchPlayerRatingAsync(matchId).Result를 호출하면, 그 워커 스레드는 Task.Delay(200)이 완료될 때까지 동기적으로 블로킹된다 — 즉, 스레드풀 스레드 하나가 실질적인 작업 없이 200ms 동안 완전히 점유된다.
  4. 워커 2개가 모두 이렇게 블로킹되면, 나머지 18개의 작업은 전역 큐에서 대기하게 된다. ThreadPool은 "큐에 처리 안 된 작업이 쌓여 있는데 실행 중(정확히는 활성) 스레드 수가 부족하다"는 신호를 감지하지만, hill-climbing 알고리즘은 즉각적으로 수십 개의 스레드를 만들지 않는다 — 성능 저하(오버스레딩으로 인한 컨텍스트 스위칭 비용)를 피하기 위해 의도적으로 초당 최대 1~2개 정도의 완만한 속도로 스레드를 추가하는 방식으로 설계되어 있다.
  5. 그 결과, 20개의 매치 처리가 "2개씩 순차적으로 처리되다가 서서히 스레드가 늘어나는" 패턴을 보이며, 전체 처리 시간이 이론적 최소치(200ms 근처)보다 훨씬 길어진다(수 초 이상 걸릴 수 있다). 이것이 스레드 기아다: 스레드가 "존재하지 않아서"가 아니라 "블로킹된 채 낭비되고 있어서" 발생한다.
  6. .Resultawait로 바꾸면, FetchPlayerRatingAsync 호출 지점에서 워커 스레드는 스레드풀에 반환되고(블로킹하지 않음), Task.Delay 완료 시 콜백이 스레드풀 큐에 다시 등록되어 그때 사용 가능한 아무 워커나 이어서 실행한다. 즉 대기 시간 동안 스레드를 점유하지 않으므로, 소수의 워커 스레드만으로도 다수의 동시 요청을 순차적으로 재사용하며 처리할 수 있어 기아가 사라진다.

흔한 오해·함정

  • "스레드풀이 알아서 스레드를 늘려주니까 .Result를 써도 결국 해결된다"고 생각하기 쉽지만, hill-climbing의 스레드 추가 속도는 의도적으로 느리게 설계되어 있어 순간적인 대량 블로킹에는 취약하다.
  • ThreadPool.SetMinThreads를 무작정 높이는 것으로 "해결"하려는 시도도 있는데, 이는 근본 원인(블로킹 대기)을 치료하지 않고 증상만 가리는 것이며 스레드 수 증가에 따른 메모리·컨텍스트 스위칭 비용이 커진다.
  • 로컬 큐/전역 큐 구분과 work-stealing은 "이미 실행 중인 스레드가 부족한 상황"을 해결해주는 메커니즘이 아니라 "이미 충분한 스레드가 있을 때 작업을 효율적으로 분배"하는 메커니즘이라는 점을 혼동하면 안 된다.

면접 포인트

  • 스레드풀의 전역 큐/로컬 큐 구조와 work-stealing이 무엇을 위한 최적화인지 정확히 구분해 설명할 수 있는가.
  • .Result/.Wait()가 "스레드가 없어서"가 아니라 "스레드가 낭비되고 있어서" 문제를 일으킨다는 것과, hill-climbing의 점진적 스레드 투입 특성을 연결해 설명할 수 있는가.
  • 동일한 문제를 await로 바꾸면 왜 해결되는지(스레드 반환·재사용 관점에서) 설명할 수 있는가.