C#/Advanced C#

[C#] Task (1) Async / Await overview

로파이 2021. 10. 23. 00:01

C#에서 병렬 처리나 비동기 작업을 수행하기 위해 제공하는 기능을 알아본다.

 

System.Threading.Tasks 네임스페이스에 포함된 Task, Task<TResult>, Parallel 클래스

 

비동기 코드는 비동기 메소드를 호출하여 그 메소드가 종료되어 반환되기 전까지 기다리지 않고 계속 실행하는 것을 의미한다.

비동기의 반대, 동기 코드는 일반적인 실행 흐름과 같으며 한 라인이 실행되어야 다음 라인이 실행될 수 있다.

 

Task 클래스

Task는 반환형이 void인 함수 타입, Action 대리자를 받아 Thread 스레드 개체를 생성하는 것처럼 Action을 할당한다.

Task myFirstTask = new Task(() => { Console.WriteLine("A new task"); });

myFirstTask.Start();

new를 통해 생성한 Task 인스턴스는 작업만 할당된 상태이다.

Start()를 호출하면 System.Threading.Tasks.TaskScheduler에 등록해서 실행하도록 하고 Task 스케줄러에 의해 Action 바탕 함수는 비동기적으로 실행하게 된다.

바탕 함수는 주로 ThreadPool의 작업자 스레드 중 하나를 이용하여 실행하게된다.

 

작업 생성과 동시에 실행하기

var tCreatedStarted = Task.Run(() =>
            {
                Thread.Sleep(1000);
                Console.WriteLine("Printed Asynchronously.");
            });
  • Task 클래스의 정적메서드 Run()을 통해 생성과 동시에 비동기적으로 실행할 수 있다.
  • 이때 Task는 ThreadPool이라는 정적 클래스가 가지고 있는 스레드 중 하나에 할당되어 실행된다.
  • Run 함수를 통해 얻은 Task 인스턴스를 다룰 수 있다.

 

Wait()

Console.WriteLine("Printed Synchronously");

tCreatedStarted.Wait();

인스턴스 메서드 Wait()을 호출하여 해당 비동기 작업이 끝날 때까지 기다릴 수 있다. 쓰레드에서 Join()을 호출하는 것과 동일한 원리이다.

 

RunSynchronously()

해당 작업을 명시적으로 동기 실행한다.

Task tCreated = new Task(() => { Console.WriteLine("A new task"); });

tCreated.RunSynchronously();

 

Task<T>

비동기로 실행할 함수의 반환이 T 타입일 때, 해당 결과를 받을 수 있는 클래스이다. Task 클래스는 비동기 실행 목적도 갖지만 새로운 스레드를 생성하여 일을 분리하여 작업하는 병행 처리용으로도 사용 가능하다.

 

특정 범위의 수에서 모든 소수 찾기 프로그램

Func<object, List<long>> FindPrimeFunc = (objRange) =>
{
  long[] range = (long[])objRange;
  List<long> found = new List<long>();

  for (long i = range[0]; i <= range[1]; ++i)
  {
  	if (IsPrime(i))
  		found.Add(i);
  }

	return found;
};

큰 범위를 여러개의 작은 범위로 나누고 각 작은 범위안의 모든 수를 찾아 List<long>에 모아 반납하는 함수이다.

            Task<List<long>>[] tasks = new Task<List<long>>[taskCount];

            long taskRange = (to - from + 1) / tasks.Length;
            long currentFrom = from;
            long currentTo = currentFrom + taskRange;

            for(int i = 0; i < tasks.Length; ++i)
            {
                Console.WriteLine("Task[{0}] : {1} ~ {2}", i, currentFrom, currentTo);

                tasks[i] = new Task<List<long>>(FindPrimeFunc, new long[] { currentFrom, currentTo });

                currentFrom = currentTo + 1;

                if (i == tasks.Length - 2)
                    currentTo = to;
                else
                    currentTo = currentFrom + taskRange;
            }

            Console.WriteLine("Please Press Enter To Start...");
            Console.ReadLine();
            Console.WriteLine("Started...");
            
            foreach(Task<List<long>> task in tasks)
            {
                task.Start();
            }

            List<long> total = new List<long>();

            foreach(Task<List<long>> task in tasks)
            {
                task.Wait();
                total.AddRange(task.Result.ToArray());
            }

각 범위를 알맞게 나누고 Task를 할당하여 작업을 실행한다. 최종 결과는 task.Wait()을 호출하면서 차례대로 모은다.

 

Parallel의 정적 메서드 For와 ForEach를 이용하여 병렬처리를 쉽게 할 수 있다. 

List<long> total = new List<long>();

Parallel.For(from, to + 1, (long i) =>
{
  if (IsPrime(i))
  {
  	lock (total)
  	{
  		total.Add(i);
  	}  
  }  
});

 

async와 await

async는 메서드, 이벤트 처리기, 태스크, 람다식 등을 수식하여 C# 컴파일러가 해당 함수를 비동기적으로 실행하도록 코드를 생성한다.

 

MSDN에 async와 await가 잘 설명되어 있는데, async와 await 키워드는 흐름대로 코드를 읽었을 때, 어떤 일련의 작업을 쉽게 알 수 있도록 하기위해 만들었다고 소개되어 있다. 번역하면 다음과 같다.

 

아침식사를 준비하는 과정에서 어떤 일련의 작업들을 생각할 수 있다. 

1) 준비된 따뜻한 커피를 내린다.

2) 후라이팬을 달구고 계란 두 개를 올린다.

3) 3장의 베이컨을 튀긴다.

4) 두 장의 빵을 토스터기에 넣는다.

5) 토스트된 두 빵 중 하나에는 버터를 바르고 하나에는 잼을 바른다.

6) 오렌지 주스를 붓는다.

 

이러한 준비 과정을 보았을 때 언듯보면 몇개의 일들은 "비동기적으로" 할 수 있다. 계란과 베이컨을 같이 만들 수 도 있고 식빵을 토스터기에 넣고 후라이팬 조리를 시작할 수도 있다. 각 준비하는 일을 하면서 비동기적으로 시작해두었던 일, 식빵을 토스터기에 넣었다면, 그것을 완료가 되었는 지 살핀다던가를 할 것이다.

 

아침 식사는 병행 처리가 아닌 비동기 작업의 좋은 예로 한 사람이(하나의 스레드)가 모든 작업을 처리할 수 있다. 비유를 계속하자면, 한 사람이 첫 작업을 완료하기 전에 다음 작업을 시작하는 방식으로 비동기적으로 아침 식사를 준비할 수 있다. 이미 시작된 모든 일들은 누가 지켜 보거나 말거나 계속 진행되고 있다. 팬을 달구면서 베이컨을 튀길 수 있고 혹은 베이컨을 튀기기 시작하고 바로 식빵을 토스터기에 넣는다.

 

한편 병행 처리는, 혼자가 아닌 여러명의 요리사가 아침 식사를 준비하는 것과 같다. 각 요리사가 온전히 자신이 맡은 일만 집중하여 수행하면 된다. 각 요리사는 작업을 완료할 때까지 다른 일을 하지 않는다.

 

다음 아침식사 준비과정을 코드로 나타낸 것이다.

        static void Main(string[] args)
        {
            Coffee cup = PourCoffee();
            Console.WriteLine("coffee is ready");

            Egg eggs = FryEggs(2);
            Console.WriteLine("eggs are ready");

            Bacon bacon = FryBacon(3);
            Console.WriteLine("bacon is ready");

            Toast toast = ToastBread(2);
            ApplyButter(toast);
            ApplyJam(toast);
            Console.WriteLine("toast is ready");

            Juice juice = PourOrangeJuice();
            Console.WriteLine("Orange juice is ready");
            Console.WriteLine("Breakfast is ready!");
        }

1) 커피 따르기 -> 2) 계란 프라이 -> 3) 베이컨 프라이 -> 4) 식빵 토스트 -> 5) 오렌지 주스 따르기순으로 구성되어 있다.

 

- 함수들의 정의

더보기
        private static Juice PourOrangeJuice()
        {
            Console.WriteLine("Pourint orange juice");
            return new Juice();
        }

        private static void ApplyJam(Toast toast) => Console.WriteLine("Putting jam on the toast");

        private static void ApplyButter(Toast toast) => Console.WriteLine("Putting butter on the toast");

        private static Toast ToastBread(int slices)
        {
            for(int slice = 0; slice < slices; ++slice)
            {
                Console.WriteLine("Putting a slice of bread in the toaster");
            }
            Console.WriteLine("Start toasting");
            Task.Delay(3000).Wait();
            Console.WriteLine("Remove toast from toaster");

            return new Toast();
        }

        private static Bacon FryBacon(int slices)
        {
            Console.WriteLine($"putting {slices} slcies of bacon in the pan");
            Console.WriteLine("cooking first side of bacon...");
            Task.Delay(3000).Wait();
            for(int slice = 0; slice < slices; ++slice)
            {
                Console.WriteLine("flipping a slice of bacon");
            }
            Console.WriteLine("cooking the second side of bacon...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Put bacon on plate");

            return new Bacon();
        }

        private static Egg FryEggs(int howMany)
        {
            Console.WriteLine("Warming the egg pan...");
            Task.Delay(3000).Wait();
            Console.WriteLine($"cracking {howMany} eggs");
            Console.WriteLine("coioking the eggs...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Put eggs on plate");

            return new Egg();
        }

        private static Coffee PourCoffee()
        {
            Console.WriteLine("Puring coffee");
            return new Coffee();
        }

https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/

차례대로 수행된 아침 식사 준비는 각 준비과정에서 걸린 시간의 합으로 대략 30분 정도 걸릴 것이다.

 

컴퓨터는 이렇게 각 작업이 완료되어야만 다음 작업을 수행하기 때문에 실제 대기시간(Task.Delay)과 Console.WriteLine과 같은 호출에 의한 대기로 실제 사람이 만드는 것보다 더 오랜 시간이 걸릴 것이다.

 

따라서 비동기 instructions을 만들고 싶다면 비동기 코드를 작성해야한다.

 

이러한 고려는 오늘날 프로그램을 만드는데 매우 중요하다. 당신이 클라이언트 코드를 짜고 있다면, 당신이 만든 UI가 사용자 입력에 따라 반응이 빨리 되기를 원할 것이다. 당신의 핸드폰이 웹에서 데이터를 다운로드 한다고 UI 일들을 하지 않아 얼음처럼 반응이 없는 것을 원하지 않을 것이다. 서버 프로그램을 만든다면, 당신의 스레드드들이 블록되는 것을 원치 않을 거이다. 그런 스레드들이 다른 요청을 처리할 수도 있기 때문이다. 비동기 코드를 만들 수 있는 곳에 동기 코드를 사용하는 것은 더 적은 비용으로 시스템을 확장할 수 있는 기회를 없애버리는 것이다.

 

성공적인 현대 어플리케이션들은 비동기 코드를 요구한다. 언어 지원과 상관없이 비동기 코드는 콜백 함수, 완료 이벤트 혹은 원래의 코드를 보기 어렵게 만드는 무언가의 수단이 필요하다. 동기 코드의 이점은 보기에 쉽게 읽힌다는 점이다. 전통적인 비동기 코드는 해당 코드가 어떻게 작업되는 지 확인할 수 있는 것이 아니라 비동기를 만드는 무언가에 집중할 수 밖에 없었다.

 

막지말고 기다리게하라. Don't block, await Instead.

다음 코드들은 동기 코드를 비동기로 만들려한 async, await를 바람직하지 못하게 적용한 예이다. 쓰여진대로, 이 코드는 스레드가 다른 작업을 하는 것을 하지 못 하게 막아버린다. 어떠한 작업이라도 진행중이라면, 이것은 중단되지 않을 것이다. 이는 마치 토스터기에 빵을 넣고 쳐다보고 있는 것과 같아서 다른 사람이 당신에게 뭐라 말해도 토스터기가 터져 올라오지 않는 이상 무시할 것이다.

 

        static async void Main(string[] args)
        {
            Coffee cup = PourCoffee();
            Console.WriteLine("coffee is ready");

            Egg eggs = await FryEggs(2);
            Console.WriteLine("eggs are ready");

            Bacon bacon = await FryBacon(3);
            Console.WriteLine("bacon is ready");

            Toast toast = await ToastBread(2);
            ApplyButter(toast);
            ApplyJam(toast);
            Console.WriteLine("toast is ready");

            Juice juice = PourOrangeJuice();
            Console.WriteLine("Orange juice is ready");
            Console.WriteLine("Breakfast is ready!");
        }

코드를 살펴보면, await 키워드는 블로킹되지 않는 방식으로 작업하여 작업이 완료되었을 때 계속 실행할 수 있는 방식을 제공한다. 

 

이제 FryEggs, FryBacon, ToastBread 같은 함수는 Task<Egg>, Task<Bacon>, Task<Toast>를 반환하도록 함수를 만들었다. 또한 함수는 async로 수식된다.

 

계란이나 베이컨을 튀기는 함수를 호출한다해도 이 코드는 블록되는 일이 없다. 또 한편으로는 이 작업들이 끝날 때 까지 기다리고 있기 때문에 다른 작업을 시작할 수 없다. 여전히 그 뒤에 있는 토스트를 만드는 것은 여전히 토스터기가 빵을 팝할때까지 쳐다보고 있는 것과 같다.

 

이제 아침 식사를 준비하는 이 스레드는 시작된 작업이 끝날 때까지 기다리는 동안 블록되지 않는다. 몇몇의 어플리케이션에서는 이정도만 필요할 수 있다. 하지만 이 시나리오에서는 무언가 더 필요하다. 계란 후라이를 만들고 베이컨을 만들기 시작하고 베이컨을 만든뒤에 토스트를 만들기 시작할 수는 없기 때문이다. 이전 작업을 완료할 때까지 기다리기전에 각 요소를 시작하는 게 좋다.

 

동시에 작업을 시작해라. Start tasks concurrently

 

많은 시나리오에서 당신은 독립된 작업을 즉시 시작하길 원할 것이다. 각 작업이 끝나면 다른 준비된 작업을 시작한다. 아침 식사의 비유에서 이것이 어떻게 당신이 빨리 준비하는 방법일 것이다. 이상적으로 모든 작업이 한번에 끝나면 따끈따끈한 식사를 할 수 있을 테니까.

 

System.Threading.Tasks.Task 클래스 그리고 관련된 타입들은 작업 중 인 Task에 대해 어느 정도 추론을 할때 사용하는 클래스들이다. 이것들이 아침식사를 실제로 만드는 것을 모방하여 코드를 짤 수 있도록 해준다. 당신은 계란, 베이컨, 토스트를 이제 동시에 시작할 수 있게 되었다. 각 필요한 행동은 각 테스크에서 다음 작업을 고려하고 무언가를 기다려야할지 생각하는 것이다.

 

먼저 작업을 시작할 때 기다리게 하지말고 실행중인 작업을 따로 Task에 담아둔다. 

Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");

Task<Egg> eggsTask = FryEggsAsync(2);
Egg eggs = await eggsTask;
Console.WriteLine("eggs are ready");

Task<Bacon> baconTask = FryBaconAsync(3);
Bacon bacon = await baconTask;
Console.WriteLine("bacon is ready");

Task<Toast> toastTask = ToastBreadAsync(2);
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("toast is ready");

Juice oj = PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");

그 다음 await를 사용하는 구문의 순서를 조정하여 베이컨과 계란 후라이를 기다리는 것을 뒤로 미룬다.

Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");

Task<Egg> eggsTask = FryEggsAsync(2);
Task<Bacon> baconTask = FryBaconAsync(3);
Task<Toast> 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!");

비동기 방식으로 준비한 아침 식사는 대략 20분 정도 걸릴 것이다. 이 방식으로 덕분에 더 빨리 식사를 준비하게 되었다.

하지만 토스트를 만드는 것이 오래걸려 계란 후라이와 베이컨을 접시에 옮기기까지 지체되었고 태워먹어버렸다.

 

다음에 올 코드들은 더 나은 방법을 제공한다. 이제 비동기 작업들을 모두 한번에 시작한다. 당신은 각 결과가 필요할 때 await로 기다리면 된다. 다음에 올 코드들은 웹 어플리케이션과 유사하며 세밀하게 나뉘어진 서비스들을 요청하고 한 페이지를 띄우기 위해 조합한다.

 

작업을 조합한다. Composition with tasks

토스트를 만드는 것이 더 오래걸려 토스트를 제외하고 아침식사 준비를 동시에 마쳤다. 토스트를 만드는 것은 비동기 작업(토스터기에 토스트를 넣는 것)과 동기적 작업(빵에 버터와 잼을 바르는것)의 구성으로 만들고 코드를 업데이트 한다. 다음 개념은 중요하다.

 

동기적(Synchrnous) 작업이 뒤따르는 비동기(Asynchronous) 작업의 조합은 비동기 작업이다. 다른 말로 어느 한 부분이라도 비동기 작업을 포함한다면 전체 작업은 비동기로 취급한다.

 

토스트를 만드는 복합 작업을 비동기 함수로 정의하면 다음과 같다. 동기적 작업(빵에 버터와 잼을 바르는것)은 비동기 작업(토스터기에 토스트를 넣는 것)이 끝난 뒤에 실행될 수 있다.

static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
    var toast = await ToastBreadAsync(number);
    ApplyButter(toast);
    ApplyJam(toast);

    return toast;
}

async 키워드로 수식된 메서드는 해당 메서드가 await 키워드가 포함되어, 비동기 작업을 포함한다고 컴파일러에게 힌트를 주는 것이다. 이 메서드는 토스트를 만든 뒤 버터와 잼을 바를 것이다.  Task<Toast>를 반환하는 테스크의 결과는 3 작업의 복합물을 나타낸다.

 

static async Task Main(string[] args)
{
    Coffee cup = PourCoffee();
    Console.WriteLine("coffee is ready");
    
    var eggsTask = FryEggsAsync(2);
    var baconTask = FryBaconAsync(3);
    var toastTask = MakeToastWithButterAndJamAsync(2);

    var eggs = await eggsTask;
    Console.WriteLine("eggs are ready");

    var bacon = await baconTask;
    Console.WriteLine("bacon is ready");

    var toast = await toastTask;
    Console.WriteLine("toast is ready");

    Juice oj = PourOJ();
    Console.WriteLine("oj is ready");
    Console.WriteLine("Breakfast is ready!");
}

이렇게 조합을 다르게 하여 새로운 작업을 정의하고 비동기 실행을 하도록 하거나 언제 await를 언제 하냐에 따라서 더 빠른 성능을 보이는 코드를 만들 수 있을 것이다. 

 

비동기적 예외 Asynchronous exceptions

이전의 상황에서는 항상 작업이 별일 없이 완료될 것이라 가정했지만 작업에 오류가 발생하여 비동기 메서드에서 예외를 던질 수 있다.

클라이언트 코드는 이 메서드를 await하는 중에 그러한 예외를 캐치할 수 있다. 

private static async Task<Toast> ToastBreadAsync(int slices)
{
    for (int slice = 0; slice < slices; slice++)
    {
        Console.WriteLine("Putting a slice of bread in the toaster");
    }
    Console.WriteLine("Start toasting...");
    await Task.Delay(2000);
    Console.WriteLine("Fire! Toast is ruined!");
    throw new InvalidOperationException("The toaster is on fire");
    await Task.Delay(1000);
    Console.WriteLine("Remove toast from toaster");

    return new Toast();
}
Pouring coffee
coffee is ready
Warming the egg pan...
putting 3 slices of bacon in the pan
cooking first side of bacon...
Putting a slice of bread in the toaster
Putting a slice of bread in the toaster
Start toasting...
Fire! Toast is ruined!
flipping a slice of bacon
flipping a slice of bacon
flipping a slice of bacon
cooking the second side of bacon...
cracking 2 eggs
cooking the eggs ...
Put bacon on plate
Put eggs on plate
eggs are ready
bacon is ready
Unhandled exception. System.InvalidOperationException: The toaster is on fire
   at AsyncBreakfast.Program.ToastBreadAsync(Int32 slices) in Program.cs:line 65
   at AsyncBreakfast.Program.MakeToastWithButterAndJamAsync(Int32 number) in Program.cs:line 36
   at AsyncBreakfast.Program.Main(String[] args) in Program.cs:line 24
   at AsyncBreakfast.Program.<Main>(String[] args)

이 코드를 적용하면 토스트를 만드는 과정에서 예외가 발생한 것을 알 수 있다. 한 비동기 작업(Task)이 예외를 던지면, Task는 faulted 잘못되어진다. 또한 Task 개체는 Task.Exception 프로퍼티에 던져진 예외를 가지고 있다. 잘못된 작업들은 그들이 awaited 상태일 때 예외를 던진다. 

 

두 가지 이해해야하는 중요한 메커니즘은 첫째 어떻게 잘못된 Task에 예외가 저장되느냐고 둘째는 예외를 Task에서 다시 꺼내 다시 예외를 던질 수 있느냐이다.

 

코드가 예외를 던지면, 예외는 Task 개체에 저장된다. Task.Exception 프로퍼티는 System.AggregateException인데 왜냐하면 비동기 작업중에 둘 이상의 예외가 던져질 수 있기 때문이다. 모든 던져진 예외는 AggregateException.InnerExcetopns라는 컬렉션에 추가된다. 만약 Exception 프로퍼티가 null 이라면, 새로운 AggregateException이 생성되고 첫번째 예외가 컬렉션 아이템으로 저장될 것이다.

 

보통은 하나의 예외를 포함하고 있다. 코드가 잘못된 task를 await하고 있다면, AggregateException.InnerExceptions 컬렉션에 있는 첫번째 예외가 다시 던져진다. 이것이 왜 콘솔에서 AggregateException가 아니라 InvalidOperationException 예외가 던져진지 알 수 있다.

 

효율적으로 작업을 대기하라. Await tasks efficiently

일련의 await 문들은 Task 클래스의 메서드를 사용함으로 향상될 수 있다. WhenAll이라는 API는 모든 인자로 전해진 Task 인스턴스들이 완료되었을 때 반환을 한다.

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!");

또 다른 옵션은 WhenAny를 사용하는 것인데 인자로 전달한 Task 인스턴스중 하나라도 완료되었다면 반환한다. 다음 코드는 while 문안에서 완료된 작업이 있을 때마다 작업 리스트에서 제거하는 방식으로 구현한다.

var breakfastTasks = new List<Task> { 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");
    }
    breakfastTasks.Remove(finishedTask);
}

 

최종 코드는 다음과 같다.

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;

namespace CSharpThread
{
    class Program 
    {
        static async Task Main(string[] args)
        {
            Coffee cup = PourCoffee();
            Console.WriteLine("coffee is ready");

            var eggsTask = FryEggsAsync(2);
            var baconTask = FryBaconAsync(3);
            var toastTask = MakeToastWithButterAndJamAsync(2);

            var breakfastTasks = new List<Task> { 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");
                }
                breakfastTasks.Remove(finishedTask);
            }
 
            Juice juice = PourOrangeJuice();
            Console.WriteLine("Orange juice is ready");
            Console.WriteLine("Breakfast is ready!");
        }

        private static Juice PourOrangeJuice()
        {
            Console.WriteLine("Pourint orange juice");
            return new Juice();
        }

        private static void ApplyJam(Toast toast) => Console.WriteLine("Putting jam on the toast");

        private static void ApplyButter(Toast toast) => Console.WriteLine("Putting butter on the toast");

        private static async Task<Toast> MakeToastWithButterAndJamAsync(int slices)
        {
            var toast = await ToastBreadAsync(slices);
            ApplyButter(toast);
            ApplyJam(toast);
            return toast;
        }

        private static async Task<Toast> ToastBreadAsync(int slices)
        {
            for (int slice = 0; slice < slices; ++slice)
            {
                Console.WriteLine("Putting a slice of bread in the toaster");
            }
            Console.WriteLine("Start toasting");
            await Task.Delay(3000);
            Console.WriteLine("Remove toast from toaster");

            return new Toast();
        }

        private static async Task<Bacon> FryBaconAsync(int slices)
        {
            Console.WriteLine($"putting {slices} slcies of bacon in the pan");
            Console.WriteLine("cooking first side of bacon...");
            await Task.Delay(3000);
            for(int slice = 0; slice < slices; ++slice)
            {
                Console.WriteLine("flipping a slice of bacon");
            }
            Console.WriteLine("cooking the second side of bacon...");
            await Task.Delay(3000);
            Console.WriteLine("Put bacon on plate");

            return new Bacon();
        }

        private static async Task<Egg> FryEggsAsync(int howMany)
        {
            Console.WriteLine("Warming the egg pan...");
            await Task.Delay(3000);
            Console.WriteLine($"cracking {howMany} eggs");
            Console.WriteLine("coioking the eggs...");
            await Task.Delay(3000);
            Console.WriteLine("Put eggs on plate");

            return new Egg();
        }

        private static Coffee PourCoffee()
        {
            Console.WriteLine("Puring coffee");
            return new Coffee();
        }

        class Juice { }
        class Bacon { };
        class Toast { };
        class Egg { };
        class Coffee { };
    }
}