Game Development, 게임개발/개발

Task, Task<TResult> 클래스 , 비동기 프로그래밍 [Unity]

게임이 더 좋아 2022. 2. 14. 22:58
반응형
728x170

일반적으로 

System.Threading.Tasks 네임스페이스 안에

2가지 클래스가 대표적으로 들어있는데

 

1. Task -> 비동기 작업을 나타냄

2. Task<TResult> -> 값을 반환할 수 있는 비동기 작업

 

 

위 2개의 클래스들의 역할은

간단하게 말하자면 쓰레드풀로부터 쓰레드를 가져와 비동기 작업을 실행한다.

 

** Task 관련 클래스들과 Parallel 클래스들을 합쳐 Task Parallel Library(TPL)이라 부르는데 기본적으로 다중 CPU 병렬 처리를 염두해서 만들었다.

 

???뭔지 모르겠다고??

알아보자

 


 

먼저 1번 Task부터 살펴보자

 

Task클래스 사용을 위해 흔히 사용되는 방법은 두 가지가 있다.

 

첫번째로

Task.Factory.StartNew()를 사용하여 실행하고자 하는 "메서드에 대한 델리게이트"를 지정하는 것이다.

** StartNew()는 쓰레드를 생성과 동시에 실행하는 방식이고

 

두 번째로

시작을 하지 않고 Task 객체를 만들기 위해서는 "Task() 생성자를 사용하여 메서드 델리게이트"를 지정한다.

 

namespace MultiThrdApp
{

	//출력을 위해, Task클래스를 사용하기 위해 namespace using
    using System;    
    using System.Threading.Tasks;    

    class Program
    {
        static void Main(string[] args)
        {
            // Task.Factory를 이용하여 쓰레드 생성과 시작
            Task.Factory.StartNew(new Action<object>(Run), null);
            Task.Factory.StartNew(new Action<object>(Run), "1st");
            Task.Factory.StartNew(Run, "2nd");
            
           
            

            Console.Read();
        }

        static void Run(object data)
        {            
            Console.WriteLine(data == null ? "NULL" : data);
        }
    }
}

 

StartNew는 수많은 오버로딩이 되어있다.

알아서 살펴보자.

** https://docs.microsoft.com/ko-kr/dotnet/api/system.threading.tasks.taskfactory.startnew?view=net-6.0 

 

실행 결과 (2번의 시도)

 

 

??? 결과가 달라졌네???

이게 바로 비동기 프로그래밍의 묘미다.

우리 생각엔 라인 순서대로 실행되어야 했지만

이젠 아니다.

 

 

시작을 하지 않고 Task 객체만을 먼저 만들기 위해서는

Task 클래스 생성자를 사용하여 메서드 델리게이트를 지정, Task 객체만을 생성한다.

**생성된 Task 객체로부터 실제 쓰레드를 시작하기 위해서는 Start() 메서드를 호출하고

**종료때까지 기다리기 위해선 Wait() 메서드를 호출한다. (★★★)

 

예를 보자

namespace MultiThrdApp
{
    using System;    
    using System.Threading.Tasks;    

    class Program
    {
        static void Main(string[] args)
        {
            // Task 생성자에 Run을 지정 Task 객체 생성
            Task t1 = new Task(new Action(Run));

            // 람다식을 이용 Task객체 생성
            Task t2 = new Task(() =>
            {
                Console.WriteLine("Long query");
            });

            // Task 쓰레드 시작
            t1.Start();
            t2.Start();

            // Task가 끝날 때까지 대기
            // 대기 하지 않으면 Main이 먼저 끝나서 결과를 보지 못함.
            t1.Wait();
            t2.Wait();            
        }

        static void Run()
        {            
            Console.WriteLine("Long running method");
        }
    }
}

 

결과 (2번의 시도)

 

어 얘네도 다르게 나오네..?

스레드들이 완료되는 시간이 달라서 그렇다.

 


 

다음은 2번인 Task<TResult>에 대해서 알아보자

우선 제너릭(C++ 템플릿)이 아닌 Task 클래스는

ThreadPool.QueueUserWorkItem()과 같이 리턴값을 쉽게 돌려 받지 못한다.

 

난... 반환받고 싶은걸???

 

비동기 델리게이트(Asynchronous Delegate)와 같이

리턴값을 돌려 받기 위해서는 Task<TResult> 클래스를 사용해야한다.

Task<TResult> 클래스의 TResult는 리턴 타입을 가리키는 것으로

리턴값은 Task객체 생성 후 Result 속성을 참조해서 얻게 된다.

 

** Result 속성을 참조할 때 만약 작업 쓰레드가 계속 실행 중이면, 결과가 나올 때까지 해당 쓰레드를 기다리게 된다.

 

 

예를 들어볼까??

namespace MultiThrdApp
{
    using System;
    using System.Threading;
    using System.Threading.Tasks;

    class Program
    {
        static void Main(string[] args)
        {
            // Task<T>를 이용하여 쓰레드 생성과 시작 -> <int>를 미루어 보아 해당 람다식의 return 값은 int겠구나?
            Task<int> task = Task.Factory.StartNew<int>(() => CalcSize("Hello World"));

            // 메인쓰레드에서 다른 작업 실행 -> Main에서 작업하는 것을 그냥 Sleep으로 둔 것
            Thread.Sleep(1000);

            // 쓰레드 결과 리턴. 쓰레드가 계속 실행중이면
            // 이곳에서 끝날 때까지 대기함
            int result = task.Result; //**Task의 작업이 끝날 때까지 해당 라인을 읽지 않음.

            Console.WriteLine("Result={0}", result);
        }

        static int CalcSize(string data)
        {
            string s = data == null ? "" : data.ToString();
            // 구현~~
            //...
            

            return s.Length;
        }
    }
}

 

당연히 결과는??

11이 나온다.

 

다만 이제 무슨 생각을 해야하느냐?? 

저 라인을 읽을 때까지.. Main이 그냥 쉬면 별로잖아..?

즉, 결괏값은 뒤에서 받아오고 메인에서는 할 거 하면 더 좋잖아??

저기 되어있는 Thread.sleep(1000) 라인에서 무슨 작업을 하면 좋겠다 라고 생각하면 굿

 

**다시 말하면 비동기 작업에 대한 결과를 꼭 반환받아야 하면서 그 비동기 작업이 무거울 경우!!!!

메인 스레드가 그냥 대기하면 너무 아까우니까 메인 스레드에서도 시킬 것은 시키자!!! 라는 말이 되겠다.

 


 

 

만약 비동기 작업 중에 취소하고 싶다면.. 어떡하지..?

** 많지는 않겠지만 해당 비동기 작업에 대해 예외처리를 하는 경우가 있겠다.

 

비동기 작업을 취소하기 위해서는 Cancellation Token을 사용하는데

작업 취소와 관련된 타입은 CancellationTokenSource 클래스와 CancellationToken 구조체이다.



CancellationTokenSource 클래스는

Cancellation Token을 생성하고 Cancel 요청을 Cancellation Token들에게 보내는 일을 담당하고

 

 

CancellationToken은

현재 Cancel 상태를 모니터링하는 여러 Listener들에 의해 사용되는 구조체이다.



작업을 취소하는 일반적인 절차는

  1. 먼저 CancellationTokenSource 필드를 선언하고
  2. CancellationTokenSource 객체를 생성하며
  3. 비동기 작업 메서드 안에서 작업이 취소되었는지를 체크하는 코드를 넣으며
  4. 취소 버튼이 눌러지면 CancellationTokenSource의 Cancel() 메서드를 호출해 작업 취소를 요청한다.
 

 

아래 예제는 시작버튼을 누르면 작업을 비동기 Task를 100초 동안 실행하다가

그 이전에 취소 버튼을 누르면 작업을 중간에 취소하는 코드이다.

 

** 한 번 완료된(혹은 취소된) Task를 다시 restart할 수 없기 때문에

새롭게 Task 객체를 생성해서 Start해야 한다.

 

 
// (1) CancellationTokenSource 필드 선언함
private CancellationTokenSource cancelTokenSource;


//버튼을 클릭하면 Run이 실행되겠음을 알겠다.
private void btnStart_Click(object sender, EventArgs e)
{
    Run();
}

// Run 함수에 async 키워드가 있는 것을 보니.. await가 1개 이상 있겠구나?
private async void Run()
{            

    //(2) CancellationTokenSource 객체 생성 -> 해당 객체로 cancel 신호를 보냄
    cancelTokenSource = new CancellationTokenSource();
    //해당 객체의 토큰을 설정함.
    CancellationToken token = cancelTokenSource.Token;

    // (Optional) 여기서의 StartNew 메서드는 StartNew(Func, CancellationToken)를 호출함.
    // 여기서 Token이 Cancel 상태(토큰이 false)이면
    // LongCalcAsync() 메서드 자체가 실행되지 않는다는 의미
    var task1 = Task.Factory.StartNew<object>(LongCalcAsync, token);             
    
    
    //어디서 많이 봤는데..? task1을 보니까 object를 반환받네?
    //반환받을 때까지 읽지 않겠구나??
    dynamic res = await task1;
    
    //만약 반환을 받았다면 button이 눌리지 않은 것.
    //반환을 받지 못했다면 button이 결과를 받기 전에 눌린 것.
    if (res != null)
    {
        this.label1.Text = "Sum: " + res.Sum;
    }
    else
    {
        this.label1.Text = "Cancelled ";
    }
}

//작업을 취소하는 버튼 -> 해당 Source의 Token들에게 cancel 신호를 보냄
// -> Token이 false가 될 것임.
private void btnCancel_Click(object sender, EventArgs e)
{
    // (4) 작업 취소
    cancelTokenSource.Cancel();
}


//비동기 실행 중 for문에서 매번 token이 cancel 요청을 받았는지 확인
private object LongCalcAsync() 
{
    int sum = 0;
    for (int i = 0; i < 100; i++)
    {
        /// (3) 작업 취소인지 체크 -> 취소 요청을 받았다면 바로 null 반환
        if (cancelTokenSource.Token.IsCancellationRequested)
        {
            return null;
        }
        sum += i;
        Thread.Sleep(1000);
    }
    
    //취소하지 않았을 경우 sum 반환.
    return new { Sum = sum };
}

 

예를 몇 가지 더 보자

사실 위에 설명한 2가지 말고도 더 있다.

 

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

class Example
{
    static void Main()
    {
        Action<object> action = (object obj) =>
                                {
                                   Console.WriteLine("Task={0}, obj={1}, Thread={2}",
                                   Task.CurrentId, obj,
                                   Thread.CurrentThread.ManagedThreadId);
                                };

        // Create a task but do not start it. -> Task 객체를 만들지만 바로 시작하지 않음.
        Task t1 = new Task(action, "alpha");

        // Construct a started task -> Task객체를 만들면서 action 바로 실행
        Task t2 = Task.Factory.StartNew(action, "beta");
        
        // Block the main thread to demonstrate that t2 is executing
        // t2의 실행이 끝날 때까지 메인 스레드는 기다림.
        t2.Wait();

        // Launch t1 -> Task 객체인 t1을 시작.
        t1.Start();
        Console.WriteLine("t1 has been launched. (Main Thread={0})",
                          Thread.CurrentThread.ManagedThreadId);
        // Wait for the task to finish. -> 역시나 기다려야함.
        t1.Wait();

        // Construct a started task using Task.Run. -> 람다식으로 바로 실행
        String taskData = "delta";
        Task t3 = Task.Run( () => {Console.WriteLine("Task={0}, obj={1}, Thread={2}",
                                                     Task.CurrentId, taskData,
                                                      Thread.CurrentThread.ManagedThreadId);
                                   });
        // Wait for the task to finish. -> 역시 대기
        t3.Wait();

        // Construct an unstarted task -> 역시나 Task 객체를 만듬.
        Task t4 = new Task(action, "gamma");
        
        // Run it synchronously -> 다만 Start로 실행하는 것이 아니라. 다르게 실행(동기적)
        // -> 메인 스레드에서 실행된다고 보면 됨.
        t4.RunSynchronously();
        
        // Although the task was run synchronously, it is a good practice
        // to wait for it in the event exceptions were thrown by the task.
        t4.Wait();
    }
}

 

결과는 이렇게 나온다.

Task=1, obj=beta, Thread=3
t1 has been launched. (Main Thread=1)
Task=2, obj=alpha, Thread=4
Task=3, obj=delta, Thread=3
Task=4, obj=gamma, Thread=1

 

 

 

 


참고링크

https://www.csharpstudy.com/Threads/taskOfT.aspx

https://docs.microsoft.com/ko-kr/dotnet/api/system.threading.tasks?view=net-6.0 

 

System.Threading.Tasks 네임스페이스

동시 및 비동기 코드를 작성하는 작업을 단순화하는 형식을 제공합니다. Provides types that simplify the work of writing concurrent and asynchronous code. 주요 형식은 대기하고 취소될 수 있는 비동기 작업을 나

docs.microsoft.com

 

반응형
그리드형