(C#) 닷넷 스레드 비동기 프로그래밍 (EAP)

 

닷넷에서는 비동기 프로그래밍 처리를 지원하는 방식이 여러가지 있습니다. 이를 닷넷에서는 ‘비동기 프로그래밍 패턴’이라고 정하고 있습니다.
비동기 프로그래밍 패턴은 세 가지의 패턴이 있습니다.

  • IAsyncResult 형태의 콜백을 사용하는 APM 패턴(IAsyncResult 패턴) 링크
  • 이벤트 기반의 EAP 패턴 링크
  • 작업 기반의 TAP 패턴 이 방식은 .NET Framework 4에서 도입되었으며, 비동기 프로그래밍에 권장되는 방식 입니다. 링크

그럼 위 비동기 프로그래밍 패턴이 어떻게 사용되고 어떤 특징이 있는지 하나씩 살펴 보겠습니다.

이벤트 기반 EAP 패턴

여러 작업을 동시에 처리하고 그에 대한 결과를 처리 하는데에 있어 멀티 스레드 기반의 복잡한 구조를 직접 처리해야 하는 어려움이 있습니다.
이러한 상황에서 디자인적인 UI요소 까지 개입되면 더 복잡해질 수 밖에 없습니다.

EAP 패턴은 비동기 작업의 진행 현황을 UI에 이벤트로 통보해 처리할때 혹은 비동기 작업이 완료 되면 이벤트로 통보 받아 처리 할때 효과적으로 사용할 수 있는 방법입니다.
가장 대표적으로는 System.ComponentModel.BackgroundWorker 클래스가 있습니다.

EAP에서 가장 핵심적인 부분은 System.ComponentModel.AsyncOperation 클래스의 역할 입니다.
System.ComponentModel.AsyncOperation 클래스는 System.ComponentModel.AsyncOperationManager 클래스의 정적 CreateOperation()
메서드를 사용해서 인스턴스를 생성할 수 있습니다.

System.ComponentModel.AsyncOperation 인스턴스가 생성 되면 비동기 작업을 호출한 (대부분은 UI 스레드) 스레드의 Context를 가질 수 있는
System.Threading.SynchronizationContext 인스턴스 속성을 포함하고 있습니다.

System.Threading.SynchronizationContext 인스턴스는 비동기 작업의 진행 상태 및 진행완료의 이벤트 통보를 보낼때 System.ComponentModel.AsyncOperation Post()메서드를 통해서 비동기적으로 System.Threading.SendOrPostCallback 델리게이트를 호출합니다.

이때 Object타입의 현재 비동기 작업의 상태정보도 같이 파라메터로 넘겨주어 진행상황 정보를 최종 UI의 이벤트로 통보 시킬 수 있습니다.

위에서 설명했던 내용을 코드로 구현해 보겠습니다.

EAP 패턴 구현

EAP패턴으로 비동기 작업을 구현하는 내용은 단순한 예제로 해봤습니다. int타입의 사용자 값(n) 하나를 받아서 1초 마다 1부터 ~ n까지 +1씩 증가시키고
그것에 대한 진행률을 이벤트로 통보하고 작업 완료시 true로 알려주는 이벤트롤 통보 하는 아주 단순한 비동기 작업 입니다. EAP 비동기 처리 클래스 이름은
DataProcess라고 정하겠습니다.

먼저 EAP패턴에 필요한 이벤트와 기타 필드를 다음처럼 정의해 봅니다.

public delegate void DataProcCompletedEventHandler(object sender, DataProcCompletedEventArgs e);

public event DataProcCompletedEventHandler DataProcCompleted;
public event EventHandler<ProgressChangedEventArgs> ProgressChanged;

private SendOrPostCallback completed;
private SendOrPostCallback progress;
private HybridDictionary userStateToLifetime = new HybridDictionary();

위 필드 하나씩 설명해보면
비동기 작업이 완료 되었을때 통보 처리 하는 이벤트
비동기 작업 진행 상태를 통보하는 이벤트
위 이벤트 통보를 비동기 적으로 호출하기 위한 System.Threading.SendOrPostCallback 델리게이트
여러개의 비동기 작업 처리시 각 비동기 작업을 구분하는데 사용되는 System.ComponentModel.AsyncOperation 개체를 관리하는 Dictionary
입니다. 이때 Key는 고유한 값이어야 해서 보통 Guid개체로 사용 됩니다.

비동기 작업 완료 이벤트 핸들러인 DataProcCompletedEventHandler는 다음과 같습니다.

public class DataProcCompletedEventArgs : AsyncCompletedEventArgs
{
  public DataProcCompletedEventArgs(int value, object result, Exception error, bool cancelled, object userState) : base(error, cancelled, userState)
  {
    Value = value;
    Result = result;
  }
  
  public int Value { get; private set; }
  public object Result { get; private set; }
}

작업 완료시 +1 처리된 사용자에게 받은 (n)값과(Value 속성) 작업 완료가 끝났는지 여부(Result 속성)에 대한 정보가 포함 되어 있습니다.

그리고 생성자에서 위 델리게이트를 연결해 줍니다.

public DataProcess()
{
  completed = this.ProcessCompleted;
  progress = this.ReportProgress;
}

델리게이트 메서드 구현부는 추후 보고, 가장 처음 비동기 작업을 시작 하는 public 메서드를 다음처럼 구현해 봅니다.

public void ProcessAsync(int value, object taskId)
{
  AsyncOperation asyncOp =
    AsyncOperationManager.CreateOperation(taskId);
  
  lock (userStateToLifetime.SyncRoot)
  {
    if (userStateToLifetime.Contains(taskId))
    {
      throw new ArgumentException("동일 Task ID가 이미 존재함", "taskId");
    }
    
    userStateToLifetime[taskId] = asyncOp;
  }
  
  Action<int, AsyncOperation> startDelegate = this.ProcessWorker;
  // DataProcCompleted 및 ProgressChanged 이벤트 알림으로 통보가 되어 콜백 파라메터는 null로 처리 합니다.
  startDelegate.BeginInvoke(value, asyncOp, null, null);
}

위에서 설명한 것 처럼 System.ComponentModel.AsyncOperationManagerSystem.ComponentModel.AsyncOperation 인스턴스를 생성 했습니다.
이때 해당 비동기 작업을 연결하는데 사용할 개체를 같이 넘겨줍니다. 이 값은 고유해야 하므로 Guid를 생성해서 사용했습니다.
그리고 생성된 System.ComponentModel.AsyncOperation 인스턴스를 Dictionary에 등록하여 관리 합니다.
이렇게 작업별로 관리 하는 이유는 해당 비동기 작업을 취소 하거나 취소 되었는지 여부를 체크 하기 위함 입니다.
그리고 실제 작업처리가 되는 즉 동기적으로 작업처리를 하는 메서드를 호출하는 래핑 메서드를 BeginInvoke로 호출 합니다.

취소 처리에 대한 노출 메서드는 다음과 같이 작성할 수 있습니다.

public void CancelAsync(object taskId)
{
  AsyncOperation asyncOp = userStateToLifetime[taskId] as AsyncOperation;
  if (asyncOp != null)
  {
    lock (userStateToLifetime.SyncRoot)
    {
      userStateToLifetime.Remove(taskId);
    }
  }
}

그리고 BeginInvoke로 호출 되는 메서드 부분 입니다.

private void ProcessWorker(int value, AsyncOperation asyncOp)
{
  bool result = false;
  Exception exception = null;
  
  if (this.TaskCanceled(asyncOp.UserSuppliedState) == false)
  {
    try
    {
      result = this.Process(value, asyncOp);
    }
    catch (Exception ex)
    {
      exception = ex;
    }
  }
  
  this.CompletionMethod(
      value,
      result,
      exception,
      this.TaskCanceled(asyncOp.UserSuppliedState),
      asyncOp);
}

private bool TaskCanceled(object taskId)
{
  return (userStateToLifetime[taskId] == null);
}

위에서 BeginInvoke로 호출하면서 넘긴 value와 비동기 작업을 시작한 스레드의 Context(보통은 UI 스레드)를 들고 있는 System.ComponentModel.AsyncOperation 인스턴스를 실제 동기로 처리 되는 메서드를 호출하면서 넘겨 줍니다.
그 전에 TaskCanceled메서드로 해당 작업이 취소 되었는지 먼저 체크합니다.
try - catch로 Exception발생 여부로 체크하여 만약 중간에 오류가 발생되었다면 Exception정보도 같이 비동기 작업 완료 이벤트를 통해 전달합니다.
CompletionMethod()메서드는 비동기 작업 완료 이벤트 통보를 처리 하는 메서드로 System.Threading.SendOrPostCallback completed델리게이트를 System.ComponentModel.AsyncOperation 의 PostOperationCompleted()메서드를 통해 비동기 적으로 호출하면서
비동기 작업의 결과 정보, 오류 정보, 취소여부 모든 정보를 같이 위에서 정의했던 DataProcCompletedEventArgs를 통해 전달해 주고 있습니다.
CompletionMethod() 메서드 부분 코드는 다음과 같습니다.

private void CompletionMethod(
  int value,
  bool result,
  Exception exception,
  bool canceled,
  AsyncOperation asyncOp)
  {
    if (!canceled)
    {
      lock (userStateToLifetime.SyncRoot)
      {
        userStateToLifetime.Remove(asyncOp.UserSuppliedState);
      }
    }
    
    DataProcCompletedEventArgs e =
        new DataProcCompletedEventArgs(
            value,
            result,
            exception,
            canceled,
            asyncOp.UserSuppliedState);
            
    asyncOp.PostOperationCompleted(completed, e);
}

실제 작업처리 부분 메서드는 다음과 같습니다.

private bool Process(int value, AsyncOperation asyncOp)
{
  ProgressChangedEventArgs e = null;
  
  for (int i = 0; i < value; i++)
  {
  
    if(this.TaskCanceled(asyncOp.UserSuppliedState))
    {
      return false;
    }
                
    System.Threading.Thread.Sleep(1000);
    e = new ProgressChangedEventArgs(i, asyncOp.UserSuppliedState);
    asyncOp.Post(progress, e);
  }
  
  return true;
}

사용자에게 받은 value값 만큼 1부터 1초 단위로 증가 하면서 System.Threading.SendOrPostCallback progress델리게이트를 비동기로 호출합니다.
동시에 현재의 진행상태 정보도 같이 넘겨주고 있습니다. 그리고 TaskCanceled메서드를 통해 해당 작업이 취소처리가 되었는지 판단하도록 합니다.

마지막으로 completed와 progress 델리게이트 메서드 코드 부분 입니다.

private void ProcessCompleted(object operationState)
{
  DataProcCompletedEventArgs e =
    operationState as DataProcCompletedEventArgs;

  DataProcCompleted(this, e);
}

private void ReportProgress(object operationState)
{
  ProgressChangedEventArgs e =
    operationState as ProgressChangedEventArgs;

  ProgressChanged(this, e);
}

ReportProgress() 메서드 에서는 진행상태 이벤트를 발생시키고 ProcessCompleted() 메서드 에서는 비동기 작업 완료 이벤트를 발생 시킵니다.

이제 위에서 구현한 EAP패턴의 DataProcess비동기 클래스를 사용한 샘플을 사용해 볼까요?

DataProcess dataProcess = new DataProcess();
dataProcess.ProgressChanged += this.DataProcess_ProgressChanged;
dataProcess.DataProcCompleted += this.DataProcess_DataProcCompleted;
dataProcess.ProcessAsync(10, Guid.NewGuid());
private void DataProcess_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
  Console.WriteLine($"진행중...{e.ProgressPercentage}");
}

private void DataProcess_DataProcCompleted(object sender, DataProcCompletedEventArgs e)
{
  if(e.Error == null)
  {
    Console.WriteLine($"완료 - 결과 : {e.Value} / {e.Result}");
  }
  else
  {
    Console.WriteLine($"Error 발생 : {e.Error.Message}");
  }
}

결과를 보면 약 1초 주기로 진행중 표시가 출력 되고 약 10초 이후 완료 문자가 출력 되는걸 확인 할 수 있습니다.
진행중…0
진행중…1
진행중…2
진행중…3
진행중…4
진행중…5
진행중…6
진행중…7
진행중…8
진행중…9
완료 - 결과 : 10 / True