https://docs.microsoft.com/en-us/dotnet/csharp/async
만약 입력과 출력에 관계된 일련의 처리 (데이터 베이스 처리나 네트워크를 통한 데이터 처리와 같은 일들)를 해야 할 경우에 이른바 asynchronous programming이 필요한데 이것은 내가 사용해 본 Node JS의 기본적인 처리 방법과 같다.
요청이 들어오면 콜백 함수를 정해두고 일이 마무리 되면 콜백을 호출하는 일련의 처리 방식을 비동기 (asynchronous) 적이다 라고 하는 것.
C#은 기본적으로 제공해주는 비동기 함수 호출을 위해 아주 유용한 라이브러리 함수가 제공되니 그것이 바로 await / async 이구나.
비동기 프로그래밍의 핵심은 Task와 Task<T> 객체인데 이들은 비동기 operation들을 모델링한다. 이 객체들은 async와 await 키워드에 의해서 지원되는데 다음과 같은 경우를 생각해 볼 수 있다.
입출력에 관계된 코드의 경우, await operation은 Task나 Task<T>를 async 함수 내부에서 리턴하게 된다.
CPU에 관계된 코드의 경우 (CPU를 background로 사용하는 코드) await는 Task.Run 함수를 사용해서 background thread를 시작하게 된다.
이 await 키워드가 바로 마법을 행하는 놈인데 이놈은 실행되는 시점에 즉시 자신을 호출한 호출자에게 컨트롤을 '양보' (yield)한다. 다른 말로 하자면 버튼을 누르는 그 즉시 버튼을 컨트롤 하는 스레드에게 우선순위를 양보함으로써 UI가 사용자에 입력에 즉각적으로 반응하도록 한다는 것.
이제 예를 들어보자. 가장 단순하게 인터넷에서 어떤 페이지의 버튼을 눌렀을 때, async와 await를 이용해서 비동기 프로그래밍을 구현한 것이다.
private readonly HttpClient _httpClient = new HttpClient();
downloadButton.Clicked += async (o, e) =>
{
// This line will yield control to the UI as the request
// from the web service is happening.
//
// The UI thread is now free to perform other work.
var stringData = await _httpClient.GetStringAsync(URL);
DoSomethingWithData(stringData);
};
매우 간단하게 비동기 프로그래밍을 구현했다. 다만, 위 코드에서 보다시피 GetStringAsync라고 하는 비동기 전용 함수가 미리 구현되어 있어서 사용한다는 점. 그리고 버튼의 클릭 이벤트 핸들러 추가시 async 키워드를 사용했다는 점 주의.
다음으로는 CPU에 바인딩 된 경우에 대해서 알아보면, 많은 일을 처리해야 하는 경우에 사용할 수 있는 것이 바로 비동기 프로그래밍이다.
아래의 코드가 바로 그 예제이며 버튼이 눌러 졌을 때 비동기적으로 background thread를 Task.Run()을 통해서 호출하고 사용자는 스무스하게 플레이 할 수 있도록 UI에게 컨트롤을 양보한다.
private DamageResult CalculateDamageDone()
{
// Code omitted:
//
// Does an expensive calculation and returns
// the result of that calculation.
}
calculateButton.Clicked += async (o, e) =>
{
// This line will yield control to the UI CalculateDamageDone()
// performs its work. The UI thread is free to perform other work.
var damageResult = await Task.Run(() => CalculateDamageDone());
DisplayDamage(damageResult);
};
여기서 몇가지 중요한 점이 있는데 await 키워드는 반드시 async로 정의된 함수 안에서만 쓸수 있다는 점. 상식적으로 생각해서 컴파일러에게 이 함수는 비동기 함수를 포함하고 있으니 너가 적절한 조치를 취해야 한다는 것을 미리 알려줘야 한다는 점에서 이런 룰이 적용된 것으로 보인다. 물론, 암묵적인 변환을 할수 있을지는 몰라도, 아마도 명시적인 것이 더 적합할 듯.
또 한가지 더 중요한 점으로 지적된 것이, 비동기 프로그래밍을 하는 것이 성능에 있어서 분명한 이득이 될 때 사용하라는 점. 비동기 프로그래밍은 공짜로 주어지는 것이 아니기 때문에 확실히 성능상의 이득이 있을때 적절히 사용하라는 점. 오버헤드가 있다는 말.
추가적인 예제가 아래와 같이 나와 있다. 특정 웹사이트에 접속해서 특정 단어가 몇개나 들어가 있는지 검사해서 숫자를 결과를 리턴하는 것인데 await와 async로 만들었다.
private readonly HttpClient _httpClient = new HttpClient();
[HttpGet]
[Route("DotNetCount")]
public async Task<int> GetDotNetCountAsync()
{
// Suspends GetDotNetCountAsync() to allow the caller (the web server)
// to accept another request, rather than blocking on this one.
var html = await _httpClient.DownloadStringAsync("http://dotnetfoundation.org");
return Regex.Matches(html, ".NET").Count;
}
드디어 Task<T>가 그 모습을 드러냈는데 T가 바로 리턴 값의 타입이다. 아래 코드는 UWP의 경우의 코드이다.
private readonly HttpClient _httpClient = new HttpClient();
private async void SeeTheDotNets_Click(object sender, RoutedEventArgs e)
{
// Capture the task handle here so we can await the background task later.
var getDotNetFoundationHtmlTask = _httpClient.GetStringAsync("http://www.dotnetfoundation.org");
// Any other work on the UI thread can be done here, such as enabling a Progress Bar.
// This is important to do here, before the "await" call, so that the user
// sees the progress bar before execution of this method is yielded.
NetworkProgressBar.IsEnabled = true;
NetworkProgressBar.Visibility = Visibility.Visible;
// The await operator suspends SeeTheDotNets_Click, returning control to its caller.
// This is what allows the app to be responsive and not hang on the UI thread.
var html = await getDotNetFoundationHtmlTask;
int count = Regex.Matches(html, ".NET").Count;
DotNetCountLabel.Text = $"Number of .NETs on dotnetfoundation.org: {count}";
NetworkProgressBar.IsEnabled = false;
NetworkProgressBar.Visbility = Visibility.Collapsed;
}
뭐 비슷한데 가운데 추가적으로 프로그래스 바를 보여주는 옵션이 있네. 굳이 UWP의 경우를 따로 보여줘야 했나 싶다만, 아마도 UWP를 대세로 밀고 있는 듯 한 느낌적인 느낌?
그리고, 추가적으로 여러개의 task가 완료되기를 기다릴수도 있는데 바로 Task.WhenAll과 Task.WhenAny와 같은 것을 통해서 여러개의 비동기 background job들을 관리 가능하다.
public async Task<User> GetUser(int userId)
{
// Code omitted:
//
// Given a user Id {userId}, retrieves a User object corresponding
// to the entry in the database with {userId} as its Id.
}
public static Task<IEnumerable<User>> GetUsers(IEnumerable<int> userIds)
{
var getUserTasks = new List<Task<User>>();
foreach (int userId in userIds)
{
getUserTasks.Add(GetUser(id));
}
return await Task.WhenAll(getUserTasks);
}
Task<User> 타입을 가지는 List를 만들어서 (getUserTasks) 각각의 userID를 리스트에 추가한 다음 한꺼번에 await를 통해서 호출하고 동시에 모든 작업이 완료되면 비동기적으로 그 결과를 리턴하게 된다.
같은 일을 LINQ를 이용하는 경우에는 다음과 같다.
public async Task<User> GetUser(int userId)
{
// Code omitted:
//
// Given a user Id {userId}, retrieves a User object corresponding
// to the entry in the database with {userId} as its Id.
}
public static async Task<User[]> GetUsers(IEnumerable<int> userIds)
{
var getUserTasks = userIds.Select(id => GetUser(id));
return await Task.WhenAll(getUserTasks);
}
훨씬 간결한 형태의 코드가 가능하다. LINQ의 파워가 느껴진다... 포스가 느껴진다...
마지막으로 중요한 몇가지 포인트들이 있는데,
1. async 함수는 반드시 최소한 한개의 await 키워드를 함수 내부에 가지고 있어야 한다. 없어도 컴파일은 되지만 효율이 겁나 떨어진다. 불필요한 async 남발은 피하라는 것.
2. async void는 반드시 event handler에만 사용하라. event의 경우 리턴값이 없으므로 Task나 Task<T>를 쓸 필요가 없고 그런 경우에는 async void를 쓰면 된다. 다만 이 경우에 컴파일러 입장에서는 몇몇 이유로 검증하는 것이 어렵기 때문에 최대한 async void를 사용하는 횟수를 줄여서 필요한 경우에만 쓰고 남발하지 말라는 것.
3. LINQ와 async를 함께 쓰는 것은 매우 포스가 있지만 중요한 점은 LINQ 자체가 구현 되어 있는 방식이 async와 맞지 않는 부분도 있고 잘못 쓰게 되면 데드락이 걸리기 때문에 정확히 알고 있는 경우 아니면 되도록이면 함께 쓰지 말라.
'프로그래밍 > C#' 카테고리의 다른 글
C# 이벤트 - 객체 지향적 메시지 (0) | 2010.06.25 |
---|---|
C# - 제너릭 (Generic) 에 대한 질문 (0) | 2010.06.18 |
Covariance and Contravariance (0) | 2010.06.18 |
델리게이트 - Delegate (0) | 2010.06.17 |