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 메서드의 반환으로 사용자 정의 타입을 사용할 수 있다는 것은 의미가 다름.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]