(C#) 커스텀 비동기 Task

 

async, await을 사용해서 비동기 작업을 처리 하기 위해서는 일반적으로는 System.Threading.Tasks.Task 클래스를
사용해서 반환되는 메서드를 만들어 처리 합니다.

보통 이런식으로 처리를 할 수 있습니다.

private async Task ExecuteTask()
{
  string result = await Foo();
}

private async Task<string> Foo()
{
  return await Task.Run(() =>
  {
    // 오래 걸리는 작업
    return "test";
  });
}

위 처럼 System.Threading.Tasks.Task 를 반환 하는 대상으로 await 처리가 가능합니다.

await 처리는 System.Threading.Tasks.Task 클래스를 사용하지 않아도

GetAwaiter() 메서드가 있는 어떠한 클래스 대상으로 사용이 가능합니다.

커스텀 Task클래스 구현

이를 이용해서 특정 역할의 비동기 작업을 처리 하는 전용 클래스 자체를 만들어 활용 할 수 있습니다.

굳이 복잡하게 직접 만들 필요 없이 잘 제공 되고 있는 System.Threading.Tasks.Task 클래스를 사용해도 되지만
다음과 같은 상황을 처리할때 응용해서 사용할 수 있을 것 같아 포스팅 하게 됬습니다.

  • 어떤 역할을 처리 하는 전용 클래스 자체를 간결하게 강제로 비동기 방식으로 제공해 줄때
  • 비동기 처리시 작업 대기중(await) loading UI를 띄우는 공용 클래스를 제공해 줄때

위에서 말했듯이 GetAwaiter() 메서드를 갖는 클래스를 구현해서 커스텀한 비동기 await처리를 할 수 있습니다.

public class MyTask<T>
{
  private Thread _thread;
  private Func<T> _action;
  private List<Action> _continuationActionList = new List<Action>();
  private bool _myTaskCompleted;

  public MyTask(Func<T> action)
  {
    _action = action;
    _thread = new Thread(new ThreadStart(this.ExecuteAction));
    _thread.Start();
  }

  public bool MyTaskCompleted
  {
    get
    {
      if (_thread == null)
      {
        return _task.IsCompleted;
      }

      return _myTaskCompleted;
    }
  }

  public T MyTaskResult { get; private set; }

  public void AddContinuation(Action action)
  {
    _continuationActionList.Add(action);
  }

  public MyTaskAwaiter<T> GetAwaiter()
  {
    return new MyTaskAwaiter<T>(this);
  }
  
  private void ExecuteAction()
  {
    T result = default(T);
    if (_action != null)
      result = _action();

    MyTaskResult = result;
    _myTaskCompleted = true;

    foreach (var continuationAction in _continuationActionList)
    {
      continuationAction();
    }
  }
}

그리고 GetAwaiter() 메서드는 await 작업이 완료 되었을때 통보 담당을 하는 ICriticalNotifyCompletion 또는 INotifyCompletion 개체를 반환 하면 됩니다.

기본적으로 위 인터페이스를 상속받아 제공해 주는 것이 TaskAwaiter 구조체 입니다.

async 키워드로 컴파일러에 의해 자동 생성 되는 코드에서 TaskAwaiter 타입의 구조체를 참조해서 사용 하는데 await작업이 끝났는지 여부의 IsCompleted속성을 호출합니다.

IsCompleted속성 구현부를 자세히 보면 해당 Task를 참조하기 위해 실제 비동기 Task를 가지고 있는 자기 자신을 생성자를 통해 넘겨 주어 사용하고 있습니다.

[TaskAwaiter 구현부 코드 일부]

private readonly Task m_task;

[__DynamicallyInvokable]
public bool IsCompleted
{
  [__DynamicallyInvokable]
  get
  {
    return m_task.IsCompleted;
   }
}

internal TaskAwaiter(Task task)
{
  m_task = task;
}

하지만 위 코드와 같이 생성자 접근자가 internal으로 외부에서 사용할 수 없게 되어 있습니다.

따라서 TaskAwaiter 구조체와 같은 형식을 직접 구현하여 사용해 줄 수 있습니다.
직접 구현한 것이 ICriticalNotifyCompletion 또는 INotifyCompletion 인터페이스를 상속 받아 구현한
MyTaskAwaiter 구조체 입니다.

public struct MyTaskAwaiter<T> : ICriticalNotifyCompletion, INotifyCompletion
{
  private readonly MyTask<T> _t;
  
  public MyTaskAwaiter(MyTask<T> t)
  {
    _t = t;
  }
  
  public bool IsCompleted => _t.MyTaskCompleted;
  
  public T GetResult()
  {
    return _t.MyTaskResult;
  }

  // OnCompleted메서드로 처리 되는 경우
  // CallContext의 LogicalCallContext 데이터가 새로운 스레드 풀에서 생성된 스레드에 이관 된다.
  public void OnCompleted(Action continuation)
  {
    _t.AddContinuation(continuation);
  }
  
  // UnsafeOnCompleted메서드로 OnCompleted처리 되는 경우
  // CallContext의 LogicalCallContext 데이터가 새로운 스레드 풀에서 생성된 스레드에 이관 되지 않는다.
  public void UnsafeOnCompleted(Action continuation)
  {
    _t.AddContinuation(continuation);
  }
}

위 처럼 await 처리를 할 수 있는 타입을 ‘awaitable’이라고 표현 합니다.

이제 직접 만든 MyTask 클래스는 다음과 같이 await로 같이 사용할 수 있습니다.

private MyTask<string> AsyncProcess()
{
  return new MyTask<string>(() =>
  {
    Thread.Sleep(5000);
    return "오래 걸리는 작업";
  });
}

private async Task AsyncProcess()
{
  var result = await AsyncProcess();
}

async 키워드 사용

그런데 MyTask를 반환 하는 메서드에 async 키워드를 사용하면 다음과 같은 오류가 발생 됩니다.

image

한마디로 async를 사용하려면 반환 타입은 void, Task, Task 형식이어야 가능하다는 것입니다.
C# 7.0 부터는 '일반화된 비동기 반환 형식'이 제공 되면서 위 타입이 아니더라도 async를 사용할 수 있는 타입을 직접 구현할 수 있습니다.

C# 컴파일러 자동 코드 생성시 참조 되는 Method Builder타입을 구현해야 하는데
이를 위해서 커스텀한 AsyncTaskMethodBuilder 구조체를 다음과 같이 구현 할 수 있습니다.

public struct AsyncMyTaskMethodBuilder<T>
{
  /// <summary>
  /// async 메서드 빌더 [컴파일러 자동 코드 생성]
  /// </summary>
  AsyncTaskMethodBuilder _methodBuilder;
  
  public MyTask<T> Task
  {
    get
    {
      return new MyTask<T>(_methodBuilder.Task);
    }
  }
  
  public static AsyncMyTaskMethodBuilder<T> Create()
  {
    return new AsyncMyTaskMethodBuilder<T> { _methodBuilder = AsyncTaskMethodBuilder.Create() };
  }

  public void SetException(Exception exception)
  {
    _methodBuilder.SetException(exception);
  }
  
  public void SetResult(T result)
  {
    _methodBuilder.SetResult();
  }

  public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine
  {
    _methodBuilder.AwaitOnCompleted(ref awaiter, ref stateMachine);
  }
  
  public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine
  {
    _methodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
  }
  
  public void SetStateMachine(IAsyncStateMachine stateMachine)
  {
    _methodBuilder.SetStateMachine(stateMachine);
  }
  
  public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
  {
    _methodBuilder.Start(ref stateMachine);
  }
}

그리고 앞서 구현했던 MyTaskAwaiter구조체에서

OnCompleted(Action continuation)
UnsafeOnCompleted(Action continuation)

위 두개 메서드 내에서 AsyncTaskMethodBuilder 에서 넘겨주는 Task에 대한 await 작업 완료 호출 코드를 추가해 줍니다.

// OnCompleted메서드로 처리 되는 경우
// CallContext의 LogicalCallContext 데이터가 새로운 스레드 풀에서 생성된 스레드에 이관 된다.
public void OnCompleted(Action continuation)
{
  if (_t.Task == null)
  {
    _t.AddContinuation(continuation);
  }
  else
  {
    _t.Task.GetAwaiter().OnCompleted(continuation);
  }
}

// UnsafeOnCompleted메서드로 OnCompleted처리 되는 경우
// CallContext의 LogicalCallContext 데이터가 새로운 스레드 풀에서 생성된 스레드에 이관 되지 않는다.
public void UnsafeOnCompleted(Action continuation)
{
  if (_t.Task == null)
  {
    _t.AddContinuation(continuation);
    }
  else
  {
    _t.Task.GetAwaiter().UnsafeOnCompleted(continuation);
  }
}

마지막으로 MyTask클래스에 AsyncMethodBuilder 어트리뷰트를 사용해 Method Builder 타입으로 사용할 수 있도록 해주고
AsyncTaskMethodBuilder 에서 넘겨주는 Task를 받아 처리 할 수 있도록 수정 합니다.

최종 MyTask클래스 코드는 다음과 같습니다.

[AsyncMethodBuilder(typeof(AsyncMyTaskMethodBuilder<>))]
public class MyTask<T>
{
  private Task _task;
  private Thread _thread;
  private Func<T> _action;
  private List<Action> _continuationActionList = new List<Action>();
  private bool _myTaskCompleted;
  
  public MyTask(Func<T> action)
  {
    _action = action;
    _thread = new Thread(new ThreadStart(this.ExecuteAction));
    _thread.Start();
  }
  
  public MyTask(Task task)
  {
    _task = task;
  }
  
  internal Task Task
  {
    get { return _task; }
  }
  
  public bool MyTaskCompleted
  {
    get
    {
      if (_thread == null)
      {
        return _task.IsCompleted;
      }
      
      return _myTaskCompleted;
    }
  }
  
  public T MyTaskResult { get; private set; }
  
  public void AddContinuation(Action action)
  {
    _continuationActionList.Add(action);
  }
  
  public MyTaskAwaiter<T> GetAwaiter()
  {
    return new MyTaskAwaiter<T>(this);
  }
  
  private void ExecuteAction()
  {
    T result = default(T);
    if (_action != null)
      result = _action();
  
    MyTaskResult = result;
    _myTaskCompleted = true;
  
    foreach (var continuationAction in _continuationActionList)
    {
      continuationAction();
    }
  }
}

이제 다음과 같이 MyTask를 반환 하는 메서드에도 async 예약어를 사용할 수 있습니다.

private async MyTask<string> AsyncProcess()
{
  return await new MyTask<string>(() =>
  {
    Thread.Sleep(5000);
    return "오래 걸리는 작업";
  });
}



이 포스터는 정성태님 블로그의 글을 참조하여 작성 하였습니다.

https://www.sysnet.pe.kr/2/0/11456

https://www.sysnet.pe.kr/2/0/11484