
100만 개의 이미지 파일 각각에 워터마크를 박는 작업을 상상해 보세요.
이럴 때 여러 개의 CPU 코어를 모두 활용하고 싶을 때는 어떻게 해야 할까요?
여러분의 멀티코어 CPU를 잠에서 깨워 모든 코어가 함께 일하게 만드는 마법, System.Threading.Tasks.Parallel클래스에 대해 알아보겠습니다.
본격적으로 시작하기 전에, 자주 혼동되는 두 개념을 확실히 짚고 넘어가야 합니다.
async/await, Task가 주로 응답성 향상을 높이는 기술이라면,
Parallel클래스는 병렬 처리와 CPU 바운드 작업의 성능 향상을 위해 설계되었습니다.
| 구분 | 비동기(Asynchrony) | 병렬성(Parallelism) |
|---|---|---|
| 목표 | 응답성 향상, 스레드 효율성 | 작업 시간 단축 |
| 핵심 | 블로킹(Blocking) 방지 | 데이터 병렬 처리 |
| 주요 도구 | async/await, Task | Parallel, PLINQ |
Parallel클래스는 반복문(for, foreach)을 간단하게 병렬로 실행할 수 있도록 도와줍니다.
내부적으로 작업을 더 작게 나누고, 스레드 풀의 스레드에 효율적으로 분배하고 관리합니다.
Parallel.For(루프의 시작(값 포함) , 루프의 끝(값 제외), 인덱스 => 수행할 작업);
Parallel.ForEach(컬렉션, 인덱스 => 수행할 작업);
[코드]
// 5개의 작업을 순차적으로 처리
Console.WriteLine("순차적 for 루프 시작:");
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"작업 {i} - 스레드 ID: {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(500); // CPU를 많이 쓰는 작업이라고 가정
Console.WriteLine($"작업 {i} 종료");
}
[실행 결과]
순차적 for 루프 시작:
작업 1 - 스레드 ID: 1
작업 1 종료
작업 2 - 스레드 ID: 1
작업 2 종료
작업 3 - 스레드 ID: 1
작업 3 종료
작업 4 - 스레드 ID: 1
작업 4 종료
작업 5 - 스레드 ID: 1
작업 5 종료
결과를 보면 하나의 스레드가 1번부터 5번까지 차례대로 작업을 수행합니다.
기존의 for루프와 사용법이 거의 똑같습니다.
[코드]
// 5개의 작업을 병렬로 처리
Console.WriteLine("병렬 Parallel.For 루프 시작:");
Parallel.For(1, 6, i =>
{
Console.WriteLine($"작업 {i} - 스레드 ID: {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(500); // CPU를 많이 쓰는 작업이라고 가정
Console.WriteLine($"작업 {i} 종료");
});
[실행 결과]
병렬 Parallel.For 루프 시작:
작업 3 - 스레드 ID: 1
작업 5 - 스레드 ID: 7
작업 2 - 스레드 ID: 9
작업 4 - 스레드 ID: 10
작업 1 - 스레드 ID: 11
작업 3 종료
작업 2 종료
작업 5 종료
작업 4 종료
작업 1 종료
결과를 보면 여러 스레드 ID가 뒤섞여 나타나고, 작업 순서가 뒤죽박죽인 것을 볼 수 있습니다.
여러 스레드가 작업을 나누어 동시에 처리하고 있다는 증거죠!
foreach루프 역시 간단하게 병렬로 바꿀 수 있습니다.
[코드]
List<string> fileList = new List<string>
{
"file1.jpg", "file2.jpg", "file3.jpg", "file4.jpg", "file5.jpg"
};
Console.WriteLine("병렬 Parallel.ForEach 루프 시작:");
Parallel.ForEach(fileList, file =>
{
Console.WriteLine($"{file} 처리 - 스레드 ID: {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(100); // 워터마크를 박는 CPU 작업 시뮬레이션
Console.WriteLine($"{file} 처리 완료");
});
[실행 결과]
병렬 Parallel.ForEach 루프 시작:
file5.jpg 처리 - 스레드 ID: 10
file1.jpg 처리 - 스레드 ID: 7
file3.jpg 처리 - 스레드 ID: 9
file4.jpg 처리 - 스레드 ID: 5
file2.jpg 처리 - 스레드 ID: 1
file2.jpg 처리 완료
file4.jpg 처리 완료
file1.jpg 처리 완료
file3.jpg 처리 완료
file5.jpg 처리 완료
둘 다 작업을 분산시켜 처리하는 것은 비슷해 보이지만,
사실은 서로 다른 목적과 자신만의 철학을 가지고 있답니다.
엄청난 양의 '당근 1,000개 썰기'라는 주문이 들어왔다고 상상해 봅시다.
Task.Run()은 실력 좋은 셰프 한 명에게 이 주문을 맡기는 것과 같아요.
- 핵심: 메인 주방(메인 스레드)의 흐름을 막지 않고,
별도의 공간에서 한 명이 묵묵히 작업을 처리합니다.- 장점: 홀 서빙이나 다른 요리 준비(UI 반응)는 멈추지 않아요. (Non-Blocking)
- 목적: 다른 작업도 할 수 있게 응답성을 향상하는 것이 목표입니다.
Parallel클래스는 헤드 셰프가 여러 명의 셰프를 지휘하는 것과 같습니다.
- 핵심: 하나의 큰 작업을 여러 명(여러 CPU 코어)이 동시에 처리합니다.
- 장점: 여러 명이 일을 나누어 하니, 작업 시간이 줄어듭니다.
- 목적: 작업 자체의 성능(Performance)을 극대화하는 것이 목표입니다.
이제 '1부터 30억까지 더하기'라는 CPU 바운드 작업을 통해 비교해 봅시다.
[코드]
using System;
using System.Diagnostics; // 처리 시간을 측정하려면 필요합니다.
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
const long limit = 3_000_000_000L;
Console.WriteLine($"1부터 {limit:N0}까지 더하는 작업을 시작합니다.");
// 1. Task.Run()을 사용하여 작업 수행
Console.WriteLine("\n1. Task.Run()을 사용한 작업 시작...");
Stopwatch sw1 = Stopwatch.StartNew();
// Task.Run은 지정된 작업을 스레드 풀의 스레드에서 비동기적으로 실행합니다.
long sum1 = await Task.Run(() =>
{
long total = 0;
for (long i = 1; i <= limit; i++)
{
total += i;
}
return total;
});
sw1.Stop();
Console.WriteLine($"결과: {sum1:N0}");
Console.WriteLine($"Task.Run() 처리 시간: {sw1.Elapsed.TotalSeconds:F2}초");
// 2. Parallel 클래스를 사용하여 작업 수행
Console.WriteLine("\n2. Parallel 클래스를 사용한 작업 시작...");
Stopwatch sw2 = Stopwatch.StartNew();
long sum2 = 0;
// 각 스레드가 공유 변수(sum2)에 동시에 접근하면 충돌이 발생하므로,
// 스레드별 로컬 합계(localTotal)를 사용하고 마지막에 합치는 방식을 사용합니다.
Parallel.For(
1,
limit + 1,
() => 0L, // 각 스레드의 지역 변수(localTotal)를 0으로 초기화
(i, loopState, localTotal) => // 각 스레드에서 실행될 본문
{
localTotal += i;
return localTotal;
},
(localTotal) => // 각 스레드의 작업이 끝나면 호출
{
// 스레드별 결과를 전체 합계에 안전하게 더함 (Interlocked 사용)
Interlocked.Add(ref sum2, localTotal);
}
);
sw2.Stop();
Console.WriteLine($"결과: {sum2:N0}");
Console.WriteLine($"Parallel 클래스 처리 시간: {sw2.Elapsed.TotalSeconds:F2}초");
}
}
[실행 결과]
1부터 3,000,000,000까지 더하는 작업을 시작합니다.
1. Task.Run()을 사용한 작업 시작...
결과: 4,500,000,001,500,000,000
Task.Run() 처리 시간: 7.14초
2. Parallel 클래스를 사용한 작업 시작...
결과: 4,500,000,001,500,000,000
Parallel 클래스 처리 시간: 5.68초
Parallel클래스는 다중 코어 CPU를 최대한 활용하여 작업 시간을 줄입니다.
Parallel은 강력하지만, 아무 때나 사용하면 오히려 성능이 저하될 수 있습니다.
작업 오버헤드: 작업을 나누고, 스레드에 할당하는 과정에는 비용(오버헤드)이 발생합니다.
반복문 안의 작업이 너무나 간단하고 빠르다면, 그냥 for루프보다 느려질 수 있습니다.
스레드 안전성(Thread Safety): 여러 스레드가 공유 변수에 동시에 접근할 때,
접근 순서에 따라 실행 결과가 달라지는 경쟁 상태(Race Condition)가 발생합니다.
// ❌위험한 코드!
int sum = 0;
Parallel.For(0, 1000, i =>
{
sum += i; // 여러 스레드가 'sum'을 동시에 수정하려고 달려든다!
});
Console.WriteLine(sum);
// 결과는 499500이 아닌 엉뚱한 값이 출력된다!
이런 경우, lock을 사용하거나 Interlocked클래스를 사용하는 등
스레드 동기화 기법을 반드시 적용해야 합니다.
I/O 바운드 작업에는 부적합: Parallel은 CPU를 최대한 활용하기 위한 것입니다.
만약 Parallel.ForEach안에서 각 파일에 대해 네트워크 요청을 보낸다면 어떨까요?
스레드 풀의 스레드들은 CPU를 쓰지 않고 응답을 기다리는 블로킹 상태가 됩니다.
이는 스레드 풀을 고갈시키는 최악의 안티패턴입니다.
Parallel클래스는 CPU 바운드 작업을 빠르게 처리할 수 있는 강력한 도구입니다.
| 항목 | 설명 |
|---|---|
| 개념 | 하나의 작업을 여러 개의 하위 작업으로 나누고, 다중 코어 CPU를 최대한 활용 |
| 사용법 | Parallel.For, Parallel.ForEach |
| 사용 목적 | CPU 바운드 작업에서 다중 코어 CPU를 최대한 활용하여 작업 시간 단축 |
| 주의할 점 | 오버헤드, 스레드 안전성(동기화 필수), I/O 바운드 작업에서 사용 금지 |