Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 9개 있습니다.)
(시리즈 글이 9개 있습니다.)
.NET Framework: 698. C# 컴파일러 대신 직접 구현하는 비동기(async/await) 코드
; https://www.sysnet.pe.kr/2/0/11351

.NET Framework: 716. async 메서드의 void 반환 타입 사용에 대하여
; https://www.sysnet.pe.kr/2/0/11414

.NET Framework: 717. Task를 포함하지 않는 async 메서드의 동작 방식
; https://www.sysnet.pe.kr/2/0/11415

.NET Framework: 719. Task를 포함하는 async 메서드의 동작 방식
; https://www.sysnet.pe.kr/2/0/11417

.NET Framework: 731. C# - await을 Task 타입이 아닌 사용자 정의 타입에 적용하는 방법
; https://www.sysnet.pe.kr/2/0/11456

.NET Framework: 737. C# - async를 Task 타입이 아닌 사용자 정의 타입에 적용하는 방법
; https://www.sysnet.pe.kr/2/0/11484

.NET Framework: 813. C# async 메서드에서 out/ref/in 유형의 인자를 사용하지 못하는 이유
; https://www.sysnet.pe.kr/2/0/11850

닷넷: 2138. C# - async 메서드 호출 원칙
; https://www.sysnet.pe.kr/2/0/13405

닷넷: 2147. C# - 비동기 메서드의 async 예약어 유무에 따른 차이
; https://www.sysnet.pe.kr/2/0/13421




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://learn.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 메서드의 반환으로 사용자 정의 타입을 사용할 수 있다는 것은 의미가 다름.



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

[연관 글]






[최초 등록일: ]
[최종 수정일: 12/7/2022]

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

비밀번호

댓글 작성자
 



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)

자마린에서 이런 에러 나서 계속 고민중이였는데 여기에 답이 있었군요
[guest]
2023-09-07 08시27분
[질문 ①]
㉠ INotifyCompletion 인터페이스는 OnCompleted(Action)를 구현해야 된다.
[출처] https://learn.microsoft.com/ko-kr/dotnet/api/system.runtime.compilerservices.inotifycompletion?view=net-7.0

㉡ ICriticalNotifyCompletion 인터페이스는 INotifyCompletion를 상속받기 때문에
OnCompleted(Action)( INotifyCompletion에서 상속됨), UnsafeOnCompleted(Action)를 구현해야 된다.
[출처] https://learn.microsoft.com/ko-kr/dotnet/api/system.runtime.compilerservices.icriticalnotifycompletion?view=net-7.0

위의 예제에서 MyTaskAwaiter 구조체는
ICriticalNotifyCompletion, INotifyCompletion 2개를 상속 받고 있는데
MyTaskAwaiter 구조체는 ICriticalNotifyCompletion만 상속 받아도 되지 않나요?
ICriticalNotifyCompletion는 INotifyCompletion를 상속 받기 때문에 ICriticalNotifyCompletion만 상속 받아도
 OnCompleted(Action)( INotifyCompletion에서 상속됨)와 UnsafeOnCompleted(Action)를 강제로 구현해야 된다고 생각하는데
(혹시나 제가 잘못 알고 있나 싶어서 테스트해보니까 부모 인터페이스를 상속 받은 자식 인터페이스를 구현하면 부모꺼도 모두 구현해야 되더라고요..)
선생님 혹시 제가 모르는 이유가 있을까요?

[질문 ②]
OnCompleted, UnsafeOnCompleted 모두 비동기 작업이 완료될 때 호출되는 메서드로
UnsafeOnCompleted 메서드는 예외 처리를 수행하지 않고 예외가 발생하면 런타임으로 전파되는 반면에
OnCompleted 메서드는 예외를 처리할 수 있다 정도로 이해해도 될까요?
한예지
2023-09-07 10시36분
[답변 1] 맞습니다. 단지, 그냥 편의상 쉽게 그것도 구현이 되었다는 것을 인지할 수 있도록 한 번 더 쓴 것에 지나지 않습니다.

[답변 2] 예외 처리에 국한하기 보다는 "ExecutionContext"를 이용한 정보 전달이 있느냐 없느냐에 해당합니다. 이에 대해서는 다음의 글을 참고하세요.

HttpContext.Current를 통해 이해하는 CallContext와 ExecutionContext
; https://www.sysnet.pe.kr/2/0/1608

위의 내용을 적용해 생각해 보면, OnCompleted와는 달리 UnsafeOnCompleted가 호출될 때는 호출 측에서 다음과 같은 식의 처리가 되었다고 생각하시면 됩니다.

ExecutionContext.SuppressFlow();

UnsafeOnCompleted();

ExecutionContext.RestoreFlow();
정성태
2023-09-10 11시54분
답변 주셔서 감사합니다.
한예지
2023-09-10 12시50분
_t = new Thread(
    (ThreadStart)(
    () =>
    {
        action();
        _isCompleted = true;

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

위의 코드에서
컴파일러가 Thread가 어떤 형식의 생성자를 받아들이는지 알고 있기 때문에
(ThreadStart) 캐스팅을 생략할 수 있음에도 적는 것은
선생님에 Code Conventions(예를 들면 Thread가 생성자로 무엇을 받는지 모르는 저와 같은 독자를 위해서)인가요?
저는 보통 생략하는데 선생님은 어떤 이유로 (ThreadStart)를 생략 안 하셨는지 알 수 있을까요?
한예지
2023-09-11 09시01분
@한예지 근래에는 ThreadStart 형식을 명시적으로 지정하지 않아도 타입 추론이 잘 되어서 저 코드가 문제 없지만, 아마 저 글을 처음 쓰는 2018년에는 저렇게 명시하지 않으면 모호함 오류가 났었을 것입니다.
정성태

... 61  62  63  64  65  66  67  68  69  70  [71]  72  73  74  75  ...
NoWriterDateCnt.TitleFile(s)
11870정성태4/16/201911589.NET Framework: 817. Process.Start로 실행한 콘솔 프로그램의 출력 결과를 얻는 방법파일 다운로드1
11869정성태4/15/201915428.NET Framework: 816. (번역글) .NET Internals Cookbook Part 2 - GC-related things [2]파일 다운로드2
11868정성태4/15/201913151.NET Framework: 815. CER(Constrained Execution Region)이란?파일 다운로드1
11867정성태4/15/201912182.NET Framework: 814. Critical Finalizer와 SafeHandle의 사용 의미파일 다운로드1
11866정성태4/9/201915721Windows: 159. 네트워크 공유 폴더(net use)에 대한 인증 정보는 언제까지 유효할까요?
11865정성태4/9/201911470오류 유형: 529. 제어판 - C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Administrative Tools is not accessible.
11864정성태4/9/201910637오류 유형: 528. '...' could be '0': this does not adhere to the specification for the function '...'
11863정성태4/9/201910429디버깅 기술: 127. windbg - .NET x64 EXE의 EntryPoint
11862정성태4/7/201912532개발 환경 구성: 437. .NET EXE의 ASLR 기능을 끄는 방법
11861정성태4/6/201912285디버깅 기술: 126. windbg - .NET x86 CLR2/CLR4 EXE의 EntryPoint
11860정성태4/5/201915570오류 유형: 527. Visual C++ 컴파일 오류 - error C2220: warning treated as error - no 'object' file generated
11859정성태4/4/201912966디버깅 기술: 125. WinDbg로 EXE의 EntryPoint에서 BP 거는 방법
11858정성태3/27/201913446VC++: 129. EXE를 LoadLibrary로 로딩해 PE 헤더에 있는 EntryPoint를 직접 호출하는 방법파일 다운로드1
11857정성태3/26/201912314VC++: 128. strncpy 사용 시 주의 사항(Linux / Windows)
11856정성태3/25/201912236VS.NET IDE: 134. 마이크로소프트의 CoreCLR 프로파일러 리눅스 예제를 Visual Studio F5 원격 디버깅하는 방법 [1]파일 다운로드1
11855정성태3/25/201914253개발 환경 구성: 436. 페이스북 HTTPS 인증을 localhost에서 테스트하는 방법
11854정성태3/25/201910194VS.NET IDE: 133. IIS Express로 호스팅하는 사이트를 https로 접근하는 방법
11853정성태3/24/201912366개발 환경 구성: 435. 존재하지 않는 IP 주소에 대한 Dns.GetHostByAddress/gethostbyaddr/GetNameInfoW 실행이 느리다면? - 두 번째 이야기 [1]
11852정성태3/20/201912620개발 환경 구성: 434. 존재하지 않는 IP 주소에 대한 Dns.GetHostByAddress/gethostbyaddr/GetNameInfoW 실행이 느리다면?파일 다운로드1
11851정성태3/19/201915530Linux: 8. C# - 리눅스 환경에서 DllImport 대신 라이브러리 동적 로드 처리 [2]
11850정성태3/18/201914071.NET Framework: 813. C# async 메서드에서 out/ref/in 유형의 인자를 사용하지 못하는 이유
11849정성태3/18/201913910.NET Framework: 812. pscp.exe 기능을 C#으로 제어하는 방법파일 다운로드1
11848정성태3/17/201911319스크립트: 14. 윈도우 CMD - 파일이 변경된 경우 파일명을 변경해 복사하고 싶다면?
11847정성태3/17/201915201Linux: 7. 리눅스 C/C++ - 공유 라이브러리 동적 로딩 후 export 함수 사용 방법파일 다운로드1
11846정성태3/15/201913573Linux: 6. getenv, setenv가 언어/운영체제마다 호환이 안 되는 문제
11845정성태3/15/201914292Linux: 5. Linux 응용 프로그램의 (C++) so 의존성 줄이기(ReleaseMinDependency) [3]
... 61  62  63  64  65  66  67  68  69  70  [71]  72  73  74  75  ...