[로봇활용_14주차] C# Parallel 클래스

최윤호·2025년 11월 8일
post-thumbnail

병렬 처리의 마법: Parallel

100만 개의 이미지 파일 각각에 워터마크를 박는 작업을 상상해 보세요.
이럴 때 여러 개의 CPU 코어를 모두 활용하고 싶을 때는 어떻게 해야 할까요?
여러분의 멀티코어 CPU를 잠에서 깨워 모든 코어가 함께 일하게 만드는 마법, System.Threading.Tasks.Parallel클래스에 대해 알아보겠습니다.

1)비동기와 병렬성은 달라요!

본격적으로 시작하기 전에, 자주 혼동되는 두 개념을 확실히 짚고 넘어가야 합니다.

  • 비동기(Asynchrony): '기다리지 않는 것'에 중점을 둡니다.
    작업이 완료되기를 기다리는 동안 스레드를 놀게 두지 않고
    다른 일을 시켜 '응답성''효율성'을 높이는 것이 목적입니다.
  • 병렬성(Parallelism): '동시에 여러 일을 하는 것'에 중점을 둡니다.
    데이터 병렬 처리와 CPU 바운드의 '전체 작업 시간'을 줄이는 것이 목적입니다.

async/await, Task가 주로 응답성 향상을 높이는 기술이라면,
Parallel클래스는 병렬 처리와 CPU 바운드 작업의 성능 향상을 위해 설계되었습니다.

구분비동기(Asynchrony)병렬성(Parallelism)
목표응답성 향상, 스레드 효율성작업 시간 단축
핵심블로킹(Blocking) 방지데이터 병렬 처리
주요 도구async/await, TaskParallel, PLINQ

2)Parallel 클래스란?

Parallel클래스는 반복문(for, foreach)을 간단하게 병렬로 실행할 수 있도록 도와줍니다.
내부적으로 작업을 더 작게 나누고, 스레드 풀의 스레드에 효율적으로 분배하고 관리합니다.

Parallel 클래스 문법

Parallel.For(루프의 시작(값 포함) , 루프의 끝(값 제외), 인덱스 => 수행할 작업);
Parallel.ForEach(컬렉션, 인덱스 => 수행할 작업);

1. 기존의 순차적 for 루프

[코드]

// 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번까지 차례대로 작업을 수행합니다.

2. Parallel.For

기존의 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가 뒤섞여 나타나고, 작업 순서가 뒤죽박죽인 것을 볼 수 있습니다.
여러 스레드가 작업을 나누어 동시에 처리하고 있다는 증거죠!

3. Parallel.ForEach

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 처리 완료

3)Task.Run() vs Parallel 클래스

둘 다 작업을 분산시켜 처리하는 것은 비슷해 보이지만,
사실은 서로 다른 목적과 자신만의 철학을 가지고 있답니다.

비유: 레스토랑 주방

엄청난 양의 '당근 1,000개 썰기'라는 주문이 들어왔다고 상상해 봅시다.

Task.Run()은 실력 좋은 셰프 한 명에게 이 주문을 맡기는 것과 같아요.

  • 핵심: 메인 주방(메인 스레드)의 흐름을 막지 않고,
    별도의 공간에서 한 명이 묵묵히 작업을 처리합니다.
  • 장점: 홀 서빙이나 다른 요리 준비(UI 반응)는 멈추지 않아요. (Non-Blocking)
  • 목적: 다른 작업도 할 수 있게 응답성을 향상하는 것이 목표입니다.

Parallel클래스는 헤드 셰프가 여러 명의 셰프를 지휘하는 것과 같습니다.

  • 핵심: 하나의 큰 작업을 여러 명(여러 CPU 코어)이 동시에 처리합니다.
  • 장점: 여러 명이 일을 나누어 하니, 작업 시간이 줄어듭니다.
  • 목적: 작업 자체의 성능(Performance)을 극대화하는 것이 목표입니다.

C# 코드로 직접 비교하기

이제 '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를 최대한 활용하여 작업 시간을 줄입니다.

4)주의 사항!

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를 쓰지 않고 응답을 기다리는 블로킹 상태가 됩니다.
이는 스레드 풀을 고갈시키는 최악의 안티패턴입니다.

5)정리

Parallel클래스는 CPU 바운드 작업을 빠르게 처리할 수 있는 강력한 도구입니다.

항목설명
개념하나의 작업을 여러 개의 하위 작업으로 나누고, 다중 코어 CPU를 최대한 활용
사용법Parallel.For, Parallel.ForEach
사용 목적CPU 바운드 작업에서 다중 코어 CPU를 최대한 활용하여 작업 시간 단축
주의할 점오버헤드, 스레드 안전성(동기화 필수), I/O 바운드 작업에서 사용 금지
profile
🚀 미래의 엔지니어를 꿈꾸는 훈련생의 기록 📝

0개의 댓글