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)
11502정성태5/12/20183301개발 환경 구성: 370. Azure VM/App Services(Web Apps)에 Let's Encrypt 무료 인증서 적용 방법 [2]
11501정성태4/17/20182256개발 환경 구성: 369. New-AzureRmADServicePrincipal로 생성한 계정의 clientSecret, key 값을 구하는 방법파일 다운로드1
11500정성태4/17/20182529개발 환경 구성: 368. PowerShell로 접근하는 Azure의 Access control 보안과 Azure Active Directory의 계정 관리 서비스
11499정성태4/17/20182099개발 환경 구성: 367. Azure - New-AzureRmADServicePrincipal / New-AzureRmRoleAssignment 명령어
11498정성태4/17/20182131개발 환경 구성: 366. Actvie Azure Directory의 사용자 유형 구분 - Guest/Member
11497정성태4/17/20182322개발 환경 구성: 365. Azure 리소스의 액세스 제어(Access control) 별로 사용자에게 권한을 할당하는 방법 [2]
11496정성태4/17/20181865개발 환경 구성: 364. Azure Portal에서 구독(Subscriptions) 메뉴가 보이지 않는 경우
11495정성태4/18/20182451개발 환경 구성: 363. Azure의 Access control 보안과 Azure Active Directory의 계정 관리 서비스
11494정성태4/16/20182252개발 환경 구성: 362. Azure Web Apps(App Services)에 사용자 DNS를 지정하는 방법
11493정성태4/16/20182245개발 환경 구성: 361. Azure Web App(App Service)의 HTTP/2 프로토콜 지원
11492정성태4/13/20182184개발 환경 구성: 360. Azure Active Directory의 사용자 도메인 지정 방법
11491정성태9/30/20192460개발 환경 구성: 359. Azure 가상 머신에 Web Application을 배포하는 방법
11490정성태4/12/20183087.NET Framework: 739. .NET Framework 4.7.1의 새 기능 - Configuration builders [1]파일 다운로드1
11489정성태4/12/20182283오류 유형: 463. 윈도우 백업 오류 - a Volume Shadow Copy Service operation failed.
11488정성태4/12/20182180오류 유형: 462. Unhandled Exception in Managed Code Snap-in - FX:{811FD892-5EB4-4E73-A147-F1E079E36C4E}
11487정성태4/12/20182337디버깅 기술: 115. windbg - 닷넷 메모리 덤프에서 정적(static) 필드 값을 조사하는 방법
11486정성태4/11/20182385오류 유형: 461. Error MSB4064 The "ComputeOutputOnly" parameter is not supported by the "VsTsc" task
11485정성태4/11/20184347.NET Framework: 738. C# - Console 프로그램이 Ctrl+C 종료 시점을 감지하는 방법파일 다운로드1
11484정성태4/11/20183392.NET Framework: 737. C# - async를 Task 타입이 아닌 사용자 정의 타입에 적용하는 방법파일 다운로드1
11483정성태4/10/20185743개발 환경 구성: 358. "Let's Encrypt"에서 제공하는 무료 SSL 인증서를 IIS에 적용하는 방법 (2) [1]
11482정성태4/10/20182963VC++: 126. CUDA Core 수를 알아내는 방법
11481정성태4/10/20184622개발 환경 구성: 357. CUDA의 인덱싱 관련 용어 - blockIdx, threadIdx, blockDim, gridDim
11480정성태10/19/20183781.NET Framework: 736. C# - API를 사용해 Azure에 접근하는 방법 [2]파일 다운로드1
11479정성태4/12/20182421.NET Framework: 735. Azure - PowerShell로 Access control(IAM)에 새로운 계정 만드는 방법
11478정성태4/9/20182796디버깅 기술: 115. windbg - 덤프 파일로부터 PID와 환경 변수 등의 정보를 구하는 방법
11477정성태4/8/20182877오류 유형: 460. windbg - sos 명령어 수행 시 c0000006 오류 발생
... 16  17  18  19  20  21  22  23  24  25  26  [27]  28  29  30  ...