비동기에 대해서 깊게 공부한다고 하더라도, 당장 코드에 녹아나지 않을 수도 있다. 그렇지만, 코드의 흐름을 정확하게 이해하기 위해서는 비동기에 대해서 명확하게 알아야하고 더 나아가서, 스레드 / 병렬 프로그래밍 / 닷넷 런타임에 대해서 알아야 완전하게 제어되는 코드의 작성이 가능하다.
일단 비동기에 대해서 정말 설명이 잘되어있는 문서.. 그 어떤 책보다도 설명이 쉽고 잘 되어있다.
https://learn.microsoft.com/ko-kr/dotnet/csharp/asynchronous-programming/
---
---
---
이런식으로 순차적이다.
비동기는 그렇지 않다. 보통 시간이 좀 걸리는 작업의 예시를 들자면 예를 들어서, 네트워크 작업이라던지 파일의 입출력이라던지, 그런 것들을 예시로 들 수 있는데 그동안에 아무것도 안할수는 없지 않다. 때문에 작업을 하는 동안에도 무엇을 할 수 있도록
-----
-------------------------(비동기작업)
--- ( 중에 실행할 작업 )
-------
이런식의 흐름이 가능하다.
블록과 논블록
블록 ( 동기의 개념 )
논블록 ( 비동기의 개념 )
동기식 작동 같은 경우에는 확실히 단순하다. 코드의 흐름도 그렇고 따로 복잡하게 생각할 필요 없다. 그러나 작동시간이 오래걸릴 수 있다. 만약에 요청한 내용의 쿼리에 대해서 병목이 발생하면 그 시간은 오로지 실행시간에 들어갈 것이다.
C#에서의 비동기 프로그래밍 모델은 TAP이라고 한다. TAP 모델은 비동기 코드에 대한 추상화를 제공한다. 파이썬 같은 싱글 스레드 모델의 경우에는 사실 비동기 처리가 그렇게 복잡할 수가 없긴하다. 싱글 스레드일수록 비동기처리는 쉬워진다. C#이나 이 계열의 멀티스레딩 언어의 경우에는 비동기모델이 복잡한 편이고 잘 알아둘 필요성이 있다.
기본적으로 API 콜을 보내서 응답값을 받기까지 시간이 걸린다. 코드를 실행하는 것보다는 훨씬 긴 시간이 걸린다. 파일시스템이나 네트워크 통신 같은 경우에는 비동기적으로 작동하도록 해야하는데, 일부 작업은 완전 비동기처리를 해서는 너무 오래걸린다. 따라서 특정 부분에 다다를 때까지 동기적으로 처리했다가 해당 부분의 코드에 다다르면 그 작업이 완료될때까지 해당 스레드는 차단된다.
위의 문서에서 든 예시에 따르면 아침을 준비할때까지 1명의 사람(스레드)가 샌드위치를 준비를 한다고 치면 계란을 굽고 -> 빵을 굽고 -> 베이컨을 굽고 -> 빵 위에 계란 올리고 -> 베이컨 올리고........ -> 완료 할때까지 꽤 걸린다. 이걸 가져다가 완전 비동기라고 한다. 이런 경우에 아침을 먹는다고 하더라도 굉장히 만족도가 떨어질텐데, 개발도 마찬가지이다.
클라이언트 프로그램을 개발할때 완전히 비동기적으로 개발되서는 절대로 안된다. 어떻게 보면 닷넷에서도 UI작업은 메인스레드가 담당하고 있는데 여기서 무거운 작업을 완전 비동기로 돌려버리면 UI는 사용자의 입력이나 어떠한 상호작용을 처리할 수가 없다. 예시로 웹에서 데이터를 다운로드하는 동안 어플리케이션에서 휴대폰이 중지된 것처럼 표시하면 안된다. 사용자 경험이 완전 망가진다. 앙대!
어떤 스레드에서 일부 비동기로해서 리소스가 많이 들어가는 작업은 그것만에 한해서 동기적으로 작동하게 한다고 했을 때 기존 스레드가 그 동기작업을 내비두고 작업에 다시 들어가지 않게 await 키워드는 작업의 진행을 차단한다. 그리고 동기로 돌린 다른 작업이 완료 되었을 때 실행을 계속 시킨다. (await 키워드가)
C#에서는 비동기처리 작업을 위한 메서드의 뒤에 Async를 붙이는 식으로 해서 비동기메서드를 제공해준다. System.ThreadingTasks.Task 및 관련 형식은 진행 중인 작업을 추론하는데 사용할 수 있는 클래스이다.
Coffee cup = PourCoffee();
Console.WriteLine("Coffee is ready");
Task eggsTask = FryEggsAsync(2);
Task baconTask = FryBaconAsync(3);
Task toastTask = ToastBreadAsync(2);
// 작업을 시작한다. 여기서 작업의 완료를 기다리지 않고, 바로 다음의 코드로 넘어간다.
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("Toast is ready");
Juice oj = PourOJ();
Console.WriteLine("Oj is ready");
Egg eggs = await eggsTask;
Console.WriteLine("Eggs are ready");
Bacon bacon = await baconTask;
Console.WriteLine("Bacon is ready");
Console.WriteLine("Breakfast is ready!");
문서에서 배울 내용이 많다. Task / Task 개체를 사용하여 실행중인 작업을 유지할 수 있고, 결과를 사용하기 전에는 각 작업을 기다린다. ( await )
async 같은 경우에는 해당 메서드에서 비동기작업이 포함된 await 문을 포함하고 있다고 컴파일러에 알린다. 이 메서드는 해당 작업을 나타내는 Task를 반환한다.
그러니까 async 함수인 exfun1이 있다고 하자
var result = await exfun1() 이라고 하면
exfun1()이 Task를 먼저 반환하고 작업이 시작되면 await은 그게 끝날때까지 기다리는 것이다.
비동기 예외
지금까지는 모든 비동기 작업이 성공적으로 모두 완료되었다는 가정하에 공부를 했다. 하지만 비동기 메서드 또한 동기메서드와 마찬가지로 예외를 던진다. (throw)
비동기적으로 실행되는 작업에서 예외를 던지면 해당 Task는 오류상태가 된다. Task 개체는 Task.Exception 속성에서 던져진 예외를 포함한다. 오류 상태인 작업이 대기되면 예외를 던진다. 오류 상태인 작업이 대기 되면 예외를 던진다.
이 말을 정리하면 비동기적으로 작업을 실행하다가 문제가 발생하면 예외를 던지는데 이것은 다른 동기 메서드와도 마찬가지이다. 그런데 다른 점은 이 예외를 Task.Exception 에서 가지고 있다가 이걸 가지고 있는 Task가 대기 상태가 되어버리면 예외를 던진다는 것이다. 즉 오류가 생겨서 비동기 메서드 안에서 예외를 던지는 throw하는 바로 그 순간과 실제로 프로그램을 실행하다가 콘솔에 예외가 뜨는 순간의 차이가 꽤 있다.
문서에서는 그래서 이해해야할 2가지 중요한 메커니즘이 있다고 한다.
1. 예외가 [ 오류 상태인 작업 ]에 저장되는 방식
2. 코드가 오류 상태인 작업을 대기 할 때 예외가 패키지 해제되었다가 다시 throw 되는 방식
비동기적으로 실행되는 코드가 예외를 던지게 되면 해당 예외는 Task에 저장된다. 보면 예외를 순간순간마다 던지는게 아니라 태스크들 각각의 예외 Task.Excption은 모아졌다가 한번에 던져진다. 먼저 발생한 예외는 이 한번에 던져지는 것의 상위에 자리 잡게 된다.
효율적인 작업대기
await Task.WhenAll()
주어진 모든 태스크가 완료될 때까지 대기한다. 여러 비동기 작업을 병렬로 실행하고 모든 작업을 완료하였을 때만 다음 코드 라인으로 진행한다. Task.WhenAll은 모든 태스크가 성공적으로 완료되었을 때 완료되며, 하나라도 예외가 발생하면 해당 예외를 포함하는 골짜기(aggregate) 예외를 발생시킨다.
Task eggsTask = FryEggsAsync(2);
Task baconTask = FryBaconAsync(3);
Task toastTask = ToastBreadAsync(2);
await Task.WhenAll(eggsTask, baconTask, toastTask);
Console.WriteLine("All breakfast items are ready!");
이 코드에서 형광펜 친 구문은 세가지 태스크가 모두 완료될 때까지 실행을 일시 중단하고 모든 태스크가 끝나야 All Breakfast Items are ready!를 출력한다.
Task.WhenAll() 은 각 태스크의 결과를 기다리면서도 비동기적인 성질을 유지하려할 때 매우 유용하다. 이를 통해서 UI의 응답성을 저해하지 않고 서버 어플리케이션에서 복잡한 병렬 처리를 수행할 수 있다. 또한 모든 태스크가 성공적으로 완료되었는지 혹은 예외가 발생했는지도 쉽게 확인할 수 있어서 예외 처리를 효율적으로 수행할 수 있다.
//
await Task.WhenAll(eggsTask, baconTask, toastTask);
Console.WriteLine("Eggs are ready");
Console.WriteLine("Bacon is ready");
Console.WriteLine("Toast is ready");
Console.WriteLine("Breakfast is ready!");
//
Task 클래스의 WhenAll 메서드는 인수 목록의 모든 작업이 완료되면 완료된 Task를 반환한다.
Task.WhenAny()
Task.WhenAny()는 여러 비동기 작업 중 하나라도 완료되면 그 즉시 진행을 재개한다.
var breakfastTasks = new List { eggsTask, baconTask, toastTask };
while (breakfastTasks.Count > 0)
{
Task finishedTask = await Task.WhenAny(breakfastTasks);
if (finishedTask == eggsTask)
{
Console.WriteLine("Eggs are ready");
}
else if (finishedTask == baconTask)
{
Console.WriteLine("Bacon is ready");
}
else if (finishedTask == toastTask)
{
Console.WriteLine("Toast is ready");
}
await finishedTask;
breakfastTasks.Remove(finishedTask);
}
매개변수로 받은 태스크 리스트 중 하나라도 완료되면 그 즉시 완료된 Task 개체를 반환한다. 때문에 await 키워드는 WhenAny로 부터 반환된 첫번째 완료된 태스크가 끝날때까지만 대기한다.
형광펜 친 부분을 보면 await finishedTask 구문은 이미 완료된 태스크에 대해서 다시 await을 호출하는데, 이것은 해당 태스크가 예외를 발생시켰을 경우에 예외를 잡아내기 위함이다. 필수적인 줄은 아니다.
그리고 Task.WhenAny에 의해서 완료된 태스크가 대기하므로 이 줄이 실행될 때는 사실상 즉시 반환된다.
그래서 위의 코드를 한번 보면 원래 Task.WhenAny는 빨리 끝낸 작업의 Task만 반환하고 끝내는건데, 맨 위에 while문을 보면 남아있는 작업은 다시 한다는 것을 알 수 있다.
비동기 모델 개요
비동기 모델을 사용하는 시나리오는 크게 2가지로 나뉜다. 하나는 사용자의 입력과 관련한 i/o 작업이고 하나는 어떠한 액션(동작)을 위한 cpu 바인딩된 작업이다. 이 경우에 Task.Run()을 적극적으로 사용한다.
i/o 바인딩된 작업이 있을 경우에 Task.Run없이 async 및 await을 사용하고 작업 병렬 라이브러리를 사용하면 안된다. Cpu 바인딩된 작업이 있고 빠른 응답이 필요할 경우 async await 을 사용하여 또 다른 스레드에서 작업을 생성한다. Task.Run() -> 동시성 및 병렬처리와 관련된 작업일 때는 tpl을 사용한다. 더 알고 싶으면 tpl을 공부한다. ( 작업 병렬 라이브러리 )
s_calculateButton.Clicked += async (o, e) =>
{
// This line will yield control to the UI while CalculateDamageDone()
// performs its work. The UI thread is free to perform other work.
var damageResult = await Task.Run(() => CalculateDamageDone());
DisplayDamage(damageResult);
};
Task.FromResult()
Task.FromResult 메서드는 이미 계산된 값을 가진 완료된 Task를 생성하고 반환하는데 사용한다. 이 메서드는 비동기메서드를 구현할 때, 실제로 비동기 작업 없이 즉시 결과를 반환해야할 경우에 유용하다. 즉, 비동기 작업을 기다릴 필요 없이 이미 알고 있는 결과를 가지고 Task 개체를 생성할 때 유용하다.
주로 테스트 목적이나 특정 조건에서 비동기 메서드를 동기적으로 동작하게 하면서도 호출코드에는 비동기 인터페이스를 유지해야할 때 유용하다.
public Task GetNumberAsync(bool returnImmediate)
{
if (returnImmediate)
{
// 비동기 작업 없이 즉시 결과를 반환
return Task.FromResult(42);
}
else
{
// 실제 비동기 작업을 수행
return SomeAsynchronousOperation();
}
}
여기에서 GetNumberAsync는 returnImmediate에 따라서 즉시 값을 반환하거나, 실제 비동기 작업을 수행한다. 이를 사용하면... 정리하면 비동기 메서드가 있지만, 특정 상황에서 비동기 로직을 실행할 필요가 없을 때, 비동기 패턴을 그대로 유지하면서도 효율적인 실행경로를 제공할 수 있다.
LINQ를 비동기 코드와 쓸때는 주의하자. LINQ는 지연실행을 사용하기 때문에 .ToList() 또는 .ToArray() 호출을 반복하도록 생성된 시퀀스를 적용해야 비동기 호출이 foreach 루프에서 수행되면 즉시 비동기 호출이 발생한다.
private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable userIds)
{
var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
return await Task.WhenAll(getUserTasks);
}
아래에 보면 GetUserAsync는 강제로 실행된다.
몇가지 조언이 문서에 있다.
1. async 메서드에는 본문에 await 키워드가 있어야 한다. await이 없으면 일시중단되지 않는다. await 이 async 메서드의 본문에서 사용되지 않으면 코드는 일반 메서드인 것처럼 컴파일되고 실행된다. 이건 컴파일러가 비동기메서드에 대해서 생성한 상태시스템을 쓰지도 않고 일반 메서드로 취급하는 거기 때문에 매우 비효율적이다.
2. 작성하는 모든 비동기 메서드 이름의 접미사에 Async를 추가해야 한다. 안하면 너가 불편해~
3. async void는 이벤트 처리기에만 사용해야 한다. 이벤트에는 반환형식이 없어서 Task 및 Task를 사용할 수 없으므로 비동기 이벤트 처리기가 작동하도록 허용하는 유일한 방법은 async void이다.
A. async void의 다른 사용은 tap 모델을 따르지 않고 다음과 같이 사용이 어려울 수가 있다.
i. async void메서드는 테스트하기가 어렵다.
ii. 호출자가 async void 메서드를 비동기로 예상하지 못하면 의도하지 않은 잘못된 결과가 일어날 수 있다.
4. LINQ에서 비동기 람다를 사용할 경우에 신중하게 스레드해라. LINQ의 람다식은 지연실행을 사용한다. 그렇기 때문에 예상치 않은 시점에 코드 실행이 끝날 수 있다. ( 결과가 언제 리턴될지 모른다는 거다 ). 이 경우에 ToArray()로 즉시 강제실행할 수 있지만 반환한 걸 담을 무언가가 필요하다. 애초에 쿼리를 만들라고 있는거지 js의 map처럼 쓰라고 만든게 아니다. 비동기와 linq의 조합은 강력하지만 신중하게 써야한다.
5. 비 차단 방식으로 작업을 기다리는 코드를 작성한다. Task가 완료될 때까지 대기하는 수단으로 현재 스레드를 차단하면 교착 상태가 발생하고 컨텍스트 스레드가 차단될 수 있고 더 복잡한 오류 처리가 필요할 수 있다.
6. 전역 개체의 상태나 특정 메서드의 실행에 의존하지마라. 대신 메서드의 반환값에만 의존하고 집중한다.
A. 그래야 코드를 더 쉽게 추론할 수 있고
B. 더 쉽게 테스트 할 수 있고
C. 비동기 및 동기 코드를 더 쉽게 혼합할 수 있고
D. 병목 현상을 피할 수 있고
E. 반환값에만 신경쓰면 비동기 코드를 간단하게 조정할 수 있다.
예제
public async Task GetUrlContentLengthAsync()
{
var client = new HttpClient();
Task<string> getStringTask =
client.GetStringAsync("https://learn.microsoft.com/dotnet");
DoIndependentWork();
string contents = await getStringTask;
return contents.Length;
}
void DoIndependentWork()
{
Console.WriteLine("Working...");
}
이 코드를 보면 느낄만한게 있다. 먼저 GetStringAsync 메서드의 작업이 시작되는 건 첫번째 형광펜 친 부분이다. 근데 보면 앞에 await이 없다. 뒤에서 즉각적으로 실행할 작업을 먼저하고 ( DoIndependentWork() ) 그 다음에 await이 있는데, 당연히 비동기 메서드의 값을 활용하는 코드를 저 사이에 넣어버리면 에러가 나겠지? await을 만나면 GetUrlContentLengthAsync 메서드는 잠시 멈추게 된다.
저 형광펜 친 부분들을 단축한다면
string contents = await client.GetStringAsync(~~~)로 된다.
반응성 향상
비동기는 웹 액세스와 같이 차단 가능성이 있는 작업에 반드시 필요하다. 동기 프로세스 안에서 할 수 있지만, 이러한 활동이 차단되면 전체 어플리케이션이 기다려야 한다. 비동기 프로세스에서 어플리케이션은 잠재적인 차단 작업이 완료될 때까지 웹 리소스에 의존하지 않는 다른 작업을 계속 수행할 수 있다.
보통 비동기 메서드가 있는 닷넷형식에는
웹 엑세스
모든 UI 관련 작업은 대체로 스레드 한개를 공유한다. 동기 어플리케이션에서 임의의 프로세스가 차단되면 모든 프로세스가 차단된다. 어플리케이션이 응답을 중지하면 기다리지 않고 어플리케이션이 망했다고 결론을 내린다. 그치만 비동기 메서드를 사용하면 어플리케이션이 UI에 계속 응답한다. 예를 들어서 창의 크기를 조정하거나 최소화할 수 있고, 작업이 완료될 때까지 기다리고 싶지 않다면 어플리케이션을 종료할 수도 있다.
문서에서 C#의 비동기 메서드는 async await이 핵심이라고 한다. 예제 코드를 보자.
public async Task GetUrlContentLengthAsync(){
// 비동기 메서드고 결과로 Task( int를 반환하는 비동기작업 )를 반환한다.
var client = new HttpClient();
Task getStringTask = client.GetStringAsync(“”);
// 작업을 시작한다. (no await)
DoIndependentWork();
string contents = await getStringTask;
// 여기서 getStringTask 작업이 끝날때까지 스레드를 멈춘다. (await)
return contents.Length;
}
void DoIndependentWork(){
Console.WriteLine(“working...”);
}
Task와 ValueTask
ValueTask는 c#에서 비동기 프로그래밍을 지원하기 위해서 사용되는 구조체이다. 일반적으로 Task와 유사한 역할을 수행하시만, 성능 최적화를 위해서 상시가 아니라 특정 상황에서만 사용된다. ValueTask는 System.Threading.Tasks 네임스페이스에 속해 있다.
Task와 ValueTask의 주요한 차이점은 Task는 클래스인 반면에 ValueTask는 구조체라는 것이다. 때문에 ValueTask는 힙 메모리 할당을 피하고, 스택 메모리에 할당될 수 있다. 따라서 작은 규모의 비동기 연산에서 ValueTask를 사용하면 가비지 컬렉션 압력을 줄이고 성능을 향상 시킬 수 있다. 그러나, 스택에 올라가기 때문에 대규모 비동기 작업에서 사용하면 안된다. 여기서 말하는 특정상황을 언급해보면
1. 비동기 작업이 대부분 동기적으로 완료될 때
A. 비동기 작업이 빠르게 완료되어 비동기 오버헤드를 줄이고 싶은 경우에 유용하다.
2. 소규모 비동기 작업을 자주 호출할 때
A. 작업 작업을 자주 호출하면 Task 보다 ValueTask를 사용하는 것이 효율적이다.
ValueTask는 Task로 변환할 수 있는데 이 경우에는 AsTask 메서드를 사용한다. ValueTask 객체는 await 키워드를 사용하여 한번만 대기해야한다. ( 아래에서 설명 )
ValueTask는 성능 최적화가 목적이기 때문에 사용이 필수는 아니다.
(부연설명)
static async ValueTask GetValueAsync()
{
await Task.Delay(1000); // 비동기 작업 시뮬레이션
return 42;
}
static async Task IncorrectUsage()
{
var valueTask = GetValueAsync();
int result1 = await valueTask; // 첫 번째 대기: 문제없음
int result2 = await valueTask; // 두 번째 대기: 잠재적 문제 발생
}
두번째 await을 실행하면 예상치 못한 결과나 예외가 발생할 수 있다. 왜냐면 ValueTask가 Task와 다르게 다시 사용할 수 없는 일회성 비동기 작업 결과를 나타내기 때문이다. 그러나, AsTask()를 사용하면 Task로 변환할 수 있고 이 경우에 Task처럼 여러번 대기할 수 있다.
참고로 Thread.~~.Sleep 대신에 await Task.Delay(milli seconds)를 쓰는 것이 낫다.
비동기 반환형식
비동기 반환형식도 잘 알아야 한다.
Task
Task나 Task를 반환하는 비동기 메서드의 경우에는 이미 잘 알거라고 상정하고 문서을 작성한다. 그런데 void랑 그 아래는 많이 접하지 않아보았다.
void를 반환하는 비동기 메서드
void 반환 형식이 필요한 경우는 보통 이벤트 처리기이다. button이 winform의 버튼 개체라고 한다면 button.click 같은 경우에 이 void를 반환형식으로 하여 만들어진 이벤트 함수를 추가해준다. button.click += func1; 이런식으로 말이다.
문제는 void를 반환하는 비동기 메서드의 호출자는 메서드에서 throw 된 예외를 catch 할 수 없다는 것이다. 때문에 이렇게 처리되지 않는 예외로 인해서 어플리케이션이 망할 수 있다.
Task나 Task를 반환하는 메서드가 예외를 throw 하는 경우에 이 예외는 반환된 Task에 저장된다. 작업이 대기하게 되면 예외가 다시 throw 된다.
public class NaiveButton
{
public event EventHandler? Clicked;
public void Click()
{
Console.WriteLine("Somebody has clicked a button. Let's raise the event...");
Clicked?.Invoke(this, EventArgs.Empty);
Console.WriteLine("All listeners are notified.");
}
}
public class AsyncVoidExample
{
static readonly TaskCompletionSource s_tcs = new TaskCompletionSource();
public static async Task MultipleEventHandlersAsync()
{
Task<bool> secondHandlerFinished = s_tcs.Task;
var button = new NaiveButton();
button.Clicked += OnButtonClicked1;
button.Clicked += OnButtonClicked2Async;
button.Clicked += OnButtonClicked3;
Console.WriteLine("Before button.Click() is called...");
button.Click();
Console.WriteLine("After button.Click() is called...");
await secondHandlerFinished;
}
// 스레드를 최대한 활용한다.
private static void OnButtonClicked1(object? sender, EventArgs e)
{
Console.WriteLine(" Handler 1 is starting...");
Task.Delay(100).Wait();
Console.WriteLine(" Handler 1 is done.");
}
private static async void OnButtonClicked2Async(object? sender, EventArgs e)
{
Console.WriteLine(" Handler 2 is starting...");
Task.Delay(100).Wait();
Console.WriteLine(" Handler 2 is about to go async...");
await Task.Delay(500);
Console.WriteLine(" Handler 2 is done.");
s_tcs.SetResult(true);
}
private static void OnButtonClicked3(object? sender, EventArgs e)
{
Console.WriteLine(" Handler 3 is starting...");
Task.Delay(100).Wait();
Console.WriteLine(" Handler 3 is done.");
}
}
// Example output:
//
// Before button.Click() is called...
// Somebody has clicked a button. Let's raise the event...
// Handler 1 is starting...
// Handler 1 is done.
// Handler 2 is starting...
// Handler 2 is about to go async...
// Handler 3 is starting...
// Handler 3 is done.
// All listeners are notified.
// After button.Click() is called...
// Handler 2 is done.
실제적인 비동기 사용 Task.Run() vs Thread 사용자가 직접
이 쯤에서 .net 개발 시에 많이 사용되는 Task.Run메서드 에 대해서 한번 보고 가자. Task.Run은 기본적으로 스레드를 사용자가 직접 관리할 필요없이 알아서 스레드 풀을 만들어서 생성 / 폐기를 해준다.
Task.Run 메서드는 cpu 바운드 작업, 그러니까 계산이 많이 들어가는 작업을 백그라운드 스레드에서 실행하려고 할 때 주로 사용한다. Task.Run은 지정된 작업을 스레드풀의 스레드에서 비동기적으로 실행한다. 이렇게 되면 UI 스레드가 멈추기 않고 반응할 수 있게 된다.
Task.Run 또한 Task나 Task를 반환한다. 당연히 그러니 await이 같이 쓰여야 한다.
스레드 병렬 실행
명시적으로 여러 스레드를 생성하고 관리하여 동시에 여러 작업을 수행하려는 경우에 사용된다. 이 경우에는 사용자가 Thread 클래스를 사용해서 직접 스레드를 생성하고 시작한다. 각 스레드는 독립적으로 실행되며, 공유 자원에 대한 동기화를 고려해야 한다. 근데 이거 잘못쓰면 디버깅할때 스레드 엄청 생기는 걸 볼 수 있다. 당연히 낭비겠지?
아래 표를 보자
특성 Task.Run 스레드 병렬 실행
추상화 수준 높음 (간단한 API) 낮음 (직접 관리)
사용 용이성 쉬움 (간결한 코드) 어려움 (복잡한 스레드 관리)
성능 및 자원 사용 ThreadPool을 사용하여 효율적 관리 가능 직접 관리 필요, 자원 사용량이 많을 수 있음
주요 사용 사례 CPU 바운드 작업, UI 응답성 유지 고성능 병렬 처리, 복잡한 병렬 알고리즘
use case ( 여러 비동기 작업을 실행할 때 )
IEnumerable.Any()와 비동기 Task.WhenAny()를 같이 쓰면 꽤 좋은 그림이 나온다.
IEnumerable.Any() 같은 경우에 안에 뭐가 하나라도 있으면 true를 반환한다. 그리고 Task.WhenAny() 같은 경우에는 하나라도 먼저 완료가 되면 일단 Task를 리턴한다. Task.WhenAny()는 매개변수로 IEnumerable를 받는다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
class Program
{
static async Task Main(string[] args)
{
// 예제를 위한 비동기 작업 리스트
List<Task> tasks = new List<Task>
{
Task.Run(async () => { await Task.Delay(1000); return 1; }),
Task.Run(async () => { await Task.Delay(2000); return 2; }),
Task.Run(async () => { await Task.Delay(500); return 3; })
};
while (tasks.Any()) // 여전히 완료되지 않은 작업이 있으면 계속 실행
{
Task<int> completedTask = await Task.WhenAny(tasks); // 하나라도 완료되면 반환
tasks.Remove(completedTask); // 완료된 작업 제거
int result = await completedTask; // 완료된 작업의 결과 가져오기
Console.WriteLine($"완료된 작업 결과: {result}");
}
}
}
useCase ( 위와 비슷 )
static async Task SumPageSizesAsync()
{
var stopwatch = Stopwatch.StartNew();
IEnumerable<Task<int>> downloadTasksQuery =
from url in s_urlList
select ProcessUrlAsync(url, s_client);
List<Task<int>> downloadTasks = downloadTasksQuery.ToList();
int total = 0;
while (downloadTasks.Any())
{
Task<int> finishedTask = await Task.WhenAny(downloadTasks);
downloadTasks.Remove(finishedTask);
total += await finishedTask;
}
stopwatch.Stop();
Console.WriteLine($"\nTotal bytes returned: {total:#,#}");
Console.WriteLine($"Elapsed time: {stopwatch.Elapsed}\n");
}
몇가지 웹에서 더 알아본 내용
Task.Delay() 와 Thread.Sleep()의 차이
호출자 스레드의 실행을 중지하느냐? Thread.Sleep()은 중지하는 반면에 Task.Delay()는 중지하지 않는다.
Task와 같은 awaitable 개체를 await 키워드 없이 쓰면 작업의 종료시점을 언제인지 알 수 없고, 때문에 작업을 제어할 수 없다.
예를 들어서 (코드시작) Task task1 = 테스크를 반환하는 함수 Func1(); (코드끝)이 있다고 하자. 그러면 이 순간 task는 시작되었다. 이후에 await task1; 이라는 코드가 나온다. 이것이 의미하는 내용은 task1이라는 작업이 끝날때까지 기다리자. 라는 것이다.
사실 우리는 저 코드를 무의식적으로 Task task1 = await Func1(); 과 같이 사용하지만 이것은 비동기처리의 장점을 모두 활동하지 못하는 것과 같다. 우리는 그 코드들 사이에서 작업이 다른 스레드에서 진행되는 동안 ( 명확히는 스레드 풀 ) 다른 필요한 작업을 하면 된다.
그래서
async 메서드 내부의 await을 만나면 어떤 경우들이 있는지 정리해보면
1. awaitable이 예외를 발생한채 끝나버리면, await은 exception을 던진다.
2. awaitable이 이미 끝난 상태라면 async 메서드를 마치 일반 메서드처럼 동기방식으로 계속 실행한다.
스레드 풀을 정확하게 알아야 할 것 같다. 이게 그냥 스레드 생성과는 많이 다른 것 같다. 다음을 보자.
생짜 스레드 ( Thread ) 와 실용적인 스레드 ( Thread Pool )
스레드 생성을 어떻게 하느냐.
static void MainThread(){
while(true) Console.WriteLine(“Create Thread”);
}
static void Main(string[] args){
Thread thread = new Thread(MainThread);
thread.IsBackground = true;
thread.Start();
}
기본적으로 스레드를 생성하면 foreground로 생성된다. 위와 같이 IsBackground 프로퍼티로 설정가능한데 foregound 같은 경우에는 Main 메서드가 종료되도 스레드가 종료되지 않고 살아있고 ( 계속 Create Thread 문자열이 나온다. ), background 같은 경우에는 Main 메서드가 종료되면 스레드도 같이 종료된다 ( 이후 얼마간 출력되다마 멈춘다. )
Join
스레드를 호출한 함수가 스레드의 종료때까지 기다리는 것. 운영체제에서 들어서 Join의 개념은 알거다.
static void Main(string[] args){
Thread thread = new Thread(MainThread);
thread.IsBackground = true;
thread.Start();
thread.Join() ; MainThread의 종료시까지 기다림
Console.WriteLine(“system end”);
}
자 근데 이렇게 스레드를 직접 만들어서 관리하는 것은 시스템적으로도, 내 머리에게도 부담이 큰 일이다. 때문에 이걸 자동으로 해주면 좋겠지. 반자동으로라도..
때문에 ThreadPool이라는 것이 있다. 이것은 C#에서 이미 만들어져 있는 스레드를 미리 빌려와서 사용 후에 다시 반환하는 것으로서 스레드를 만드는 것보다 부담이 훨씬 적다. 주요 메서드를 보자.
ThreadPool.QueueUserWorkItem(함수이름)
TaskCreationOptions.LongRunning
public class Program
{
static void MainThread(object? obj)
{
for(int i = 0; i < 5; i++) Console.WriteLine("Create Thread");
}
static void Main(string[] args)
{
// 쓰레드를 최소 1개 최대 3개까지밖에 못빌려주도록 제한
ThreadPool.SetMinThreads(1, 1);
ThreadPool.SetMaxThreads(3, 3);
for (int i = 0; i < 3; i++)
{
// Task의 LongRunning 옵션 사용
Task t = new Task(() => { while (true) ; }, TaskCreationOptions.LongRunning);
t.Start();
}
ThreadPool.QueueUserWorkItem(MainThread);
while (true) ;
}
}
Task를 생성하면서 TaskCreationOptions.LongRunning옵션을 사용하는데 Task도 기본적으로 스레드 풀에서 돌아가지만 LongRunning 옵션을 생성해둔 것은 별도의 스레드를 생성한다.
이건 더 알아봐야 한다.
스레드 풀을 사용하는 주된 이유는 스레드의 생성과 소멸에 따르는 오버헤드를 줄이기 위해서이다. 스레드를 생성할 때 운영체제에 자원을 요청하고, 초기화하는 과정이 필요하다. 이런 과정은 시간과 자원을 상당히 많이 소모한다. 특히, 많은 수의 스레드가 짧으 시간에 반복적으로 생성되고 소멸될 때 이 오버헤드는 더욱 커진다.
스레드 풀은 이런 문제를 해결하기 위해서 미리 정해진 수의 스레드를 생성하고, 작업 요청이 잇을 때 이 스레드들을 재사용한다. 작업이 완료되면 스레드는 다시 풀로 반환되고, 다음 작업 요청시에 재사용 된다. 이 방법은 스레드의 생성과 소멸에 따르는 오버헤드를 크게 줄인다.
스레드 풀은 일반적으로 짧은 시간 내에 완료될 수 있는 작업에 가장 적합하다. 이것은 스레드 풀 내의 스레드 수가 제한되어 있기 때문이다. 만약에 스레드 풀을 사용해서 오래 걸리는 작업을 실행하게 되면, 해당 스레드는 작업이 완료될때까지 반환되지 않는다. 이로 인해서 스레드 풀 내의 다른 작업들이 대기 상태로 남게 되어서 결국 성능 저하로 이어질 수 있다.
TaskCreatingOptions.LongRunning 옵션에 대해서
Task 개체를 생성할 때 TaskCreationOptions.LongRunning옵션을 사용하면 해당 작업은 장시간 실행될 것은 예상되는 경우에 사용한다. 이 옵션을 사용하면 닷넷 런타임은 작업을 스레드 풀의 스레드가 아니라 별도의 스레드에서 실행하도록 최적화한다. 이것은 오래 걸리는 작업이 스레드 풀의 자원을 장기간 점유하지 않도록 하여, 스레드 풀의 효율성을 유지할 수 있게 한다.
그럼 ThreadPool.QueueUserWorkItem(작업메서드) 와 Task.Run( () => 작업메서드() ) 가 뭐가 정확히 다를까?
일단 두 호출 방식 모두 백그라운드에서 작업을 실행하기 위해서 사용되지만 내부적으로는 다르다.
전자의 경우에는
1. 직접적으로 스레드 풀을 사용한다. 닷넷의 스레드 풀에서 직접 작업을 실행하고 이것은 스레드 풀의 스레드 중 하나를 사용하여 지정된 작업(작업메서드)를 비동기적으로 실행한다.
2. 이 메서드는 WaitCallback 대리자를 사용하여 작업을 스케줄링하며, 이 대리자는 object 타입의 단일 매개변수를 받는다. 따라서 MainThread 메서드는 object? 타입의 매개변수를 받도록 정의되어야 한다.
3. QueueUserWorkItem은 반환값이 없어서 실행된 작업의 완료상태를 직접적으로 추적하거나 결과를 받아오는 것이 기본적으로 불가능하다.
후자 Task.Run
1. Task Parallel Library(TPL)을 사용한다. Task.Run은 tpl의 일부로 더 높은 수준의 추상화를 제공한다. 이것을 사용하면 스레드풀의 스레드를 사용하여 작업을 실행하지만, Task 개체를 통해 작업의 상태를 추적하고, 결과를 반환받을 수 있는 등 추가적인 기능을 사용할 수 있다.
2. 매개변수 전달과 반환값 : Task.Run에 전달된 람다식에서는 직접적으로 매개변수를 전달할 필요가 없다. 대신 필요한 모든 데이터는 람다식 또는 클로저를 통해서 캡쳐할 수 있다. 또한 Task.Run은 작업의 완료를 나타내는 Task 개체를 반환하는데 이 개체를 사용해서 작업의 완료를 대기하거나 작업이 성공적으로 완료되었는지 여부를 확인할 수 있다.