본문 바로가기

진리는어디에/C#

[C#] 비동기 프로그래밍 - Task

이번 포스트에서는 스레드와 스레드 풀의 제약 사항을 해결해줄 'Task'에 대해 살펴 보겠다.

들어가며

이전 까지 포스트에서 C#의 스레드에 대해 알아 보았다. 앞의 글을 안봤다고 다시 돌아가서 볼 필요는 없다. 앞의 글들은 단지 이번 포스트 "Task"를 설명하기 위한 준비작업이었을 뿐이다.

간단하게 앞의 내용을 요약하면, 스레드를 지속적으로 생성하고 삭제하면 오버헤드가 발생한다. 이것을 해결하기 위해 스레드 풀을 사용했지만, C# 스레드 풀의 제약 사항으로인해 작업이 완료 되기도 전에 프로세스가 종료 될 수 있다는 단점이 있었다(이유가 궁금하면 위 링크 되어있는 포스트들에 정리해놨으니 살펴보고 오도록하자).

이 모든 단점을 커버 해주는 것이 이번에 소개할 Task다. Task는 기본적으로 백그라운드 속성의 스레드이며, 스레드 풀을 사용하며, 해당 스레드가 종료 할 때까지 대기하는 메소드도 존재한다. 심지어 결과값 까지 리턴 받을 수 있다. C#에서 Task는 Thread와 비교하여 이런 차이를 가진다. 앞으로 여러분들은 앞에 살펴 봤던 Thread와 ThreadPool을 잊고 Task를 사용하도록 하자.

Task 쓰세요. 두번 쓰세요.

Task를 이용해 비동기 작업 시작

  • 먼저 Task를 사용하기 위해서는 System.Threading.Tasks 네임스페이스가 필요하다.
using System.Threading.Tasks
  • Task 스레드가 수행할 메소드의 형식
    Task는 두가지 버전의 형식이 있다. 반환 값이 없는 Action을 인자로 사용하는 버전과 반환 값이 있는 Func<T>를 인자로 사용하는 제네릭 버전이다. 아래 생성자들 외에도 많은 오버로딩된 생성자들이 있지만 주로 사용되는 것은 아래 네가지 정도로 요약 된다.
// Action을 인자로 사용하는 버전
public Task(Action action);
public Task(Action<object?> action, object? state); // 인자가 인는 Action

// Funct<T>를 인자로 사용하는 제네릭 버전
public Task(Func<TResult> function);
public Task(Func<object?, TResult> function, object? state); // 인자가 있는 Func
  • 반환 값이 없는 Task 사용 예제 - Action
// 인자가 없는 함수
static void Foo1() { Console.WriteLine("Foo1"); }
// 인자가 있는 함수
static void Foo2(object obj) { Console.WriteLine($"Foo2:{obj}"); }
    
public static void Main()
{
    Task t1 = new Task(Foo1);                           
    t1.Start();
        
    Task t2 = new Task(Foo2, "Hello World");            
    t2.Start();
}
  • 반환 값이 있는 제너릭 Task 사용 예제 - Func<T>
// int 타입을 리턴하는 인자가 없는 함수
static int Foo3() { Console.WriteLine("Foo3");  return 100;  }
// int 타입을 리턴하는 인자가 있는 함수
static int Foo4(object obj) { Console.WriteLine($"Foo4:{obj}"); }

public static void Main()
{
    Task<int> t3 = new Task<int>(Foo3);                 
    t3.Start();
    
    Task<int> t4 = new Task<int>(Foo4, "Hello World");  
    t4.Start();
}
  • Task를 만드는 또 다른 방법 - Run 스태틱 메소드
    객체를 만들어 Start 메소드를 호출하는 방법 외에도, Run 스태틱 메소드를 이용하여 바로 쓰레드를 실행하는 것도 가능하다. 단, Run 스태틱 메소드는 인자가 없는 메소드만 사용 가능하다. 만일 Run메소드를 사용하는데 인자가 필요하다면 람다 표현식을 이용하여 인자를 넘겨 줄 수 있다. 
static void Foo5() { Console.WriteLine("Foo5"); }

public static void Main()
{
    Task.Run(Foo5); // 객체를 만들지 않고 정적 메소드를 사용 가능
                    // 단, 인자가 없는 메소드만 사용가능. 인자를 사용하려면 람다 표현식을 사용해야 한다.

    // Start 메소드 호출이 필요 없다.   
    
    Task t5 = Task.Run(Foo5);
    t5.Wait()       // Run 스태틱 메소드로 바로 실행하더라도 리턴되는 Task갤체를 이용해 Wait 가능
}

Task 완료 대기 - Wait 메소드

Task는 Wait 메소드를 이용해 스레드 작업이 완료 될 때까지 진행을 블로킹 할 수 있다. 또한 리턴 값이 있는 제너릭 Task의 경우 Result 멤버를 이용해 스레드 종료시 반환 값을 얻을 수 있다.

static int Foo6()
{
    Console.WriteLine("Foo6");
    Thread.Sleep(3000);      // 3초간 스레드 멈춤
    return 200;
}

public static void Main()
{
    Task<int> t6 = new Task<int>(Foo6);
    t6.Start();
    
    t6.Wait();              // 스레드가 종료 될 때 까지 대기
    
    int result = t6.Result; // 만일 Wait를 안한 경우, 스레드가 종료 될 때까지 대기한다.
    Console.WriteLine($"{result}"); 
}

위 예제의 13라인 Result 멤버를 주목. Result 멤버는 앞에서 말했다 싶이 제너릭 Task에서만 사용 가능하며 스레드 함수에서 리턴한 값을 저장하고 있다. 만일 Wait() 메소드를 호출하지 않았다고 하더라도 Result 값을 얻는 부분에서 스레드가 종료 될 때까지 블로킹 된다.

Run 스태틱 메소드를 이용하여 실행 하더라도 리턴되는 Task 객체를 이용해 Wait 가능하다.

Task의 속성 확인

앞에서 Task는 Thread와 ThreadPool의 단점을 보완하기 위해 사용한다고 말했었다. 쓰레드가 종료 되기 전까지 Wait() 메소드를 이용해 대기하는 것은 확인 하였으니, Task를 생성하면 이것이 쓰레드 풀에서 생성되는지 일반 쓰레드 처럼 생성 되는지 확인해 보도록 하자.

static void Foo7()
{
    // Task는 쓰레드 풀을 사용하는가?
    Console.WriteLine($"{Thread.CurrentThread.IsThreadPoolThread}"); 
    // Task는 백그라운드 스레드인가?
    Console.WriteLine($"{Thread.CurrentThread.IsBackground}");       
}

public static void Main()
{
    Task.Run(Foo7).Wait();
}

// OUTPUT :
// True
// True

위 예제의 결과를 확인하면 현재 스레드(Thread.CurrentThread)의 IsThreadPoolThread와 IsBackground 프로퍼티가 모두 True인 것을 확인 할 수 있다.

스레드 풀 사용하지 않는 Task 만들기

스레드 풀 모델에 어울리는 Task는 짧은 시간 내에 처리 할 수 있는 작업들이 추천된다. 만일 처리 시간이 긴 작업이 있다면 스레드 풀의 스레드 보다는 별도의 전용 스레드를 생성하는 것이 더 효율적일 수 있다. 하지만 앞에서 살펴본 Task는 애초에 생성시 스레드 풀의 스레드로 생성된다.

스레드 풀이 아닌 별도의 스레드를 생성하기 위해서는 Task를 생성시 생성자의 인자로 TaskCreationOptions.LongRunning 옵션을 넘겨주면 된다. 단, Run 스태틱 메소드의 경우 TaskCreationOptions를 사용 할 수 없다.

static void Foo8()
{
    Thread.Sleep(10000); // 매우 긴 작업
}

public static void Main()
{
    Task t7 = new Task(Foo8, TaskCreationOptions.LongRunning); // 블록킹 작업등 시간이 오래걸리는 스레드를 풀에 만들지 않기 위해 주는 옵션
    t7.Start();
    // Task.Run은 인자를 줄 수 없으므로 무조건 풀에 만들어 진다.
    t7.Wait();
}

// OUTPUT :
// is thread in threadpool? False
// is background thread? True

Task 연속 실행

Task의 연속 실행에 대해 설명하기 위해 한가지 상황을 가정해보자. 여러분은 지금 GUI이벤트 프로그램을 작성하고 있다. 이 프로그램에서 메인 스레드는 항상 유저 입력을 대기하고 처리해야 하기 때문에 절대 블로킹 되어서는 안된다. 이 때 굉장히 시간이 오래 걸리는 작업(Foo)이 필요해졌다고 가정을 더해보자.

우리는 앞에서 배운 Task를 이용해 다른 스레드에서 시간이 오래 걸리는 임의의 작업을 수행하도록 시키고 메인 스레드는 계속 유저의 입력을 처리 하도록 만들수 있다. 만일 이 임의의 작업이 완료된 후 그 결과를 가지고 다른 작업(Bar)을 해야 하는 경우가 발생한다면 어떻게 해야 할까? 

먼저 아래 처럼 가장 단순하게 Wait 메소드나 Result 멤버를 이용해서 스레드가 완료 될때까지 기다리는 방법이 있을 것이다.

class Program
{
    public static int Foo(object obj)
    {
        int count = (int)obj;
        int result = 0;
        Console.WriteLine("Start Task Foo");
        for (int i = 0; i <= count; i++)
        {
            Thread.Sleep(100);
            result += i;
        }
        Console.WriteLine("Finish Task Foo");
        return result;
    }

    public static void Bar(int input)
    {
        Console.WriteLine($"Print result in Bar:{input}");
    }
    public static void Main()
    {
        // Task<int> t = new Task<int>(Foo, 10);
        // t.Start();

        Task<int> t = Task.Run(() => Foo(10));

        int result = t.Result; // 여기서 Foo가 완료 될때까지 블로킹
        Bar(result);
    }
}

하지만 분명히 메인 스레드는 절대 블로킹 되어서는 안된다는 조건이 있었다. 위 방법은 Foo 메소드의 작업이 완료 될 때까지 메인 스레드를 블로킹하게 되므로 결국 우리가 원하는 조건을 만족하지 못한다.

ContinueWith 메소드

Task 클래스는 위와 같은 연속 실행을 위해 ContinueWith메소드를 제공한다. ContinueWith은 Task 객체의 작업이 완료되면 비동기적으로 바로 이어 실행 되는 연속 작업을 만든다.

ContinueWith 메소드의 정의는 아래와 같다.

Task ContinueWith (Action<System.Threading.Tasks.Task> continuationAction);

매개변수로 Task를 인자로 받는 Action 타입을 가지며 Task를 리턴한다. 이제 Wait로 코드를 블로킹하는 것이 아닌 ContinueWith을 이용해 연속 실행 될 작업을 등록하고 메인 스레드는 계속 진행 하도록 변경해보도록 하자.

    //public static void Bar(int input)
    public static void Bar(Task<int> t)           // Task를 인자로 받도록 변경
    {
        Console.WriteLine($"Print result in Bar:{t.Result}");
    }
    
    public static void Main()
    {
        Task<int> t = Task.Run(() => Foo(10));
        
        //int result = t.Result;
        //Bar(result);
        t.ContinueWith(Bar);                      // Bar 메소드를 연속 작업에 등록 한다
        
        Console.WriteLine("Main Thread");
        Console.ReadLine();                        // 프로세스 종료 방지를 위함
    }

// OUTPUT :
// Main thread <-- 블로킹 되지 않고 메인 스레드 진행함을 보여줌
// Start Task Foo
// Finish Task Foo <-- 한참 뒤에 출력
// Print result in Bar:55
  • 2라인의 Bar 메소드의 형태가 int를 인자로 받는것에서 ContinueWith에 등록되는 Action<Task> 형태로 변경 되었다. 
  • 13라인. 기존의 Result 멤버를 이용해 대기하던 것 대신 ContinueWith 메소드에 Bar를 등록하는 것으로 변경 되었다. 메인 스레드는 여기서 블로킹 되지 않고 계속 진행 되어 "Main Thread"가 가장 먼저 출력 되는 것을 확인 할 수 있다.
  • 16라인. 프로세스가 죽는 것을 방지하기 위한 안전장치다. 별의미 없다. 궁금해 하지 말자.

위 예제를 실행하면 메인 스레드가 블록 되지 않고 16라인까지 멈춤 없이 진행 된 것을 확인 할 수 있다. 또한 Foo 메소드 실행 완료 후 그 결과도 Bar 메소드에 의해 바로 출력 되는것 또한 확인 가능하다.

여러개 연속 작업 등록하기

다시 예제로 돌아가 이번에는 여러개의 연속 작업을 등록해보자. Task가 내부적으로 연속 실행 메소드들에 대한 콜렉션을 유지하고 있기 때문에 연속 실행 메소드는 동시에 여러개 등록이 가능하다.

    public static void Main()
    {
        Task<int> t = Task.Run(() => Foo(10));

        t.ContinueWith(Bar);                      // Bar 메소드를 연속 작업에 등록 한다
        t.ContinueWith((task) => Console.WriteLine($"Print result in lambda:{t.Result}"));

        Console.WriteLine("Main Thread");
        Console.ReadLine();                        // 프로세스 종료 방지를 위함
    }

// OUTPUT :
//  Main Thread
//  Start Task Foo
//  Finish Task Foo
//  Print result in lambda:55 <-- 람다 함수가 먼저 출력 되었다.
//  Print result in Bar:55

이번에는 람다 함수를 ContinueWith에 등록 후 실행 했다. 그런데 출력 결과가 이상하다. 우리는 분명히 Bar 메소드를 먼저 등록하고 람다 함수를 등록했는데, 실행되는 순서는 그와 상관 없이 람다 함수가 먼저 실행 되었다(여러번 실행해보면 연속 실행 메소드가 실행 되는 순서는 그때 그때 다르다는 것을 알 수 있다).

여러분이 Task를 이용해 작업을 실행하면 내부적으로 스레드 풀에 그 작업을 집어 넣게 된다. 그러면 스레드 풀 내의 스레드 하나가 작업을 맡아 실행하게 되고 메인 스레드는 계속 진행 되며 ContinueWith 호출을 만나 연속 실행 작업들을 Task 객체의 내부 콜렉션에 저장한다.

Task의 작업이 완료 되면, 자신이 저장하고 있던 연속 실행 작업들을 스레드 풀에 다시 집어 넣게 된다. 이러면 스레드 풀에 대기 중인 스레드들중 '적당한' 스레드들이 깨어나 최선을 다해 새로 들어오는 작업들을 처리한다.

시스템이 판단하기에 적당한 스레드들이 어떤 순서로 연속 처리 작업을 맡게 될지 모르므로 위와 같이 ContinueWith 등록 순서와 상관 없이 스레드가 먼저 할당 된 연속 작업 중의 하나가 먼저 실행 되는 것이다.

Task 작업 완료 시,
ContinueWith에 등록된 작업들을 스레드 풀에 넣어 실행 되도록 한다.
하지만 스레드 풀에서 어떤 순서로 어떤 스레드가 작업을 처리 할 지는 모른다.

연속 작업 동기화하기

앞에서 ContinueWith 메소드로 연속 작업을 여러개 등록 후 연속 작업들의 실행 순서를 보장 받지 못하는 것을 확인 했다. 그렇다면 연속 작업 사이에 순서가 중요한 경우는 어떻게 해야 하는가?

연속 작업의 순서를 보장 받지 못하는 이유는 스레드 풀에 있는 어떤 스레드가 어떤 순서로 작업을 맡게 될지 모르기 때문이다. 하지만 ContinueWith 메소드의 두번째 인자로 TaskContinuationOption 열거형 타입의 ExecuteSynchronously를 넘겨주면 Task를 실행한 스레드를 이용해 해당 연속 작업을 처리하라는 의미다.

두 번째 인자로 TaskContinuationOption.ExecuteSynchronously 사용

처리하는 스레드가 하나가 되므로 결국 동기화가 이루어 진다.

    public static void Main()
    {
        Task<int> t = Task.Run(() => Foo(10));

        t.ContinueWith(Bar, TaskContinuationOptions.ExecuteSynchronously); 
        t.ContinueWith((task) => Console.WriteLine($"Print result in lambda:{t.Result}"), 
                                               TaskContinuationOptions.ExecuteSynchronously);

        Console.WriteLine("Main Thread");
        Console.ReadLine();                        // 프로세스 종료 방지를 위함
    }

// OUTPUT :
//  Main Thread
//  Start Task Foo
//  Finish Task Foo
//  Print result in Bar:55
//  Print result in lambda:55

TaskAwaiter 이용해 연속 실행 하기

Task의 ContinueWith 메소드를 이용하는 방법 외에 TaskAwaiter를 이용하여 연속 실행하는 방법이 있다. 자주 쓰이진 않지만 나중에 설명할 async/await에 필요하므로 살펴만 보고 넘어가자.

가장 먼저 TaskAwaiter를 이용하기 위해서는 System.Runtime.CompilerServices 네임스페이스의 사용이 필요하다. 자세한 사항들은 아래 예제 코드의 주석들을 참고하자.

    public static void Main()
    {
        Task<int> t = Task.Run(() => Foo(10));

        //t.ContinueWith(Bar);

        TaskAwaiter<int> awaiter = t.GetAwaiter();  // Task 객체로 부터 TaskAwaiter 객체 얻어 옴
        awaiter.OnCompleted(() => Bar(t));          // TaskAwaiter의 Complete 메소드에 연속 작업 등록. 
                                                    // 인자 없는 Action 타입.
        awaiter.OnCompleted(() => Console.WriteLine($"Print result in lambda:{awaiter.GetResult()}"));

        Console.WriteLine("Main Thread");
        Console.ReadLine();                        
    }

// OUTPUT :
//  Main Thread
//  Start Task Foo
//  Finish Task Foo
//  Print result in Bar:55 <-- 순서 보장 안됨
//  Print result in lambda:55 <-- 순서 보장 안됨

TaskAwaiter의 OnComplete 메소드에 인자로 넘겨지는 연속 실행 메소드의 형태는 ContinueWith 메소드와는 다르게 인자가 없는 Action 형이다. 그래서 인자를 넘겨주기 위해 앞에서 배웠던 대로 람다 함수를 사용했다.

또 하나 주목할  점은, TaskAwaiter 역시 작업의 실행 결과를 얻어 올 수 있는데, Task 처럼 Result 멤버대신 GetResult라는 메소드를 제공한다.

마치며

사실 별 내용 아닌데 예제 코드가 많다 보니 포스트가 엄청 길어졌다. C#에서 Task는 기존 Thread와 ThreadPool의 단점을 모두 보완할 수 있고, 뒤에서 설명될 async/await에 필수 요소이므로 꼭 기억해야 한다.

[C#] 비동기 프로그래밍 - async/await에서 나머지 이야기를 계속 하도록 하겠다.

부록 1. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!