ThreadPool은 .NET 애플리케이션의 기본 동시성 엔진입니다. Worker Thread 수를 적절히 관리하면 지연과 포화를 줄이고 안정적으로 처리량을 높일 수 있습니다. 실무에서 적용 가능한 최적화 포인트를 정리합니다.
1. ThreadPool 동작 이해
ThreadPool은 Worker Thread(작업 실행)와 I/O Completion Thread(비동기 I/O 완료 처리)를 관리합니다. Task, async/await, Parallel API는 기본적으로 ThreadPool을 사용합니다. 중요한 원칙은 “스레드를 막지 말고, 비동기 I/O를 활용하라”입니다.
ThreadPool은 부하에 맞춰 자동으로 스레드를 주입하지만, 초기 지연과 과도한 스레드 생성(컨텍스트 스위칭 증가)을 방지하려면 최소 스레드 조정과 동시성 제한을 병행하는 것이 좋습니다.
2. 최소/최대 스레드 조정 전략
최대 스레드 변경은 특별한 이유가 없으면 피하고, 최소 스레드는 워밍업과 초기 부하 대응을 위해 조절합니다. 기본값은 런타임/환경에 따라 달라집니다.
using System;
using System.Threading;
class PoolTuning
{
public static void TuneMinThreads()
{
ThreadPool.GetMinThreads(out int workerMin, out int ioMin);
ThreadPool.GetMaxThreads(out int workerMax, out int ioMax);
Console.WriteLine($"Min: W={workerMin}, IO={ioMin}; Max: W={workerMax}, IO={ioMax}");
// CPU 코어 수 기준으로 보수적으로 워밍업
int targetMinWorkers = Math.Max(workerMin, Environment.ProcessorCount * 2);
// I/O는 과도한 동시성 피하기 위해 그대로 두거나 상황에 맞게 조정
ThreadPool.SetMinThreads(targetMinWorkers, ioMin);
Console.WriteLine($"Set Min Workers -> {targetMinWorkers}");
}
}
워밍업으로 초기 주입 지연을 줄입니다.
using System;
using System.Threading;
class PoolWarmup
{
public static void Warm()
{
ThreadPool.GetMinThreads(out int workerMin, out _);
ManualResetEventSlim allDone = new(false);
int remaining = workerMin;
for (int i = 0; i < workerMin; i++)
{
ThreadPool.QueueUserWorkItem(_ =>
{
// 짧은 워크로 스레드 주입 유도
Thread.SpinWait(50_000);
if (Interlocked.Decrement(ref remaining) == 0)
allDone.Set();
});
}
allDone.Wait();
}
}
주의: 최대 스레드를 크게 올리면 컨텍스트 스위칭과 GC 압력이 증가합니다. 동시성은 스레드 수가 아니라 작업 특성에 맞게 제어해야 합니다.
3. CPU 작업과 I/O 작업 분리
CPU 바운드 작업은 적정 병렬도로 실행하고, I/O는 진짜 비동기 API를 await로 처리합니다. LongRunning은 전용 스레드를 만들므로 소수의 장시간 작업에만 제한적으로 사용합니다.
using System;
using System.Net.Http;
using System.Threading.Tasks;
class WorkPatterns
{
static readonly HttpClient http = new HttpClient();
public static async Task MixAsync()
{
// CPU 바운드: ThreadPool에서 실행
var cpuTask = Task.Run(() => HeavyCompute());
// I/O 바운드: 비동기 I/O, 스레드 점유 없음
var ioTask = http.GetStringAsync("https://example.com");
await Task.WhenAll(cpuTask, ioTask);
Console.WriteLine($"CPU={cpuTask.Result}, IO len={ioTask.Result.Length}");
}
static int HeavyCompute()
{
int sum = 0;
for (int i = 0; i < 10_000_00; i++) sum += i;
return sum;
}
public static void DedicatedLongRunning()
{
// 전용 스레드: 소수의 지속 작업에만 사용
Task.Factory.StartNew(
() => BackgroundLoop(),
TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach);
}
static void BackgroundLoop()
{
while (true)
{
// 주기적 작업
Task.Delay(1000).Wait();
}
}
}
4. 동시성 제한으로 과부하 방지
최대 스레드를 조정하기보다 작업 레벨에서 동시성을 제한합니다. SemaphoreSlim, Parallel.ForEachAsync, Channel 등으로 처리량과 안정성을 동시에 확보합니다.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
class ConcurrencyGate
{
public static async Task ProcessAsync(IEnumerable<int> items)
{
int degree = Environment.ProcessorCount; // CPU 바운드 기준
using var gate = new SemaphoreSlim(degree);
var tasks = new List<Task>();
foreach (var item in items)
{
await gate.WaitAsync();
tasks.Add(Task.Run(async () =>
{
try
{
await DoWorkAsync(item);
}
finally
{
gate.Release();
}
}));
}
await Task.WhenAll(tasks);
}
static async Task DoWorkAsync(int i)
{
// I/O와 CPU가 섞인 작업 예시
await Task.Delay(10); // I/O 대기
var _ = i * i; // 짧은 CPU
}
}
.NET 6+에서는 Parallel.ForEachAsync로 간결하게 동시성 제한이 가능합니다.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class ParallelLimit
{
public static async Task RunAsync(IEnumerable<string> urls)
{
var opt = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount };
await Parallel.ForEachAsync(urls, opt, async (url, ct) =>
{
// 비동기 I/O를 사용하고 블로킹 금지
await Task.Delay(10, ct);
});
}
}
5. ThreadPool 포화 상태 감지 및 모니터링
포화(대기 큐 증가)는 지연을 유발합니다. 가용 스레드를 주기적으로 확인하고, 관측 지표로 대응합니다.
using System;
using System.Threading;
class PoolMonitor
{
public static void Sample()
{
ThreadPool.GetAvailableThreads(out int workerAvail, out int ioAvail);
ThreadPool.GetMaxThreads(out int workerMax, out int ioMax);
double workerUtil = 1.0 - (double)workerAvail / workerMax;
double ioUtil = 1.0 - (double)ioAvail / ioMax;
Console.WriteLine($"Worker Util={workerUtil:P0}, IO Util={ioUtil:P0}");
}
}
프로덕션에서는 dotnet-counters, EventCounters, 로깅으로 대기 시간, 큐 길이, 처리량을 함께 추적하세요. 스레드풀 이벤트, GC, HTTP 클라이언트 지표를 종합하면 병목을 정확히 파악할 수 있습니다.
6. 고급 최적화 팁
ExecutionContext를 활용하지 않는 단순 작업이라면 UnsafeQueueUserWorkItem으로 오버헤드를 줄일 수 있습니다. 단, 현재 컨텍스트(보안/진단/동기화) 전파가 필요 없는 경우에만 사용합니다.
using System;
using System.Threading;
class FastQueue
{
public static void EnqueueFast()
{
ThreadPool.UnsafeQueueUserWorkItem(static state =>
{
// 매우 짧은 계산, 로컬 상태만 사용
int x = 0; for (int i = 0; i < 1000; i++) x += i;
}, null);
}
}
또한 비동기 API는 ValueTask를 적절히 사용해 할당을 줄이고, sync-over-async(예: .Result, .Wait())를 피하여 Worker Thread 점유를 방지합니다.
7. 실전 체크리스트
- 블로킹 호출을 비동기로 전환합니다.
- 최소 스레드로 초기 워밍업 지연을 줄입니다.
- 최대 스레드는 바꾸지 말고 작업 레벨 동시성을 제한합니다.
- CPU와 I/O 경로를 분리하고 각각에 맞는 병렬도를 사용합니다.
- 가용 스레드/지표를 주기적으로 모니터링합니다.
- LongRunning은 소수의 지속 작업에만 사용합니다.
- ExecutionContext가 불필요하면 UnsafeQueueUserWorkItem을 고려합니다.
이 원칙을 따르면 ThreadPool Worker Thread를 안전하게 최적화하고, 지연과 포화를 줄이며 처리량을 개선할 수 있습니다.
'C#' 카테고리의 다른 글
| C# 파일 시스템 감시(FileSystemWatcher)로 이벤트 처리 (0) | 2026.07.10 |
|---|---|
| C# BlockingCollection<T>로 생산자-소비자 패턴 구현 (0) | 2026.07.09 |
| C# DateTime.ParseExact와 TryParseExact 활용 (0) | 2026.07.08 |
| C# 플래그 Enum과 비트 필드 활용 패턴 (0) | 2026.07.08 |
| C# Dictionary의 ValueComparer 커스터마이징 (0) | 2026.07.08 |