Programming/C#

C# 5.0 변경점 - 호출자 정보, 비동기 호출

lee308812 2019. 4. 24. 22:23

- C# 5.0에 대응되는 닷넷 프레임워크는 4.5이고 주요 개발환경은 Visual Studio 2012, 2013이다. 닷넷 4.5는 윈도우 XP, 서버 2003을 지원하지 않는다는 점에 유의하자. 만약 개발한 프로그램이 XP/서버 2003에서 실행되어야 한다면 닷넷 4.0용 응용프로그램을 만들어야 한다.

 

- 닷넷 프레임워크 4.5, 4.6, 4.7의 특징으로, 이것이 닷넷 4.0의 교체판이라는 점이다. 기존의 닷넷 프레임워크는 컴퓨터에 설치하면 "%windir%\Microsoft.NET\Framework" 폴더 아래에 각 버전 번호에 해당하는 폴더가 생성되는 식으로 설치 되었으나, 닷넷 4.5 ~ 4.7은 닷넷 4.0이 설치되어 있다면 덮어써버리고, 4.0이 설치되지 않았다면 새롭게 4.0 폴더에 설치된다.

 

[ 호출자 정보 ]

- C/C++에서 사용되는 __LINE__, __FILE__ 과 같은 매크로 상수가 실제로 디버깅에 매우 유용하게 사용됬기에, C#에서도 이에 대한 요구사항을 수용해 호출자 정보(caller information)로 구현되었다.

하지만, 매크로를 통해 구현된 것은 아니고 C#의 특징을 살려 특성(attribute)과 선택적 매개변수의 조합으로 구현되었다.

using System;
using System.Runtime.CompilerServices;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            LogMessage("테스트 로그");
        }

        static void LogMessage(string txt,
            [CallerMemberName] string memberName = "",
            [CallerFilePath] string filePath = "",
            [CallerLineNumber] int lineNumber = 0)
        {
            Console.WriteLine("텍스트 : " + txt);
            Console.WriteLine("LogMessage를 호출한 메서드 이름 : " + memberName);
            Console.WriteLine("LogMessage를 호출한 소스코드의 파일명 : " + filePath);
            Console.WriteLine("LogMessage를 호출한 소스코드의 라인 번호 : " + lineNumber);
        }
    }
}

 

- "호출자 정보"란 단어 그대로 "호출하는 측의 정보"를 메서드의 인자로 전달하는 것을 말한다. 현재는

CallerMemberName, CallerFilePath, CallerLineNumber 세 가지만 허용된다.

 

- 호출자 정보는 소스코드와 관련이 있기 때문에 이 정보들은 C# 컴파일러에 의해 소스코드 컴파일 시점에 다음과 같이 치환되어 빌드된다.

using System;
using System.Runtime.CompilerServices;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            LogMessage("테스트 로그", "Main", @"d:\temp\ConsoleApp1\Program.cs", 10);
        }
    }
}

 

[ 비동기 호출 ]

- C# 5.0에는 async와 await 예약어가 새롭게 추가되었다. 이 예약어를 이용하면 비동기 호출을 마치 동기 호출처럼 구현하는 코드를 작성할 수 있다.

 

- 기존 비동기 버전의 BeginRead 메서드를 호출했을 때는 Read 동작 이후의 코드를 별도로 분리해 Completed 같은 형식의 메서드에 담아 처리해야 하는 불편함이 있었다.

 

using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;

namespace ConsoleApp1
{
    class FileState
    {
        public byte[] buffer;
        public FileStream File;
    }

    class Program
    {
        static void Main(string[] args)
        {
            using (FileStream fs = new FileStream(@"C:\windows\system32\drivers\etc\HOSTS", FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
            {
                FileState state = new FileState();
                state.buffer = new byte[fs.Length];
                state.File = fs;

                fs.BeginRead(state.buffer, 0, state.buffer.Length, readCompleted, state);
            }
        }

        static void readCompleted(IAsyncResult ar)
        {
            FileState state = ar.AsyncState as FileState;
            state.File.EndRead(ar);
			
            // 아래 두 라인을 그냥 실행하면 동기식
            string txt = Encoding.UTF8.GetString(state.buffer);
            Console.WriteLine(txt);
        }
    }
}

- 그런데 위의 코드를 가만히 보면 동기를 비동기로 바꾸는 것은 Read 호출 이후의 코드를 BeginRead에 전달하는 것으로 해결된다는 사실을 알 수 있다. 혹시 이 작업을 컴파일러가 알아서 해줄수는 없을지 그런 목적으로 탄생한 것이 C# 5.0의 async/await 예약어이다.

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

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            AwaitRead();
            Console.ReadLine();
        }

        private static async void AwaitRead()
        {
            using (FileStream fs = new FileStream(@"C:\windows\system32\drivers\etc\HOSTS", FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
            {
                byte[] buf = new byte[fs.Length];

                Console.WriteLine("Before ReadAsync: " + Thread.CurrentThread.ManagedThreadId);
                await fs.ReadAsync(buf, 0, buf.Length);
                Console.WriteLine("After ReadAsync: " + Thread.CurrentThread.ManagedThreadId);

                // 아래의 두 라인은 C# 컴파일러가 분리해 ReadAync 비동기 호출이 완료된 후 호출
                string txt = Encoding.UTF8.GetString(buf);
                Console.WriteLine(txt);
            }
        }
    }
}

- .NET 프레임워크 4.5에 구현된 FileStread 타입은 await 비동기 호출에 사용되는 ReadAync 메서드를 새롭게 제공한다. 이는 BeginRead와 마찬가지로 비동기로 호출되나, ... Async 류의 비동기 호출에 await 예약어가 함께 쓰이면 C# 컴파일러는 이를 인지하고 그 이후의 코드를 묶어서 ReadAsync의 비동기 호출이 끝난 후에 실행되도록 코드를 변경해서 컴파일한다. 위 코드를 실행해 보면, await fs.ReadAsync 코드가 호출되기 전의 스레드 ID와 호출된 후의 스레드 ID가 다르게 출력되는 것을 확인할 수 있다.

 

- await 예약어는 메서드에 async가 지정되지 않으면 예약어로 인식하지 않으므로 변수명으로도 쓸 수 있다.

 

 

닷넷 4.5 BCL에 추가된 Async 메서드

 

- WebClient 타입은 HTTP 요청을 전달해 그 응답을 문자열로 반환하는 DownloadString 메서드를 제공하는데, 이를 이용한 동기 호출 및 비동기 호출 버전은 다음과 같다.

using System;
using System.Net;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            WebClient wc = new WebClient();
            string txt = wc.DownloadString("http://www.microsoft.com");
            Console.WriteLine(txt);
        }
    }
}
using System;
using System.Net;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            WebClient wc = new WebClient();

            // DownloadStringAsync 동작이 끝났을 때, 호출할 이벤트 등록
            wc.DownloadStringCompleted += wc_DownloadStringCompleted;

            // DownloadString의 비동기 메서드
            wc.DownloadStringAsync(new Uri("http://www.microsoft.com"));
            Console.ReadLine();
        }

        static void wc_DownloadStringCompleted(object sender, DownloadStringCompletedEventArgs e)
        {
            Console.WriteLine(e.Result); // e.Result == HTML 텍스트
        }
    }
}

 

- 닷넷 4.0 BCL까지는 위와 같이 처리해야 했지만, C# 5.0 + 닷넷 프레임워크 4.5부터는 async/await의 도움을 받아 다음과 같이 마치 동기 호출을 하는 것처럼 간단하게 작성할 수 있다.

using System;
using System.Net;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            AwaitDownloadString();

            Console.ReadLine();
        }

        private static async void AwaitDownloadString()
        {
            WebClient wc = new WebClient();
            string txt = await wc.DownloadStringTaskAsync("http://www.microsoft.com");

            Console.WriteLine(txt);
        }
    }
}

- Socket 타입에는 async/await와 동작하는 Async 메서드가 추가되지 않았지만, 대신 관련 기능을 제공하는 TcpClient의 NetworkStream 타입에 추가되었다. 이를 이용해 복잡했던 TCP 서버의 비동기 통신을 다음과 같이 좀 더 이해하기 쉬운 코드로 변경할 수 있다.

using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            TcpListener listener = new TcpListener(IPAddress.Any, 10200);
            listener.Start();

            while(true)
            {
                TcpClient client = listener.AcceptTcpClient();
                ProcessTcpClient(client);
            }
        }
        
        private static async void ProcessTcpClient(TcpClient client)
        {
            NetworkStream ns = client.GetStream();

            byte[] buffer = new byte[1024];
            int received = await ns.ReadAsync(buffer, 0, buffer.Length);

            string txt = Encoding.UTF8.GetString(buffer, 0, received);

            byte[] sendBuffer = Encoding.UTF8.GetBytes("Hello : " + txt);
            await ns.WriteAsync(sendBuffer, 0, sendBuffer.Length);
            ns.Close();
        }
    }
}

 

Task, Task<TResult> 타입

 

- await로 대기할 수 있는 ...Async 메서드의 반환값이 모두 Task 또는 Task<TResult> 유형이다.

* FileStream 타입 : public Task<int> ReadSync(byte[] buffer, int offset, int count);

* WebClient 타입 : public Task<string> DownloadStringTaskAsync(string address);

* NetworkStream 타입 :

 public Task<int> ReadAsync(byte[] buffer, int offset, int count);

 public Task WriteAsync(byte[] buffer, int offset, int count);

 

- Task 타입은 반환값이 없는 경우 사용되고, TResult 타입은 TResult 형식 매개변수로 지정된 반환값이 있는 경우로 구분되는데, await 비동기 처리와는 별도로 원래부터 닷넷 4.0 부터 추가된 병렬 처리 라이브러리(TPL : Task Parallel Library)에 속한 타입이다.

 

- 따라서 await 없이 Task 타입을 단독으로 사용하는 것도 가능한데, 간단하게 설명하면 ThreadPool.QueueUserWorkItem 메서드의 대용으로 사용할 수 있다.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            // 기존의 QueueUserWorkItem
            ThreadPool.QueueUserWorkItem((obj) =>
            {
                Console.WriteLine("process workItem");
            }, null);

            // .NET 4.0의 Task 타입을 이용해 별도의 스레드에서 작업
            Task task1 = new Task(
                () =>
                {
                    Console.WriteLine("process taskItem");
                });
            task1.Start();

            Task task2 = new Task(
                (obj) =>
                {
                    Console.WriteLine("process taskItem(obj)");
                }, null);
            task2.Start();

            Console.ReadLine();
        }
    }
}

- Task 타입의 생성자는 C# 3.0에 추가된 Action 타입의 델리게이트 인자를 받는다. Task 타입이 ThreadPool의 QueueUserWorkItem과 차별화된 점이라면 좀 더 세밀하게 제어할 수 있다는 것이다. 예를 들어, QueueUserWorkItem의 경우 전달된 작업이 완료되기를 기다리는 코드를 작성하려면 EventWaitHandle 타입과 함께 사용했으나, Task 타입을 이용하면 아래와 같이 코드가 간결해진다.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            Task taskSleep = new Task(() => { Thread.Sleep(3000); });
            taskSleep.Start();
            taskSleep.Wait(); // Task의 작업이 완료될 때까지 대기

            Console.WriteLine("taskSleep End.");

            // 굳이 Task 객체를 생성할 필요 없이 Action 델리게이트를 전달하자 마자 곧바로 작업 시작
            Task.Factory.StartNew(() => { Console.WriteLine("process taskitem"); });
            Task.Factory.StartNew((obj) => { Console.WriteLine("process taskitem(obj)"); }, null);

            Console.ReadLine();
        }
    }
}

- Task와는 달리 Task<TResult> 타입은 값을 반환할 수 있다. 일반적으로 QueueUserWorkItem의 경우 단순히 코드를 스레드 풀의 자유 스레드에 던져서 실행하는 것만 가능했던 반면, Task<TResult> 타입은 코드의 실행이 완료된 후, 원한다면 반환값까지 처리할 수 있게 개선했다.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            Task<int> task = new Task<int>(
                () =>
                {
                    Random rand = new Random((int)DateTime.Now.Ticks);
                    return rand.Next();
                });

            task.Start();
            task.Wait();
            Console.WriteLine("무작위 숫자 값 : " + task.Result);
        }
    }
}

- Task 타입의 생성자가 Action 델리게이트를 인자로 받았던 반면, Task<TResult> 타입은 Func 델리게이트를 인자로 받는다. 반환값은 작업이 완료된 후, Result 속성을 통해 받을 수 있다.

 

- Task.Factory의 StartNew 메서드는 Task를 반환하는데, Task<TResult>를 반환하는 용도로 StartNew<TResult> 메서드도 제공한다.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            Task<int> taskReturn = Task.Factory.StartNew<int>(() => 1);
            taskReturn.Wait();

            Console.WriteLine(taskReturn.Result);
        }
    }
}

- await와 함께 사용될 메서드는 반드시 Task, Task<TResult>를 반환하는 것만 가능하며, async 예약어가 지정되는 메서드에도 void와 Task, Task<TResult>만 반환할 수 있다는 제약이 있다.

 

- async void 유형은 해당 메서드 내에서 예외가 발생했을 때 그것이 처리되지 않은 경우 프로세스가 비정상적으로 종료되므로 권장되지 않는다. 그럼에도 마이크로소프트가 async void를 허용할 수 밖에 없었던 것은 System.Windows,Forms.dll을 사용한 윈도우 폼 프로그램에서 이벤트 처리기의 델리게이트로 사용하는 EventHandler 타입이 다음과 같이 정의되어있기 때문이다.

 

public delegate void EventHandler(object sender, EventArgs e);

 

- 예를 들어, 윈도우가 로드됐다는 이벤트를 Windows Forms 응용 프로그램에서는 다음과 같이 이벤트 처리기를 정의해 사용하게 된다. Form1_Load 메서드 내에서 await 호출을 하려면 async 예약어를 추가해야 하는데 이미 정해진 EventHandler 델리게이트 형식으로 인해 async Task, async Task<T>는 불가능하고 어쩔 수 없이 async void로 만들 수 밖에 없었다.

using System;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            this.Load += new System.EventHandler(this.Form1_Load);
        }

        void Form1_Load(object sender, EventArgs e)
        {
        }
    }
}

- 따라서, 이벤트 처리기를 제외하고는 async void의 사용은 가능한 지양하고 async Task, async Task<T>를 사용하는 것이 권장된다.

 

 

- Async 처리가 적용되지 않는 메서드에 대해 Task를 반환하는 부가 메서드를 만드는 것으로 await 비동기 처리를 할 수 있다. 예를 들어 File.ReadAllText는 그에 대응되는 비동기 버전의 메서드를 제공하지 않아, 아래와 같이 복잡하게 구현이 필요하다.

using System;
using System.IO;

namespace ConsoleApp1
{
    class Program
    {
        public delegate string ReadAllTextDelegate(string path);

        static void Main(string[] args)
        {
            string filePath = @"C:\windows\system32\drivers\etc\HOSTS";

            ReadAllTextDelegate func = File.ReadAllText;
            func.BeginInvoke(filePath, actionCompleted, func);

            Console.ReadLine();
        }

        static void actionCompleted(IAsyncResult ar)
        {
            ReadAllTextDelegate func = ar.AsyncState as ReadAllTextDelegate;
            string fileText = func.EndInvoke(ar);

            Console.WriteLine(fileText);

        }
    }
}

- 하지만, 이 코드를 Task<TResult>로 바꾸면 await를 이용해 쉽게 비동기 호출을 적용할 수 있다. 이를 위해, ReadAllText 기능을 감싸는 비동기 버전의 메서드를 하나 더 만들기만 하면 된다.

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

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            AwaitFileRead(@"C:\windows\system32\drivers\etc\HOSTS");

            Console.ReadLine();
        }

        private static async Task AwaitFileRead(string filePath)
        {
            string fileText = await ReadAllTestAsync(filePath);
            Console.WriteLine(fileText);

            // Task 반환 타입을 갖는 메서드이지만 async 예약어가 지정됐으므로
            // C#가 적절하게 코드를 자동으로 변환해 주기 때문에 return이 필요 없다.
        }

        static Task<string> ReadAllTestAsync(string filePath)
        {
            return Task.Factory.StartNew(() =>
            {
                return File.ReadAllText(filePath);
            });
        }
    }
}

 

[ 비동기 호출의 병렬 처리 ]

- await와 Task의 조합으로 할 수 있는 매력적인 작업 가능데 하나가 바로 병렬로 비동기 호출을 하는 것이다. 예를 들어, 각각 3초, 5초가 걸리는 작업이 있다고 가정해보자.

using System;
using System.Threading;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            int result3 = Method3();
            int result5 = Method5();

            Console.WriteLine(result3 + result5);
        }

        private static int Method3()
        {
            Thread.Sleep(3000);
            return 3;
        }

        private static int Method5()
        {
            Thread.Sleep(5000);
            return 5;
        }
    }
}

 

- 당연히 8초가 걸린다. 이를 개선하기 위해, Method3과 Method5를 병렬로 수행하면 5초 만에 작업을 끝낼 수 있다. 기존에는 이 작업을 Thread를 이용해 처리할 수 있었다.

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

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            Dictionary<string, int> dict = new Dictionary<string, int>();

            Thread t3 = new Thread((result) =>
            {
                Thread.Sleep(3000);
                dict.Add("t3Result", 3);
            });

            Thread t5 = new Thread((result) =>
            {
                Thread.Sleep(5000);
                dict.Add("t5Result", 5);
            });

            t3.Start(dict);
            t5.Start(dict);

            t3.Join();
            t5.Join();

            Console.WriteLine(dict["t3Result"] + dict["t5Result"]);

        }
       
    }
}

 

- 이 작업을 동일하게 Task<TResult> 타입으로도 구현할 수 있다. Main 메서드를 실행 중인 Thread가 task3/task5가 완료될 때까지 계속 기다리게 된다.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            // Task를 이용해서 병렬로 처리
            var task3 = Method3Async();
            var task5 = Method5Async();

            // task3, task5가 끝날때까지 현재 Thread를 대기
            Task.WaitAll(task3, task5);

            Console.WriteLine(task3.Result + task5.Result);
        }

        private static Task<int> Method3Async()
        {
            return Task.Factory.StartNew(() =>
            {
                Thread.Sleep(3000);
                return 3;
            });
        }

        private static Task<int> Method5Async()
        {
            return Task.Factory.StartNew(() =>
            {
                Thread.Sleep(5000);
                return 5;
            });
        }
        
    }
}

 

- 하지만 3초짜리 작업과 5초짜리 작업을 동시에 비동기 호출로 처리하려면 어떻게 해야 할까? Task<TResult>와 await의 도움을 받으면 다음과 같이 간단하게 해결된다. Task.WhenAll과 await의 조합으로 Main, DoAsyncTask 메서드를 수행하는 스레드가 task3, task5 작업이 완료될 때까지 대기하지 않고 곧바로 다음 작업을 계속해서 수행한다. 물론, await 이후에 나온 Console.WriteLine 코드는 C# 컴파일러에 의해 task3, task5가 완료된 시점에 비동기로 실행되도록 변경된다.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            DoAsyncTask();

            Console.ReadLine();
        }
        
        private static async Task DoAsyncTask()
        {
            var task3 = Method3Async();
            var task5 = Method5Async();

            await Task.WhenAll(task3, task5);

            Console.WriteLine(task3.Result + task5.Result);
        }

        private static Task<int> Method3Async()
        {
            return Task.Factory.StartNew(() =>
            {
                Thread.Sleep(3000);
                return 3;
            });
        }

        private static Task<int> Method5Async()
        {
            return Task.Factory.StartNew(() =>
            {
                Thread.Sleep(5000);
                return 5;
            });
        }
    }
}