들어가며
이전 포스트에서는 C#의 'Task' 사용법에 대해 알아 보았다. 예를 들어 여러분이 Task를 이용해 GUI 프로그램을 만든다고 가정하자. GUI프로그램은 UI이벤트를 처리하기 위해 메인 스레드는 블로킹 되지 않고 항상 유저의 입력을 받을 수 있도록 블로킹 되지 않아야 하며, 따라서 시간이 오래 걸리는 작업들은 Task를 이용해 다른 스레드에서 한다. 그리고 작업들이 처리되어야 하는 순서가 있다면 - 연속 실행 또는 연속 작업이라고 함 - Task의 ContinueWith 메소드 또는 TaskAwaiter를 이용하여 Task가 완료 되면 등록된 다음 작업을 처리 한다.
Task에 대한 설명은 [여기]에서 찾아 볼 수 있다. 이번 포스트는 Task의 사용법에 대해서는 따로 설명을 하지 않을 예정이니 혹시 모른다면 살펴 보고 다시 돌아 오도록 하자.
'Task의 연속 실행'의 문제점은..문제점이라기 보다 불편한점은 코드가 항상 분리 되어야 한다는 것이다. 만일 Task의 ContinueWith 메소드를 이용해 메인 스레드를 블록하지 않으면서 A작업 후 B작업이 실행되야 한다면 아래와 같은 코드를 만들 수 있다.
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
public static void TaskA() // 작업 A
{
Task<int> t = Task.Run(() =>
{
Console.WriteLine($"Do TaskA, returns 10 (ThreadID:{Thread.CurrentThread.ManagedThreadId})");
Thread.Sleep(2000); // 작업이 오래 걸리고 있는 중..
return 10;
});
t.ContinueWith(TaskB); // TaskA 완료 후, TaskB 실행
}
public static void TaskB(Task<int> t) // 작업 B, TaskA의 결과 값이 필요하다
{
Console.WriteLine($"Do TaskB with {t.Result} from TaskA (ThreadID:{ Thread.CurrentThread.ManagedThreadId})");
}
public static void Main()
{
TaskA();
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"Main thread waits UI event..{i + 1} (ThreadID:{ Thread.CurrentThread.ManagedThreadId})");
Thread.Sleep(1000);
}
Console.WriteLine("Program finished");
}
}
// OUTPUT :
// Do TaskA, returns 10 (ThreadID:4)
// Main thread waits UI event..1 (ThreadID:1)
// Main thread waits UI event..2 (ThreadID:1)
// Main thread waits UI event..3 (ThreadID:1)
// Do TaskB with 10 from TaskA (ThreadID:5)
// Main thread waits UI event..4 (ThreadID:1)
// Main thread waits UI event..5 (ThreadID:1)
// Program finished
위 예제를 보면 작업A(TaskA)와 작업B(TaskB)가 다른 메소드로 분리되어 구현 된다. 위 예제는 간단해서 불편함을 잘 못느낄 수 있지만, 실무에서 위와 같이 비동기 작업을 위해 메소드를 분리하게 된다면 많은 상태 저장을 위한 변수들이며 신경써야 할 것들이 한두가지가 아니다.
Task를 이용해 '연속 실행' 코드 만들기 힘들다.
그래서 C# 5.0 부터 async/await가 도입되어 마치 동기화 프로그래밍을 하듯 비동기 프로그래밍을 할 수 있게 되었다. 이번 포스트에서는 async/await를 이용한 비동기 프로그래밍에 대해 알아 보도록 하겠다.
async 메소드 작성하기
지금 부터 앞에서 ContinueWith을 이용해 처리했던 연속 실행 예제를 async/await 를 이용해 보다 간단한 코드로 변경해 보도록 하겠다.
먼저, 비동기 메소드는 메소드 정의에 async 키워드를 사용해야 한다. TaskA 메소드에 async 키워드를 적용해보자.
public static async void TaskA() // 작업 A
{
Task<int> t = Task.Run(() =>
{
Console.WriteLine($"Do TaskA, returns 10 (ThreadID:{Thread.CurrentThread.ManagedThreadId})");
Thread.Sleep(2000); // 작업이 오래 걸리고 있는 중
return 10;
});
t.ContinueWith(TaskB); // TaskA 완료 후, TaskB 실행
}
그리고 비동기로 처리 되어야 하는 부분에 await 연산자를 적용해준다. 그 전에 await 연산자의 특성을 알아 보자. await 연산자는 아래와 같은 특성을 가진다.
- await 연산자는 비동기 작업, 즉, Task를 피연산자로 받는다.
- await 연산자는 피연산자의 비동기 작업이 완료 될 때 까지 바깥쪽 async 메소드의 실행을 일시 중단한다.
- 만일 비동기 작업의 리턴값이 있다면(Task<T>와 같은 제너릭 Task라면) await 연산자는 작업 결과를 리턴한다.
- 이미 완료 된 비동기 작업 피연산자에 await 연산자가 적용되면 바깥쪽 비동기 메소드를 일시 중단하지 않고 결과를 즉시 반환한다.
- await 연산자는 비동기 메소드를 실행하는 스레드를 블로킹 하지 않는다.
await 연산자는 피연산자로 Task를 받고, 그 결과값을 리턴해 준다고 한다. 그럼 다음과 같이 코드를 수정 할 수 있다.
public static async void TaskA() // 작업 A
{
Task<int> t = Task.Run(() =>
{
Console.WriteLine($"Do TaskA, returns 10 (ThreadID:{Thread.CurrentThread.ManagedThreadId})");
Thread.Sleep(2000); // 작업이 오래 걸리고 있는 중
return 10;
});
int n = await t;
//t.ContinueWith(TaskB); // TaskA 완료 후, TaskB 실행
Console.WriteLine($"Do TaskB with {n} from TaskA (ThreadID:{ Thread.CurrentThread.ManagedThreadId})");
}
9라인에서 ContinueWith을 호출하는 대신 await 연산자를 적용하고 TaskB로 나뉘어 있던 부분이 합쳐 졌다는것 외에는 큰 변화는 없다.
여기서 특이한 점은 Task<int> 타입을 int타입에 대입하고 있는 것이다. 위에서 await의 특성 중 작업의 리턴값이 있다면 작업 결과를 리턴한다는 것이 이것을 말하는 것이었다. Task<int>의 경우 await 연산자를 적용하면 내부적으로 int로 변경 되어 리턴된다.
비동기 작업의 종료를 대기해야 할 땐 await
await에 대해 설명을 덧붙이자면, 비동기 메소드를 실행 하다 await를 만나게 되면 프로그램은 Task를 이용해 스레드 풀에 작업을 요청하고, 메소드의 모든 진행 상태를(변수 등등) 저장 후 진행을 중단한다(리턴이 아니라 중단이다). 그리고 비동기 작업이 완료 되면 저장되어 있던 상태를 불러와 스레드 풀의 스레드에게 나머지 작업의 진행을 요청하게 된다. 위 결과에서는 비동기 작업 시작과 완료 시 처리하는 스레드가 동일하지만 비동기 처리의 시작과 나머지를 처리하는 스레드가 항상 같다고 보장하지 않는다.
아주 간단한 수정만으로 앞의 예제와 동일한 결과를 얻을 수 있다. 실제 그런지 아래 전체 코드를 실행 해보자.
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
public static async void TaskA() // 작업 A
{
string syncString = "비동기임에도 동기 처럼 사용되는 것을 보여 주기 위한 변수";
int n = await Task.Run(() =>
{
Console.WriteLine($"Do TaskA, returns 10 (ThreadID:{Thread.CurrentThread.ManagedThreadId})");
Thread.Sleep(2000); // 작업이 오래 걸리고 있는 중
return 10;
});
Console.WriteLine($"Do TaskB with {n} from TaskA (ThreadID:{ Thread.CurrentThread.ManagedThreadId})");
Console.WriteLine(syncString);
}
public static void Main()
{
TaskA();
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"Main thread waits UI event..{i + 1} (ThreadID:{ Thread.CurrentThread.ManagedThreadId})");
Thread.Sleep(1000);
}
Console.WriteLine("Program finished");
}
}
// OUTPUT :
// Do TaskA, returns 10 (ThreadID:4) <-- 비동기 작업 시작
// Main thread waits UI event..1 (ThreadID:1)
// Main thread waits UI event..2 (ThreadID:1)
// Main thread waits UI event..3 (ThreadID:1)
// Do TaskB with 10 from TaskA (ThreadID:4) <-- 비동기 작업 완료, 다음 작업 시작
// 비동기임에도 동기 처럼 사용되는 것을 보여 주기 위한 변수
// Main thread waits UI event..4 (ThreadID:1)
// Main thread waits UI event..5 (ThreadID:1)
// Program finished
이 예제 역시 이전 예제와 동일하게 메인 스레드를 블록하지 않고 비동기로 TaskA에서 리턴된 결과를 이용해 TaskB를 실행 하는 것을 확인 할 수 있다.
반환값이 있는 비동기 메소드
앞의 예제에서 async 메소드의 리턴 타입은 void였다. 하지만 async 메소드는 void, Task, Task<T> 이렇게 세 가지 리턴 타입을 가질 수 있다.
만일 비동기 메소드가 종료되는 것을 대기해야 할 필요가 있을 때 Task나 Task<T>를 리턴하는 비동기 메소드를 사용 할 수 있다. 지금까지 배웠던것을 복습하는 정도의 간단한 개념이므로 설명은 아래 예제의 주석으로 대신하겠다.
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
public static async Task AsyncReturnType()
{
await Task.Run(() =>
{
Console.WriteLine($"Start AsyncReturnType(ThreadID:{Thread.CurrentThread.ManagedThreadId})");
Thread.Sleep(2000); // 뭔가 오래 걸리는 작업
});
Console.WriteLine($"Finish AsyncReturnType(ThreadID:{Thread.CurrentThread.ManagedThreadId})");
// Task를 리턴하는 코드가 없지만 현재 실행되는 스레드를 관리하는 Task 객체가 자동으로 리턴 된다.
}
public static async Task<int> AsyncReturnTypeT()
{
int n = await Task.Run(() =>
{
Console.WriteLine($"Start AsyncReturnTypeT(ThreadID:{Thread.CurrentThread.ManagedThreadId})");
Thread.Sleep(2000); // 뭔가 오래 걸리는 작업
return 10;
});
Console.WriteLine($"Finish AsyncReturnTypeT(n:{n}, ThreadID:{Thread.CurrentThread.ManagedThreadId})");
// Task<int>인 경우는 int 타입의 값을 리턴해야 한다.
// int 값을 가지는 Task 객체가 자동으로 리턴 된다.
return n;
}
public static void Main()
{
Task t1 = AsyncReturnType();
t1.Wait();
Task<int> t2 = AsyncReturnTypeT();
Console.WriteLine(t2.Result);
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"Main thread waits UI event..{i + 1} (ThreadID:{ Thread.CurrentThread.ManagedThreadId})");
Thread.Sleep(1000);
}
Console.WriteLine("Program finished");
}
}
// OUTPUT :
// Start AsyncReturnType(ThreadID:4)
// Finish AsyncReturnType(ThreadID:4)
// Start AsyncReturnTypeT(ThreadID:4)
// Finish AsyncReturnTypeT(n:10, ThreadID: 4)
// 10
// Main thread waits UI event..1 (ThreadID:1)
// Main thread waits UI event..2 (ThreadID:1)
// Main thread waits UI event..3 (ThreadID:1)
// Main thread waits UI event..4 (ThreadID:1)
// Main thread waits UI event..5 (ThreadID:1)
// Program finished
마치며
- 비동기 메소드는 반환 값 앞에 async 키워드를 붙인다.
- 비동기 메소드 내에서 await를 사용하면 메소드가 수행을 임시 중단하고, 프로그램은 계속 진행 한다.
- 비동기 메소드의 반환 타입은 void, Task, Task<T> 세 가지중 하나를 사용 할 수 있다.