- 뜬금없이 뭐여..?

지금까지는 닷넷 4.0에 추가된 TPL과 PLINQ를 통해서 멀티 스레드 프로그래밍을 하는 방법을 살펴봤습니다. 그러면, 잠깐 추억을 되살릴겸, 뭐가 어떻게 달라졌는지도 한번 비교해 볼겸 해서, 닷넷 3.5까지의 멀티 스레드 프로그래밍 방법을 잠깐 살펴보도록 하겠습니다. 호호호호


- Thread와 다이다이로 작업하던 시절.

TPL은 System.Threading.Tasks를 사용해서, ThreadPool을 내부적으로 사용한다고 말씀을 드렸었습니다. 하지만, 그것 닷넷 4.0이나, 닷넷 3.5에서는 Reactive Extension(Rx)을 통해서 추가적으로 지원하는 기능이구요. 그 이전에는 직접적으로 Thread나 ThreadPool을 이용해서 프로그래밍 해야 했습니다. 그럼 Thread를 직접 사용하던 코드를 예제로 한번 보시죠.

using System;
using System.Threading;

namespace Exam18
{
    class Program
    {
        static readonly int max = 10000;

        public static void PrintAsync()
        {
            for (int count = 0; count < max; count++)
            {
                Console.Write("|");
            }
            Console.WriteLine("추가 스레드 끝");
        }

        static void Main(string[] args)
        {
            ThreadStart threadStart = PrintAsync;
            Thread thread = new Thread(threadStart);

            //추가 스레드 시작
            thread.Start();

            //현재 작업중인 스레드에서도 반복문 시작
            for (int count = 0; count < max; count++)
            {
                Console.Write("-");
            }
            Console.WriteLine("메인 스레드 끝");

            //혹시 현재 스레드가 빨리 끝나더라도,
            //추가 스레드가 끝날 때 까지 기다리기.           
            thread.Join();
        }
    }
}

<코드1> Thread와 다이다이로.

<코드1>을 보면, 맨 처음에 Task를 소개해드리면서 사용했던 예제를 Thread를 사용하도록 바꾼 코드입니다. 차이점이 있다면, ThreadStart타입의 델리게이트를 사용해야 한다는 것과, Wait()메서드가 아니라 Join()메서드를 사용한다는 것이죠. 결과를 보시면, Task를 사용했던 것과 동일합니다.

---------|||||||-|||||||||||--------------|||||||||-------------|||||------|||||||||||---------
-||||||||--------|||||||||||||-----메인 스레드 끝||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|||||||||||||||||||||||||||||||||||||||||||||||||||추가 스레드 끝
계속하려면 아무 키나 누르십시오 . . .
<결과1> Thread를 사용한 결과.

그리고 Thread를 보면, Task와 마찬가지로 실행을 제어할 수 있도록 몇가지 속성을 제공하는데요, 그 목록을 보면 아래와 같습니다.

 속성  설명 
 Join()  추가 스레드가 완료되기 전에 메인 스레드가 완료되면, 추가 스레드가 하던 작업은 다 날아간다. 그래서 추가 스레드의 작업이 완료될 때까지 메인 스레드가 기다리도록 한다.
 IsBackground  이 속성은 기본적으로 false이다. 즉, 스레드는 기본적으로 foreground작업인데, 그 때문에 스레드가 완료되기 전까지는 프로세스를 종료시킬 수 없다. 이 속성을 true로 주면, 스레드의 작업이 완료되기 전에도 프로세스를 종료시킬 수 있다.
 Priority  Join메서드를 사용한 경우에, 이 속성을 통해서 스레드의 우선순위를 바꿀 수 있다.
 ThreadState  이 속성을 통해서 스레드의 상태를 파악할 수 있는데, Aborted, AbortRequested, Background, Runnging, Stopped, StopRequested, Suspended, SuspendRequested, Unstarted, WaitSleepJoin등의 상태 값을 얻을 수 있다.
 Thread.Sleep()  현재 실행 중인 스레드의 실행을 명시한 시간만큼 일시정시 시키는 메서드이다.
 Abort()  이름 그대로, 스레드를 중지시키는 메서드. ThreadAbortException이 발생된다.
<표1> Thread의 속성.

위의 Thread멤버 중에서, Task에도 있는 건, Join()과 ThreadState뿐입니다. 왜 그럴까요? 일반적으로 권장되지 않는 것들이기 때문이죠. 그래서 닷넷 프레임워크 4.0으로 프로그래밍 할 때는, 위에서 언급한 것들 중에서 Task에 없는 속성들을 될 수 있으면 사용하지 말아야 합니다.


- ThreadPool을 사용해보자.

ThreadPool을 사용하면, 새로운 스레드를 계속 해서 생성하기 보다 기존에 있는 스레드를 재활용해서 추가적인 스레드 생성을 막을 수 있습니다. 참고로, TPL이 내부적으로 ThreadPool을 사용한다고 말씀드렸었죠? 그럼 ThreadPool을 사용하는 예제도 한번 보시죠.

using System;
using System.Threading;

namespace Exam19
{
    class Program
    {
        static readonly int max = 10000;

        public static void PrintAsync(object state)
        {
            for (int count = 0; count < max; count++)
            {
                Console.Write(state.ToString());
            }
            Console.WriteLine("추가 스레드 끝");
        }

        static void Main(string[] args)
        {
            ThreadPool.QueueUserWorkItem(PrintAsync, "|");
           
            //현재 작업중인 스레드에서도 반복문 시작
            for (int count = 0; count < max; count++)
            {
                Console.Write("-");
            }
            Console.WriteLine("메인 스레드 끝");

            //혹시 현재 스레드가 빨리 끝나더라도,
            //추가 스레드가 끝날 때 까지 기다리기.           
            Thread.Sleep(1000);
        }
    }
}

<코드2> ThreadPool을 사용한 코드.

<코드2>를 보시면, ThreadPool을 사용하고 있는데요. QueueUserWorkItem메서드를 통해서 작업을 추가하고 있습니다. 그러면, 자동으로 스레드를 활용해서 작업을 시작하게 되구요. 결과는 앞선 예제와 동일합니다. 그런데, ThreadPool을 사용할 때 장점만이 있는 건 아닌데요. 작성한 코드외에도 다른 라이브러리등에서 내부적으로 시간이 많이 걸리는 I/O작업 등에 ThreadPool을 사용한다면, 그 작업이 끝날 때까지 기다려야 하거나, 심한 경우에는 데드락이 발생하기도 합니다. 그리고 Thread나 Task를 사용할 때와는 다르게 ThreadPool은 실행 중인 작업에 접근할 수 있는 방법이 없습니다. 그래서 실행 중인 작업을 조종한다거나, 상태를 확인할 수 가 없죠. 그래서 <코드2>를 보시면, Join()이나 Wait()대신에, Thread.Sleep()메서드를 통해서 추가 스레드가 끝날 때까지 메인 스레드를 기다리게 합니다.


- 마치면서

오늘은 닷넷 3.5 까지의 멀티 스레드 프로그래밍 방법에 대해서 알아봤는데요. 크게 다른 모습은 없습니다. 다만, 좀 더 안전하고 간단한 방법을 제공하는 것이죠. 대한민국도 16강에 진출했는데 오늘은 여기까지 하시죠!...응??


- 참고자료

1. Essential C# 4.0, Mark Michaelis, Addison Wesley
저작자 표시 비영리 변경 금지
신고
- 이건 또 무슨 신개념 속담 드립인거.

저는 늘 의문을 품어왔습니다...는 훼이크고 이번 포스트를 준비하면서 의문을 가지게 되었습니다. 분명 병렬 프로그래밍의 정신은 남아도는 코어를 활용해서 협력을 해서 작업을 좀 더 빨리 끝내자고 하는 건데요, 그런면에서 '백지장도 맞들면 낫다'는 말은 병렬 프로그래밍의 정신을 잘 표현하는 선조들의 지혜라고 볼 수 있습니다. 그런데요.... 과연 백지장같이 갓난 아기도 혼자들 수 있는 걸 같이 드는게 과연 나은 일일까요? 오히려 혼자 할 때보다 못한 결과를 가져오지는 않을까요? 오늘은 그에 대한 이야기입니다.


- LINQ도 맞들면 낫다, 어헣.

LINQ는 데이터 쿼리에 가까운 표현을 사용하면서, 데이터 쿼리시에 직관적이고 선언적인 코드를 활용할 수 있도록 해주었는데요. 거기에 이전 포스트들에서 설명드렸던 Parallel.For나 Parallel.ForEach처럼 매우 간단하게 남아도는 코어를 활용할 수 있도록 하는 방법을 제공합니다.

using System;
using System.Linq;
using System.Threading.Tasks;
using System.Threading;

namespace Exam15
{
    class Program
    {
        static void Main(string[] args)
        {
            int[] nums = Enumerable.Range(1, 10000).ToArray();

            Func<int, int> square = (num) => {
                Console.WriteLine(Task.CurrentId);
                Thread.Sleep(10);
                return num * num;
            };

            nums = nums.AsParallel()
                .Select(square)
                .ToArray();
        }
    }
}

<코드1> LINQ를 맞드는 예제.

<코드1>을 보시면, 1부터 1000까지의 숫자를 가진 배열을 생성하고, 각 수를 제곱한 수를 구하는 코드입니다. 기존의 LINQ코드와 다른 점이 있다면, 제곱 연산을 수행하기 위한 데이터 소스인 nums에 대해서 AsParallel()을 호출했다는 것입니다. <코드1>에선 AsParallel()의 리턴타입이 ParallelQuery<int>인데요, LINQ에서는 Enumerable을 사용하지만, PLINQ에서는 ParallelEnumerable을 사용합니다.

<코드1>을 보면, 정말 간단하게 병렬 프로그래밍이 구현되는데요. 정말 저렇게 간단한 방법으로 병렬 쿼리가 실행되는지 확인하기 위해서 Task.CurrentId를 통해서 실행중인 스레드의 Id를 출력하도록 했습니다. 그리고 비교적 일관성 있는 결과를 얻기 위해서 Thread.Sleep를 통해서 실행을 조금 여유롭게 해줬죠. 결과를 보실까요?

(생략)
3
1
4
2
3
1
4
2
3
1
4
2
3
1
4
2
3
1
4
2
3
1
4
2
3
1
4
2
3
1
4
2
계속하려면 아무 키나 누르십시오 . . .
<결과1> LINQ를 맞든 결과.

3->1->4->2의 패턴이 반복되는 걸 확인하실 수 있습니다. 물론, 실행도중에 패턴은 바뀌기도 합니다만, 분명 AsParallel()메서드를 호출하는 것 만으로도 병렬 프로그래밍이 구현된 것이죠. 그런데, 출력되는 스레드의 아이디를 보면, 딱 4개만 생성된 걸 확인할 수 있는데요. 제 컴퓨터의 CPU가 쿼드코어라서 딱 4개만 생성된 것 같습니다. 그런데 왜 딱 4개만 생성된 걸까요? 이전에 TPL을 활용해서 작업할 때는 4개 이상의 스레드도 생성되어서 작업을 처리했는데 말이죠. 그건 PLINQ가 병렬 쿼리를 처리하는 방식에서 원인을 찾을 수 있습니다.

제가 술을 먹고 만취한 상태에서 글을 적어서 그럴까요? 아래 내용은 새빨간 거짓말 입니다!!! 낄낄낄-_-;;; 스레드가 4개만 생성된 건, PLINQ가 분할 알고리즘으로 구간 분할을 사용하기 때문에 그렇습니다. 그리고 정확한 설명은, PLINQ는 ParallelEnumerable타입 같이 병렬 쿼리를 돌려도 안전한 타입에 대해서는 주저없이 쿼리를 병렬화 해서 작업을 하지만, IEnumerable타입 같이 병렬로 쿼리를 돌릴 때, 안전하다고 보장할 수 없는 경우에는 순차적인 쿼리로(정확히 말하지만, 순차적인 쿼리가 아니라 Chunk 분할 알고리즘을 통해서 데이터 소스에 락을 걸고, 스레드가 한번에 작업할 덩어리를 떼어주는 형태로)작업을 하게 됩니다. 오해 없으시길 바랍니다! 어헣-_-;;;

PLINQ는 AsParallel()메서드로 데이터 소스에 대해서 병렬처리를 원했다고 하더라도 항상 병렬적으로 처리를 하지는 않습니다. 예를 들면, 작업이 너무나 간단해서, 병렬적으로 처리할 때 오히려 손해를 보는경우가 있습니다. 작업이 너무 간단하기 때문에 각 스레드가 처리하는 작업의 시간이 매우 짧고, 그래서 작업 처리에 걸리는 시간보다, 스레드 간의 작업전환에 더 많은 시간이 걸리는 것이죠. 그래서 PLINQ는 AsParallel()이 호출되면, 우선 쿼리를 분석합니다. 그리고 그 쿼리가 간단하다는 판단을 하면, 병렬적으로 처리하기 보다는 순차적으로 처리를 하게 되는 것이죠. <결과1>에서 스레드가 4개가 돌아간 것은, CPU의 코어가 4개 이기 때문에, 코어별로 스레드가 한 개씩 생성된 것입니다. 각 코어의 입장에서 보자면, 스레드가 한 개씩 있는 셈이므로 작업전환이 필요없겠죠. 참고로, 듀얼 코어인 제 노트북에서 실행한 결과는 아래와 같습니다.

(생략)
1
2
1
2
1
1
2
1
2
1
2
2
1
2
1
2
1
2
1
2
1
계속하려면 아무 키나 누르십시오 . . .
<결과2> 듀얼 코어에서 맞든 LIINQ 결과.

그런가 하면, 몇 개의 스레드를 생성할 것인지 명시해 줄 수도 있는데요. <코드2>와 같이 3개의 스레드를 명시해주고 결과를 보겠습니다.

using System;
using System.Linq;
using System.Threading.Tasks;
using System.Threading;

namespace Exam16
{
    class Program
    {
        static void Main(string[] args)
        {
            int[] nums = Enumerable.Range(1, 10000).ToArray();

            Func<int, int> square = (num) =>
            {
                Console.WriteLine(Task.CurrentId);
                Thread.Sleep(10);
                return num * num;
            };

            nums = nums.AsParallel()
                .WithDegreeOfParallelism(3)               
                .Select(square)
                .ToArray();
        }
    }
}

<코드2> LINQ를 맞들 스레드의 개수를 지정하는 코드.

(생략)
1
3
2
3
1
2
1
3
2
1
3
2
1
3
2
1
3
2
3
1
2
3
계속하려면 아무 키나 누르십시오 . . .
<결과3> 3개의 스레드로 맞든 LINQ 결과.

패턴은 약간 불안정할 때도 있지만, 대략 1->2->3의 순서를 유지하고 있습니다. 그런데, 왜 이렇게 스레드의 개수를 정해 줄 수도 있게 했을까요? 바로 최적화 때문입니다. 기본적으로 PLINQ의 알고리즘은 많은 경우를 테스트해서 최적화 알고리즘을 만들어 놓았기 때문에, 대부분의 경우는 기본옵션으로 실행하는 것이 가장 좋은 결과를 냅니다. 하지만, 그렇지 못한 경우가 있을 수 있는데요. 그럴 때, 테스트를 통해서 적절한 스레드 개수를 지정할 수 있도록 옵션을 둔 것이죠.

위에서 쿼리 식이 단순하면, 순차적으로 실행한다고 말씀을 드렸는데요, 쿼리 식이 병렬로 실행하기에 안전하지 못한 경우에, 순차적으로 실행하다고 말씀을 드렸는데요, 그런 경우도 병렬적으로 실행을 강제할 수 있습니다. 쿼리 식에 '.WithExecutionMode(ParallelExecutionMode.ForceParallelism)'메서드를 추가하면, 기본 알고리즘과는 상관없이 무조건 병렬적으로 실행하도록 합니다. 실행시간을 테스트한다거나 할때 유용하게 사용할 수 있는 옵션이겠죠.


- LINQ 맞들기 취소는 어떠케?

이번에는 PLINQ 쿼리를 취소하는 방법에 대해서 알아보겠습니다. 지금까지 취소에는 CancellationTokenSource를 활용했었죠? 마찬가지 입니다. 똑같이 Token을 활용해서 취소에 사용하되, 사용하는 방법이 조금씩 다른 것 뿐이지요.

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace Exam17
{
    class Program
    {
        public static int[] SimpleParallelTask(int[] source,
            CancellationToken token)
        {
            Func<int, int> square = (num) =>
            {
                Console.WriteLine(Task.CurrentId);
                Thread.Sleep(10);
                return num * num;
            };
           
            return source.AsParallel()
                .WithCancellation(token)
                .WithDegreeOfParallelism(3)
                .Select(square)
                .ToArray();
        }

        static void Main(string[] args)
        {
            CancellationTokenSource cts =
                new CancellationTokenSource();

            Console.WriteLine("끝내려면 아무키나 누르세요");

            int[] nums = Enumerable.Range(1, 10000).ToArray();

            Task task = Task.Factory.StartNew(() =>
                {
                    nums = SimpleParallelTask(nums, cts.Token);
                }, cts.Token);

            Console.Read();

            cts.Cancel();
            Console.WriteLine("-------------------------------------");

            try
            {
                task.Wait();
            }
            catch (AggregateException)
            {
                Console.WriteLine("쿼리가 중간에 취소되었습니다.");
            }
        }
    }
}

<코드3> LINQ 맞들기 취소하기.

<코드3>을 보면, AsParallel메서드의 결과로 리턴되는 ParallelQuery타입에 포함된 .WithCancellation메서드를 사용해서 PLINQ 쿼리에 CancellationToken을 넘겨준다는 것을 제외하고는 Parallel.For, Parallel.ForEach와 동일한 방법을 사용하고 있습니다. 결과도 예측할 수 있듯이 동일합니다.

(생략)
1
3
2
1
3
2
1
3
2
1
3
2
1
3
2
1
3

-------------------------------------
쿼리가 중간에 취소되었습니다.
계속하려면 아무 키나 누르십시오 . . .

<결과4> LINQ 맞들기를 취소한 결과.


- 마치면서

어떠셨나요? '백지장도 맞들면 낫다'는 속담이 PLINQ에서는 항상 참이 아니라는 게 말이죠. 이래서 병렬 프로그래밍이 어려운가 봅니다. 어허허허헣. 악플 사절(죽을때 까지 미워할거임)! 피드백 환영! 호호호호^^


- 참고자료

1. Essential C# 4.0, Mark Michaelis, Addison Wesley
저작자 표시 비영리 변경 금지
신고

- 취소는 어렵지.

약속은 하는 것 보다, 취소하는 게 어렵습니다. 게다가 취소할 때는 적절한 타이밍을 놓치면, 안 좋은 기억만 남기게 되죠. 그래서 프로그램에서도 취소를 제대로 할 수 있도록 지원하는 게 중요합니다. 누구나 실수 할 수 있거든요.


- TPL과 함께 취소 좀 더 쉽게하기. 어헣.

TPL은 두가지 형태의 병렬성을 지원합니다. 첫 번째는 작업 병렬성(Task Parallel)이고, 두 번째는 데이터 병렬성(Data Parallelism)입니다. 작업 병렬성은 하나 이상의 작업이 동시에 진행되는 것을 말하구요, 데이터 병렬성은 연속적인 데이터에 대해서 동일한 작업이 동시적으로 수행되는 것을 말합니다. 기존까지 Task클래스와 관련해서 살펴봤던게 작업 병렬성을 위한 것이었다면, 이번에는 데이터 병렬성을 지원하는 부분을 살펴보겠습니다.

데이터 병렬성을 매우 손쉽게 지원하기 위해서 System.Threading.Tasks.Parallel클래스에 병렬성을 지원하는 for와 foreach를 추가했습니다. Parallel.For와 Parallel.ForEach가 바로 그 것인데요. 하나씩 살펴보겠습니다.

using System;
using System.Linq;
using System.Threading.Tasks;

namespace Exam13
{
    class Program
    {
        static void Main(string[] args)
        {
            int[] nums = Enumerable.Range(1, 1000).ToArray<int>();
            Parallel.For(0, 1000, (i) =>
            {
                nums[i] = nums[i] * nums[i];
            });

            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine(nums[i].ToString());               
            }
        }
    }
}

<코드1> 간단한 병렬 예제.

<코드1>을 보면, 1부터 1000까지의 정수 배열을 만든 뒤에, 각각의 수를 제곱하는 코드입니다. i번째의 숫자를 제곱해서 그 결과를 i번째 인덱스에 넣는 작업과, i+1번째의 숫자를 제곱해서 그 결과를 i+1번째 인덱스에 넣는 작업은 별개의 작업이며, 동시에 수행가능한 작업이죠. 저렇게 for와 거의 비슷한 모양으로 작성하고, for대신에 Parallel.For를 써주는 것 만으로도 남아도는 CPU의 코어를 활용할 수 있다니. 간편하죠?

Parallel.ForEach와 병렬 루프에서 예외를 처리하는 부분은 이미 다룬 부분이기 때문에 건너뛰구영. 바로, 병렬 루프를 취소하는 방법에 대해서 알아보겠습니다. 지난 포스트에서 작업을 취소하는 방법에 대해서 알아봤었는데요. 이번에도 크게 다르지 않습니다. 동일하게 CancellationTokenSource와 CancellationToken클래스를 활용합니다. 다만, 방법이 약간 다른데요, 예제를 보시죠.

using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Threading.Tasks;
using System.Threading;

namespace Exam14
{
    class Program
    {
        static void Main(string[] args)
        {
            CancellationTokenSource cts =
                new CancellationTokenSource();
            ParallelOptions parallelOptions =
                new ParallelOptions
                {
                    CancellationToken = cts.Token
                };
            cts.Token.Register(
                () => Console.WriteLine("Cancelling....")
            );

            Console.WriteLine("끝내려면 엔터키를 누르세용.");

            IEnumerable<string> files =
                Directory.GetFiles("C:\\음악", "*", SearchOption.AllDirectories);
            List<string> fileList = new List<string>();

            Console.WriteLine("파일 개수 : {0}", files.Count());

            Task task = Task.Factory.StartNew(() =>
                {
                    try
                 {
                        Parallel.ForEach(files, parallelOptions,
                        (file) =>
                        {
                            FileInfo fileInfo = new FileInfo(file);
                            if (fileInfo.Exists)
                            {
                                if (fileInfo.Length >= 10000000)
                                {
                                    fileList.Add(fileInfo.Name);
                                }
                            }
                        });
                 }
                 catch (OperationCanceledException)
                 {
                 }
                });

            Console.Read();

            cts.Cancel();
            task.Wait();
           
            foreach (var file in fileList)
            {
                Console.WriteLine(file);
            }
            Console.WriteLine("총 파일 개수 : {0}",fileList.Count());
        }
    }
}

<코드2> 병렬 루프에서의 작업 취소.

<코드2>를 보면, Parallel.ForEach이용해서, 음악 파일 중에서 10메가가 넘는 파일만 찾아서 리스트에 담고 있습니다. 그리고 루프 취소와 모니터링을 위해서 CancellationTokenSource, CancellationToken클래스를 활용하고 있습니다. 다른점이 있다면, 병렬 루프에 옵션을 주기 위해서 ParallelOptions클래스를 사용하고 있다는 것이죠. 그리고 생성된 ParallelOptions타입의 객체에 Token을 주고, 그 객체를 Parallel.ForEach루프에 매개변수로 넘겨주고 있습니다. 결과를 보면, 늦게 취소를 한 경우에는 리스트가 모두 완성된 반면에, 빨리 취소를 한 경우에는 리스트가 만들어지다가 만 걸 확인할 수 있죠.

끝내려면 엔터키를 누르세용.
파일 개수 : 2746

Cancelling....

(중략...)

05 サンクチュアリ.mp3
06 空のように 海のように.mp3
07 月の虹.mp3
총 파일 개수 : 380
계속하려면 아무 키나 누르십시오 . . .

<결과1> 늦게 취소해서 다 완성된 리스트.

끝내려면 엔터키를 누르세용.
파일 개수 : 2746

Cancelling....

(중략...)

01.mp3
02.mp3
03.mp3
01.うるわしきひと.mp3
총 파일 개수 : 256
계속하려면 아무 키나 누르십시오 . . .

<결과2> 중간에 취소해서 만들어지다 만 리스트.

ParallelOptions를 통해서 CancellationToken을 받은 병렬 루프는 내부적으로, IsCancellationRequested속성을 계속해서 주시하고 있습니다. 그리고, 이 취소 요청이 들어와서 이 속성이 true가 되면, 그 이후로는 새로운 루프가 시작되는 걸 막아버리는 것이죠. 그리고 병렬 루프가 취소되었음을 외부에 알릴 수 있는 유일한 방법이 OperationCanceledException을 통해서 인데요. <코드2>를 보면, catch를 통해서 예외를 잡긴하지만, 무시해버렸습니다. 그래서 Register메서드를 통해서 등록된 "Cancelling...."이라는 메세지가 출력되고 프로그램이 종료된 것이죠.


- 마치면서

역시 병렬처리를 간단하게 만들어 주는 만큼, 병렬처리를 취소하는 방법도 최대한 간단하게 만들어 주네요. TPL만쉐이! 어헣.


- 참고자료

1. Essential C# 4.0, Mark Michaelis, Addison Wesley
2. http://msdn.microsoft.com/en-us/library/dd537608.aspx
3. http://msdn.microsoft.com/en-us/library/dd537609.aspx

저작자 표시 비영리 변경 금지
신고

- 뭐, 미안하다고?

선진국에 가보면, 약간만 부딛힐 듯한 상황이라면, 서로 'Excuse me', '스미마셍'같이 서로를 배려하는 모습을 볼 수 있습니다. 우리나라에서는 아직 길을 걸으면서 뒷 사람에게 담배연기를 선사한다던가, 뭐 그리 급한지 보행자일 때는 운전자를, 운전할 때는 보행자를 씹으면서 급하게 서두르는 모습을 쉽게 볼 수 있습니다. 층간소음이 일어나면 오히려 윗집이 더 큰소리를 치기도 하죠. 시민의식으로 겨루는 한일전에서도 완승을 거뒀으면 좋겠다는 생각을 합니다만, 저 역시 모범시민은 아니기에 같이 노력해야겠죠. 어허허허헣. 오늘은 닷넷이 예절을 얼마나 잘 지키는 지, 한번 살펴보겠습니다.


- Stop it, Now!

위 소제목을 보시고, 잭 바우어를 떠올렸다면, 24시의 팬이시겠군요. 잭 바우어는 너무나도 급한 상황을 많이 만나는데요, 상대방에게는 정말 미안하지만, 상황을 해결하기 위해서 윽박지르고, 때로는 때리고, 아주 때로는 다리를 쏘는 등등등! 의 방법을 사용합니다.

아흙. 닷넷의 멀티 스레드 환경을 한번 생각해보죠. 여러개의 스레드가 작업을 처리하는 동안, 하나의 스레드는 사용자의 UI에서 입력을 기다립니다. 그리고 사용자가 취소버튼을 누르면, 사용자의 의지를 이어받아서 다른 스레드들을 취소해야 하는데요. 기존의 .NET 3.5까지는 작업 중인 스레드를 취소하는 게 매우 무례했습니다. 취소해야 할 때는 기냥 바로 끼어들어서 취소해버렸기 때문이죠. 그렇게 하면, 데이터 업데이트가 이뤄지는 도중에 취소되어서 부분적으로만 데이터가 업데이트 된다든지, 자원해제가 제대로 안 된다든지 하는 부작용의 위험이 항상 존재합니다. 그래서 가능하면, 다른 방법이 전혀 없을 때, 이렇게 하는 것이 좋겠죠?

물론 기존의 방식도 여전히 활용가능하지만, 이젠 닷넷이 많이 예의를 갖췄습니다. 닷넷 4.0에 새롭게 추가된 PLINQ나 TPL을 사용하는 경우에는 취소 요청 접근법(cancellation request approach)만 사용할 수 있는데요, 이런 방식을 협력적인 취소(cooperative cancellation)이라고 합니다. 즉, 한 스레드가 다른 스레드를 강제로 종료시키는 게 아니라, 작업 취소 API를 통해서 작업을 취소해줄 것을 요청하는 것이죠. 취소 플래그를 통해서 취소요청을 받은 작업은 취소요청에 어떻게 응답할 것인지 선택할 수 있습니다. 예제를 하나 보시죠.

using System;
using System.Threading.Tasks;
using System.Threading;

namespace Exam11
{
    class Program
    {
        static void PrintDash(CancellationToken cancellationToken)
        {
            cancellationToken.Register(Canceled);

            while (!cancellationToken.IsCancellationRequested)
            {
                Console.Write("-");
            }
        }

        static void Canceled()
        {
            Console.WriteLine("작업이 취소되었군요!!");
        }

        static void Main(string[] args)
        {
            string stars = "*".PadRight(Console.WindowWidth - 1, '*');

            CancellationTokenSource cancellationTokenSource =
                new CancellationTokenSource();

            Task task = Task.Factory.StartNew(
                () => PrintDash(cancellationTokenSource.Token));
           
            Console.ReadLine();

            cancellationTokenSource.Cancel();
            Console.WriteLine(stars);
            task.Wait();
            Console.WriteLine("작업의 완료상태 : {0}", task.Status);
            Console.WriteLine();
        }
    }
}

<코드1> 취소 요청 접근법.

<코드1>은 그냥 평범하게 '-'를 출력하는 예제입니다. 하지만, 새로운 클래스가 몇개 보이는데요. CancellationTokenSource, CancellationToken클래스말이죠.

namespace System.Threading
{
    [ComVisible(false)]
    [DebuggerDisplay("IsCancellationRequested = {IsCancellationRequested}")]
    public struct CancellationToken
    {
        public CancellationToken(bool canceled);
       
        public static bool operator !=(CancellationToken left, CancellationToken right);

        public static bool operator ==(CancellationToken left, CancellationToken right);

        public bool CanBeCanceled { get; }

        public bool IsCancellationRequested { get; }

        public static CancellationToken None { get; }

        public WaitHandle WaitHandle { get; }

        public bool Equals(CancellationToken other);

        public override bool Equals(object other);

        public override int GetHashCode();

        public CancellationTokenRegistration Register(Action callback);

        public CancellationTokenRegistration Register(Action<object> callback, object state);

        public CancellationTokenRegistration Register(Action callback, bool useSynchronizationContext);

        public CancellationTokenRegistration Register(Action<object> callback, object state, bool useSynchronizationContext);

        public void ThrowIfCancellationRequested();
    }
}

<코드2> 구조체 CancellationToken.

CancellationToken클래스는 말 그대로, 현재 이 토큰이 어떤 상태에 있는지 모니터링 하기 위한 정보를 갖고 있습니다. 이 토큰이 현재 취소 요청을 받았는지, 취소요청을 받으면 어떤 행동을 취할 것인지 등을 확인하고, 설정할 수 있습니다.

namespace System.Threading
{
    [ComVisible(false)]
    public sealed class CancellationTokenSource : IDisposable
    {
        public CancellationTokenSource();

        public bool IsCancellationRequested { get; }

        public CancellationToken Token { get; }

        public void Cancel();

        public void Cancel(bool throwOnFirstException);

        public static CancellationTokenSource CreateLinkedTokenSource(params CancellationToken[] tokens);

        public static CancellationTokenSource CreateLinkedTokenSource(CancellationToken token1, CancellationToken token2);

        public void Dispose();
    }
}

<코드3> CancellationTokenSource 클래스

그리고 CancellationTokenSource는 CancellationToken의 기반이 되는 클래스로(Source라는 이름이 붙어있죠), CancellationTokenSource에서 생성된 각각의 Token에 대해서 취소를 요청하는 역할을 합니다. CancellationTokenSource에서 Cancel메서드로 취소요청을 하면, 같은CancellationTokenSource에서 생성된 Token들은 전부 취소요청을 받는 셈이죠.


한가지 주목해서 보실 점은 CancellationToken가 클래스가 아니라 구조체라는 것입니다. 즉, Token을 매번 다른 객체에 넘겨줄 때마다 새로운 복사본이 생성된다는 것이죠. 그래서 각각의 스레드에 넘겨진 Token은 각각 독립적인 복사본 이므로, Cancel메서드는 스레드 안전성(thread-safe)을 확보할 수 있습니다. 만약에 참조가 그냥 복사된다면, 각각의 스레드가 Token에 손을 대면, 다른 스레드가 참조하는 Token에도 동일한 변화가 생겨서 예측불가능한 일이 벌어지겠죠.

<코드1>을 보면, 병렬적으로 수행되는 작업에서 취소 요청을 모니터링하기 위해서, CancellationToken을 인자로 넘겨주는 것을 볼 수 있습니다. 그래서 PrintDash메서드 내부에서 IsCancellationRequested속성을 통해서 작업 취소 요청이 들어왔는지 계속 해서 확인하게 되죠. 그럼 <코드1>을 실행 해볼까요?

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--작업이 취소되었군요!!
*******************************************************************************
작업의 완료상태 : RanToCompletion

계속하려면 아무 키나 누르십시오 . . .

<결과1> <코드1>의 실행결과.

<결과1>을 보면, 작업의 완료상태를 출력하는 부분이 있는데요, 이 부분에서 RanToCompletion이 출력되고 있습니다. 그래서 만약, ContinueWith메서드로 연쇄 작업을 연결하고, 옵션을 OnlyOnCanceled로 설정해준다고 하더라도, 연쇄작업은 실행되지 않습니다. 작업은 완료된 상태이기 때문에, 연쇄 작업이 취소되었다는 에러메세지만 확인할 수 있을 뿐이죠. 그렇다면, 연쇄작업을 이용해서 <코드1>과 동일한 결과를 내려면 어떻게 해야 할까요?

using System;
using System.Threading.Tasks;
using System.Threading;

namespace Exam12
{
    class Program
    {
        static void PrintDash(CancellationToken cancellationToken)
        {
            while (!cancellationToken.IsCancellationRequested)
            {
                Console.Write("-");
            }

            if (cancellationToken.IsCancellationRequested)
            {
                cancellationToken.ThrowIfCancellationRequested();
            }
        }

        static void Main(string[] args)
        {
            string stars = "*".PadRight(Console.WindowWidth - 1, '*');

            CancellationTokenSource cancellationTokenSource =
                new CancellationTokenSource();

            Task task = Task.Factory.StartNew(
                () => PrintDash(cancellationTokenSource.Token),
                cancellationTokenSource.Token);

            Task canceledTask = task.ContinueWith(
                (antecedentTask) => Console.WriteLine("작업이 취소되었군요!!"),
                TaskContinuationOptions.OnlyOnCanceled);

            Console.ReadLine();

            cancellationTokenSource.Cancel();
            Console.WriteLine(stars);
            canceledTask.Wait();
            Console.WriteLine("작업의 완료상태 : {0}", task.Status);
            Console.WriteLine();
        }
    }
}

<코드4> <코드1>과 동일하지만, 연쇄작업을 사용하는 코드.

<코드4>가 바로, <코드1>과 동일한 결과를 내는 코드입니다.(사실 완전히 같지는 않습니다. 실행해보면, '작업이 취소되었군요!'라는 멘트가 출력되는 위치가 다르지요.) 이런식으로 연쇄작업을 연결해놓고, 병렬로 실행되는 메서드 안에서, ThrowIfCancellationRequested()메서드를 통해서, 취소되었을 때, 취소되었다는 표시를 하도록 한 것이죠. 그러면, 연쇄 작업이 바톤을 이어받아서 실행을 계속하게 됩니다. 그리고 또 한가지 차이점은 작업을 생성할 때, 인자로 Token을 넘겨준다는 것이지요.


- 마치면서.

요즘 월드컵을 보면 16강이 가능할 것 같다는 생각이 들기도 하는데요. 꼭! 갔으면 좋겠네요!! ......이게 마무리 멘트라니-_-!! 어헣.


- 참고자료

1. Essential C# 4.0, Mark Michaelis, Addison Wesley

저작자 표시 비영리 변경 금지
신고

- To be continue??

한 때, 뮤직비디오를 드라마 처럼 만드는 게 유행했던 때가 있었습니다. 기억하시나요? 한창 조성모가 데뷔했을 때로 기억하는데요. 노래 한곡에 뮤직비디오가 여러편이 있어서, 하나가 공개 되고 나면, 'To be continue..'라는 자막이 나오면서 애간장을 태우곤 했었죠. 그게 오늘 내용과 무슨 상관이냐!?!? 별 상관없습니다-_- 어허허헣.


- 뒤를 부탁해, ContinueWith

지금까지는 작업을 만들어서 돌리고, 작업이 종료되면 그냥 끝났었는데요. 한 작업이 어떤 상태로 끝났을 때, 그 상태에 따라서 다른 작업이 연쇄적으로 일어나도록 설정하고 싶은 경우가 있겠죠. Task클래스에는 그런 경우를 위해서 ContinueWith라는 메서드가 준비되어 있습니다. 낄낄낄. 이 메서드는 선행 작업(antecedent task)에 하나 이상의 연쇄적인 작업을 연결 시켜놓고, 선행 작업의 상태나 종료에 따라서 연쇄적으로 이후 작업이 실행되도록 설정할 수 있도록 해줍니다. 그러면, 선행 작업의 상태에 대해서 설정가능한 상태가 뭐가 있는지 한번 확인해볼까요?

 상태  설명 
 None  아무 값도 명시되지 않으면 사용되는 기본 값으로, 선행작업의 상태와 관계없이, 선행작업이 종료되면 바로 시작된다.
 NotOnRanToCompletion  선행작업이 끝난 상태에서는 연쇄작업을 실행계획에 넣지 않는다.
 NotOnFaulted  선행작업이 처리안된 예외를 던지면, 연쇄작업을 실행계획에 넣지 않는다.
 NotOnCanceled  선행작업이 취소되면, 연쇄작업을 실행계획에 넣지 않는다.
 OnlyOnRanToCompletion  선행작업이 정상적으로 완료된 상태에서만 연쇄작업을 실행계획에 추가시킨다.
 OnlyOnFaulted  선행작업이 처리안된 예외를 던지는 상태에서만 연쇄작업을 실행계획에 추가시킨다.
 OnlyOnCanceled  선행작업이 취소된 상태에서만 연쇄작업을 실행계획에 추가시킨다.
<표1> http://msdn.microsoft.com/en-us/library/system.threading.tasks.taskcontinuationoptions.aspx에서 참조한 상태값과 설명

그리고 ContinueWith메서드는 Task타입의 객체를 리턴하는 데요, 그 덕분에 연쇄적으로 작업에 작업을 계속해서 추가할 수도 있습니다. 그러면, 예제를 한번 확인해볼까요?

using System;
using System.Threading.Tasks;

namespace Exam9
{
    class Program
    {
        static string Calc(object from)
        {
            long sum = 0;
            long start = (long)from;
            Console.WriteLine("현재 이 메서드를 실행중인 스레드 ID : {0}",
                Task.CurrentId);

            for (long i = start; i < 100000000; i++)
            {
                sum += i;
                if(i == 100000 || i == 200000)
                {
                    //100000에 이르면, 그냥 실행을 종료시킨다.
                    throw new ApplicationException("그냥 에러가 났음");
                }
            }
            Console.WriteLine("계산 끝");
            return sum.ToString();
        }

        static void Main(string[] args)
        {
            Task<string> task = new Task<string>(
                Calc, 1L);

            task.Start();

            Task<string> justDoIt = task.ContinueWith<string>(
                (antecedentTask) =>
                {
                    Console.WriteLine("이전 작업 상태 : {0}", antecedentTask.Status);
                    return Calc(100001L);
                },
                TaskContinuationOptions.None);

            Task<string> justDoIt2 = justDoIt.ContinueWith<string>(
                (antecedentTask) =>
                {
                    Console.WriteLine("이전 작업 상태 : {0}", antecedentTask.Status);
                    return Calc(200001L);
                },
                TaskContinuationOptions.None);

            try
            {
                Console.WriteLine(task.Result);
            }
            catch (AggregateException ex)
            {
                foreach (var item in ex.InnerExceptions)
                {
                    Console.WriteLine("에러 : {0}", item.Message);
                }
            }
            finally
            {
                try
                {
                    Console.WriteLine(justDoIt.Result);
                }
                catch (AggregateException ex)
                {
                    foreach (var item in ex.InnerExceptions)
                    {
                        Console.WriteLine("에러 : {0}", item.Message);
                    }
                }
                finally
                {
                    Console.WriteLine(justDoIt2.Result);
                    Console.WriteLine("끝");
                }
            }
        }
    }
}

<코드1> 연쇄작업 물리기.

<코드1>은 Calc메서드를 실행하는 작업을 하나 시작하고, 그 작업에 연쇄작업을 하나 연결하고, 그 연쇄작업에 또 연쇄작업을 하나 연결한 예제입니다. 연쇄작업의 옵션을 보면, 전부 'TaskContinuationOptions.None'로 되어 있는데요. <표1>에서 확인해보면, 선행작업이 어떤 식으로 종료되든, 종료가 되면 이어서 실행하도록 하는 옵션이죠. 그리고 Calc메서드를 보면, for루프의 카운트가 100000과 200000에 이르면, 예외를 던지고 실행을 종료하도록 했습니다. 그리고 작업을 보면, task는 1부터, justDoIt은 100001부터 실행하므로 둘다 예외를 만나겠죠. 그리고 마지막 justDoIt2는 200001부터 실행하므로 실행을 종요하게 됩니다. 그럼 결과를 보죠.(설명을 위한 예제이므로 실제로 이렇게 짜면 곤란하겠죠)

현재 이 메서드를 실행중인 스레드 ID : 1
이전 작업 상태 : Faulted
현재 이 메서드를 실행중인 스레드 ID : 2
이전 작업 상태 : Faulted
현재 이 메서드를 실행중인 스레드 ID : 3
에러 : 그냥 에러가 났음
에러 : 그냥 에러가 났음
계산 끝
4999979949900000

계속하려면 아무 키나 누르십시오 . . .
<결과1> 연쇄작업 실행 결과.

우리가 예측한대로, 선행작업이 실패하면서, 바로 다음작업이 연쇄적으로 실행되는 걸 확인할 수 있습니다. 지난 포스트에서 Wait메서드를 통해서 추가적으로 생성된 스레드의 결과를 기다리거나, Result속성을 통해서 결과값을 요청하지 않으면 추가스레드의 결과는 그냥 무시된다고 했었는데요. 연쇄작업도 마찬가지 입니다. 연쇄작업을 연결해 놓았다고 해도, Wait로 기다리거나 Result를 요구하지 않으면 그냥 무시되어 버리는 것이죠. <코드1>에서 justDoIt2.Result부분을 아래 처럼 주석처리하고 실행해보죠.

finally
{
    //Console.WriteLine(justDoIt2.Result);
    Console.WriteLine("끝");
}
<코드2> 주석 처리.

그리고 결과는 보면,

현재 이 메서드를 실행중인 스레드 ID : 1
이전 작업 상태 : Faulted
현재 이 메서드를 실행중인 스레드 ID : 2
이전 작업 상태 : Faulted
현재 이 메서드를 실행중인 스레드 ID : 3
에러 : 그냥 에러가 났음
에러 : 그냥 에러가 났음

계속하려면 아무 키나 누르십시오 . . .
<결과2> 작업이 무시된 결과

justDoIt2가 진행하던 작업의 결과는 무시된 걸 확인할 수 있습니다.

<표1>에서 None옵션은 선행작업이 어떤 이유로 종료가 되었든, 종료가 되었다면 바로 이어서 연쇄작업을 실행하도록 하는 옵션인데요. 그러면, <코드1>과 동일한 결과를 가져오려면 어떤 옵션을 사용하면 되는 걸까요? 한번 생각해보시죠. 어헣. 힌트는 예외를 던지고 종료되는 작업의 상태를 보시면 바로 정답이 나옵니당. 정답은 <코드3>을 보시죠!

Task<string> justDoIt = task.ContinueWith<string>(
    (antecedentTask) =>
    {
        Console.WriteLine("이전 작업 상태 : {0}", antecedentTask.Status);
        return Calc(100001L);
    },
    TaskContinuationOptions.OnlyOnFaulted);

Task<string> justDoIt2 = justDoIt.ContinueWith<string>(
    (antecedentTask) =>
    {
        Console.WriteLine("이전 작업 상태 : {0}", antecedentTask.Status);
        return Calc(200001L);
    },
    TaskContinuationOptions.OnlyOnFaulted);

<코드3> 같은 결과를 가져오는 코드

그렇씁니다. OnlyOnFaulted는 선행작업이 처리안된 예외를 던지는 상태에서만 연쇄작업을 실행시키는 옵션이죠. 어허허허헣.

그렇다면, 작업의 성공이나 실패를 자동으로 통지하도록 하는 방법을 사용할 수 있을 것 같습니다.

using System;
using System.Threading.Tasks;

namespace Exam10
{
    class Program
    {
        static string Calc(object from)
        {
            long sum = 0;
            long start = (long)from;
            Console.WriteLine("현재 이 메서드를 실행중인 스레드 ID : {0}",
                Task.CurrentId);

            for (long i = start; i < 100000000; i++)
            {
                sum += i;
                if (i == 100000)
                {
                    //100000에 이르면, 그냥 실행을 종료시킨다.
                    throw new ApplicationException("그냥 에러가 났음");
                }
            }
            Console.WriteLine("계산 끝");
            return sum.ToString();
        }

        static void Main(string[] args)
        {
            Task<string> task = new Task<string>(
                Calc, 1L);

            task.Start();

            Task completedTask = task.ContinueWith(
                (antecedentTask) =>
                {
                    Console.WriteLine("Task State: 무사 완료!!");
                    //작업 완료 후에 이어서 뭔가를 처리
                },
                TaskContinuationOptions.OnlyOnRanToCompletion);

            Task faultedTask = task.ContinueWith(
                (antecedentTask) =>
                {
                    Console.WriteLine("Task State: 처리되지 않은 예외 발견!!");
                },
                TaskContinuationOptions.OnlyOnFaulted);

            try
            {
                completedTask.Wait();
                faultedTask.Wait();
            }
            catch (AggregateException ex)
            {
                foreach (var item in ex.InnerExceptions)
                {
                    Console.WriteLine("에러 : {0}", item.Message);
                }
            }
        }
    }
}

<코드4> 연쇄 작업으로 상태를 감지

<코드4>를 보시면, 작업이 무사히 끝났을 때와 처리안된 예외가 생겨서 종료될 때에 실행되도록 연쇄작업을 두개 연결해 놓았습니다. 일단 결과를 보면요,

현재 이 메서드를 실행중인 스레드 ID : 1
Task State: 처리되지 않은 예외 발견!!
에러 : 작업이 취소되었습니다.
계속하려면 아무 키나 누르십시오 . . .
<결과3>

예상대로, OnlyOnFaulted로 설정된 연쇄작업이 실행된 것을 확인할 수 있습니다. 왜냐면 작업을 1부터 시작하도록 했기 때문에, 카운트가 100000에 다다르면, 처리안된 예외가 발생하기 때문이죠. 작업의 상태값을 100001L이상으로 설정하면, OnlyOnRanToCompletion으로 설정된 연쇄작업이 실행되겠죠.

그런데, 두 경우다 예외가 하나씩 잡히는 걸 확인할 수 있습니다. '작업이 취소되었습니다'라는 예외인데요. 이유는 간단합니다. task에 OnlyOnRanToCompletion와 OnlyOnFaulted인 연쇄작업 두개를 연결했기 때문에, 둘 중 하나만 항상 실행이 됩니다. 그래서 둘 중에 하나는 실행이 되지 못한채, 선행작업이 끝나버리는 거죠. 그래서 Wait메서드를 통해서 기다리고 있던 작업은 종료되지 못하고 취소가 되는 마는 것이죠.


- 마무리

오늘은 선행작업의 상태에 따라서 연쇄적으로 작업을 실행하는 예제를 살펴봤습니다. 다음에 뵙쬬!


- 참고자료

1. Essential C# 4.0, Mark Michaelis, Addison Wesley

저작자 표시 비영리 변경 금지
신고

- 인생이 원하는 대로 가지는 않더라.

우리는 인생을 살면서, 여러가지 계획을 세웁니다. 하지만, 보통 계획을 세울 때, 예외적인 상황을 감안하지 않는 경우가 많습니다. 그래서 늘 일정은 실패로 끝나게 되고, '아, 난 안되나 보다.'하고 절망하게 되는 것이죠. 자세한 내용은 최인철 교수님의 '프레임'을 참고하시면..... 순간 책 소개 코너로 빠져들뻔 했군요. 어헣.

아무튼, 우리는 인생에서 뿐만 아니라 프로그래밍에서도 생각외의 순간을 많이 만나게 됩니다. '도대체 어떤 **가 이런 거 까지 해볼까?'하는 생각으로 안이하게 프로그래밍을 하다보면 기상 천외한 버그 리포트를 받게 됩니다. 그래서 예외 처리가 중요한 것이죠. 오늘은 병렬 프로그래밍에서 예외 처리 하는 법에 대해서 간단하게 이야기를 해보려고 합니다.


- 차이점 하나.

기존의 프로그래밍에서는 그저 예외를 발생시 처리하고 싶은 구문을 try로 감싸면 되었는데요. 병렬 프로그래밍에서는 어떨까요? Task.Start()를 try로 감싸면 결과를 얻을 수 있을까요? 한번 실험해보죠.

using System;
using System.Threading.Tasks;

namespace Exam5
{
    class Program
    {
        static string Calc(object from)
        {
            long sum = 0;
            long start = (long)from;
            Console.WriteLine("현재 이 메서드를 실행중인 스레드 ID : {0}",
                Task.CurrentId);

            for (long i = start; i < 100000000; i++)
            {
                sum += i;
                if (i == 100000)
                {
                    //100000에 이르면, 그냥 예외를 던진다ㅋ.
                    throw new ApplicationException("그냥 에러가 났음");
                }
            }
            Console.WriteLine("계산 끝");
            return sum.ToString();
        }

        static void Main(string[] args)
        {
            Task<string> task = new Task<string>(
                Calc, 1L);

            try
            {
                task.Start();
            }
            catch (AggregateException ex)
            {
                foreach (var item in ex.InnerExceptions)
                {
                    Console.WriteLine("에러 : {0}", item.Message);
                }
            }

            Console.WriteLine(task.Result);
        }
    }
}

<코드1> Start를 try로 감싸기

<코드1>을 보시면, task.Start()를 try로 감싸고 있습니다. 과연 실행중에 던지는 예외를 잘 받을 수 있을까요? 결과는 아래와 같습니다.

현재 이 메서드를 실행중인 스레드 ID : 1

처리되지 않은 예외: System.AggregateException: 하나 이상의 오류가 발생했습니다.
---> System.ApplicationException: 그냥 에러가 났음
   위치: Exam5.Program.Calc(Object from) 파일 C:\Users\boram\Documents\Visual St
udio 10\Projects\Chapter9\Exam7\Program.cs:줄 21
   위치: System.Threading.Tasks.Task`1.InvokeFuture(Object futureAsObj)
   위치: System.Threading.Tasks.Task.InnerInvoke()
   위치: System.Threading.Tasks.Task.Execute()
   --- 내부 예외 스택 추적의 끝 ---
   위치: System.Threading.Tasks.Task.ThrowIfExceptional(Boolean includeTaskCance
ledExceptions)
   위치: System.Threading.Tasks.Task.Wait(Int32 millisecondsTimeout, Cancellatio
nToken cancellationToken)
   위치: System.Threading.Tasks.Task`1.get_Result()
   위치: Exam5.Program.Main(String[] args) 파일 C:\Users\boram\Documents\Visual
Studio 10\Projects\Chapter9\Exam7\Program.cs:줄 45
계속하려면 아무 키나 누르십시오 . . .

<결과1> <코드1>의 실행 결과

<결과1>을 보면, 예외는 전혀 잡히지 않았습니다. 왜 일까요? 작업에서 실행하는 코드(여기서는 Calc메서드)는 Start메서드 내에서 실행되는 게 아니라, Start메서드로 작업이 시작된 이후에야 별도로 시작되기 때문이죠. 그래서 Start메서드에 try를 걸어봤자 예외는 잡을 수 없습니다.

가만히 생각해보면, 작업 안에서 처리되는 예외(즉, Calc메서드 내에서 발생하고 처리되는 예외)는 전혀 고민할 필요가 없겠죠. 하지만, 처리되지 못한 예외가 발생하는 경우는 바깥에서 처리할 방법을 찾아야 합니다.

CLR 2.0 버전까지는 이런 처리되지 못한 예외가 발생하면, 예외가 버블링되면서, 상위 계층으로 전파되면서 결국에는 윈도우 에러 보고 대화상자를 열게 만들고, 프로그램은 종료되었습니다. 하지만, 작업 내부에서 처리되지 못한 예외라고 하더라도, 작업 바깥에서 처리할 수 있는 방법이 있다면, 프로그램이 종료되는 것 보다는 바깥에서 처리할 수 있게 하는 게 더 나은 방법이겠죠.

작업내부에서 처리 안된 예외(unhandled exception)가 발생했다면, 그 예외는 일단 작업을 마무리 짓는 멤버(Wait(), Result, Task.WaitAll(), Task.WaitAny())가 호출되기 전까지는 조용히 기다립니다. 그리고 마무리 멤버의 호출에서 처리 안된 예외가 발생하게 되는 것이죠. <코드1>에서 Start메서드를 try블록으로 감쌌지만, 예외를 잡을 수 없었던 이유가 바로 여기에 있습니다. 처리 안된 예외는 마무리 멤버와 함께 발생하기 때문이죠. 그러면, <코드1>을 수정해서, 예외를 제대로 잡도록 수정해보겠습니다.

task.Start();

try
{
    Console.WriteLine(task.Result);
}

catch (AggregateException ex)
{
    foreach (var item in ex.InnerExceptions)
    {
        Console.WriteLine("에러 : {0}", item.Message);
    }
}

<코드2> Result를 try로 감싸라.

<코드2>를 보면, 작업을 마무리 짓는 멤버 중의 하나인, Result를 try블록으로 감싸고 있습니다. 그리고 결과를 보면,

현재 이 메서드를 실행중인 스레드 ID : 1
에러 : 그냥 에러가 났음
계속하려면 아무 키나 누르십시오 . . .
<결과2> 제대로 처리된 예외.

예외가 제대로 처리 된 것을 확인할 수 있습니다.


- 차이점 둘.

혹시 <코드1>, <코드2>를 주의 깊게 보신 분이라면, 처음보는 예외 하나를 발견하셨을지도 모르겠습니다. 바로 AggregateException인데요, 이에 대해서 이야기를 좀 해보겠습니다.

AggregateException은 닷넷 프레임워크 4.0에서 처음 추가된 예외 타입인데요, MSDN의 설명을 보면(http://msdn.microsoft.com/en-us/library/system.aggregateexception.aspx), '프로그램 실행 중에 발생하는 하나 또는 여러개의 에러를 표현하는 수단'이며, 주로 TPL과 PLINQ에서 활용되고 있다고 합니다.

aggregate는 여러 개의 작은 것들을 서로 합치는 이미지를 갖고 있는데요, AggregateException은 그렇다면 여러 개의 예외를 하나로 합치는 예외타입이라는 말이 됩니다. 그런데, 여러 개의 예외는 어디서 오는 걸까요? 예전에 작성했던 예제를 조금 수정해서 확인 해보도록 하죠.

using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Threading.Tasks;

namespace Exam7
{
    class Program
    {
        static void Main(string[] args)
        {
            IEnumerable<string> files =
                Directory.GetFiles("C:\\음악", "*", SearchOption.AllDirectories);
            List<string> fileList = new List<string>();

            Console.WriteLine("파일 개수 : {0}", files.Count());
           
            Parallel.ForEach(files, (file) =>
            {
                FileInfo fileInfo = new FileInfo(file);
                if (fileInfo.Exists)
                {
                    if (fileInfo.Length >= 15000000)
                    {
                        throw new ApplicationException("15메가 넘는 파일이!!");
                    }
                    else if (fileInfo.Length >= 1000000)
                    {
                        fileList.Add(fileInfo.Name);
                    }
                    Console.Write("{0}", Task.CurrentId.ToString());                   
                }
            });
        }
    }
}

<코드3> 약간 수정된 예제.

<코드3>이 바로 그 예제인데요, 예제를 보면, 음악 폴더에서 15메가 넘는 파일이 발견되면, 예외를 발생하도록 되어있습니다. flac같은 파일로 보자면, 15메가 넘는 파일은 흔히 있겠지만, 저는 서민이라 mp3를 선호합니다. 어헣-_-. 아무튼, 이 예제를 실행 시켜보면요, 간혹 15메가 넘는 파일이 몇개는 있기 마련이기 때문에, 실행 중에 에러가 나게 되어 있습니다. 한번 디버깅을 해보죠.


<그림1> 병렬 스택 창

<그림1>의 병렬 스택을 보시면요, 병렬 ForEach문에 의해서 4개의 작업자 스레드가 생성된 걸 확인할 수 있습니다.(리스트의 크기나, CPU자원 상태등에 따라서 개수는 계속해서 변합니다.)

<그림2> 병렬 작업 창

그리고 <그림2>의 병렬 작업창을 보면, 2번 스레드가 15메가 넘는 파일을 만난 것을 확인할 수 있습니다. 그러면, 2번 스레드가 예외를 던질텐데, 나머지 작업은 어떻게 될까요? 처리 되지 않은 예외가 발생하는 순간, 다른 작업들은 모두 날아가버립니다.

그런데, 각 스레드 별로 파일 리스트를 쪼개서 줬을 텐데요. 각 스레드가 각자의 목록을 가지고 작업을 하다보면, 각 스레드 별로 15메가가 넘는 파일을 발견하게 될 것입니다. 이런 예외들을 만날 때 마다 처리하지 말고, 모두 모아서 한번에 처리하려면 어떻게 해야 할까요? 그래서 바로 AggregateException을 사용하는 거죠.

using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Threading.Tasks;
using System.Collections.Concurrent;

namespace Exam8
{
    class Program
    {
        static void Main(string[] args)
        {
            IEnumerable<string> files =
                Directory.GetFiles("C:\\음악", "*", SearchOption.AllDirectories);
            List<string> fileList = new List<string>();

            Console.WriteLine("파일 개수 : {0}", files.Count());

            var exceptions = new ConcurrentQueue<Exception>();

            try
            {
                Parallel.ForEach(files, (file) =>
                {
                    FileInfo fileInfo = new FileInfo(file);
                    if (fileInfo.Exists)
                    {
                        try
                        {
                            if (fileInfo.Length >= 15000000)
                            {
                                throw new ApplicationException("15메가 넘는 파일이!!");
                            }
                            else if (fileInfo.Length >= 1000000)
                            {
                                fileList.Add(fileInfo.Name);
                            }
                            Console.Write("{0}", Task.CurrentId.ToString());
                        }
                        catch (Exception ex)
                        {
                            exceptions.Enqueue(ex);
                        }
                    }
                });

                throw new AggregateException(exceptions);
            }
            catch (AggregateException ex)
            {
                foreach (var item in ex.InnerExceptions)
                {
                    Console.WriteLine("\n에러 : {0}", item.Message);
                }

                Console.Write("\n파일 리스트 계속해서 보기(엔터키를 치세요)");
                Console.ReadLine();
            }
            finally
            {
                foreach (string file in fileList)
                {
                    Console.WriteLine(file);
                }

                Console.WriteLine("총 파일 개수 : {0}", fileList.Count());
            }
        }
    }
}

<코드4> AggregateException을 사용.

ConcurrentQueue는 Queue긴 하지만, 여러 스레드에 의해서 동시에 큐에 추가를 하거나 해도 안전하도록 만들어진(thread-safe) Queue입니다. 그 큐에서 예외가 발생할 때마다, 예외를 저장해뒀다가, 한꺼번에 AggregateException으로 던지는 것이죠. 그리고 바깥쪽의 catch블록에서 예외를 받아서 내부의 예외 목록을 하나씩 처리하는 것입니다.

(생략)
에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

에러 : 15메가 넘는 파일이!!

파일 리스트 계속해서 보기(엔터키를 치세요)

<결과 3> 처리과정에서 생긴 예외를 모두 모아서 처리.


- 마무리.

오늘 예외처리에 대해서 봤습니다. 다음은~? 작업을 연쇄적으로 처리할 수 있도록 하는 부분을 보겠습니돠. 그럼 평안하시길. 어허허허허헣.


- 참고자료

1. Essential C# 4.0, Mark Michaelis, Addison Wesley
2. http://msdn.microsoft.com/en-us/library/dd460695.aspx
3. http://msdn.microsoft.com/en-us/magazine/ee321571.aspx
4. http://msdn.microsoft.com/en-us/library/system.aggregateexception.aspx

저작자 표시 비영리 변경 금지
신고

- 잡설없이 바로 고고고!

지난 포스트의 마지막 예제에서 Task 클래스의 IsCompleted라는 속성을 사용했었습니다. 이름 그대로, 해당 작업이 끝났는지를 확인할 수 있는 속성인거죠. 이런 속성말고는 또 뭐가 있을까요? 몇 가지 쓸모 있는 속성을 들을 예제를 통해서 확인해보도록 하죠.

using System;
using System.Threading.Tasks;

namespace Exam4
{
    class Program
    {
        static string Calc(object from)
        {
            long sum = 0;
            long start = (long)from;
            Console.WriteLine("현재 이 메서드를 실행중인 스레드 ID : {0}",
                Task.CurrentId);

            for (long i = start; i < 100000000; i++)
            {
                sum += i;
            }
            return sum.ToString();
        }

        static void Main(string[] args)
        {
            Task<string> task = new Task<string>(Calc, 1L);

            task.Start();

            Console.WriteLine("추가 스레드의 ID {0}",
                task.Id);

            Console.WriteLine("추가 스레드에 제공된 상태 값 : {0}",
                task.AsyncState.ToString());

            while (!task.IsCompleted)
            {
                if (task.Status == TaskStatus.Running)
                {
                    Console.Write(".");
                }
            }

            Console.WriteLine("");
            //여기서 추가 스레드가 끝날 때 까지 기다린다.
            Console.WriteLine(task.Result);
            System.Diagnostics.Trace.Assert(
                task.IsCompleted);
        }
    }
}

<코드1> 유용한 속성을 사용한 예제

<코드1>은 기본적으로 유용하게 사용되는 속성들을 활용한 간단한 예제입니다. 우선 결과를 볼까요?

추가 스레드의 ID 1
현재 이 메서드를 실행중인 스레드 ID : 1
추가 스레드에 제공된 상태 값 : 1
................................................................................................................................................................
................................................................................................................................................................
................................................................................................................................................................
................................................................................................................................................................
................................................................................................................................................................
................................................................................................................................................................
...............................................................................................................................................................
................................................................................................................................................................
-중략-
................................................................................................................................................................
......................................................................................
4999999950000000
계속하려면 아무 키나 누르십시오 . . .
<결과1> 실행 결과

그럼 속성을 하나씩 확인 해보면서 왜 저런 결과가 나왔는지 확인해보죠.

 속성명  설명 
 Task.CurrentId  현재 Task.CurrentId 호출을 처리하는 스레드의 Id
 AsyncState  스레드에 추척가능한 값을 부여한다. 예를 들어서 스레드가 3개가 있고, 각각 1,2,3 이라는 값을 부여했다면, 각각의 스레드의 AsyncState는 1,2,3 이므로 이 값을 다른 용도로 사용할 수도 있고, 스레드를 구별하는데 사용할 수도 있다. 
 Id  각각의 작업에 부여되는 고유한 Id
 Status  현재 작업의 상태를 표시한다. Created, WatingForActivation, WaitingForRun, Running, WaitingForChildrenToComplete, RanToComplete, Canceled, Faulted등이 있다. 
<표1> 유용한 속성들

네 각각의 속성을 한번 정리해봤습니다. 어헣. 추가 스레드의 아이디는 1이었죠. 그리고, Calc메서드를 실행하는 스레드도 역시 아이디가 1인 추가 스레드이기 때문에, Calc메서드 안에서 Task.CurrentId로 현재 실행중인 스레드의 아이디를 출력하면 1이 출력되는 것이구요. 스레드를 생성하면서, 상태값으로 1을 넘겨줍니다. 그래서, 그 상태값을 Calc메서드 내부에서 받아서 사용하기도 하고, 추후에 스레드의 상태값을 출력해볼 수도 있는 것이죠.


- 스레드 자세히 들여다 보기

그럼 비주얼 스튜디오에 새로 추가된 툴을 이용해서, 어떤 스레드가 생성되는지 한번 확인해보도록 하겠습니다. 아마, 뒤에서 더 자세하게 설명드리겠지만, 오늘은 그냥 간단하게 살펴보는 정도로 하도록 하죠. <코드1>에 아래와 같이 코드를 추가합니다.

Debugger.Break();

Console.WriteLine("추가 스레드의 ID {0}",
    task.Id);

Console.WriteLine("추가 스레드에 제공된 상태 값 : {0}",
    task.AsyncState.ToString());
           
while (!task.IsCompleted)
{
    if (task.Status == TaskStatus.Running)
    {
        Debugger.Break();
        Console.Write(".");
    }
}

<코드2> 수정한 코드

Debugger클래스는 System.Diagnostics에 정의되어 있는데요, 브레이크 포인트를 걸지 않아도, Break메서드가 호출된 곳에서 실행을 멈추고 디버거를 사용할 수 있습니다. 다만, 그냥 Debug모드로 놓고 컴파일하지 않고 실행(Ctrl + F5)을 하면, 계속 에러가 나니 주의하시구요.

우선 F5로 실행을 하신다음에, 첫번째 브레이크 포인트에 걸리면, '디버그'메뉴에서 '창'메뉴로 들어가서 '스레드', '병렬스택', '병렬작업'을 띄웁니다. 그리고 '스레드'를 보면,

<그림1> 스레드 창

다음과 같이 현재 떠있는 스레드의 목록을 볼 수 있습니다. 저 중에, '.NET SystemEvents'라고 된 스레드는 이벤트의 발생을 주시하고 있는 스레드이구요, 'vshost.RunParkingWindow'라고 된 스레드는 비주얼 스튜디오 호스팅 프로세스입니다. 그리고 '주 스레드'는 현재 Main메서드를 실행 중인 스레드를 말하구요. 그리고 ID가 4112라고 된 스레드가 추가로 생성한 스레드입니다. 어째서 그런지 확인을 해볼까요? 병렬 스택 창을 한번 확인해보죠.


<그림2> 병렬 스택 창

위 그림은 병렬 스택 창에서 스레드 그룹의 제목('4개 스레드'라고 쓰인 부분)에 마우스를 올리면 어떤 스레드가 있는지 보여주는 장면입니다. 총 3개의 스레드가 있는데, 앞에서 설명드린 3개의 스레드외에 작업자 스레드가 하나 있는 걸 보실 수 있습니다. 바로 저 스레드가 우리가 추가로 생성한 스레드인거죠. 그런데, 호출 스택에 Main메서드를 실행하는 주 스레드만 한 단계 진행한 걸 볼 수 있는데요, 그렇다면 아직 추가 스레드에 작업이 물린 상태는 아닌 것 같습니다. 그러면, F5키를 눌러서 다음으로 넘어가 볼까요? 그리고 병렬 스택을 보면,


<그림 3> 바뀐 병렬 스택 창

이미 추가 스레드도 작업에 들어간 상태이다 보니, 추가 스레드가 한 단계 더 진행해서, Calc메서드를 실행하고 있다고 나옵니다. 그리고 병렬 작업 창을 한번 볼까요?

<그림 4> 병렬 작업 창

상태는 실행 중(== TaskStatus.Running)이고, 할당된 스레드는 4112번 작업자 스레드라는 걸 확인할 수 있습니다. 이런 디버깅 툴을 잘 활용하면, 복잡한 병렬 프로그래밍을 하는 데 좀 더 편안하게 작업할 수 있겠죠? 이 디버깅 툴에 대해서는 나중에 좀 더 자세하게 설명드리도록 하지용.


- 마무리

앤더스 헬스버그는 C#에 영향을 미치는 3대 트렌드가 선언적, 동시적, 동적 프로그래밍 이라고 했는데요, 선언적 프로그래밍은 이미 LINQ를 통해서 편하게 지원되고, PLINQ로 확장이 되었죠. 그리고 동적 프로그래밍은 dynamic타입과 DLR을 통해서 지원이 되구요. 그리고 동시적 프로그래밍은 TPL과 이런 다양한 툴을 통해서 좀 더 제대로된 지원을 하고 있습니다. 제대로만 쓴다면, 비주얼 스튜디오 2010에서 쫌 편하게 작업할 수 있겠네요. 늘 문제가 되는건 제대로 쓸 줄도 모르면서 불평만 하는 저 같은 양민이져 어헣-_-.


- 참고자료

1. Essential C# 4.0, Mark Michaelis, Addison Wesley
2. http://msdn.microsoft.com/en-us/library/dd554943.aspx
3. http://msdn.microsoft.com/en-us/library/microsoft.win32.systemevents.aspx
4. http://social.msdn.microsoft.com/Forums/en-US/wpf/thread/f8ccec3a-25db-4d3b-a90a-e758f6243356/

저작자 표시 비영리 변경 금지
신고
- 작업해본 적이나 있수?

물론이죠-_-;; 이 나이에 작업해 본 적도 없으면, 마법사 정도가 아니라, 신이 됐겠죠. 어헣. 오늘의 작업은 그 작업은 아니고... 스레드와 관련된 작업입니다. 부디 오해 없으시길 바라고, 작업의 기본은 다른 연예 서적에서 얻으시길.


- Task 시작하기.

지난 포스트에서 했던 예제를 한번 돌아보겠습니다.

using System;
using System.Threading.Tasks;

namespace Exam2
{
    class Program
    {
        static void Main(string[] args)
        {
            const int max = 10000;

            //현재 작업중인 스레드외에 추가로 스레드를 생성
            Task task = new Task(() =>
                {
                    for (int count = 0; count < max; count++)
                    {
                        Console.Write("|");
                    }
                });

            //추가 스레드 시작
            task.Start();

            //현재 작업중인 스레드에서도 반복문 시작
            for (int count = 0; count < max; count++)
            {
                Console.Write("-");
            }

            //혹시 현재 스레드가 빨리 끝나더라도,
            //추가 스레드가 끝날 때 까지 기다리기.           
            task.Wait();
        }
    }
}

<코드1>

Main메서드를 실행하는 스레드 외에 또 하나의 스레드를 추가로 생성해서, 두 개의 스레드로 화면에 다른 문자열을 출력하는 예제였죠. 이 예제를 보면, Task라는 클래스를 사용하고 있습니다. 이 클래스는 닷넷 프레임워크 4.0에 새롭게 추가된 클래스인데요. 기존의 멀티스레드 프로그래밍을 한 단계 높은 추상화를 통해서 프로그래머의 실수를 줄이고, 좀 더 직관적인 코드를 작성할 수 있게 해주는 TPL(Task Parallel Library)에 포함되어서 추가된 클래스입니다. 중심에 있는 클래스라고 볼 수 있죠.

Task클래스는 관리되지 않는 스레드를 한단계 감싸서 추상화를 시킨 클래스입니다. 내부적으로 스레드 풀을 사용하는 데요, 내부적으로는 System.Threading.ThreadPool을 사용해서 요청에 따라 스레드를 새로 생성하거나, 이미 생성된 스레드를 재활용해서 부하를 줄입니다.

새로운 Task가 실행할 동작은 델리게이트를 통해서 명시해주는데요, <코드1>에서 굵게 처리된 부분이 바로 그 부분입니다. 카운트에 따라서 문자열을 출력하는 델리게이트를 생성자에 넘겨주고 있는 거죠. 물론, 이렇게 델리게이트를 넘겨준다고 해서 바로 스레드가 실행되는 건 아닙니다. Start메서드를 통해서 실행을 해줘야만 스레드가 실행되는 것이죠.


- Task가 끝날 때?

그러면, <코드1>은 두개의 스레드가 실행이 되면서 서로 다른 문자열을 번갈아 가면서 출력하겠죠. 여기서 한가지 생각해볼게 있습니다. 콘솔 어플리케이션은 Main메서드의 실행으로 시작하고, Main메서드의 끝과 함께 종료됩니다. 그렇다면, Main메서드의 실행을 맡은 스레드가 종료되었는데, 추가로 생성한 스레드의 작업이 안끝났다면 어떤 일이 벌어질까요?

//현재 작업중인 스레드외에 추가로 스레드를 생성
Task task = new Task(() =>
    {
        for (int count = 0; count < max; count++)
        {
            Console.Write("|");
        }
        Console.WriteLine("추가 스레드 끝");
    });

//추가 스레드 시작
task.Start();

//현재 작업중인 스레드에서도 반복문 시작
for (int count = 0; count < max; count++)
{
    Console.Write("-");               
}
Console.WriteLine("메인 스레드 끝");

//혹시 현재 스레드가 빨리 끝나더라도,
//추가 스레드가 끝날 때 까지 기다리기.           
//task.Wait();

<코드2>

<코드1>을 <코드2>와 같이 수정한 다음에 실행해보죠. 그러면, 둘 중의 어떤 스레드가 빨리 끝날까요? 그건 그때 그때 다릅니다-_- 그래서 아래와 같은 두 경우가 생길 수 있죠.

||||-----||||||||--------------|||||||||||||-------------||||||||||||-----------||||||---------------||||||||||||------------|||||||||||-----------||||||||||||--|||||||||||||-------|||||||||||-------------||||||||||||--------------||||||||||----------------|||---------------||||||||||||||-----||-------------||||||||||||----------------|||||||||||||-------------||||||||||||-------------|||---|------||||||||||||||-----------------|---------------||||||||||||||---------------메인 스레드 끝
|계속하려면 아무 키나 누르십시오 . . .
<결과1> 메인스레드가 먼저 끝나는 경우

|----------------|||||||||||||-----------||||||||||||||---------||||||||||||-----||||||||||||||||---------------||||||||||||||--------------||-----------||||||||------------|||||||||||||||-------------|--------------|||||||-------------|||||---||추가 스레드 끝----------------------------------------------------------------------------------------------------------------------------------------------------------메인스레드 끝
계속하려면 아무 키나 누르십시오 . . .
<결과2> 추가 스레드가 먼저 끝나는 경우

<결과2>는 추가 스레드가 먼저 끝나면서 모든 결과가 출력이 되었지만, <결과1>은 Main메서드의 실행을 맡은 메인 스레드가 먼저 끝나면서 프로그램이 종료되었고, 따라서 추가스레드의 나머지 결과는 날아가 버렸습니다. Wait메서드는 메인 스레드가 먼저 끝나더라도, 추가 스레드가 끝날 때까지 기다리게 하는 역할을 합니다. 그래서 메인 스레드가 빨리 끝나더라도, 항상 추가 스레드의 결과까지 제대로 출력되게 되는 것이죠.

||||||||--------|||||||||||||||--------------|||||||||||||------------|||||||||||||--------||||
||-메인 스레드 끝||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||추가 스레드 끝
계속하려면 아무 키나 누르십시오 . . .
<결과3> Wait메서드를 사용한 경우


- 가는 길은 여러갈래.

앞에서 스레드의 시작은 Start메서드를 통한다고 말씀 드렸지만, 항상 그런 것은 아닙니다. 생성과 동시에 실행을 시킬 수도 있습니다.

Task task = Task.Factory.StartNew(() =>
                {
                    for (int count = 0; count < max; count++)
                    {
                        Console.Write("|");
                    }
                    Console.WriteLine("추가 스레드 끝");
                });
<코드3> 생성과 동시에 스레드 시작

<코드1>의 Task생성 부분을 <코드3>과 같이 수정하고, Start메서드 호출부분을 주석처리하면, 동일한 결과를 얻을 수 있습니다.

그리고 <코드1>에서는 메인스레드가 추가 스레드가 끝날 때까지 기다리기 위해서 Wait메서드를 사용했지만, 다른 방법도 있습니다. 만약에 추가 스레드에 입력된 델리게이트가 결과값을 반환하고, 메인 스레드에서 그 결과값을 사용해야 한다면, 메인 스레드는 추가 스레드의 작업이 끝나서 결과가 나올 때까지 기다립니다. Wait메서드 없이도 말이죠. 어찌보면 당연한 이야기죠 ㅋ

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Exam3
{
    class Program
    {
        static void Main(string[] args)
        {
            Task<string> task = Task.Factory.StartNew<string>(
                () =>
                {
                    long sum = 0;
                    for (long i = 0; i < 100000000; i++)
                    {
                        sum += i;
                    }
                    return sum.ToString();
                });

            foreach (char busySymbol in BusySymbols())
            {
                if (task.IsCompleted)
                {
                    Console.WriteLine('\b');
                    break;
                }
                Console.WriteLine(busySymbol);
            }

            Console.WriteLine();
            //여기서 추가 스레드가 끝날 때 까지 기다린다.
            Console.WriteLine(task.Result);
            System.Diagnostics.Trace.Assert(
                task.IsCompleted);
        }

        private static IEnumerable<char> BusySymbols()
        {
            string busySymbols = @"-\|/-\|/";
            int next = 0;
            while (true)
            {
                yield return busySymbols[next];
                next = (++next) % busySymbols.Length;
                yield return '\b';
            }
        }
    }
}

<코드 4> 결과 기다리기.

<코드4>는 추가 스레드의 결과를 계속 기다리다가, 결과가 나오는 순간, 출력하고 끝납니다.


- 멀티스레드 쉽고만?

이라고 생각하시면 곤란하구요-_-;; 열심히 공부 중인데, 역시 어렵습니다. 다만, Task클래스가 상당히 많은 부분을 간소화 시켜 주기 때문에, 한 층 더 편해진 느낌이랄까요? 오늘 여기까지!


- 참고자료

1. Essential C# 4.0, Mark Michaelis, Addison Wesley
저작자 표시 비영리 변경 금지
신고
Task Parallel Library
 
Parallel Extension 은 PLINQ 와 더불어 확장 가능한 Task Parallel Library 를 제공합니다. Task Parallel Library 는 PLINQ 를 이용하지 않고 개별적이고 수동적인 병렬 처리 작업을 위해 사용할 수 있습니다.
 
Task Parallel Library 는 크게 세 가지 방법으로 병렬 처리를 위한 Library 를 제공합니다.
 
Loops
 
 
 

 

[그림1] Parallel.For 를 이용한 병렬 처리
 
 


[그림2] Parallel.Foreach 를 이용한 병렬 처리
 
Task Parallel Extension 으로 병렬 처리를 쉽게 처리할 수 있으며, 병렬 처리로 인자값을 넘기거나 하는 작업을 쉽게 할 수 있습니다.
 
Statements
 

 


[그림3] Parallel.Invoke 를 이용한 병렬 처리
 
 
Task
 
특히 Parallel Extension Library 에서 Task 는 수동적으로 병렬 처리를 하기 위해 다양한 기능을 지원합니다. 정교하게 스레드(Thread) 를 처리했던 것에 비하면 심플하고도 직관적으로 병렬 작업을 처리할 수 있습니다.
 
Task 는 보다 정교하게 병렬 처리 작업을 할 수 있습니다.
l 대기
l 취소
l 연장
l 상하(부모/자식) 간의 관계
l 디버그 지원
 
아래는 ThreadPool.QueueUserWorkItem 처럼 바로 작업을 시작하도록 합니다.
 

Task.StartNew(…);

 
아래는 Task 에 대해 대기 및 취소 작업을 진행하도록 합니다.
 

Task t1 = Task.StartNew(…);
t1.Wait();
t1.Cancel();
Task t2 = t1.ContinueWith(…);

 
아래는 작업에 대해 지속적인 병렬 처리를 가능하도록 합니다.
 

var p = Task.StartNew(() => {
    var c = Task.StartNew(…);
}

 
아래는 특정 작업의 결과를 받아 올 수 있습니다.
 

var p =
 Future.StartNew(() => C());
int result = p.Value;
 

 
 
Coordination Data Structures
 
병렬 처리 작업은 PLINQ 와 TPL(Task Parallel Library) 를 지원하기 위해 기존의 데이터 컬렉션 등이 등장하였습니다. 내부적으로 동기화를 지원하지 않았던 문제들을 지원하게 되었고, 특히 오늘날 멀티 코어(Multi Core) 프로세스를 위해 많은 동기적인 문제를 고민해야 했습니다. .NET Framework 4.0 은 이러한 공통적인 문제들을 해결하기 할 수 있습니다.
 
l Thread-safe collections
       ConcurrentStack<T>
       ConcurrentQueue<T>
       ConcurrentDictionary<TKey,TValue>
      
l Work exchange
       BlockingCollection<T>
       IProducerConsumerCollection<T>
l Phased Operation
       CountdownEvent
       Barrier
l Locks
       ManualResetEventSlim
       SemaphoreSlim
       SpinLock
       SpinWait
l Initialization
       LazyInit<T>
       WriteOnce<T>
저작자 표시 비영리 동일 조건 변경 허락
신고