.NET/C#

[C#] 멀티태스킹(Multitasking) - 2. 비동기(Asynchronously)

클리엘 2022. 7. 14. 12:41
728x90

3. 비동기(Asynchronously) 실행

 

우선 어떻게 같은 시간에 동시적으로 여러 작업이 실행될 수 있는지를 알아보기 테스트 가능한 아래 3개의 메서드를 생성합니다.

static void ThreadInfo()
{
    Thread t = Thread.CurrentThread;
    Console.WriteLine($"스레드 Id: {t.ManagedThreadId}, 우선순위: {t.Priority}, 백그라운드: {t.IsBackground}, 태스크명: {t.Name ?? string.Empty}");
}

static void MethodA()
{
    Console.WriteLine("A실행시작");
    OutputThreadInfo();
    Thread.Sleep(5000);
    Console.WriteLine("A실행종료");
}

static void MethodB()
{
    Console.WriteLine("B실행시작");
    OutputThreadInfo();
    Thread.Sleep(3000);
    Console.WriteLine("B실행종료");
}

static void MethodC()
{
    Console.WriteLine("C실행시작");
    OutputThreadInfo();
    Thread.Sleep(1000);
    Console.WriteLine("C실행종료");
}

각 Method에서는 동작 시간을 고의적으로 지연시키기 위해 A는 5초, B는 3초, C는 1초 정도로 Sleep() 메서드를 호출하였으며 실행 직전 ThreadInfo() 메서드를 통해 각각의 스레드 정보를 표시하도록 하였습니다.

 

그런 다음 메서드를 각각 순서대로 호출합니다. 이때 StopWatch를 통해 실행시간이 얼마나 소요되었는지를 표시합니다.

Stopwatch timer = Stopwatch.StartNew();

Console.WriteLine("작업시작");

MethodA();
MethodB();
MethodC();

Console.WriteLine($"총 소요시간 : {timer.ElapsedMilliseconds:#,##0}");

해당 실행은 메서드를 순서대로 실행하는 동기방식이며 아래와 비슷한 결과를 보일 것입니다.

작업시작
A실행시작
스레드 Id: 1, 우선순위: Normal, 백그라운드: False, 태스크명:
A실행종료
B실행시작
스레드 Id: 1, 우선순위: Normal, 백그라운드: False, 태스크명:
B실행종료
C실행시작
스레드 Id: 1, 우선순위: Normal, 백그라운드: False, 태스크명:
C실행종료
총 소요시간 : 9,017

(1) Task를 사용한 비동기 실행

 

Thread 클래스는 .NET의 초기버전 때부터 Thread를 생성하고 관리하는 데 사용 가능한 클래스였지만 직접적으로 사용하기에는 다소 까다로운 면이 있었습니다.

 

그러다가 .NET Framework 4.0에 와서는 Task클래스가 추가되었는데 Thread클래스를 wrapper 한 것으로 이전보다 좀 더 쉽게 Thread를 생성하고 관리할 수 있도록 하였으며 Task에 wrapper 된 다중 스레드는 같은 시간에 코드를 동시에 실행할 수 있도록 하였는데 이를 '비동기'라고 합니다.

 

Task의 Instance를 통해 메서드를 실행하는 데는 3가지 방법이 존재하며 이들에 대한 장단점의 논의는 GitHub repository를 통해 이루어지고 있습니다. 각각은 구문상으로 약간의 차이가 존재하지만 어떤 방법이든 새로운 Task를 정의하고 시작해야 합니다.

Stopwatch timer = Stopwatch.StartNew();

Console.WriteLine("작업시작");

Task taskA = new(MethodA);
taskA.Start();

Task taskB = Task.Factory.StartNew(MethodB); 

Task taskC = Task.Run(MethodC);

Console.WriteLine($"총 소요시간 : {timer.ElapsedMilliseconds:#,##0}");

위 코드를 실행하고 나면 이전의 결과와는 확연히 다른 내용을 볼 수 있게 됩니다.

작업시작
A실행시작
C실행시작
B실행시작
스레드 Id: 7, 우선순위: Normal, 백그라운드: True, 태스크명: .NET ThreadPool Worker
스레드 Id: 6, 우선순위: Normal, 백그라운드: True, 태스크명: .NET ThreadPool Worker
스레드 Id: 4, 우선순위: Normal, 백그라운드: True, 태스크명: .NET ThreadPool Worker
총 소요시간 : 5

각각의 메서드는 Thread Pool로부터 할당된 새로운 background worker thread로 실행되었으며 각각 독립적으로, 거의 동시에 실행되었음을 알 수 있습니다.

 

심지어 게다가 각각의 메서드가 완전히 실행이 종료되기도 전에 Console App이 종료되었다는 사실 또한 알 수 있습니다.('실행 완료'라는 메시지가 표시되지 않았습니다.)

 

(2) Task 대기

 

경우에 따라 특정 Task가 실행 완료 때까지 고의적으로 대기시켜야 하는 경우도 있는데 이를 위해서는 아래 메서드를 사용합니다.

Wait() Task의 Instance에서 호출될 수 있으며 해당 Task가 실행완료때까지 대기합니다.
Task.WaitAny(Task[]) 정적메서드이며 배열로 지정된 모든 Task가 실행완료될때까지 대기합니다.
Task.WaitAll(Task[]) 정적메서드이며 배열로 지정된 모든 Task가 실행완료될때까지 대기합니다.

실제 코드상에서 각각의 Task를 생성한 후 아래와 같이 실행 완료 대기를 걸어두게 되면

Task[] tasks = { taskA, taskB, taskC };
Task.WaitAll(tasks);

Console App은 이들의 실행이 모두 완료될 때까지 종료되지 않고 그대로 대기하게 되며 모든 메서드의 실행이 완전히 완료하게 되면 그때서야 Console App의 실행 역시 종료하게 됩니다.

작업시작
A실행시작
B실행시작
C실행시작
스레드 Id: 7, 우선순위: Normal, 백그라운드: True, 태스크명: .NET ThreadPool Worker
스레드 Id: 4, 우선순위: Normal, 백그라운드: True, 태스크명: .NET ThreadPool Worker
스레드 Id: 6, 우선순위: Normal, 백그라운드: True, 태스크명: .NET ThreadPool Worker
C실행종료
B실행종료
A실행종료
총 소요시간 : 5,010

3개의 메서드는 거의 동시에 실행되지만 실행이 종료되는 시점은 메서드에서 Sleep 한 시간에 따라 C->B->A의 순서로 종료하게 됩니다. CPU는 각각의 프로세스에 시간을 분배하여 각 프로세스에서 생성한 스레드(Thread)를 실행할 수 있도록 하는데 이 과정에서 메서드의 실행을 제어할 수는 없습니다.

 

(3) Task의 실행

 

위 예제를 실행한 이후에는 그저 모든 Task가 작업이 종료되기를 기다리는 것만이 할 수 있는 전부입니다. 그러나 어떤 경우는 하나의 Task가 다른 Task로부터의 결과의 의존하는 경우가 있는데 이때는 continuation task를 사용할 수 있습니다.

 

예를 들어 아래 예제는 MethodB()가 실행된 후 특정값을 반환하면 MethodA()에서는 그 값을 받아 동작을 수행해야 하고 최종적으로 그 결과를 string형식으로 반환하고 있는데

static void MethodA(int wait)
{
    Console.WriteLine("A실행시작");
    ThreadInfo();
    Thread.Sleep(wait);
    Console.WriteLine("A실행종료");
}

static int MethodB()
{
    Console.WriteLine("B실행시작");
    ThreadInfo();
    Thread.Sleep(3000);
    Console.WriteLine("B실행종료");

    return 5000;
}

Task 선언 시 위 메서드를 다음과 같이 지정해 주면

Task<string> result = Task.Factory.StartNew(MethodB).ContinueWith(previousTask => MethodA(previousTask.Result));
Console.WriteLine(result.Result);

StartNew에서는 가장 먼저 실행되어야 할 메서드를 지정하고 ContinueWith을 통해 이젠 메서드 실행 후의 값을 Result로 받아 MethodA() 메서드를 실행하게 됩니다. 그리고 그 결과를 Task<string>으로 받고 있으며 다시 Result를 통해 결괏값을 표시합니다.

스레드 Id: 4, 우선순위: Normal, 백그라운드: True, 태스크명: .NET ThreadPool Worker
B실행종료
A실행시작
스레드 Id: 6, 우선순위: Normal, 백그라운드: True, 태스크명: .NET ThreadPool Worker
A실행종료
5초간 Sleep

(4) 중첩(Nested)과 하위(Child) task

 

Nested task는 내부에서 다른 task를 생성하는 것이며 Child task는 상위 task가 완료되기 전에 완료되어야 하는 Nested task입니다.

static void MethodA()
{
    Console.WriteLine("A실행시작");
    Task task = Task.Factory.StartNew(MethodB);
    Console.WriteLine("A실행종료");
}

static void MethodB()
{
    Console.WriteLine("B실행시작");
    Thread.Sleep(3000);
    Console.WriteLine("B실행종료");
}

예제에서 MethodA()는 내부에서 MethodB()를 시작합니다.

Task task = Task.Factory.StartNew(MethodA);
task.Wait();

//A실행시작
//A실행종료
//B실행시작

결과를 보면 MethodA()는 실행이 종료되기까지 대기했지만 MethodB()는 그렇지 않았습니다. 심지어 어떤 경우는 MethodB()가 시작되기도 전에 위 코드를 실행시키는 App자체의 실행이 종료되는 경우도 있을 것입니다.

 

앞서 하위(Child) task는 상위(Parent) task의 실행이 완료되기 전에 먼저 완료되어야 한다고 했었는데 예제의 MethodB()를 현재 task를 실행하는 상위 task(App)와 연결하면 App은 MethodB()의 실행이 완료되기를 기다리게 됩니다.

Task task = Task.Factory.StartNew(MethodB, TaskCreationOptions.AttachedToParent);
A실행시작
A실행종료
B실행시작
B실행종료

(5) From~ 메서드

 

비동기 실행 메서드에서 경우에 따라 task자체가 아닌 Task에 대한 예외나 다른 값을 반환해야 하는 경우가 있을 때 아래 메서드를 통해 예외를 반환하거나 task가 실행이 취소되었음을 알려줄 수 있습니다.

FromResult<TResult>(TResult) Result속성이 non-task result이고 Status속성이 RanToCompletion인 Task<TResult>형식을 생성합니다.
FromException<TResult>(Exception) 특정 예외를 가지고 완료된 Task<TResult>형식을 생성합니다.
FromCanceled<TResult>(CancellationToken) 특정 cancellation token과 함께 완료된 Task<TResult>형식을 생성합니다.
static Task<int> Cal(int? i, int? j)
{
    if (i == null || j == null)
    {
        return Task.FromException<int>(new ArgumentNullException("값이 존재하지 않습니다."));
    }

    int? r = i + j;

    return Task.FromResult<int>(r ?? 0);
}

이러한 메서드는 아래와 같이 값을 받아올 수 있지만

Task<int> result = Cal(10, 20);

Console.WriteLine(result.Result);

Task자체를 반환해야 하는 경우에는 CompletedTask를 사용합니다.

static Task Cal(int? i, int? j)
{
    //,,,

    return Task.CompletedTask;
}

이는 개념상 void 메서드와 동일합니다.

728x90