[C#] Task와 비동기 스레딩

[원본 링크]

이 Task란 놈은 Thread와 비슷하면서도 다른 놈이다.
이건 기본적으로 받은 함수를 스레드풀에 올린다.(스레드에 안 돌 수도 있음)

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

namespace ThreadingTest
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("main thread 시작");

            var tasks = new List<Task>();
            tasks.Add(new Task(() => Console.WriteLine("첫번째 스레드요")));
            tasks.Add(new Task(() => Console.WriteLine("두번째 스레드요")));
            tasks.Add(new Task(() => Console.WriteLine("세번째 스레드요")));
            tasks.Add(new Task(() => Console.WriteLine("네번째 스레드요")));

            //전부 실행
            foreach(var task in tasks)
                task.Start();

            //다 끝날때까지 대기
            foreach (var task in tasks)
                task.Wait();

            Console.WriteLine("main thread 종료");
        }
    }
}

Wait는 Thread의 Join과 유사한 역할을 한다.
뭐 여기까지는 기존의 스레드와 다를 게 없다.

태스크가 스레드와 다른 점중 하나는 리턴값을 처리할 수도 있다는 것이다.
위에서 쓴건 리턴값이 없는 버전이고, 리턴버전으로 Task가 또 따로 있다.

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

namespace ThreadingTest
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("main thread 시작");

            var tasks = new List<Task<String>>();
            tasks.Add(new Task<String>(() => 
            {
                Console.WriteLine("첫번째 스레드요");
                return "Boom"; 
            }));
            tasks.Add(new Task<String>(() =>
            {
                Console.WriteLine("두번째 스레드요");
                return "Baaaaam";
            }
            ));

            //전부 실행
            foreach(var task in tasks)
                task.Start();

            //다 끝날때까지 대기했다가 리턴값 가져옴
            Console.WriteLine($"{tasks[0].Result} {tasks[1].Result}"); //반환값 가져옴

            Console.WriteLine("main thread 종료");
        }
    }
}

리턴값을 사용할 경우엔 굳이 Wait를 쓸 필요는 없다.
.Result로 리턴값을 가져오려고 할 경우, 태스크가 다 끝났으면 바로 가져오고 아니면 끝날때까지 블럭시키기 때문이다.

아 그리고 Task는 객체를 생성 후 Start하는 방식이 아니라 Run이라는 정적메서드로도 생성할 수도 있다.

취향대로 골라쓰면 된다.
저것도 리턴 여부에 따라 Run과 Run로 나뉘어있다.


그리고 기존의 비동기 실행법을 대충 보자.
논 블러킹 IO를 쓸 때는 보통 Begin으로 시작하는 메서드들을 쓰면 됐다.
아래 코드는 그냥 텍스트 하나 써보내는 간단한 예제다.

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

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("## 메인 스레드 시작");

            var writer = new FileStream("text.txt", FileMode.Create, FileAccess.Write);

            var bytes = Encoding.UTF8.GetBytes("안녕");

            //스레드로 분리해서 돌림.
            writer.BeginWrite(bytes, 0, bytes.Length,
                /*다 끝나면 호출됨*/
                (IAsyncResult result) => Console.WriteLine("!! 쓰기 완료"), null);

            //스레드 분리 후 바로 실행됨
            for (int i = 0; i < 20; i++)
                Console.WriteLine("@ 메인 스레드 작업중...");

            Console.WriteLine("## 메인 스레드 종료");  
        }
    }
}

이렇게 해도 잘 돌아가는데, 사실 저 Begin 메서드에 맞는 짝으로 End가 항상 있다.
그것도 함께 써주는 편이 좋다.
왜인지는 모르겠는데 아무튼 그러라고 한다.
이렇게 쓰면 된다.



뭐 대충 이런식으로 말이다.

아 그리고 델리게이트로도 Begin/End 호출이 지원되는데, 델리게이트에 반환타입이 있다면 End 함수가 그 반환을 책임진다. 그럴때는 더욱 필요하다.


그런데 C# 5.0부터는 이 Begin/End 쌍 대신에 다른 놈이 지원이 된다.
바로 Async가 붙은 메서드들과 async 키워드와 await 키워드다.
Async가 붙은 함수들은 항상 호출 앞에 await가 붙어야 한다. 안그러면 블럭된다.
일단 async 키워드가 붙은 메서드나 람다식은 그게 비동기 함수거나 비동기 함수를 사용하는 함수임을 나타낸다.
그리고 await가 붙은 부분 뒤는 내부에서 사용한 비동기 동작이 끝나거든 수행이 되도록 컴파일러가 처리를 해준다. await는 async 함수에서만 사용이 가능하다.

using System.Threading.Tasks;
using System.IO;
using System.Text;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("## 메인 스레드 시작");

            new Action(async () =>
            {
                var writer = new FileStream("text.txt", FileMode.Create, FileAccess.Write);

                var bytes = Encoding.UTF8.GetBytes("안녕");

                //스레드로 분리해서 돌림.
                await writer.WriteAsync(bytes, 0, bytes.Length);

                //여기부터는 저 await 수행 완료 후 돌아감
                Console.WriteLine("!! 쓰기 작업 완료");
            })();

            //스레드 분리 후 바로 실행됨
            for (int i = 0; i < 20; i++)
                Console.WriteLine("@ 메인 스레드 작업중...");

<br>

            Console.WriteLine("## 메인 스레드 종료");  
        }
    }
}

이러면 잘 되는데

만약 await를 빼면

이렇게 블럭이 걸려버린다.
평범한 함수가 되어버린 것이다.

기본적으로 await 가능한 비동기함수들은 전부 Task를 반환한다.
await가 그 Task를 받아서 어떻게 잘 비동기처리를 해주는듯하다.
원리는 잘 모르겠다만, 위에서 쓴 WriteAsync를 직접 구현해볼 수도 있다.

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

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("## 메인 스레드 시작");

            //직접 await 가능한 비동기함수 구현
            Func<String, Task> MyWriteAsync = (String text) =>
            {
                var writer = new FileStream("text.txt", FileMode.Create, FileAccess.Write);

                var bytes = Encoding.UTF8.GetBytes(text);

                //스레드로 분리해서 돌림.
                return Task.Factory.StartNew
                (() => writer.Write(bytes, 0, bytes.Length));
            };

            new Action( async () =>
            {
                await MyWriteAsync("안녕하시오");
                Console.WriteLine("!! 쓰기 완료!");
            })();

<br>

            //스레드 분리 후 바로 실행됨
            for (int i = 0; i < 20; i++)
                Console.WriteLine("@ 메인 스레드 작업중...");

            Console.WriteLine("## 메인 스레드 종료");  
        }
    }
}

똑같이 잘 된다.

아마 await의 작동방식은 이런 것 같다.
1.반환한 태스크를 받고, 그 뒤의 부분을 따로 분리해서 함수로 묶어놓는다.
2.그리고 태스크를 실행하고, 완료 후에 뒷부분이 수행되도록 잘 처리를 해준다.
3.await한 함수의 반환타입과 값은 Task가 벗겨진 값이 된다. await를 붙인 Task라면 void가 되고 Task이라면 R이 된다.