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)
11542정성태6/9/20183253개발 환경 구성: 381. Azure Web App 확장 예제 - Remove Custom Headers
11541정성태9/14/20182386개발 환경 구성: 380. Azure Web App 확장 배포 방법 [1]
11540정성태6/9/20182632개발 환경 구성: 379. Azure Web App 확장 예제 제작 [2]
11539정성태6/8/20182245.NET Framework: 763. .NET Core 2.1 - Tiered Compilation 도입파일 다운로드1
11538정성태6/8/20181978.NET Framework: 762. .NET Core 2.1 - 확장 도구(Tools) 관리
11537정성태6/8/20183359.NET Framework: 761. C# - SmtpClient로 SMTP + SSL/TLS 서버를 이용하는 방법 [4]
11536정성태6/8/20183414.NET Framework: 760. Microsoft Build 2018 - The future of C# 동영상 내용 정리 [1]파일 다운로드1
11535정성태6/7/20183274.NET Framework: 759. C# - System.Span<T> 성능 [1]
11534정성태10/7/20194646.NET Framework: 758. C# 7.2 - Span<T> [3]
11533정성태6/8/20186412.NET Framework: 757. 포인터 형 매개 변수를 갖는 C++ DLL의 함수를 C#에서 호출하는 방법파일 다운로드1
11532정성태6/5/20183241.NET Framework: 756. JSON의 escape sequence 문자 처리 방식
11531정성태6/5/20185911오류 유형: 468. JSON.parse가 허용하지 않는 문자 [9]
11530정성태6/10/20184007.NET Framework: 755. C# 7.2 - 스택에만 생성할 수 있는 값 타입 지원 - "ref struct" [2]파일 다운로드1
11529정성태5/23/20183078.NET Framework: 754. 닷넷의 관리 포인터(Managed Pointer)와 System.TypedReference [5]파일 다운로드1
11528정성태6/10/20183085.NET Framework: 753. C# 7.2 - 3항 연산자에 ref 지원(conditional ref operator) [1]
11527정성태5/17/20182789오류 유형: 467. RDP 로그인 에러 - This could be due to CredSSP encryption oracle remediation.
11526정성태6/10/20182552.NET Framework: 752. C# 7.2 - 메서드의 반환값 및 로컬 변수에 ref readonly 기능 추가파일 다운로드1
11525정성태6/10/20182750.NET Framework: 751. C# 7.2 - 메서드의 매개 변수에 in 변경자 추가파일 다운로드1
11524정성태6/10/20183700.NET Framework: 750. C# 7.2 - readonly 구조체 [3]파일 다운로드1
11523정성태5/15/20183096.NET Framework: 749. C# - 값 형식의 readonly 인스턴스에 대한 메서드 호출 시 defensive copy 발생 [1]파일 다운로드1
11522정성태5/15/20181970개발 환경 구성: 378. Azure - VM 진단 설정 화면의 "This subscription is not registered with the Microsoft.Insights resource provider."
11521정성태5/15/20181985개발 환경 구성: 377. Azure - 원하는 성능 데이터로 모니터링 대시보드 구성
11520정성태5/12/20182606.NET Framework: 748. C# 7.1 - 참조 어셈블리(Ref Assemblies)
11519정성태3/13/20192798개발 환경 구성: 376. ASP.NET Web Application 프로젝트의 FileSystem 배포(Publish) 시 Before/After Task 설정 방법 [1]
11518정성태5/11/20182496.NET Framework: 747. C# 7.0에서도 부분적으로 가능해진 "타입 추론을 통한 튜플의 변수명 자동 지정"
11517정성태5/10/20182774.NET Framework: 746. Azure runbook 예제 - 6시간 동안 수행 중인 VM을 중지 [1]파일 다운로드1
... 16  17  18  19  20  21  22  23  24  25  26  27  [28]  29  30  ...