Notice
Recent Posts
Recent Comments
Link
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Archives
Today
Total
관리 메뉴

nomad-programmer

[Programming/C#] async 한정자와 await 연산자 본문

Programming/C#

[Programming/C#] async 한정자와 await 연산자

scii 2020. 10. 1. 02:13
명명 규칙

규칙에 따라 일반적으로 대기 가능한 형식 (예: Task, Task<T>, ValueTask, ValueTask<T>)을 반환하는 메소드에는 "Async"로 끝나는 이름을 사용해야 한다.
비동기 작업을 시작하지만 대기 가능한 형식을 반환하지 않는 메소드는 "Async"로 끝나는 이름을 사용하지 않아야 하지만, "Begin", "Start" 또는 일부 다른 동사로 시작하여 이 메소드가 작업 결과를 반환하거나 예외가 발생하지 않음을 알려야 한다.

여기서 이벤트, 기본 클래스 또는 인터페이스 계약으로 다른 이름을 제안하는 규칙을 무시할 수 있다. 예를 들어, OnButtonClick과 같은 공용 이벤트 처리기의 이름을 변경할 수 없다.

정리하자면,
* 반환형이 있는 비동기 메소드의 이름은 접미사로 "Async"를 붙인다.
* 반환형이 없는 비동기 메소드의 이름은 접두사로 "Begin", "Start" 또는 일부 다른 동사를 붙인다.
* 공용 이벤트 처리기의 이름은 변경할 수 없다.

async 한정자와 await 연산자로 만드는 비동기 코드

async 한정자와 await 연산자는 C# 5.0에서 새롭게 도입된 장치이다.

async 한정자는 메소드, 이벤트 처리기, 태스크, 람다식 등을 수식함으로써 C# 컴파일러가 이들을 호출하는 코드를 만날 때 호출 결과를 기다리지 않고 바로 다음 코드로 이동하도록 실행 코드를 생성한다. async는 한정자이므로 메소드 또는 이벤트 처리기를 선언할 때 다음과 같이 다른 한정자들과 함께 사용하면 된다.

public static async Task MyMethodAsync()
{
    // ...
}

이렇게 async 한정자로 메소드나 태스크를 수식하기만 하면 비동기 코드가 만들어진다. 다만 async 로 한정하는 메소드는 반환 형식이 Task나 Task<TResult> 또는 void 형식이어야 한다는 제약이 있다. 실행하고 잊어버릴(Shoot and Forgot) 작업업을 담고 있는 메소드라면 반환 형식을 void로 선언하고, 작업이 완료될 때까지 기다리는 메소드라면 Task, Task<TResult>로 선언하면 된다.

async로 선언한 void형식의 메소드는 호출 즉시 호출자에게 제어를 돌려준다. void 메소드는 async 한정자 하나만으로도 "완전한 비동기" 코드가 되는 것이다. 
하지만 Task, Task<TResult> 형식의 메소드는 async로 수식하기만 해서는 보통의 동기 코드와 다름없이 동작한다. async 한정자가 무색하게 말이다.

C# 컴파일러는 Task 또는 Task<TResult> 형식의 메소드를 async 한정자가 수식하는 경우, await 연산자가 해당 메소드 내부의 어디에 위치하는지를 찾는다. 그리고 await 연산자를 찾으면 그곳에서 호출자에게 제어를 돌려주도록 실행 파일을 만든다.

만약 내부에서 끝내 await 연산자를 만나지 못하면 호출자에게 제어를 돌려주지 않으므로 그 메소드/태스크는 동기적으로 실행하게 된다.
  1. async로 한정한 void 형식 메소드는 await 연산자가 없어도 비동기로 실행된다.
  2. async로 한정한 Task 또는 Task<TResult>를 반환하는 메소드/태스크/람다식은 await 연산자를 만나는 곳에서 호출자에게 제어를 돌려주며, await 연산자가 없는 경우 동기로 실행된다.

async 한정자와 await 연산자가 어떻게 비동기 코드를 형성하는지 살펴보자.

비동기 코드의 흐름

위 그림에서 Caller()의 실행이 시작되면, 1의 흐름을 따라 문장1이 실행되고, 이어서 2를 따라 MyMethodAsync() 메소드의 실행으로 제어가 이동한다. MyMethodAsync() 에서는 3을 따라 문장2가 실행되고 나면 async 람다문을 피연산자로 하는 await 연산자를 만나게 된다. 바로 여기에서 CLR은 4를 따라 제어를 호출자인 Caller() 에게로 이동시키고, 위 그림에서 점선으로 표시되어 있는 a와 b의 흐름을 동시에 실행하게 된다.

async 한정자 & await 연산자의 예제

using System;
using System.Threading.Tasks;

namespace CSharpExample
{
    internal class MainApp
    {
        async static private void MyMethodAsync(int count)
        {
            Console.WriteLine("C");
            Console.WriteLine("D");

            await Task.Run(async () =>
            {
                for (int i = 1; i <= count; i++)
                {
                    Console.WriteLine($"{i}/{count} ...");

                    // Task.Delay()는 Thread.Sleep()의 비동기 버전이라 할 수 있다.
                    await Task.Delay(1000);
                }
            });
            Console.WriteLine("G");
            Console.WriteLine("H");
        }

        static void Caller()
        {
            Console.WriteLine("A");
            Console.WriteLine("B");

            MyMethodAsync(3);

            Console.WriteLine("E");
            Console.WriteLine("F");
        }

        static int Main(string[] args)
        {
            Caller();

            // 프로그램 종료 방지
            Console.ReadLine();

            return 0;
        }
    }
}


/* 결과

A
B
C
D
E
F
1/3 ...
2/3 ...
3/3 ...
G
H

*/
Task.Delay() 함수

Task.Delay() 함수가 하는 일은 매개 변수로 입력된 시간 후에 Task 객체를 반환하는 것이다. 실질적인 역할은 Thread.Sleep()과 동일하다고 할 수 있다.
하지만 Task.Delay()는 Thread.Sleep()과 중요한 차이를 가진다. Thread.Sleep()는 스레드 전체를 블록시키는데 반해, Task.Delay()는 스레드를 블록시키지 않는다. 만약 UI 스레드 안에서 Thread.Sleep() 호출하면 UI가 Sleep()이 반환되기까지 사용자에게 응답하지 못하겠지만, Task.Delay()를 사용하면 해당 메소드의 반환 여부와 관계없이 UI가 사용자에게 잘 응답한다.

* Task.Delay() : 스레드를 블록시키지 않는다.

* Thread.Sleep() : 스레드 전체를 블록시킨다.

.NET 프레임워크가 제공하는 비동기 API

.NET 프레임워크 클래스 라이브러리 곳곳에 추가된 ~Async()라는 이름의 메소드들이 비동기 API들이다.

System.IO.Stream 클래스가 제공하는 읽기/쓰기 메소드의 동기 버전과 비동기 버전

동기 버전 메소드 비동기 버전 메소드 설명
Read ReadAsync 스트림에서 데이터를 읽는다.
Write WriteAsync 스트림에서 데이터를 기록한다.
// 동기 버전

static long CopySync(string fromPath, string toPath)
{
    using(
        var fromStream = new FileStream(fromPath, FileMode.Open))
    {
        long totalCopied = 0;
        using(
            var toStream = new FileStream(toPath, FileMode.Create))
        {
            byte[] = buffer = new byte[1024];
            int nRead = 0;
            while ((nRead = fromStream.Read(buffer, 0, buffer.Length)) != 0)
            {
                toStream.Write(buffer, 0, nRead);
                totalCopied += nRead;
            }
        }
        return totalCopied;
    }
}
// 비동기 버전

// async로 한정한 코드를 호출하는 코드도 역시 async로 한정되어 있어야 한다.
// 반환 형식은 Task 또는 void형이어야 한다.
async Task<long> CopySync(string fromPath, string toPath)
{
    using(
        var fromStream = new FileStream(fromPath, FileMode.Open))
    {
        long totalCopied = 0;
        using(
            var toStream = new FileStream(toPath, FileMode.Create))
        {
            byte[] = buffer = new byte[1024];
            int nRead = 0;
            
            // RaedAsync()와 WriteAsync() 메소드는 .NET 프레임워크에 async로 한정되어 있다.
            // 이들을 호출하려면 await 연산자가 필요하다.
            while ((nRead = await fromStream.ReadAsync(buffer, 0, buffer.Length)) != 0)
            {
                await toStream.WriteAsync(buffer, 0, nRead);
                totalCopied += nRead;
            }
        }
        return totalCopied;
    }
}
using 문은 Python의 with 문과 똑같은 역할을 하는 문법이다.

CopySync() 메소드나 CopyAsync() 메소드는 아무런 기능적 차이가 없다. 똑같이 파일을 복사하고, 복사를 마친 뒤에는 파일의 크기를 반환한다. 하지만 이 둘을 사용자 인터페이스에서 호출해보면 프로그램의 응답성에 큰 차이가 있음을 확인할 수 있다.

CopySync() 메소드는 일단 호출하고 나면 실행이 종료될 때까지 사용자 인터페이스가 사용자에게 거의 응답을 하지 못한다.
CopyAsync() 메소드는 실행되는 중간에도 여전히 사용자가 사용자 인터페이스에 접근하는 데 아무런 문제가 없다.

I/O Bound

I/O 바운드란 컴퓨터가 어떤 작업을 할 때 CPU보다는 입출력에 더 많은 시간을 사용하는 상황을 말한다. 반대로 I/O보다 CPU에서 대부분의 시간을 사용하는 경우는 CPU 바운드라고 한다.


I/O 바운드의 대표적인 예가 바로 CPU는 거의 놀고 입출력만 분주히 수행되는 파일 읽기/쓰기이다.

비동기 파일 복사 예제

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

namespace CSharpExample
{
    internal class MainApp
    {
        // 비동기로 파일 복사 후 복사한 파일 용량 반환.
        static async Task<long> CopyAsync(string fromPath, string toPath)
        {
            long fileSize = 0;
            using (Stream fromStream = new FileStream(fromPath, FileMode.Open),
                toStream = new FileStream(toPath, FileMode.Create))
            {
                byte[] buffer = new byte[1024];
                int nRead = 0;
                while((nRead = await fromStream.ReadAsync(buffer, 0, buffer.Length)) != 0)
                {
                    await toStream.WriteAsync(buffer, 0, nRead);
                    fileSize += nRead;
                }

            }
            return fileSize;
        }

        static async void DoCopy(string fromPath, string toPath)
        {
            long fileSize = await CopyAsync(fromPath, toPath);
            Console.WriteLine($"File Size: {fileSize} Bytes");
        }

        static int Main(string[] args)
        {
            string srcFile = @"c:/users/scii/desktop/aaa.txt";
            string dstFile = @"c:/users/scii/desktop/aaa2.txt";

            DoCopy(srcFile, dstFile);

            Console.ReadLine();

            return 0;
        }
    }
}


/* 결과

File Size: 61 Bytes

*/
Comments