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)
11770정성태11/5/20184389Graphics: 27. .NET으로 구현하는 OpenGL (1) - OpenGL.Net 라이브러리파일 다운로드1
11769정성태11/5/20181546오류 유형: 501. 프로젝트 msbuild Publish 후 connectionStrings의 문자열이 $(ReplacableToken_...)로 바뀌는 문제
11768정성태11/2/20181891.NET Framework: 801. SOIL(Simple OpenGL Image Library) - Native DLL 및 .NET DLL 제공
11767정성태11/20/20182615사물인터넷: 55. New NodeMcu v3(ESP8266)의 IR LED (적외선 송신) 제어파일 다운로드1
11766정성태11/30/20182706사물인터넷: 54. 아두이노 환경에서의 JSON 파서(ArduinoJson) 사용법
11765정성태10/29/20181959개발 환경 구성: 420. Visual Studio Code - Arduino Board Manager를 이용한 사용자 정의 보드 선택
11764정성태10/26/20182800개발 환경 구성: 419. MIT 라이선스로 무료 공개된 Detours API 후킹 라이브러리
11763정성태10/25/20182138사물인터넷: 53. New NodeMcu v3(ESP8266)의 https 통신
11762정성태10/25/20182893사물인터넷: 52. New NodeMcu v3(ESP8266)의 http 통신파일 다운로드1
11761정성태10/25/20182502Graphics: 26. 임의 축을 기반으로 3D 벡터 회전파일 다운로드1
11760정성태10/24/20181389개발 환경 구성: 418. Azure - Runbook 내에서 또 다른 Runbook 스크립트를 실행
11759정성태10/24/20181484개발 환경 구성: 417. Azure - Runbook에서 사용할 수 있는 다양한 메서드를 위한 부가 Module 추가
11758정성태6/7/20192187.NET Framework: 800. C# - Azure REST API 사용을 위한 인증 획득 [3]파일 다운로드1
11757정성태10/23/20182091개발 환경 구성: 416. Visual Studio 2017을 이용한 아두이노 프로그램 개발(및 디버깅)
11756정성태10/19/20181851오류 유형: 500. Visual Studio Code의 아두이노 프로그램 개발 시 인텔리센스가 안 된다면?
11755정성태10/19/20183963오류 유형: 499. Visual Studio Code extension for Arduino - #include errors detected. [1]
11754정성태10/19/20182242개발 환경 구성: 415. Visual Studio Code를 이용한 아두이노 프로그램 개발 - 새 프로젝트
11753정성태10/19/20186523개발 환경 구성: 414. Visual Studio Code를 이용한 아두이노 프로그램 개발
11752정성태10/18/20181416오류 유형: 498. SQL 서버 - Database source is not a supported version of SQL Server
11751정성태10/18/20181701오류 유형: 497. Visual Studio 실행 시 그래픽이 투명해진다거나, 깨진다면?
11750정성태10/18/20181612오류 유형: 496. 비주얼 스튜디오 - One or more projects in the solution were not loaded correctly.
11749정성태10/18/20181727개발 환경 구성: 413. 비주얼 스튜디오에서 작성한 프로그램을 빌드하는 가장 쉬운 방법
11748정성태10/18/20182277개발 환경 구성: 412. Arduino IDE를 Store App으로 설치한 경우 컴파일만 되고 배포가 안 되는 문제
11747정성태10/17/20182185.NET Framework: 799. C# - DLL에도 EXE처럼 Main 메서드를 넣어 실행할 수 있도록 만드는 방법파일 다운로드1
11746정성태10/15/20181588개발 환경 구성: 411. Bitvise SSH Client의 인증서 모드에서 자동 로그인 방법파일 다운로드1
11745정성태10/15/20181621오류 유형: 495. TFS 파일/폴더 삭제 - The item [...] could not be found in your workspace, or you do not have permission to access it.
... 16  17  18  [19]  20  21  22  23  24  25  26  27  28  29  30  ...