Microsoft MVP성태의 닷넷 이야기
글쓴 사람
홈페이지
첨부 파일

C# - await을 Task 타입이 아닌 사용자 정의 타입에 적용하는 방법

일반적으로 await을 사용해 비동기 작업을 하려면 다음과 같이 Task를 반환하는 메서드를 대상으로 하게 됩니다.

class Program
{
    static async Task Main(string[] args)
    {
        await CallAsync();
    }

    static async Task<string> CallAsync()
    {
        return await new TaskFactory().StartNew(() => { Thread.Sleep(5000); return "test"; });
    }
}

그런데 지난 글에 설명한 C# 컴파일러의 async 메서드에 대한 자동 생성 코드를 보면,

TaskAwaiter<string> awaiter;
if (num != 0)
{
    this.<getStringTask>5__1 = new TaskFactory().StartNew<string>(Program.<>c.<>9__2_0 ?? (Program.<>c.<>9__2_0 = new Func<string>(Program.<>c.<>9.<GetFileContents>b__2_0)));
    awaiter = this.<getStringTask>5__1.GetAwaiter();
    if (!awaiter.IsCompleted)
    {
        this.<>1__state = num = 0;
        this.<>u__1 = awaiter;
        Program.<GetFileContents>d__2 stateMachine = this;
        this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<string>, Program.<GetFileContents>d__2>(ref awaiter, ref stateMachine);
        return;
    }
}

특이하게도 요구 사항이 단지 GetAwaiter() 메서드가 제공되느냐에 대한 여부일 뿐입니다. 따라서, 굳이 Task를 반환하지 않아도 우리가 구현하는 메서드의 반환 타입이 GetAwaiter 메서드만 제공해 주고 있다면 C#은 정상적으로 컴파일할 수 있습니다. 예를 들어 다음과 같이 코드를 구성하면 됩니다.

class Program
{
    static async Task Main(string[] args)
    {
        await TestAwait();
    }

    private static MyTask TestAwait()
    {
        return new MyTask();
    }
}

public class MyTask
{
    public TaskAwaiter GetAwaiter()
    {
        TaskAwaiter ta; // struct 타입이므로.
        return ta;
    }
}

위의 코드만으로도 C# 컴파일러는 문제없이 바이너리를 잘 생성해 줍니다. 하지만 실행 시 오류가 발생합니다.




실행 시 오류의 호출 스택은 다음과 같습니다.

System.NullReferenceException
  HResult=0x80004003
  Message=Object reference not set to an instance of an object.
  Source=mscorlib
  StackTrace:
   at System.Runtime.CompilerServices.TaskAwaiter.get_IsCompleted()
   at ConsoleApp1.Program.<Main>d__0.MoveNext() in E:\task_continue\ConsoleApp1\ConsoleApp1\Program.cs:line 19
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at ConsoleApp1.Program.<Main>(String[] args)

원인은 async의 자동 생성 코드에서 IsCompleted를 호출하기 때문인데, TaskAwaiter 타입의 IsCompleted는 다음과 같이 구현되어 있어,

[__DynamicallyInvokable]
public bool IsCompleted
{
    [__DynamicallyInvokable]
    get
    {
        return this.m_task.IsCompleted; // m_task 값이 null임.
    }
}

당연히 null 참조 예외가 발생하는 것입니다. 이를 해결하려면 TaskAwaiter의 생성자에 Task 타입을 전달해 생성해야 하는데,

public TaskAwaiter GetAwaiter()
{
    TaskAwaiter ta = new TaskAwaiter(
        new TaskFactory().StartNew(() => { })
        );
    return ta;
}

아쉽게도 Task 인자를 받는 TaskAwaiter의 생성자는 internal 접근자가 지정되어 있어 외부에서 사용할 수 없습니다. 따라서 우리는 TaskAwaiter를 재사용하는 코드를 작성할 수 없습니다.




제 책에서도 설명하고 있지만 C# 7.0의 신규 기능에 보면,

C# 7의 새로운 기능
; https://docs.microsoft.com/ko-kr/dotnet/csharp/whats-new/csharp-7

"일반화된 비동기 반환 형식"이라고 해서 Task 대신 ValueTask 형식이 나옵니다. 그리고 그 ValueTask는 System.Threading.Tasks.Extensions 어셈블리에서 제공하고 있는데, 여기서 궁금함이 생깁니다. 어떻게 ValueTask는 TaskAwaiter를 정상적으로 구현할 수 있었을까요?

재미있는 것은, ValueTask는 TaskAwaiter를 사용하지 않고 자신만의 ValueTaskAwaiter 타입을 구현해 GetAwaiter 메서드에서 반환합니다. 처음에 봤을 때는, 이게 뭔가 싶었습니다. TaskAwaiter는 struct 타입이기 때문에 상속이 안되므로 당연히 ValueTaskAwaiter는 기존의 TaskAwaiter에 형변환이 안 됩니다. 자동 생성 코드를 다시 보면, C#은 async 메서드에 대해 TaskAwaiter를 사용합니다.

TaskAwaiter<string> awaiter;
if (num != 0)
{
    this.<getStringTask>5__1 = new TaskFactory().StartNew<string>(Program.<>c.<>9__2_0 ?? (Program.<>c.<>9__2_0 = new Func<string>(Program.<>c.<>9.<GetFileContents>b__2_0)));
    awaiter = this.<getStringTask>5__1.GetAwaiter();
    // ...[생략]...
}

그런데 ValueTask는 ValueTaskAwaiter를 반환하는데 이대로라면 컴파일 시 오류가 발생하는 것이 맞습니다. 하지만... 잘 컴파일이 된다는 것은 뭔가 또 다른 비밀이 있다는 것입니다. 결국 ValueTask를 사용한 소스 코드를 역어셈블해 살펴보니 그 비밀이 풀립니다.

보면, C# 컴파일러는 GetAwaiter 메서드가 반환하는 타입을 자동 생성된 코드에서 사용해 줍니다. 즉, 위의 자동 생성 코드는 ValueTask에서 다음과 같이 바뀝니다.

ValueTaskAwaiter<string> awaiter;
if (num != 0)
{
    this.<getStringTask>5__1 = new TaskFactory().StartNew<string>(Program.<>c.<>9__2_0 ?? (Program.<>c.<>9__2_0 = new Func<string>(Program.<>c.<>9.<GetFileContents>b__2_0)));
    awaiter = this.<getStringTask>5__1.GetAwaiter();
    // ...[생략]...
}

그렇습니다. Task를 대체하는 사용자 정의 await 기능을 제공하고 싶다면 GetAwaiter 메서드의 구현과 함께 그것이 반환하는 타입도 우리가 제공하면 됩니다. 따라서, 다음과 같이 구현할 수 있습니다.

using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static async Task Main(string[] args)
        {
            await TestAwait();
        }

        private static MyTask TestAwait()
        {
            MyTask task = new MyTask();
            return task;
        }
    }

    public class MyTask
    {
        public MyTaskAwaiter GetAwaiter()
        {
            MyTaskAwaiter ta = new MyTaskAwaiter();
            return ta;
        }
    }

    public struct MyTaskAwaiter : ICriticalNotifyCompletion, INotifyCompletion
    {
        public bool IsCompleted
        {
            get { return true; }
        }

        public void GetResult()
        {
        }

        public void OnCompleted(Action continuation)
        {
        }

        public void UnsafeOnCompleted(Action continuation)
        {
        }
    }
}

이렇게 await 대상으로 사용할 수 있는 타입을 "awaitable"하다고 표현합니다.




그럼, 뼈대는 완성되었으니 Task 없이 단순히 Thread 타입만을 사용해 기존 await 동작과 유사하게 실행되도록 다음과 같이 코딩을 완성할 수 있습니다.

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

namespace ConsoleApp1
{
    class Program
    {
        static async Task Main(string[] args)
        {
            await TestAwait();
            Console.WriteLine("test");
        }

        private static MyTask TestAwait()
        {
            MyTask task = new MyTask( () =>
            {
                Thread.Sleep(5000);
            });

            return task;
        }
    }

    public class MyTask
    {
        Thread _t;
        bool _isCompleted;
        List<Action> _continuation = new List<Action>();

        public void AddContinuation(Action action)
        {
            _continuation.Add(action);
        }

        public bool IsCompleted
        {
            get { return _isCompleted; }
        }

        public MyTask(Action action)
        {
            _t = new Thread(
                (ThreadStart)( 
                () =>
                {
                    action();
                    _isCompleted = true;

                    foreach (var item in _continuation)
                    {
                        item();
                    }
                }
                )
            );

            _t.Start();
        }

        public MyTaskAwaiter GetAwaiter()
        {
            MyTaskAwaiter ta = new MyTaskAwaiter(this);
            return ta;
        }
    }

    public struct MyTaskAwaiter : ICriticalNotifyCompletion, INotifyCompletion
    {
        MyTask _task;

        public MyTaskAwaiter(MyTask t)
        {
            _task = t;
        }

        public bool IsCompleted
        {
            get
            {
                return _task.IsCompleted;
            }
        }

        public void GetResult()
        {
        }

        public void OnCompleted(Action continuation)
        {
            _task.AddContinuation(continuation);
        }

        public void UnsafeOnCompleted(Action continuation)
        {
            _task.AddContinuation(continuation);
        }
    }
}

실행하면, 위의 코드는 기존 Task 기반으로 구현한 다음의 코드와 유사하게 동작하는 것을 볼 수 있습니다.

static async Task Main(string[] args)
{
    await TaskAwait();
    Console.WriteLine("test");
}

private static Task TaskAwait()
{
    return new TaskFactory().StartNew(() => { Thread.Sleep(5000); });
}

(첨부 파일은 이 글의 예제를 포함합니다.)




이 글을 쓰면서 한 가지 궁금한 것이 생겼습니다. 마이크로소프트는 C# 7.0을 내놓으면서 신규 기능으로 "일반화된 비동기 반환 형식"을 ValueTask와 함께 예를 들어 소개하고 있는데, 엄밀히 말해서 C# 5.0 컴파일러에서도 이 글에서 구현한 MyTask / MyTaskAwaiter 타입은 잘 컴파일이 됩니다. 즉, 이것은 C# 7.0의 신규 기능이라고 볼 수 없는데 왜? 마이크로소프트는 그런 식으로 소개를 했느냐입니다.

게다가 ValueTask 역시 C#과는 무관하게 NuGet을 통해 별도 배포되는 System.Threading.Tasks.Extensions 어셈블리에서 구현하고 있을 뿐입니다.

이 때문에, 제 책에 다음과 같이 썼던 내용이 틀리게 됩니다.

C# 5.0부터 구현된 async 예약어가 붙는 메서드는 반환 타입이 반드시 void, Task, Task 중의 하나여야만 했다. 달리 말하면 수많은 비동기 시나리오에서 개발자가 최적화할 수 있는 여지를 닫아 버린 것이다. ...[중간생략]... C# 7.0부터는 사용자 정의 Task 타입을 구현하고 이를 async의 반환 타입으로 사용할 수 있도록 허용한다.


즉, 개발자가 최적화할 수 있는 여지는 C# 5.0부터 이미 열려 있었기 때문에 "일반화된 비동기 반환 형식"에 따라 사용자 정의 Task 타입을 정의할 수 있었습니다.


[업데이트: 2018-03-10] await 대상에 대한 사용자 정의 타입이 가능한 것과 async 메서드의 반환으로 사용자 정의 타입을 사용할 수 있다는 것은 의미가 다름.



[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]





[최초 등록일: ]
[최종 수정일: 4/11/2018 ]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer@outlook.com

비밀번호

댓글 쓴 사람
 



2018-04-11 09시15분
C# - async를 Task 타입이 아닌 사용자 정의 타입에 적용하는 방법
; http://www.sysnet.pe.kr/2/0/11484
정성태
2018-04-17 12시59분
다음의 경로에 있는 C# 스펙 문서에,

"C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC#\Specifications\1033\CSharp Language Specification.docx"

"7.7.7.1 Awaitable expressions" 목차에 보면 awaitable에 대한 요건이 명시되어 있습니다. 간단하게 요약해 보면 이 글에서 쓴 데로 정의한 타입이 await에 쓰일 수 있고, 그 외에 dynamic 타입이 가능하다고 합니다. 즉, 다음의 코드도 가능합니다.

async static Task TestMethod()
{
    dynamic inst = 5;
    await inst;
}

물론, 위의 메서드는 컴파일 시 오류는 없지만 실행 시에는 발생하게 됩니다.
정성태
2018-08-21 12시23분
[지현명] LOBL010PageViewModel+<SearchAll>d__30.MoveNext ()
TaskAwaiter.ThrowForNonSuccess (System.Threading.Tasks.Task task)
TaskAwaiter.HandleNonSuccessAndDebuggerNotification (System.Threading.Tasks.Task task)
TaskAwaiter.ValidateEnd (System.Threading.Tasks.Task task)
TaskAwaiter.GetResult ()
LOBL010PageViewModel+<<-ctor>b__29_5>d.MoveNext ()
AsyncMethodBuilderCore+<>c.<ThrowAsync>b__6_0 (System.Object state)
SyncContext+<>c__DisplayClass2_0.<Post>b__0 ()
Thread+RunnableImplementor.Run ()
IRunnableInvoker.n_Run (System.IntPtr jnienv, System.IntPtr native__this)
(wrapper dynamic-method) System.Object.16(intptr,intptr)

자마린에서 이런 에러 나서 계속 고민중이였는데 여기에 답이 있었군요
[손님]

... 16  17  18  19  20  21  22  [23]  24  25  26  27  28  29  30  ...
NoWriterDateCnt.TitleFile(s)
11672정성태8/28/20181550오류 유형: 479. 윈도우 - 시스템 설정에서 도메인 참가를 위한 "Change" 버튼이 비활성화된 경우
11671정성태10/23/20184298사물인터넷: 39. 아두이노에서 적외선 송신기 기본 사용법파일 다운로드1
11670정성태10/17/20183333사물인터넷: 38. 아두이노에서 적외선 수신기 기본 사용법파일 다운로드1
11669정성태8/24/20182328개발 환경 구성: 394. 윈도우 환경에서 elasticsearch의 한글 블로그 검색 인덱스 구성
11668정성태8/28/20182430오류 유형: 478. 윈도우 업데이트(KB4458842) 이후 SQL Server 서비스 시작 오류
11667정성태8/28/20182033오류 유형: 477. "Use Unicode UTF-8 for worldwide language support" 옵션 설정 시 SQL Server 2016 설치 오류 [1]
11666정성태8/22/20181807사물인터넷: 37. 아두이노 - 코딩으로 대신하는 오실레이터 회로의 소리 출력파일 다운로드1
11665정성태8/22/20182609사물인터넷: 36. 오실레이터 회로 동작을 아두이노의 코딩으로 구현하는 방법파일 다운로드1
11664정성태8/22/20182844개발 환경 구성: 393. 윈도우 환경에서 elasticsearch의 한글 형태소 분석기 설치
11663정성태8/25/20183979개발 환경 구성: 392. 윈도우 환경에서 curl.exe를 이용한 elasticsearch 6.x 기본 사용법
11662정성태8/21/20182208사물인터넷: 35. 병렬 회로에서의 커패시터파일 다운로드1
11661정성태8/21/20182086사물인터넷: 34. 트랜지스터 동작 - 컬렉터-이미터 간의 저항 측정파일 다운로드1
11660정성태9/4/20181941사물인터넷: 33. 세라믹 커패시터의 동작 방식파일 다운로드1
11659정성태8/19/20182150사물인터넷: 32. 9V 전압에서 테스트하는 PN2222A 트랜지스터파일 다운로드1
11658정성태8/18/20183553사물인터넷: 31. 커패시터와 RC 회로파일 다운로드3
11657정성태8/21/20182629사물인터넷: 30. 릴레이(Relay) 제어파일 다운로드3
11656정성태8/18/20181644사물인터넷: 29. 트랜지스터와 병렬로 연결한 LED파일 다운로드1
11655정성태8/18/20182841사물인터넷: 28. 저항과 병렬로 연결한 LED파일 다운로드1
11654정성태8/18/20181973사물인터넷: 27. 병렬 회로의 저항, 전압 및 전류파일 다운로드1
11653정성태8/18/20181864사물인터넷: 26. 입력 전압에 따른 LED의 전압/저항 변화파일 다운로드1
11652정성태9/11/20181774사물인터넷: 25. 컬렉터 9V, 베이스에 5V와 3.3V 전압으로 테스트하는 C1815 트랜지스터파일 다운로드1
11651정성태9/4/20183499사물인터넷: 24. 9V 전압에서 테스트하는 C1815 트랜지스터파일 다운로드3
11650정성태8/18/20181907사물인터넷: 23. 가변저항으로 분압파일 다운로드1
11649정성태8/14/20182042사물인터넷: 22. 저항에 따른 전류 테스트파일 다운로드1
11648정성태10/17/20182719사물인터넷: 21. 퓨즈를 이용한 회로 보호파일 다운로드3
11647정성태8/8/20182088오류 유형: 476. 음수의 음수는 여전히 음수가 되는 수(절대값이 음수인 수)
... 16  17  18  19  20  21  22  [23]  24  25  26  27  28  29  30  ...