Notice
Recent Posts
Recent Comments
Link
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
05-16 04:51
관리 메뉴

nomad-programmer

[Programming/C#] Task와 Task<TResult> 본문

Programming/C#

[Programming/C#] Task와 Task<TResult>

scii 2020. 9. 29. 21:59
병렬 처리와 비동기 처리의 차이

병렬 처리와 비동기 처리는 비슷한 용어 같지만 뜻이 엄밀히 다르다.

병렬 처리 : 하나의 작업을 여러 작업자가 나눠서 수행한 뒤 다시 하나의 결과로 만드는 것을 병렬 처리라한다.

비동기 처리 : 작업 A를 시작한 후 A의 결과가 나올 때까지 마냥 대기하는 대신 곧이어 다른 작업 B, C, D, ...를 수행하다가 작업 A가 끝나면 그 때 결과를 받아내는 처리를 말한다.

마이크로소프트는 더 쉽게 비동기 코드를 작성할 수 있도록 하는 도구와 장치를 준비했다. System.Threading.Tasks 네임스페이스의 클래스들과 async 한정자와 await 연산자들이다.

.NET 프레임워크 4.0부터 도입된 System.Threading.Tasks 네임스페이스에는 병행성 코드나 비동기 코드를 개발자들이 손쉽게 작성할 수 있도록 돕는 여러 가지 클래스가 있다. 

Tasks 와 멀티 스레드

System.Threading.Tasks 네임스페이스의 클래스들은 하나의 작업을 쪼갠 뒤 쪼개진 작업을 동시에 처리하는 코드와 비동기 코드를 위해 설계되었다.

반면에 Thread 클래스는 여러 개의 작업을 나누지 않고 각각 처리해야 하는 코드에 적합하다.

Thread 클래스로도 하나의 작업을 쪼개 처리하는 코드와 비동기 코드를 작성할 수는 있지만(Task 등의 클래스들도 내부적으로는 Thread를 이용하여 구현되었다), System.Threading.Tasks의 클래스들을 이용하는 것보다는 어렵다.

System.Threading.Task 클래스

Task 클래스는 비동기 코드를 손쉽게 작성할 수 있도록 도와준다. 그런데 비동기(Asynchronous)코드란 무엇일까? 비동기 코드를 설명하기 전에 먼저 동기(Synchronous)코드를 대해 알아보자.

동기 코드는 메소드를 호출한 뒤에 이 메소드의 실행이 완전히 종료되어야만(즉, 반환되어야만) 다음 메소드를 호출할 수 있다. 가령 다음의 코드에서 Slash() 메소드가 동기로 동작하고 실행 시간이 1초라고 한다면 "다음 코드"로 넘어가는 데 모두 3초의 시간이 소요된다.

Swordsman obj = new Swordsman();
obj.Slash();
obj.Slash();
obj.Slash();

// 다음 코드

반면에 비동기 코드는 궁수(Archer)가 활을 쏠 때처럼 동작한다. 궁수는 화살을 쏘고 나면 바로 다음 화살을 쏠 준비를 할 수 있다. 이미 쏜 화살에 대해서는 잊어버리는 것이다. 이것을 영어로 하면 "Shoot(또는 Fire) And Forget"이라고 하는데, 비동기 코드의 동작을 묘사할 때 자주 쓰이는 표현이기도 하다. 비동기 코드도 궁수가 화살을 쏘는 것처럼 메소드를 호출한 뒤에 메소드의 종료를 기다리지 않고 바로 다음 코드를 실행한다. 다음의 코드에서 Shoo() 메소드가 비동기로 동작한다면 해당 메소드가 언제 종료되든 관계 없이 CLR은 단숨에 "다음 코드"까지 실행해 나간다.

Archer obj = new Archer();
obj.Shoot();
obj.Shoot();
obj.Shoot();

// 다음 코드

위와 같이 비동기로 동작하는 메소드는 async 한정자와 await 연산자를 이용해 구현할 수 있다.

Task 클래스는 인스턴스를 생성할 때 Action 대리자를 넘겨받는다. 다시 말해 반환형을 갖지 않는 메소드와 익명 메소드, 무명 함수 등을 넘겨받는다는 것이다. 다음 코드는 Task의 인스턴스를 생성할 때 매개 변수로 넘겨받은 Action 대리자를 실행하는 예이다.

// Action 대리자 기반의 무명 함수 선언
Action someAction = () =>
{
    Thread.Sleep(1000);
    Console.WriteLine("Printed asynchronously.");
};

Task myTask = new Task(someAction);
// 생성자에서 넘겨받은 무명 함수를 비동기로 호출한다.
myTask.Start();

Console.WriteLine("Printed synchronously.");
// myTask 비동기 호출이 완료될때까지 기다린다.
myTask.Wait();


/* 결과

Printed synchronously.
Printed asynchronously.

*/

Task의 생성자는 여러 버전으로 오버로딩되어 있으나 앞의 코드에서 사용한 버전은 Action 대리자와 Action 대리자를 매개 변수로 받는다.

이렇게 생성한 Task의 인스턴스는 그 다음 줄에서 보듯 Start() 메소드를 호출하여 생성자에서 넘겨받은 Action 대리자를 비동기로 실행시킨다. 
선언한 Action 대리자는 Thread.Sleep(1000) 코드 때문에 실행을 완료할 때까지 최소 1초는 소요한다. 그 전에 프로그램은 "Printed synchronously"를 화면에 출력하고 myTask.Wait() 메소드 호출부에 가서 myTask가 실행 중인 비동기 코드가 완료될 때까지 대기한다. 이렇게 대기하던 프로그램은 비동기로 실행하고 있던 매개 변수로 전달된 someAction 대기자가 "Printed asynchronously"를 출력하고 나면 종료한다.

위의 예제는 Task 클래스의 생성자와 Task.Start() 메소드를 따로 호출하는 예제 코드였지만, Task 클래스를 사용하는 조금 더 일반적인 방법은 다음과 같이 Task.Run() 메소드를 이용하는 것이다.

var myTask = Task.Run( () =>
    {
        // Task의 생성과 시작을 단번에 한다.
        // Task가 실행할 Action 대리자도 무명 함수로 대신 사용할 수 있다.
        Thread.Sleep(1000);
        Console.WriteLine("Printed asynchronously.");
    }
}

Console.WriteLine("Printed synchronously.");

myTask.Wait();


/* 결과

Printed synchronously.
Printed asynchronously.

*/

Task의 생성자와 Start() 메소드를 따로 호출하든 Task.Run() 메소드를 이용해서 단번에 처리하든 편한것으로 사용하면 된다.

세 개의 Task를 이용하여 세 개의 파일을 복사하는 예제

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

namespace CSharpExample
{
    internal class MainApp
    {
        static int Main(string[] args)
        {
            string srcFile = args[0];

            Action<object> FileCopyAction = (object state) =>
            {
                string[] paths = (string[])state;
                if (File.Exists(paths[1]))
                {
                    File.Delete(paths[1]);
                }
                File.Copy(paths[0], paths[1]);

                Console.WriteLine("TaskID: {0}, ThreadID: {1}, {2} was copied to {3}",
                    Task.CurrentId, Thread.CurrentThread.ManagedThreadId, paths[0], paths[1]);
            };

            // 두 번째 매개 변수는 FileCopyAction의 매개 변수로 사용된다.
            Task t1 = new Task(FileCopyAction, new string[] { srcFile, srcFile + ".cpy1" });

            Task t2 = Task.Run(() =>
            {
                FileCopyAction(new string[] { srcFile, srcFile + ".cpy2" });
            });

            t1.Start();

            Task t3 = new Task(FileCopyAction, new string[] { srcFile, srcFile + ".cpy3" });

            // Task는 코드의 비동기 실행을 위한 Start() 메소드뿐 아니라 
            // 동기실행을 위한 Runsynchronously() 메소드도 제공한다.
            // 이 메소드는 실행이 끝나야 반환되지만, 좋은 습관을 위하여 Wait()는 호출해주는 것이 좋다.
            t3.RunSynchronously();

            t1.Wait();
            t2.Wait();
            t3.Wait();

            return 0;
        }
    }
}


/* 결과

$ ./UsingTask.exe ./System.ValueTuple.dll
TaskID: 1, ThreadID: 4, ./System.ValueTuple.dll was copied to ./System.ValueTuple.dll.cpy1
TaskID: 2, ThreadID: 3, ./System.ValueTuple.dll was copied to ./System.ValueTuple.dll.cpy2
TaskID: 3, ThreadID: 1, ./System.ValueTuple.dll was copied to ./System.ValueTuple.dll.cpy3

*/

두 개의 Task는 비동기로 파일을 복사하고, 나머지 한 개의 Task는 동기로 파일 복사를 수행한다.

Task는 코드의 비동기 실행을 위한 Start() 메소드뿐 아니라 동기 실행을 위한 Runsynchronously() 메소드도 제공한다.
이 메소드는 실행이 끝나야 반환되지만, 좋은 습관을 위하여 Wait() 메소드를 호출해주는 것이 좋다.


코드의 비동기 실행 결과를 주는 Task<TResult> 클래스

예를 들어 15개의 비동기 작업을 한 후 그 결과를 취합해야 한다고 생각해보자. 결과를 취합하지 않아도 된다면야 Task가 아닌 Thread 클래스만으로도 얼마든지 그 일을 해내도록 코드를 작성할 수 있지만 결과를 취합해야 한다면 일이 굉장히 복잡해진다. Task<TResult>는 코드의 비동기 실행 결과를 손쉽게 취합할 수 있도록 도와준다.

다음은 Task<TResult> 클래스를 사용하는 예제이다. 인스턴스를 생성하고 실행하는 과정 자체는 Task 클래스의 사용법과 별로 다르지 않다. 다만 Task가 비동기로 수행할 코드를 Action 대리자로 받는 대신 Func 대리자로 받는다는 점과 결과를 반환받을 수 있다는 것에 주목해야 한다.

var myTask = Task<List<int>>.Run( () =>
    {
        Thread.Sleep(1000);
        
        List<int> list = new List<int>();
        list.Add(3);
        list.Add(4);
        list.Add(5);
        
        // Task<TResult>는 TResult 형식의 결과를 반환한다.
        return list;
    }
};

var myList = new List<int>();
myList.Add(0);
myList.Add(1);
myList.Add(2);

myTask.Wait();

// myList의 요소는 0, 1, 2, 3, 4, 5가 된다.
myList.AddRange(myTask.Result.ToArray());

위의 코드에서 myTask.Result 프로퍼티가 비동기 작업이 끝나야 반환하므로 myTask.Wait() 메소드는 호출하지 않아도 된다. 하지만 Task클래스를 사용할 때 좋은 습관을 위하여 항상 Wait() 메소드를 호출하도록하자.

Task<TResult> 클래스를 활용한 예제

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

namespace CSharpExample
{
    internal class MainApp
    {
        private static bool IsPrime(long number)
        {
            if (number < 2)
            {
                return false;
            }
            else if (number % 2 == 0 && number != 2)
            {
                return false;
            }

            for (long i = 2; i < number; i++)
            {
                if (number % i == 0)
                {
                    return false;
                }
            }
            return true;
        }

        static int Main(string[] args)
        {
            long fromNum = 0;
            long toNum = 100000;
            int chunkNum = 5;

            Func<object, List<long>> GetPrimeNum = (ranges) =>
            {
                long[] arr = (long[])ranges;
                List<long> found = new List<long>();
                for (long i = arr[0]; i < arr[1]; i++)
                {
                    if (IsPrime(i))
                    {
                        found.Add(i);
                    }
                }
                return found;
            };

            Task<List<long>>[] tasks = new Task<List<long>>[chunkNum];

            long from = fromNum;
            long to = toNum / tasks.Length;

            for (int i = 0; i < tasks.Length; i++)
            {
                Console.WriteLine("Task[{0}]: {1} ~ {2}", i, from, to);

                tasks[i] = new Task<List<long>>(GetPrimeNum, new long[] { from, to });

                from = to + 1;

                if (i == tasks.Length - 2)
                {
                    to = toNum;
                }
                else
                {
                    to = to + (toNum / tasks.Length);
                }
            }

            DateTime startTime = DateTime.Now;

            foreach (Task<List<long>> task in tasks)
            {
                task.Start();
            }

            List<long> total = new List<long>();

            foreach (Task<List<long>> task in tasks)
            {
                task.Wait();
                total.AddRange(task.Result.ToArray());
            }

            DateTime endTime = DateTime.Now;

            TimeSpan ellapsed = endTime - startTime;

            Console.WriteLine("prime count: {0}", total.Count);
            Console.WriteLine("ellased time: {0}", ellapsed);

            return 0;
        }
    }
}


/* 결과

Task[0]: 0 ~ 20000
Task[1]: 20001 ~ 40000
Task[2]: 40001 ~ 60000
Task[3]: 60001 ~ 80000
Task[4]: 80001 ~ 100000
prime count: 9592
ellased time: 00:00:01.4994680

*/

위의 예제 프로그램은 프로그램 매개 변수로 입력받은 두 수 사이에 존재하는 모든 소수의 목록을 반환한다. 소수 찾기는 문제 특성상 각 수를 일일이 검사해야 한다. 만약 하나의 Task만으로 n개의 수에 대해 소수 찾기를 한다면 "소수 판정 시간 X n" 만큼의 시간이 소요될 것이다. 하지만 m개의 CPU가 있는 시스템에서 m개 만큼의 Task가 n개의 수를 m으로 나눈 범위만큼 각각 소수 찾기를 한다면 작업 시간이 훨씬 줄어든다.

Comments