반복되는 로직을 병렬로 처리해서 더 나은 퍼포먼스를 확보하자
C#으로 이것저것 프로젝트를 진행하면서, 작게는 수십 번, 많게는 수천여 번 반복되는 동일한 프로세스를 가능한 빠른 시간 안에 처리할 수 있는 로직을 작성해야 됐습니다. 동일한 프로세스를 반복하는 경우, 반복 문인 for 문이나 while 문으로 간편하게 처리를 할 수 있지만, 가능한 최단 시간 안에 끝내야 된다는 전제 조건이 있었기 때문에 '어떻게 하면 더 효율적으로 처리할 수 있을까?'하고 꽤나 고민을 했었습니다.
그 결과, 반복문 자체를 병렬 처리하는 것으로 꽤나 긴 시간을 단축할 수 있겠다는 결론으로 도달을 했었는데요, '어떻게 병렬 처리하는 것이 좋을까?' 하고 알아보니까 닷넷 4.0부터는 PFX(Parallel Framework)라고 불리는 병렬 처리 프레임워크가 System.Threading.Task에 추가되어 있었습니다.
Parallel 클래스를 통해서 정말 간단한 코드로 처리해야 되는 프로세스를 분할하고, CPU 코어 및 스레드에 맞추어 분산하여 병렬 처리, 보다 빠른 시간 안에 프로세스를 끝마칠 수 있도록 제공해 주고 있었는데요, 한 번 간단하게 코드를 통해서 살펴보겠습니다.
Parallel.For(0, 1000, (index) =>
{
// 코드 작성
});
Parallel.ForEach(list, index =>
{
// 코드 작성
});
Parallel.Invoke(
() => { /* 코드 작성 */ },
() => { /* 코드 작성 */ },
() => { /* 코드 작성 */ },
() => { /* 코드 작성 */ },
() => { /* 코드 작성 */ }
);
Parallel 클래스 안에는 For, ForEach, Invoke 총 세 가지 메서드가 포함되어 있는데요, Parallel.For, Parallel.ForEach 같은 경우 우리가 흔히 반복 처리에 사용하는 For 문과 ForEach 문과 약간의 문법 차이가 있을 뿐 사용 방법 자체는 동일합니다. 다만, 처리 결과 및 과정에 있어서는 분명하게 차이가 있습니다.
Parallel.For(시작 값, [종료 값, (인덱스) => { });
Parallel.ForEach(반복하는 리스트 또는 배열, 인덱스 => { });
간단하게 예를 들어서 0부터 1000까지 있는 프로세스를 처리해야 되는 상황이라고 할 때, For 문이나 ForEach 문 같은 경우 순차적으로 0번부터 1000번까지 처리를 진행합니다. 하지만, Parallel.For와 Parallel.ForEach는 0번부터 1000번까지 순차적으로 진행하는 것이 아니라, CPU가 가지고 있는 코어 및 스레드에 나누어서 병렬 처리가 이루어집니다. 이에 따라서 결과는 뒤죽박죽 올라오지만 처리 속도만큼은 정말 확연하게 차이를 보여줍니다. 간단하게 예시 코드를 통해서 확인해보지요.
static void Main(string[] args)
{
int MAX = 10000000;
ForLoop(MAX);
ParallelForLoop(MAX);
}
static void ForLoop(int MAX)
{
Stopwatch watch = new Stopwatch();
watch.Start();
for (int i = 0; i < MAX; i++)
{
Console.Write("{0}: {1}", Thread.CurrentThread.ManagedThreadId, i);
}
watch.Stop();
Console.WriteLine("\nFor Loop Time : " + watch.Elapsed.ToString());
}
static void ParallelForLoop(int MAX)
{
Stopwatch watch = new Stopwatch();
watch.Start();
Parallel.For(0, MAX, (i) =>
{
Console.Write("{0}: {1}", Thread.CurrentThread.ManagedThreadId, i);
});
watch.Stop();
Console.WriteLine("\nParallel For Loop Time : " + watch.Elapsed.ToString());
}
정말 단순하게 숫자를 0부터 10,000,000까지 세어보는 시간을 측정해보았는데요, For 문 같은 경우 5분 18초가 소요된 반면, Parallel.For 같은 경우 4분 55초 소요된 것을 확인할 수 있습니다. 데이터 처리를 순차적으로 진행하는 것과 병렬로 처리하는 것의 속도 차이는 큰 차이가 없어 보이면서도 확실하게 보여주고 있습니다.
다만, 처리해야 되는 데이터양이 적거나 프로세스가 짧은 경우에 있어서는 순차 처리가 병렬 처리보다 더 빠를 수도 있습니다. 이는 병렬 처리를 위해 처리를 분할하는 과정에서 발생하는 시간 차이입니다. 고로, Parallel.For 문과 Parallel.ForEach 문은 어떠한 규모의 데이터나 프로세스를 처리하는가에 따라서 적절하게 활용하는 것이 중요합니다.
Parallel.Invoke(
() => { Console.WriteLine("{0}: {1}.{2}", Thread.CurrentThread.ManagedThreadId, DateTime.Now, DateTime.Now.Millisecond); },
() => { Console.WriteLine("{0}: {1}.{2}", Thread.CurrentThread.ManagedThreadId, DateTime.Now, DateTime.Now.Millisecond); },
() => { Console.WriteLine("{0}: {1}.{2}", Thread.CurrentThread.ManagedThreadId, DateTime.Now, DateTime.Now.Millisecond); },
() => { Console.WriteLine("{0}: {1}.{2}", Thread.CurrentThread.ManagedThreadId, DateTime.Now, DateTime.Now.Millisecond); },
() => { Console.WriteLine("{0}: {1}.{2}", Thread.CurrentThread.ManagedThreadId, DateTime.Now, DateTime.Now.Millisecond); },
() => { Console.WriteLine("{0}: {1}.{2}", Thread.CurrentThread.ManagedThreadId, DateTime.Now, DateTime.Now.Millisecond); },
() => { Console.WriteLine("{0}: {1}.{2}", Thread.CurrentThread.ManagedThreadId, DateTime.Now, DateTime.Now.Millisecond); },
() => { Console.WriteLine("{0}: {1}.{2}", Thread.CurrentThread.ManagedThreadId, DateTime.Now, DateTime.Now.Millisecond); },
() => { Console.WriteLine("{0}: {1}.{2}", Thread.CurrentThread.ManagedThreadId, DateTime.Now, DateTime.Now.Millisecond); },
() => { Console.WriteLine("{0}: {1}.{2}", Thread.CurrentThread.ManagedThreadId, DateTime.Now, DateTime.Now.Millisecond); }
);
마지막으로 Parallel.Invoke는 여러 코드를 Action delegate로 받아들여서 다중 스레드를 통해 병렬로 Task를 나누어서 한 번에 실행하여 병렬 처리하는 기능을 제공하고 있습니다. 즉, 여러 코드를 한 번에 실행해야 될 때 활용할 수 있습니다.
지금까지 Parallel을 활용한 C#의 병렬 처리 방법에 대해서 정리해보았습니다. 적절하게 잘 활용한다면 충분히 큰 효율을 얻을 수 있는 방법으로 명확하게 입출력을 구분하고, 메모리가 서로 공유되어서 충돌 나는 일이 없도록 관리만 잘 한다면, 분명히 더 빠르고 좋은 성능을 제공하는 코드를 작성할 수 있는 방법입니다.
코드에 따라 약간의 차이가 있겠지만, 제가 진행한 몇 가지 프로젝트에서 적용해본 결과로는 시간을 약 3배 정도는 거뜬하게 앞당기고 더 나은 퍼포먼스를 볼 수 있었습니다. 고로, 처리하고자 하는 것에 따라 적절하게 잘 사용하길 바라며 글을 마무리합니다.