먼저 비유부터 해 보자.
어떤 프로그램(EXE)을 실행하면, 운영체제 입장에서는 프로세스 하나가 생긴다.
그리고 그 안에서 실제로 일을 하는 단위가 스레드다.
예를 들어 엑셀을 켜면:
C# 콘솔 프로그램도 마찬가지다.
Thread를 만들어 스레드를 더 늘릴 수 있다.가장 기초적인 예제부터 보자.
using System;
using System.Threading;
class Program
{
static void Main()
{
Console.WriteLine("메인 스레드 시작");
// 1) 새 스레드에서 실행할 작업 정의
Thread t = new Thread(DoWork);
// 2) 새 스레드 시작
t.Start();
// 3) 메인 스레드는 자기 할 일 계속
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"[Main] i = {i}");
Thread.Sleep(500); // 0.5초 쉬기
}
// 4) 새 스레드가 끝날 때까지 기다리고 싶으면 Join 사용
t.Join();
Console.WriteLine("메인 스레드 종료");
}
static void DoWork()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"[Worker] i = {i}");
Thread.Sleep(700); // 0.7초 쉬기
}
}
}
new Thread(DoWork) : 새 직원(스레드)을 한 명 더 채용t.Start() : 그 직원에게 DoWork 일을 시킴for 루프를 돌면서 [Main] ... 출력DoWork 안에서 [Worker] ... 출력
콘솔에 [Main] ..., [Worker] ...가 섞여서 출력되면
두 개의 스레드가 동시에 일을 하고 있다는 뜻이다.
Thread.Sleep(500) : “이 스레드는 0.5초 동안 잠깐 쉰다”t.Join() : “스레드 t가 끝날 때까지 여기서 기다린다”입문 단계에서는 “스레드를 직접 만들면 이렇게 여러 작업을 동시에 돌릴 수 있다” 정도만 이해하고 넘어가면 된다.
방금 본 new Thread(...)는 이렇게 생각할 수 있다.
“작업 하나 처리하려고, 직원을 한 명 새로 채용한다.”
그런데 현실에서 이러면 비효율적이다.
그래서 회사에서는 보통 이렇게 한다.
“기본 직원들을 몇 명 쭉 고용해 두고, 일이 생기면 그 직원들에게 나눠준다.”
이게 바로 스레드풀(Thread Pool) 개념이다.
.NET 내부에는 이미 ThreadPool이 준비되어 있고, 우리는 거기에 작업을 큐(queue)에 넣어 사용하면 된다.
using System;
using System.Threading;
class Program
{
static void Main()
{
Console.WriteLine("메인 스레드 시작");
// 스레드풀에 작업을 큐에 넣기
ThreadPool.QueueUserWorkItem(DoWork);
Console.WriteLine("메인 스레드는 계속 진행 중...");
Console.ReadKey(); // 프로그램이 바로 종료되지 않게 대기
}
// 스레드풀용 콜백 메서드는 보통 object? 하나를 인수로 받는 시그니처를 가진다.
static void DoWork(object? state)
{
Console.WriteLine(
$"스레드풀 작업 시작, Thread ID = {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(1000); // 1초 정도 일하는 척
Console.WriteLine("스레드풀 작업 끝");
}
}
ThreadPool.QueueUserWorkItem(DoWork);
DoWork(object? state)
object? 하나 받는 형태가 기본state를 사용하지 않으니까 null 그대로 둬도 된다.| 구분 | new Thread 직접 생성 |
ThreadPool / Task.Run |
|---|---|---|
| 스레드 생성 비용 | 스레드 만들 때마다 비용 큼 | 미리 만든 스레드를 재사용 → 효율적 |
| 수명 관리 | Start, Join, 종료 시점 직접 관리 |
작업만 던지면 끝나면 알아서 반환 |
| 개수 제한 관리 | 너무 많이 만들면 OS에 부담 | .NET이 내부적으로 최대 스레드 수를 조절 |
| 사용 난이도 | 입문자에게는 다소 복잡 | Task.Run, async/await로 비교적 쉬움 |
| 요즘 권장 방식 | 특수한 경우 아니면 잘 안 씀 | 대부분 스레드풀 + Task / async 조합 사용 |
실무에서는 보통:
직접Thread를 만드는 경우는 특수한 상황이고,
대부분은 스레드풀 +Task/async/await조합으로 처리한다.
Task.Run도 스레드풀 위에서 돈다
요즘 C# 코드에서는 ThreadPool.QueueUserWorkItem 대신
Task.Run을 훨씬 더 자주 쓴다.
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Console.WriteLine("메인 시작");
// Task.Run 안의 코드는 스레드풀 스레드에서 실행된다.
await Task.Run(() =>
{
Console.WriteLine(
$"백그라운드 작업, Thread ID = {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(1000);
Console.WriteLine("백그라운드 작업 끝");
});
Console.WriteLine("메인 종료");
}
}
Task.Run(() => { ... })는 내부적으로 스레드풀에 작업을 던지는 것이라고 생각하면 된다.await를 쓰면, 그 작업이 끝난 다음에 다시 이어서 실행할 수 있다.그래서 요약하면:
new Thread 또는 ThreadPool.QueueUserWorkItem 사용Task + async/await 사용
(내부에서는 결국 스레드풀이 일을 한다)
화면(UI)을 가진 프로그램(윈폼, WPF 등)에서는
무거운 작업을 메인(UI) 스레드에서 돌리면 화면이 멈춘다.
그래서 보통 스레드풀/백그라운드에서 작업을 돌리고, 결과만 가져온다.
콘솔에서 비슷한 느낌만 보이는 예제를 하나 보자.
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Console.WriteLine("프로그램 시작");
// 무거운 작업을 스레드풀에서 실행
Task<long> work = Task.Run(() =>
{
Console.WriteLine("무거운 작업 시작");
Thread.Sleep(3000); // 3초 걸리는 작업이라고 가정
Console.WriteLine("무거운 작업 끝");
return 12345L; // 결과 예시
});
Console.WriteLine("메인은 다른 일 계속 할 수 있음...");
// 여기서 결과를 기다림 (중간에 UI라면 다른 이벤트 처리 가능)
long result = await work;
Console.WriteLine($"작업 결과: {result}");
Console.WriteLine("프로그램 종료");
}
}
프로그램 시작 출력무거운 작업 시작 출력 (스레드풀 스레드)메인은 다른 일 계속 할 수 있음... 출력 (메인 스레드)무거운 작업 끝 출력await work 이후 작업 결과: 12345 출력프로그램 종료 출력
중요한 건, 무거운 작업 때문에 메인 흐름이 멈추지 않는다는 점이다.
이런 패턴이 UI 프로그램이나 서버 프로그램에서 매우 자주 사용된다.
new Thread(...) 로 직접 스레드를 만들 수 있지만, 요즘은 특별한 경우에만 사용ThreadPool.QueueUserWorkItem 또는 Task.Run으로 작업을 던지면, 풀 안의 스레드가 실행스레드와 스레드풀 개념을 이해했다면, 다음 단계로 이런 것들을 공부해 보면 좋다.
lock, Monitor, Interlocked 등