C# 6.0 완벽 가이드/ 동시성과 비동기성

Contents

소개

  • 동시성이 필요한 상황은 대부분 다음 네 범주 중 하나에 속한다.
    • 반응성 좋은 UI 작성
    • 다수의 요청을 동시에 처리
    • 병렬 프로그래밍
    • 예측 실행
  • 현재의 컴퓨터 구조에서 프로그램은 다중 스레드 적용(multithreading)이라고 부르는 일반적인 메커니즘을 이용해서 여러 작업을 동시에 실행한다. 이중 스레드 적용은 동시성의 근본 개념 중 하나이며 CLR과 운영체제 모두 다중 스레드 적용을 지원한다. 따라서 동시적인 프로그램을 만들려면 스레드 적용의 기초, 특히 스레드들이 공유 상태에 미치는 영향을 이해하는 것이 꼭 필요하다.

스레드 적용

  • 스레드는 독립적인 실행 흐름 또는 실행 경로이다. 한 스레드의 코드는 다른 모든 스레드와는 독립적으로 진행된다.
  • 각 스레드는 운영체제의 한 프로세스 안에서 실행된다. 프로세스는 프로그램이 실행되는 하나의 격리된 환경을 제공한다.
    • 단일 스레드 프로그램에서는 프로세스의 격리된 환경에서 스레드 하나만 실행되며, 따라서 그 스레드가 그 환경의 모든 것을 독차지한다.
    • 다중 스레드 프로그램에서는 한 프로세스 안에서 여러 스레드가 실행된다. 그 스레드들은 동일한 실행 환경(특히, 동일한 메모리)를 공유한다. 이는 다중 스레드 적용이 유용한 이유 중 하나이다.
    • 예컨대 한 스레드가 배경에서 자료를 만들어서 메모리에 저장하면 다른 한 스레드가 메모리의 자료를 표시할 수 있다. 그러한 자료를 공유되는 상태(shared state) 줄여서 공유 상태라고 부른다.

스레드 생성

  • Windows 스토어 앱에서는 응용 프로그램이 직접 스레드를 만들어서 시작할 수 없다. 대신 반드시 과제 객체를 사용해야 한다.
  • 기본적으로 클라이언트 프로그램은 운영체제가 자동으로 생성한 단일 스레드로 시작한다. 또 다른 스레드를 띄우지 않는 한 프로그램은 단일 스레드 응용 프로그램으로서 수명을 마치게 된다.
  • 새 스레드를 띄우는 방법은 간단하다. Thread 객체를 인스턴스화해서 Start 메서드를 호출하면된다. Thread의 가장 간단한 생성자는 ThreadStart 대리자 하나를 받는데, 그 대리자는 스레드 시동 메서드, 즉 스레드가 실행할 매개변수 없는 메서드를 가리켜야 한다. 예를 들면 다음과 같다.
using System;
using System.Threading;

class ThreadTest
{
  static void Main()
  {
    // 새 스레드를 띄워서 WriteY()를 실행한다.
    Thread t = new Thread(WriteY);
    t.Start();

    // 그와 동시에 주 스레드도 나름의 작업을 진행한다.
    for (int i = 0; i < 1000; i++) Console.Write("x");
  }

  static void WriteY()
  {
    for (int i = 0; i < 1000; i++) Console.Write("y");
  }
}

출력 예:
xxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxx
...
  • 주 스레드는 새 스레드 t를 만들고 문자 y를 거듭 출력하는 메서드를 그 스레드에서 실행한다. 그와 동시에 스레드 자신도 문자 x를 계속 출력한다. 아래 그림에 두 스레드의 실행 흐름이 나와있다.
    • 단일 코어 컴퓨터에서는 동시성을 흉내 내기 위해 운영체제가 시간 조각(slice)들을 각 스레드에 할당한다(일반적으로 Windows에서 시간 조각 하나는 20ms이다)
    • 다중 코어나 다중 프로세서 컴퓨터에서는 실제로 두 스레드가 병렬로 실행된다(단, 컴퓨터의 다른 활성 프로세스의 스레드들과 코어를 두고 경쟁할 수 있다)
    • 위 예에제서 출력이 x와 y가 뒤섞인 이유는 Console이 동시적인 요청들을 처리하는 메커니즘의 세부사항 때문이다.

  • 다른 스레드의 코드 실행 때문에 어떤 스레드의 실행이 잠시 중단된 것을 가리켜서 스레드 실행이 선점되었다(preempted)라고 말한다. 다중 스레드와 관련해서 뭔가가 잘못된 이유를 설명할 때 이 선점이라는 단어가 종종 등장한다.
  • 스레드의 실행이 시작된 시점에서 스레드가 종료되기 전까지는 해당 인스턴스의 IsAlive 속성이 true를 돌려준다. 스레드는 Thread의 생성자에 전달한 대리자의 실행이 끝나면 종료된다. 일단 종료된 스레드를 다시 실행하지는 못한다.
  • 각 스레드에는 Name 속성이 있다. 이 속성에 적당한 이름을 설정해 두면 디버깅이 편해진다.
    • 스레드 이름은 한 번만 설정할 수 있다. 이름을 다시 바꾸려 하면 예외가 발생한다.
  • 정적 Thread.CurrentThread 속성은 해당 호출이 실행되고 있는 스레드를 돌려준다.
Console.WriteLine(THread.CurrentThread.Name);

Join 메서드와 Sleep 메서드

  • 현재 스레드에서 다른 스레드의 실행이 끝나길 기다리려면 해당 스레드 객체에 대해 Join 메서드를 호출한다.
static void Main()
{
  Thread t = new Thread(Go);
  t.Start();
  t.Join();
  Console.WriteLine("스레드 t가 종료되었음");
}

static void Go() { for (int i = 0; i < 1000; i++) Console.Write("y"); }
  • 이 예제코드는 ‘y’를 1000번 출력한 다음 즉시 ‘스레드 t가 종료되었음’을 출력한다.
    • Join 호출시 만료시간을 밀리초 단위의 정수 또는 TimeSpan 객체로 지정할 수 있다. 그런 경우 Join은 만일 스레드가 종료되었으면 true를 시간이 만료되었으면 false를 돌려준다.
  • Thread.Sleep은 현재 스레드를 일정 시간 ‘재운다’
Thread.Sleep(TimeSpan.FromHours(1));  // 1시간 동안 수면
Thread.Sleep(500);  // 500ms 동안 수면
  • Thread.Sleep(0)은 스레드의 현재 시간 조각을 즉시 포기하고 CPU 사용권을 다른 스레드들에게 자발적으로 양도한다. Thread.Yield()도 같은 효과를 내지만, 같은 프로세서에서 실행되는 스레드들에게만 양도한다는 점이 다르다.
  • 실제 응용 프로그램에서 Sleep(0)이나 Yield는 종종 고급 성능 최적화 수단으로 쓰인다. 또한 이들은 스레드 안전성 관련 문제점을 드러내는 훌륭한 진단도구이기도 하다. 만일 코드의 어딘가에 Thread.Yield()를 추가했는데 프로그램이 오작동한다면, 프로그램에 버그가 있는 것이 거의 확실하다.
  • Sleep이나 Join 호출이 반환되기까지는 스레드의 실행이 차단된다.

차단

  • 어떤 이유로 (이를테면 Sleep을 호출했거나 다른 스레드를 기다리기 위해 Join을 호출해서) 실행이 일시 정지된 스레드를 가리켜 차단되었다(blocked)고 말한다.
    • 차단된 스레드는 즉시 자신의 CPU 시간 조각을 양보한다. 그때부터 차단 조건이 만족되어서 차단이 풀릴 때까지 그 스레드는 프로세서의 시간을 전혀 소비하지 않는다.
    • 주어진 스레드가 차단되었는지는 ThreadState 속성으로 알 수 있다.
bool blocked = (somThread.ThreadState & ThreadState.WaitSleepJoin) != 0;
  • ThreadState는 자료의 세 계층을 비트별 연산으로 결합한 형태의 플래그 열거형이다. 그러나 이 열거형의 값들은 대부분 중복이거나 쓰이지 않거나 폐기 예정이다. 다음의 확장 메서드는 ThreadState에서 가장 유용한 네 플래그 Unstarted, Running, WaitSleepJoin, Stopped만 남기고 나머지는 제거한다.
public static ThreadState Simplify (this ThreadState ts)
{
  return ts & (ThreadState.Unstarted | ThreadState.WaitSleepJoin | ThreadState.Stopped);
}
  • ThreadState 속성은 진단용으로 유용하지만 동기화에 사용하기는 적합하지 않다. ThreadState 속성을 읽어서 스레드의 상태를 알아내는 시점과 그 상태에 근거해서 뭔가를 수행하는 시점 사이에서 스레드의 상태가 변할 수 있기 때문이다.
  • 스레드가 차단되거나 차단이 풀리면 운영체제는 문맥 전환(context switch)을 수행한다. 문맥 전환에는 약간의 부담이 있기 때문에 보통의 경우 1-2ms 정도의 시간 지연이 생긴다.

입출력 한정 대 계산량 한정

  • 대부분의 시간을 뭔가가 일어나길 기다리는데 소비하는 연산을 가리켜 입출력에 한정되는(I/O-bound) 연산이라고 부른다.
    • 웹 페이지를 내려받거나 Console.ReadLine을 호출하는 것이 입출력 한정 연산의 예이다. (대체로 입출력 한정 연산은 입력이나 출력에 관여하지만 꼭 그래야 하는 것은 아니다. Thread.Sleep 호출도 입출력 한정으로 간주한다)
  • 반면 대부분의 시간을 CPU를 많이 사용하는 작업을 수행하는데 소비하는 연산을 가리켜 계산량에 한정되는(compute-bound) 연산이라고 부른다.

차단 대 회전

  • 입출력 한정 연산의 형태는 두 가지이다. 하나는 연산이 완료될 때까지 현재 스레드에서 동기적으로 기다리는 것이고(Console.ReadLine이나 Thread.Sleep, Thread.Join이 이에 해당한다) 또 하나는 연산이 완료되었을 때 어떤 콜백이 호출되는 것이다.
  • 연산 완료를 동기적으로 기다리는 입출력 한정 연산은 스레드를 차단한다. 루프에서 주기적으로 대기 연산을 실행할 수도 있는데, 이를 회전(spinning)이라고 부른다.
while (DateTime.Now < nextStartTime)
  Thread.Sleep(100);
  • 뭔가를 기다리는데 사용할 수 있는 더 나은 기법들이 있긴 하지만 (이를테면 타이머나 신호 전송 등) 나중에 이야기하고 다음처럼 Sleep 호출없이 그냥 루프를 돌려서 스레드를 회전할 수도 있다는 점을 언급하고 넘어가겠다.
while (DateTime.Now < nextStartTime);
  • 대체로 이런 회전 연산은 프로세서 시간을 크게 낭비한다. CLR과 운영체제의 관점에서는 스레드가 무너가 중요한 계산을 하는 것처럼 보이므로 귀중한 자원들을 스레드에 할당하게 된다. 결과적으로 사실상 입출력 한정 연산이 계산량 한정 연산으로 변하는 셈이 된다.
  • 회전 및 차단과 관련해서 간과하기 쉬운 점이 두 가지 있다.
    • 첫째로 어떤 조건이 곧 만족될 상황에서는 아주 잠깐의 회전이 효과적일 수 있다. 그런 경우 문맥 전환의 부담과 잠복지연을 피할 수 있기 때문이다. .NET Framework는 이를 지원하기 위한 특별한 메서드들과 클래스들을 제공한다.
    • 둘째로 차단의 비용이 반드시 0인 것은 아니다. 살아 있는 모든 스레드는 각각 약 1MB의 메모리를 차지하며 CLR과 운영체제는 차단된 스레드들도 계속해서 관리해야 한다. 따라서 수백, 수천의 동시적 연산들을 처리해야 하는 고도로 I/O에 한정되는 프로그램에서는 차단이 문제가 될 수 있다. 그런 프로그랢은 대기 시 스레드를 아예 폐기하고 콜백을 사용하는 접근방식을 사용해야 한다. 나중에 설명할 비동기 패턴의 한 용도는 그런 접근방식을 구현하는 것이다.

지역 상태 대 공유 상태

  • CLR은 각 스레드에 고유한 메모리 스택을 배정한다. 이 덕분에 스레드마다 지역 변수들이 따로 유지된다. 다음은 지역 변수가 하나 있는 메서드를 정의하고 그 메서드를 주 스레드와 새로 생성한 스레드에서 동시에 호출하는 예이다.
static void Main()
{
  new Thread(Go).Start();  // 새 스레드에서 Go를 호출한다.
  Go();  // 주 스레드에서 Go를 호출한다.
}

static void Go()
{
  // cycles라는 지역 변수를 선언해서 사용한다.
  for (int cycles = 0; cycles < 5; cycles++) Console.Write('?');
}
  • 각 스레드의 메모리 스택에 cycles 변수의 개별적인 복사본이 만들어지므로 이 예제는 예상대로 물음표를 10개 출력한다.
  • 한편 다음은 여러 스레드가 같으 ㄴ객체 인스턴스를 가리키는 공통의 참조를 통해서 자료를 공유하는 예이다.
class ThreadTest
{
  bool _done;

  static void Main()
  {
    ThreadTest tt = new ThreadTest();  // 공통의 인스턴스를 생성한다.
    new Thread(tt.Go).Start();
    tt.Go();
  }

  void Go()  // 이것이 인스턴스 메서드임을 주목할 것
  {
    if (!_done) { _done = true; Console.Write("Done"); }
  }
}
  • 두 스레드가 같은 ThreadTest 인스턴스로 Go를 호출하므로, 두 스레드는 _done 필드를 공유하게 된다. 결과적으로 Done이 두 번이 아니라 한 번만 출력 된다.
  • 람다 표현식이나 익명 대리자에 갈무리된 지역 변수는 컴파일러가 필드로 바꾸어서 컴파일하므로, 그런 변수들도 공유될 수 있다.
class ThreadTest
{
  static void Main()
  {
    bool done = false;
    ThreadStart action = () =>
    {
      if (!done) { done = true; Console.WriteLine("Done"); }
    }
    new Thread(action).Start();
    action();
  }
}
  • 다음처럼 정적 필드를 이용해서 스레드들이 자료를 공유할 수도 있다.
class ThreadTest
{
  static bool _done;  // 정적 필드는 같은 응용 프로그램 도메인의 모든 스레드가 공유한다.

  static void Main()
  {
    new Thread(Go).Start();
    Go();
  }

  static void Go()
  {
    if (!_done) { _done = true; Console.Write("Done"); }
  }
}
  • 이상의 세 예제는 스레드 안전성(thread safety)이라는 또 다른 핵심 개념을 보여준다. Done이 두 번 출력될 수도 있다는 점에서 이 예제들의 출력은 사실 비결정론적이다. 다행히 그럴 확률은 낮다. 그러나 Go 메서드의 문장들의 순서를 다음과 같이 바꾸면 Done이 두 번 출력될 확률이 극적으로 높아진다.
static void Go()
{
  if (!_done) { Console.Write("Done"); _done = true; }
}
  • 이 예제의 문제점은 한 스레드가 if  문의 조건을 평가하는 시점과 done을 true로 설정하는 시점 사이에서 다른 어떤 스레드가 WriteLine 문을 실행할 수 있다는 것이다.
  • 이 예제는 쓰기 가능 공유 상태(shared writeable state) 때문에 간헐적으로 오류가 발생하는 여러 상황 중 하나를 보여준다. 다중 스레드 적용 기법은 이런 골치 아픈 오류로 악명이 높다. 다음 절에서 잠금을 이용해서 이 문제를 해결하는 방법을 제시하겠지만, 가능하면 애초에 공유 상태를 피하는 것이 더 나은 해결책이다.
    • 그런 접근 방식에 도움이 되는 비동기 프로그래밍 패턴들을 잠시 후에 살펴볼 것이다.

잠금과 스레드 안전성

  • 앞의 예제에 있는 문제점을, 공유 필드를 읽고 쓰는 연산을 독점 자물쇠로 잠가서 해결할 수 있다. 그런 잠금 처리를 위해 C#은 lock이라는 키워드를 제공한다.
class ThreadSafe
{
  static bool _done;
  static readonly object _locker = new object();

  static void Main()
  {
    new Thread(Go).Start();
    Go();
  }

  static void Go()
  {
    lock (_locker)
    {
      if (!_done) { Console.Write("Done"); _done = true; }
    }
  }
}
  • 두 스레드가 하나의 자물쇠(lock)를 두고 동시에 경합하면 (여기서 자물쇠는 임의의 참조 형식 객체이다. 지금 예에서는 _locker가 자물쇠이다) 한 스레드만 자물쇠를 잠금 수 있고 다른 스레드는 그 자물쇠가 풀릴 때까지 기다려야 한다. 그리고 lock 문의 코드 블록은 자물쇠를 잠근 (또는 획득한) 스레드만 실행할 수 있다.
    • 결과적으로 lock 문의 코드 블록에는 한 번에 하나의 스레드만 진입하게 되며 따라서 이 예제는 Done을 단 한 번만 출력한다.
    • 이런 방식으로 보호되는(다중 스레드 문맥의 비결정성으로부터) 코드를 가리켜서 스레드에 안전하다(thread-safe)고 말한다.
  • 변수 자신을 1증가하는 간단한 연산조차도 반드시 스레드에 안전한 것은 아니다. x++라는 하나의 C# 표현식이 바탕 프로세서에서는 읽기, 증가, 쓰기라는 개별적인 세 개의 연산으로 실행될 수 있기 때문이다.
    • 따라서 두 스레드가 잠금 영역 x++를 동시에 실행하는 경우 변수가 한 번이 아니라 두 번 증가할 수 있다. 특정 조건에서는 기존 내용의 비트들에 새 내용의 비트들이 섞여서 x의 값이 깨지는 더 나쁜 결과가 빚어질 수도 있다.
  • 잠금이 스레드 안전성의 특효약은 아니다. 필드 접근 코드를 잠그는 것을 까먹기 쉬우며, 잠금 자체의 고유한 문제점(교착 등)도 있다.
  • 잠금의 바람직한 예로 ASP.NET 응용 프로그램을 만들 때 응용 프로그램이 자주 사용하는 데이터베이스 객체를 위한 공유 메모리 내부 캐시에 접근하는 코드는 잠금으로 보호하는 것이 좋다.
    • 그런 종류의 응용에는 코딩 시 실수할 부분이 별로 없으며, 교착(deadlock)이 발생할 여지도 없다.

스레드에 자료 전달

  • 스레드 시동 메서드에 인수들을 전달해야 할 때도 있다. 가장 간단한 방법은 해당 인수들로 메서드를 호출하는 람다식을 사용하는 것이다.
static void Main()
{
  Thread t = new Thread( () => Print("t에서 인사드립니다") );
  t.Start();
}

static void Print (string message) { Console.WriteLine(message); }
  • 이 접근방식을 이용하면 메서드에 임의의 인수들을 전달할 수 있다. 또는 다음처럼 전체 구현을 하나의 다중문 람다로 감쌀 수도 있다.
new Thread( () => 
{
  Console.WriteLine("다른 스레드에서 실행 중");
  Console.WriteLine("참 쉽죠?");
}).Start();
  • C# 3.0 이전에는 람다식이 없었다. 당시에는 다음과 같이 Thread의 Start 메서드에 인수를 전달하는 기법을 사용했다.
static void Main()
{
  Thread t = new Thread(Print);
  t.Start("t에서 인사드립니다");
}

static void Print (object messgeObj) 
{ 
  string message = (string) messageObj;  // 캐스팅이 필요함
  Console.WriteLine(message);
}
  • 이런 호출이 가능한 것은 Thread 생성자에 다음 두  대리자 형식을 받는 중복적재 버전들이 존재하기 때문이다.
public delegate void ThreadStart();
public delegate void ParameterizedThreadStart(object obj);
  • ParameterizedThreadStart에는 인수가 하나뿐이라는 한계가 있다. 또한 그 인수 형식이 object라서 대부분의 경우 명시적인 캐스팅이 필요하다.

람다식과 갈무리된 변수

  • 앞의 예에서 보았듯이 스레드에 자료를 전달하는 가장 편리하고도 강력한 수단은 람다식이다. 한 가지 주의할 점은 람다식에 갈무리된 변수를 스레드가 시작된 후에 수정하는 실수를 저지르면 안된다는 점이다. 다음 예를 보자.
for (int i = 0; i < 10; i++)
  new Thread(() => Console.Write(i)).Start();
  • 이 예제 코드의 출력은 비결정론적이다. 예컨대 0223557799 와 같은 출력이 나올 수도 있다.
    • 문제는 루프가 돌아가는 동안 i 변수가 같은 메모리 장소를 참조한다는 것이다. 즉, 각 스레드는 실행 도중 값이 변할 수도 있는 변수로 Console.Write를 호출하는 상황이다.
    • 해결책은 다음처럼 임시 변수를 사용하는 것이다.
for (int i = 0; i < 10; i++)
{
  int temp = i;
  new Thread(() => Console.Write(temp)).Start();
}
  • 이렇게 하면 0에서 9까지의 숫자가 정확히 한 번씩 출력된다 (그 숫자들이 출력되는 순서는 여전히 비결정론적이다. 스레드들의 실행이 시작되는 시간이 비결정론적이기 때문이다.)
  • 이는 8장의 갈무리된 변수에서 설명한 문제점과 비슷하다. 즉, 이는 다중 스레딩 적용에 관한 문제일 뿐만 아니라 for 루프의 변수 갈무리 관련 C# 규칙에 관한 문제이기도 하다. 이 문제점은 C# 5이전의 foreach 루프에도 적용된다.
  • 변수 temp는 각 루프 반복의 지역 범위에 속하므로 각 스레드는 서로 다른 메모리 장소를 갈무리한다. 따라서 이제는 문제가 사라진다. 다음은 수정 전의 코드에 이는 문제점을 좀 더 명확히 보여주는 예이다.
string text = "t1";
Thread t1 = new Thread(() => Console.WriteLine(text));

text = "t2";
Thread t2 = new Thread(() => Console.WriteLine(text));

t1.Start(); t2.Start();
  • 두 람다식 모두 같은 변수 text를 갈무리하므로 t2만 두 번 출력된다.

예외처리

  • 스레드를 생성, 실행하는 코드가 속산 try/catch/finally 블록은 생성, 실행된 스레드 자체와는 무관하다. 다음 프로그램을 생각해 보자.
public static void Main()
{
  try
  {
    new Thread(Go).Start();
  }
  catch (Exception ex)
  {
    // 여기에는 절대 도달하지 않는다.
    Console.WriteLine("Exception");
  }
}

static void Go() { throw null; } // NullReferenceException을 던진다.
  • 이 예의 try/catch 문은 아무 효과가 없다. 새로 생성한 스레드가 던진 NullReferenceException은 처리되지 않는다. 각 스레드가 독립적인 실행 경로라는 점ㅇ르 생각하면 이런 행동 방식을 이해할 수 있을 것이다.
  • 스레드의 예외를 처리하려면 예외 처리부를 스레드 시동 메서드로 옮겨야 한다.
public static void Main()
{
  new Thread(Go).Start();
}

static void Go() 
{ 
  try
  {
    ...
    throw null;  // NullReferenceException 예외가 아래의 catch 절에서 잡힌다.
    ...
  }
  catch (Exception ex)
  {
    // 여기서 흔히 예외를 기록하거나 이 스레드의 실행이 끝났음을 다른 스레드에 신호하는 등의 처리를 수행한다.
    ...
  }
}
  • 주 스레드에 예외 처리부를 두는(주로 실행 스택의 좀 더 상위 수준에) 것과 마찬가지로 실제 응용 프로그램에서는 모든 스레드 시동 메서드에 예외 처리부를 두어야 한다. 예외가 처리되지 않고 스레드를 벗어나면 응용 프로그램 전체가 종료되며 사용자는 흉한 대화상자를 보게 된다.
    • 그런 예외 처리 블록을 작성할 때 오류를 무시하는 경우는 드물다. 보통은 그런 블록에서 예외의 세부사항을 기록한다. 그런 후 세부사항을 프로그램 제작자의 웹 서버에 보내도 되는지 허락을 받는 대화상자를 띄우기도 한다. 마지막으로 응용 프로그램을 재시작하는 것도 생각해 봐야 한다. 예기치 않은 예외 때문에 프로그램이 유효하지 않은 상태가 되었을 수 있기 때문이다.

중앙집중적 예외 처리

  • WPF, Windows Store, Windows Forms 응용 프로그램에서는 Application.DispatcherUnhandledException 이벤트나 AppDomain.CurrentDomain.UnhandledException 이벤트, Application.ThreadException 이벤트를 구독해서 프로그램 전역 범위에서 예외를 처리하는 것도 가능하다. 메시지 루프를 통해서 호출되는 코드의 임의의 지점에서 미처리 예외가 발생하면 이 이벤트들이 발생한다.
    • 이 이벤트들은 버그를 기록하고 보고하는 최종적인 기회를 제공한다는 점에서 대단히 유용하다(단, 프로그램이 따로 생성한 비 UI 스레드의 미처리 예외에 대해서는 이 이벤트들이 발생하지 않음을 주의해야 한다.)
    • 이 이벤트들을 처리하면 프로그램이 강제로 종료되는 일을 방지할 수 있다. 단 그런 예외 때문에 프로그램의 상태가 깨지는(그리고 또 다른 미처리 예외로 이어진느) 문제를 피하려면 응용 프로그램을 다시 시작하는 것이 나을 수 있다.
  • AppDomain.CurrentDomain.UnhandledException 이벤트는 임의의 스레드의 임의의 미처리 예외에 대해 발생한다. 그러나 CLR 버전 2.0 부터는 이벤트 처리부가 끝나면 기본적으로 CLR이 응용 프로그램을 강제로 종료한다.
    • 강제 종료를 피하고 싶으면 응용 프로그램 구성 파일에 다음과 같은 요소를 추가해야 한다.
<configuration>
  <runtime>
    <legacyUnhandledExceptionPolicy enabled="1" />
  </runtime>
</configuration>
  • 이런 설정은 다수의 응용 프로그램 도메인을 담는 프로그램에 유용할 수 있다. 응용 프로그램의 기본 도메인이 아닌 도메인에서 미처리 예외가 발생했을 때 응용 프로그램 전체를 재시작하지 않고 해당 도메인만 파괴한 후 다시 생성할 수 있기 때문이다.

전경 스레드 대 배경 스레드

  • 기본적으로 프로그램이 명시적으로 생성한 스레드는 전경 스레드(foreground thread)이다. 전경 스레드가 하나라도 살아 있으면 응용 프로그램은 종료되지 않는다.
    • 반면 배경 스레드(background thread)는 그렇지 않다. 모든 전경 스레드가 종료되면 응용 프로그램이 종료되며, 그러면 실행 중이던 모든 배경 스레드는 즉시 강제로 종료된다.
    • 스레드의 전경/배경 여부는 스레드의 우선순위와는 무관하다. (우선순위는 스레드에 할당되는 실행시간에 관한 것이다.)
  • 스레드의 배경 여부는 IsBackground 속성으로 조회 또는 변경할 수 있다.
static void Main(string[] args)
{
  Thread worker = new Thread(() => Console.ReadLine());
  if (args.Length > 0) worker.IsBackground = true;
  worker.Start();
}
  • 이 프로그램을 아무 인수 없이 실행하면 프로그램은 일꾼 스레드(worker)를 그대로 전경 스레드로 남겨둔다. 일꾼 스레드는 ReadLine을 호출하며, 이 호출은 사용자가 엔터 키를 눌러야 반환되므로 일꾼 스레드는 그때까지 기다린다.
    • 그 전에 주 스레드가 종료되어도 전경 스레드가 하나 살아 있으므로 응용 프로그램은 계속 실행 중인 상태로 남는다. 그러나 어떤 명령줄 인수를 주어서 이 프로그램을 실행하면 일꾼 스레드는 배경 스레드가 되기 때문에 주 스레드가 끝나서 응용 프로그램이 종료되면 일꾼 스레드도 함께 종료된다(ReadLine 호출이 강제로 끝난다)
  • 프로세스가 이런 식으로 종료되면 배경 스레드의 실행 스택에 있던 finally 블록들은 실행되지 않는다. 따라서 임시 파일 삭제 같은 어떤 정리 작업을 finally 블록(또는 using 문)을 이용해서 수행한다면 응용 프로그램 종료시 스레드 합류(Join 메서드를 이용한)나 신호 대기를 이용해서 배경 스레드들이 정상적으로 종료되길 기다릴 필요가 있다.
    • 어떤 방법을 사용하든 만료시간을 지정하는 것이 바람직하다. 그래야 어떤 이유로 스레드가 종료를 거부해도 일정 시간이 지나면 스레드가 종료되게 만들 수 있다. 그렇게 하지 않으면 응용 프로그램이 제대로 종료되지 않으며, 그러면 사용자는 번거롭게도 Windows의 작업 관리자를 이용해서 직접 프로그램을 종료해야 한다.
  • 전경 스레드에는 이런 처리가 필요 없지만 스레드가 종료되지 않는 버그를 피하는데 신경을 써야 하는 것은 여전하다. 응용 프로그램이 제대로 종료되지 않는 흔한 이유 하나는 종료되었어야 할 전경 스레드가 여전히 살아 있는 것이다.

스레드 우선순위

  • 스레드의 Priority 속성은 스레드에 할당되는 실행 시간(운영체제의 다른 활성 스레드들에 상대적인)을 결정한다. 설정 가능한 우선순위 값들은 다음과 같다.
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
  • 스레드 우선순위는 여러 스레드가 동시에 활성화 되었을 때 중요하다.
    • 한 스레드의 우선순위를 높이면 다른 스레드들의 실행 기회가 고갈될 수 있으므로 조심해야 한다.
    • 어떤 스레드의 우선순위를 다른 프로세서의 스레드들보다 높이려면 System.Diagnostics의 Process 클래스를 이용해서 프로세스 자체의 우선순위도 높여야 한다.
using (Process p = Process.GetCurrentProcess())
  p.PriorityClass = ProcessPriorityClass.High;
  • 이러한 우선순위 상승은 최소한의 작업만 수행하는, 그리고 작업 수행시 잠복 지연이 적은(즉, 아주 빠르게 반응하는) 비 UI 프로세스에 적합하다. 계산량이 많은 응용 프로그램(특히 UI도 갖춘)에서는 프로세스 우선순위를 높이면 다른 프로세스의 실행 기회가 고갈되어서 전체가 느려질 수 있다.

신호 대기

  • 종종 다른 어떤 스레드가 신호할(signal) 때까지 한 스레드를 대기 상태로 두어야 할 때가 있다.
    • 가장 간단한 신호 대기 수단은 ManualResetEvent이다. 한 스레드에서 ManualResetEvent 객체에 대해 WaitOne을 호출하면, 그 스레드는 다른 스레드가 Set을 호출해서 신호할 때까지 기다린다. 이를 ‘신호를 보낸다.’ 또는 ‘신호를 연다(open).’라고 표현하기도 한다.
    • 다음 예제는 먼저 스레드를 하나 띄운다. 그 스레드는 ManualResetEvent가 신호되길 기다린다. 주 스레드는 2초 기다린 후에 그 스레드에 신호한다.
var signal = new ManualResetEvent(false);
new Thread(() => 
{
  Console.WriteLine("신호 대기 중");
  signal.WaitOne();
  signal.Dispose();
  Console.WriteLine("신호 받았음");
}).Start();

Thread.Sleep(2000);
signal.Set();  // 신호를 연다.
  • Set을 호출하면 신호는 계속 열린(보낸) 상태로 남는다. 필요하다면 Reset을 호출해서 신호를 닫힌(아직 보내지 않은) 상태로 되돌릴 수 있다.
  • ManualResetEvent는 CLR이 제공하는 여러 신호 수단 중 하나이다.

리치 클라이언트 응용 프로그램의 다중 스레드 적용

  • WPF, Windows Store, Windows Forms 응용 프로그램에서 시간이 오래 걸리는 연산을 주 스레드에서 실행하면 응용 프로그램의 반응성이 떨어진다. 주 스레드는 그런 연산 외에 UI를 갱신하고 키보드와 마우스 이벤트들을 처리하는 메시지 루프도 돌려야 하기 때문이다.
  • 이 문제를 해결하는데 흔히 쓰이는 접근 방식은 일꾼 스레드(worker thread)를 따로 띄워서 시간이 오래 걸리는 연산을 수행하는 것이다. 일꾼 스레드는 시간이 오래 걸리는 연산을 수행하고, 그것이 끝나면 UI를 갱신한다.
    • 그런데 모든 리치 클라이언트 응용 프로그램이 사용하는 스레드 적용 모형에는 UI 요소들과 컨트롤들에 접근할 수 있는 스레드는 그것들을 생성한 스레드(주로는 주 UI 스레드)뿐이라는 제약이 있다. 이를 위반하면 프로그램이 예기치 않은 방식으로 작동하거나 예외가 발생한다.
  • 따라서 일꾼 스레드에서 UI를 갱신하려면 UI를 직접 갱신하는 것이 아니라 UI 갱신 요청을 UI 스레드에 전달하는 기법(이를 전문 용어로 인도 또는 마샬링(marshallling)이라고 부른다)을 사용해야 한다. 다음은 이를 수행하는 저수준 방식들이다.
    • WPF에서는 UI 요소의 Dispatcher 객체에 대해 BeginInvoke나 Invoke를 호출한다.
    • Windows Store 앱에서는 Dispatcher 객체에 대해 RunAsync나 Invoke를 호출한다.
    • Windows Forms에서는 UI 컨트롤에 대해 BeginInvoke나 Invoke를 호출한다.
  • 이 메서드들은 모두 실행하고자 하는 메서드를 참조하는 대리자를 받는다.
    • BeginInvoke/RunAsync는 UI 스레드의 메시지 대기열(키보드, 마우스, 타이머 이벤트를 처리하는데 쓰이는 그 대기열이다)에 그 대리자를 추가한다.
    • Invoke도 그렇게 하지만 UI 스레드가 그 메시지를 읽어서 처리할 때까지 현재 스레드를 차단한다는 차이가 있다. 이 덕분에 Invoke는 대리자가 참조하는 그 메서드의 반환값을 현재 스레드에까지 돌려준다.
    • 반환값이 필요하지 않다면 호출자를 차단하지 않는, 그리고 교착의 여지가 없는 BeginInvoke/RunAsync가 낫다.
  • 다음은 Application.Run이 하는 일을 의사코드로 표현한 것이다.
    • 일꾼 스레드가 대리자를 인도해서 UI 스레드에서 실행되게 할 수 있는 것은 바로 이런 형태의 루프 덕분이다.
while(!thisApplication.Ended)
{
  메시지 대기열에 메시지가 들어오길 기다린다
  메시지가 들어오면 그 종류에 따라 다음과 같이 처리한다
    키보드/마우스 메시지 -> 해당 이벤트 처리부 발동
    사용자 BeginInvoke 메시지 -> 대리자 실행
      사용자 Invoke 메시지 -> 대리자 실행 및 결과 제시
}
  • 이해를 돕기 위한 예로 한 WPF 창에 txtMessage라는 텍스트 상자가 있다고 하자. 그리고 그 텍스트 상자의 내용을 일꾼 스레드가 시간이 오래 걸리는 어떤 작업을 수행한 후 갱신한다고 하자. 다음이 그러한 코드이다.
partial class MyWindow : Window
{
  public MyWindow()
  {
    InitializeComponent();
    new Thread(Work).Start();
  }

  void Work()
  {
    Thread.Sleep(5000);  // 시간이 걸리는 작업을 흉내낸다.
    UpdateMessage("답은 42");
  }

  void UpdateMessage(string message)
  {
    Action action = () => txtMessage.Text = message;
    Dispatcher.BeginInvoke(action);
  }
}
  • 이 코드를 실행하면 즉시 창이 뜨며, 그 창은 사용자의 입력에 잘 반응한다. 5초 후에 텍스트 상자의 내용이 갱신된다. Windows Forms용 코드도 이와 거의 같다.
    • (코드 생략)

다중 스레드

  • 한 응용 프로그램에서 여러 개의 UI 스레드를 둘 수도 있다. UI 스레드들이 각자 다른 창을 소유하게만 하면 된다. 하나의 응용 프로그램에 최상위 창이 여러 개 있는 경우에 그런 구조가 흔히 쓰인다.
    • 그런 응용 프로그램을 흔히 SDI(Single Document Interface; 단일 문서 인터페이스) 응용 프로그램이라고 부른다. 대표적인 예는 Microsoft Word이다.
    • 일반적으로 SDI 창은 작업 표시줄에 개별적인 ‘응용 프로그램’으로 나타나며 서로 격리되어서 작동한다. 그런 창마다 따로 UI 스레들르 두면 각 창이 사용자의 입력에 좀 더 잘 반응하게 된다.

동기화 문맥

  • System.ComponentModel 이름공간에는 동기화 문맥을 대표하는 Synchronization Context라는 추상 클래스가 있다. 이 클래스는 리치 클라이언트 API 마다 다른 스레드 인도 방식을 일반화하는 기반으로 쓰인다.
  • 이동기기와 데스크톱을 위한 리치 클라이언트 API들은 각자 SynchronizationContext의 파생 클래스를 정의해서 인스턴스화한다.
    • 정적 속성 SynchronizationContext.Current를 통해서 그 인스턴스에 접근할 수 있다(단 UI 스레드에서만) 특히 이 속성을 갈무리함으로써 일꾼 스레드에서 연산 결과를 UI 컨트롤에 게시(posting) 할 수 있다.
partial class MyWindow : Window
{
  SynchronizationContext _uiSyncContext;

  public MyWindow()
  {
    InitializeComponent();
    // 현재 UI 스레드의 동기화 문맥을 갈무리한다.
    _uiSyncContext = SynchronizationContext.Current;
    new Thread(Work).Start();
  }

  void Work()
  {
    Thread.Sleep(5000);  // 시간이 걸리는 작업을 흉내낸다.
    UpdateMessage("답은 42");
  }

  void UpdateMessage(string message)
  {
    // 대리자를 UI 스레드에 인도한다.
    _uiSyncContext.Post(_ => txtMessage.Text = message, null);
  }
}
  • 이러한 접근방식에는 모든 리치 클라이언트 사용자 인터페이스에 대해 같은 기법을 사용할 수 있는 장점이 있다(ASP.NET도 SynchronizationContext를 자신에 맞게 파생한다. ASP.NET의 파생 클래스는 페이지 처리 이벤트들이 이후의 비동기 연산들에 따라 차례로 처리되게 하거나 HttpContext를 보존하는 등의 좀 더 정교한 처리도 수행한다)
  • Post를 호출하는 것은 Dispatcher나 Control에 대해 BeginInvoke를 호출하는 것에 해당한다. 또한 Invoke에 해당하는 Send 메서드도 있다.

스레드 풀

  • 스레드를 하나 새로 띄우면 지역 변수 스택 등을 마련하는데 수백 마이크로초가 소비된다. 스레드 풀(thread pool)은 재활용 가능한 스레드들을 미리 만들어서 풀에 담아 둠으로써 그런 지연을 제거한다.
    • 스레드 풀은 효율적인 병렬 프로그래밍과 세밀한 동시성(fine-grained concurrency)을 구현하는데 필수이다. 스레드 풀을 이용하면 스레드 시동에 필요한 추가부담에 압도당하지 않고도 짧은 연산들을 실행할 수 있다.
  • 다음은 스레드 풀에서 가져온 스레드(줄여서 풀 스레드)를 사용할 때 주의할 점 몇 가지이다)
    • 풀 스레드의 Name은 설정할 수 없다. 따라서 풀 스레드를 사용할 때는 디버깅이 조금 어려워진다.
    • 풀 스레드는 항상 배경 스레드이다.
    • 풀 스레드를 차단하면 성능이 떨어질 수 있다.
  • 풀 스레드의 우선순위를 변경하는데는 별다른 제약이 없다. 스레드를 다시 풀에 되돌리면 우선순위가 보통 수준으로 복원된다.
  • 현재 스레드가 풀 스레드인지는 Thread.CurrentThread.IsThreadPoolThread 속성으로 알아낼 수 있다.

풀 스레드 실행

  • 풀 스레드에서 뭔가를 실행하는 가장 쉬운 방법은 Task.Run 메서드를 이용하는 것이다.
Task.Run(() => Console.WriteLine("여기는 스레드 풀"));
  • Task는 .NET Framework 4.0에서 도입되었다. 그 전에는 다음처럼 ThreadPool.QueueUserWorkItem을 호출하는 방법이 흔히 쓰였다.
ThreadPool.QueueUserWorkItem(notUsed => Console.WriteLine("Hello"));
  • 다음 구성요소들은 내부적으로 스레드 풀을 사용한다.
    • WCF, Remoting, ASP.NET, ASMX Web Services 응용 프로그램 서버
    • System.Timers.Timer와 System.Threading.Timer
    • 병렬 프로그래밍 수단들
    • (이제는 쓸모 없어진) BackgroundWorker 클래스
    • 비동기 대리자(역시 이제는 쓸모 없어짐)

스레드 풀의 건강 상태

  • 스레드 풀은 일시적으로 계산량 한정 작업들이 늘어나도 CPU의 초과구독(over-subscription)이 발생하지 않게 한다는 또 다른 기능이 있다.
    • 초과구독은 활성 스레드가 CPU 코어 개수보다 많은 상황을 뜻한다. 그런 경우 운영체제는 여러 스레드에 시간 조각을 할당해서 동시성을 흉내낸다. 그러나 그렇게 하면 값비싼 문맥 전환이 발생할 뿐만 아니라 현대적인 프로세서들이 높은 성능을 달성하는데 꼭 필요한 CPU 캐시가 무효화 될 수 있기 때문에 성능이 나빠진다.
  • CLR은 작업들을 스레드 풀의 대기열에 집어넣고 그 실행을 적절히 제한함으로써 초과구독을 방지한다. CLR은 우선 하드웨어 코어 개수까지만 동시적인 작업들을 실행하고, 그 뒤부터는 언덕 오르기(hill-climbing) 알고리즘을 이용해 작업 부하를 끊임없이 특정 방향으로 조정함으로써 동시성 수준을 조율한다.
    • 만일 처리량이 증가하면 같은 방향으로 계속 진행하고 그렇지 않으면 반대 방향으로 간다.
    • 이 덕분에 프로세스들과 스레드들이 CPU를 두고 서로 경쟁하는 상황에서도 전체적인 처리량이 항상 최적의 성능 곡선을 따르게 된다.
  • CLR의 전략은 다음 두 족너이 만족될 때 아주 잘 작동한다.
    • 작업 항목들이 대부분 짧게 끝나서(대략 250ms 미만, 이상적으로는 100ms 미만) CLR이 성능을 측정하고 조율할 기회가 많다.
    • 풀 스레드들 중 대부분의 시간을 차단된 상태로 보내는 것들이 그렇지 않은 것들에 비해 소수이다.
  • 스레드의 차단이 문제가 되는 이유는 차단된 스레드가 마치 CPU를 열심히 활용하고 있는 것처럼 보이기 때문이다.
    • CLR은 그런 상황을 검출해서 보정할(풀에 더 많은 스레드를 주입해서) 정도로 똑똑하지만, 그래도 그런 스레드들이 많으면 이후에 풀이 초과구독에 취약해질 수 있다.
    • 또한 CLR이 새 스레드를 풀에 주입하는 속도를 제한함에 따라 잠복지연이 발생할 수 있다. 특히 응용 프로그램의 수명 주기 초반에서 그렇다(자원을 덜 소비하는 프로세스를 선호하는 클라이언트 운영체제에서는 더욱 그렇다)
  • 스레드 풀을 건강하게 유지하는 것은 CPU를 최대한 활용하고자 할 때 (예컨대 병렬 프로그래밍 API를 통해서) 특히나 중요한 문제이다.

Task 클래스

  • 스레드는 동시성 실현을 위한 저수준 도구이며 그런 만큼 한계가 있다. 특히 다음과 같은 단점이 존재한다.
    • 스레드를 시작할 때 스레드에 자료를 전달하기는 쉽지만, Join으로 스레드를 합류시킬 때 스레드의 ‘반환값’을 간단하게 얻을 수는 없다. 어떤 형태로든 공유 필드를 만들어야 한다. 그리고 스레드 연산이 예외를 던지는 경우 그 예외를 잡아서 전파하기도 쉽지 않다.
    • 스레드의 연산이 끝난 후 다른 뭔가를 수행하게 만들 수 없다. 반드시 Join으로 합류시켜야 한다(그러면 현재 스레드가 차단된다)
  • 이런 단점들은 세밀한 동시성을 구현하는데 방해가 된다. 다른 말로 이 단점들 때문에 더 작은 동시적 연산들을 조합해서 더 큰 동시적 연산을 만들기가 어렵다. 그러다 보니 직접적인 저수준 동기화 기법들(잠금, 신호 대기 등)에 더욱 의존하게 되며, 그로부터 또 다른 문제점들이 발생한다.
  • 스레드를 직접 사용하는 것은 성능에도 해가 될 수 있다. 수백, 수천의 동시적 I/O 한정 연산들을 실행해야 하는 경우 스레드 기반 접근방식에서는 단지 스레드들을 유지하는데만 수백, 수천 MB의 메모리를 소비하게 된다.
  • Task 클래스는 이 모든 문제를 푸는데 도움이 된다. Task는 스레드보다 더 높은 수준의 추상이다. 하나의 Task 객체는 스레드로 구현될 수도 있고 아닐 수도 있다. 하나의 동시적 연산(concurrent operation)을 대표한다.
    • Task 객체는 조합할 수 있다 (연속(continuation) 기능을 이용해서 여러 Task 객체들을 사슬처럼 이을 수 있다)
    • Task 객체는 시동 잠복지연(startup latency)을 완화하기 위해 스레드 풀을 활용할 수 있다. 또한 TaskCompletetionSource와 함께 사용하는 경우 콜백 방식으로도 활용할 수 있다. 즉, 스레드 없이도 I/O 한정 연산들을 기다리는 것이 가능하다.
  • Task 클래스와 관련 형식들은 .NET Framework 4.0에서 병렬 프로그래밍 라이브러리의 일부로 도입되었다. 그러나 이후 계속 개선되어서 이제는 좀 더 일반적인 동시성 시나리오들에서도 잘 작동하며(대기자의 활용을 통해서) C#의 비동기 함수들의 바탕이 되는 형식들로까지 자리 잡았다.

Task를 이용한 동시 연산 시작

  • .NET Framework 4.5부터 스레드 기반 Task 작업을 시작하는 가장 쉬운 방법은 정적 메서드 Task.Run을 사용하는 것이다. 다음처럼 Action 형식의 대리자를 지정하기만 하면 된다.
    • .NET Framework 4.0에서는 Task.Factory.StartNew를 호출하면 된다. (대충 말하면 전자는 후자의 단축 표기라 할 수 있다)
Task.Run(() => Console.WriteLine("Foo"));
  • Task 객체는 기본적으로 풀 스레드를 사용한다. 풀 스레드는 배경 스레드이므로, 주 스레드가 끝나면 프로그램이 생성한 Task 작업들도 모두 끝난다. 따라서 이번 절의 예제들을 콘솔 응용 프로그램 형태로 시험해 보려면 Task 작업을 띄운 후 반드시 주 스레드를 차단해야 한다.(작업 객체에 대해 Wait를 호출하거나 Console.ReadLine으로 사용자 입력을 기다리는 등)
static void Main()
{
  Task.Run(() => Console.WriteLine("Foo"));
  Console.ReadLine();
}
  • LINQPad의 이 책 관련 예제들에는 Console.ReadLine이 없는데, 이는 어차피 LINQPad 프로세스가 살아 있으면 배경 스레드들이 종료되지 않기 때문이다.
  • 이런 식으로 Task.Run을 호출하는 것은 다음과 같이 스레드를 시동하는 것과 비슷하다.
new Thread(() => Console.WriteLine("Foo")).Start();
  • Task.Run은 하나의 Task 객체를 돌려준다. Thread 객체에서처럼 그 객체를 이용해서 작업의 진행 상황을 조회할 수 있다. (그러나 Task.Run이 돌려준 객체에 대해 Start를 호출할 수는 없다. Task.Run은 소위 ‘뜨거운'(이미 실행이 시작된) 작업 객체를 돌려준다. ‘차가운’ 작업 객체가 필요하다면 Task의 생성자를 직접 호출하면 되지만, 실제 응용에서 그럴 필요가 있는 경우는 드물다.)
  • 한 Task 작업의 실행 상태는 해당 객체의 Status 속성으로 알아낼 수 있다.

Wait 메서드

  • 어떤 작업 객체에 Wait를 호출하면 그 작업이 완료될 때까지 현재 스레드의 실행이 차단된다. 이는 어떤 스레드에 대해 Join을 호출하는 것과 비슷하다.
Task task = Task.Run(() =>
{
  Thread.Sleep(2000);
  Console.WriteLine("Foo");
});
Console.WriteLine(task.IsCompleted);  // false
task.Wait();  // 작업이 완료될 때까지 차단됨
  • Wait에는 만료시간과 취소 토큰을 받는 버전이 있다. 이를 이용하면 작업이 완료되기 전에 대기를 끝낼 수 있다.

오래 실행되는 작업

  • 기본적으로 CLR은 작업 객체들을 풀 스레드에서 실행한다. 풀 스레드는 짧게 실행되는 계산량 한정 연산에 이상적이다. 오래 실행되는 그리고 스레드를 차단하는 연산에서는 다음과 같이 풀 스레드를 사용하지 않도록 하는 것이 바람직하다.
Task task = Task.Factory.StartNew(() => ..., TaskCreationOptions.LongRunning);
  • 오래 실행되는 Task 작업을 하나만 풀 스레드에서 돌리는 것은 별로 문제가 되지 않는다. 성능상의 문제는 그런 작업 객체들을 병렬로 여러 개 돌릴 때 발생한다. 그런 상황에서는 TaskCreationOptions.LongRunning 대신 다음과 같은 해법들을 사용하는 것이 더 나은 경우가 많다.
    • I/O 한정 연산의 경우에는 TaskCompletionSource와 비동기 함수들을 이용해서 스레드 대신 콜백으로 동시성을 구현한다.
    • 계산량에 한정되는 연산에서는 생산자/소비자 대기열을 이용해서 그런 작업들의 동시성을 제한함으로써 다른 스레드나 프로세스들의 고갈을 피한다.

작업 결과 반환

  • Task에는 Task<TResult>라는 제네릭 파생 클래스가 있다. 이 클래스의 인스턴스는 호출자에게 값을 돌려줄 수 있다.
    • Task<TResult> 인스턴스를 생성하려면 Action 대신 Func<TResult> 형식의 대리자 객체(또는 그와 호환되는 람다식)로 Task.Run을 호출하면 된다.
Task<int> task = Task.Run(() => { Console.WriteLine("Foo"); return 3; });
  • 대리자의 반환값은 나중에 Result 속성으로 얻을 수 있다. 해당 작업이 아직 끝나지 않은 상태에서 이 속성에 접근하면, 작업이 끝날 때까지 현재 스레드가 차단된다.
int result = task.Result;  // 아직 끝나지 않았으면 차단됨
Console.WriteLine(result);  // 3
  • 다음 예제는 LINQ를 이용해서 2에서 3백만까지의 정수들에 있는 소수(prime number)들의 개수를 세는 작업 객체를 생성한다.
Task<int> task = Task.Run(() => 
  Enumerable.Range(2, 3000000).Count(n =>
    Enumerable.Range(2, (int)Math.Sqrt(n)-1).All(i => n % i > 0)));

Console.WriteLine("작업 실행 중...");
Console.WriteLine("답은 " + task.Result);
  • 이 예제를 실행하면 “작업 실행 중…”이 출력되고 몇 초 후에 “답은 216815″가 출력된다.
  • 나중에 즉 미래에 사용할 수 있게 될 결과(Result 속성)를 캡슐화한다는 점에서, Task<TResult> 인스턴스를 미래(future) 객체라고 생각해도 될 것이다. 실제로 초기 CTP에서 Task와 Task<Result>가 처음 등장했을 때 후자의 이름은 Future<TResult>였다.

예외

  • 스레드와는 달리 작업 객체는 예외를 잘 전파한다. 작업 객체의 코드 안에서 발생한 예외가 처리되지 않으면(즉, 작업에 장애(fault)가 발생하면) CLR은 그 예외를 Wait를 호출하거나 Task<TResult>의 Result 속성에 접근한 코드 쪽으로 다시 던진다.
// NullReferenceException을 던지는 Task 객체를 시작한다.
Task task = Task.Run(() => { throw null; });
try
{
  task.Wait();
}
catch (AggregateException aex)
{
  if (aex.InnerException is NullReferenceException)
    Console.WriteLine("Null!");
  else
    throw;
}
  • 병렬 프로그래밍 상황에서 예외가 잘 전파되게 하기 위해 CLR은 예외를 AggregateException으로 감싸서 전달한다.
  • 작업 객체에 장애가 있는지를 예외를 다시 던지지 않고도 파악할 수 있다. Task의 IsFaulted 속성과 IsCanceled 속성을 사용하면 된다. 만일 두 속성 모두 false이면 아무런 오류도 발생하지 않은 것이다.
    • 만일 IsCanceled가 true이면 그 작업에서 OperationCanceledException 형식의 예외가 발생한 것이다.
    • 만일 IsFaulted가 true이면 그 외의 예외가 발생한 것이며, 이 경우 Exception 속성을 통해서 오류의 종류를 알아낼 수 있다.

예외와 자율 작업

  • 자율적인 ‘띄운 다음 그냥 잊어버리면 되는’ 작업 (즉, Wait 메서드나 Result 속성, 또는 이들에 해당하는 연속 작업(continuation task)을 통해서 현재 스레드와 합류하지 않을 작업)의 경우, 장애가 소리 없이 묻히지 않게 하려면 스레드에서처럼 작업 코드 자체에서 명시적으로 예외를 처리하는 것이 바람직하다.
  • 자율 작업의 미처리 예외를 관찰되지 않는 예외(unobserved exception) 줄여서 미관찰 예외라고 부른다. CLR 4.0에서는 미관찰 예외가 발생하면 실제로 응용 프로그램이 종료된다(범위를 벗어난 작업 객체를 쓰레기 수거기가 수거할 때 CLR은 객체의 종료자에서 그 예외를 다시 던진다)
    • 알아채지 못했을 수도 있는 문제점을 드러내 준다는 점에서 이런 행동 방식은 프로그래머에게 도움이 되기도 했지만, 예외 발생 시점이 애매해서 오해의 여지가 많았다. 작업 객체에서 문제가 있는 코드가 실행되고 한참 후에야 쓰레기 수거기가 작동할 수도 있기 때문이다. 이것이 비동기성의 특정 패턴을 복잡하게 만든다는 점을 발견한 CLR 개발팀은 CLR 4.5에서 이런 행동 방식을 폐기했다.
  • 어떤 결과를 얻지 못했다는 점을 나타내기만 하는 예외라면 그리고 그 결과에 더 이상 관심이 없어진 상황이라면, 예외를 무시해도 무방하다. 예컨대 사용자가 어떤 웹 페이지를 내려받으라고 요청했다가 그것을 취소했다면, 그 웹페이지가 존재하지 않음을 알리는 예외는 무시해도 된다.
    • 그러나 프로그램에 버그가 있음을 뜻하는 예외는 무시하지 않는 것이 좋다. 그 이유는 2가지 이다.
      • 그 버그 때문에 프로그램이 유효하지 않은 상태가 될 수 있다.
      • 그 버그 때문에 나중에 더 많은 예외가 발생할 수 있으며, 초기의 오류를 기록하지 않으면 진단이 어려워진다.
  • 미관찰 예외를 전역 수준에서 잡으려면 정적 이벤트 TaskScheduler.UnobservedTaskException을 구독하면 된다. 이 이벤트에 대한 처리부에서 오류를 기록해 두는 것이 바람직하다.
  • 미관찰 예외와 관련해서 놓치기 쉬운 미묘한 상황이 두 가지 있다.
    • Task 객체의 작업이 끝나길 만료시간을 지정해서 기다릴 때 만일 그 만료시간이 지난 후에 장애가 발생하면 미관찰 예외가 발생한다.
    • Task 객체의 작업에 장애가 발생한 후에 Exception 속성을 조회하면 해당 예외가 ‘관찰되는 예외’로 바뀐다.

연속

  • 연속(continuation)이란 작업 객체에게 ‘지금 하는 일을 마치면 계속해서 다른 일을 실행하라’고 지시하는 것이다. 일반적으로 연속 기능은 연산이 완료되었을 때 실행되는 콜백의 형태로 구현된다. 연속해서 실행될 콜백을 기존 작업 객체에 부착하는 방법은 두가지이다.
    • 첫 방법은 .NET Framework 4.5에 도입된 것으로, C#의 비동기 함수들을 사용한다는 점에서 특히나 의미가 크다. 다음은 이 방법을 보여주는 예로 이전에 소수 세기를 연속 기능을 이용해서 다시 작성한 것이다.
Task<int> task = Task.Run(() => 
  Enumerable.Range(2, 3000000).Count(n =>
    Enumerable.Range(2, (int)Math.Sqrt(n)-1).All(i => n % i > 0)));

var awaiter = task.GetAwaiter();
awaiter.OnCompleted(() => 
{
  int result = awaiter.GetResult();
  Console.WriteLine(result);
});
  • 작업 객체에 대해 GetAwaiter를 호출하면 대기자(awaiter) 객체가 반환된다. 이 객체에 대한 OnCompleted 호출은 선행 작업(antecedent task)이 완료되었을 때(또는 장애가 발생했을 때) 호출될 대리자를 지정한다. 이미 완료된 작업 객체에 대해 연속용 콜백을 부착해도 오류가 나지 않는다. 그런 경우 CLR은 그 콜백이 즉시 실행되도록 실행 일정을 잡는다.
  • 앞의 예제에서 나온 두 메서드(OnCompleted와 GetResult)를 제공하기만 한다면 어떤 객체도 대기자로 사용할 수 있다 .그런 모든 멤버를 단일하게 통합하는 인터페이스나 기반 클래스는 없다.(단, OnCompleted는 INotifyCompletion 인터페이스의 일부이다)
  • 선행 작업에 장애가 발생한 경우 연속 코드에서 awaiter.GetResult를 호출하면 예외가 다시 던져진다. GetResult를 호출하는 대신 그냥 선행 작업 객체의 Result 속성을 통해서 작업 결과를 얻을 수도 있다. GetResult 호출의 장점은 선행 작업에 장애가 있는 경우 예외가 AggregateException으로 재포장되지 않고 그대로 던져진다는 것이다. 그 덕분에 catch 블록들을 좀 더 간단하고 깔끔하게 작성할 수 있다.
  • 비제네릭 작업 객체의 경우 GetResult의 반환 형식은 void이다. 이 경우 GetResult의 유용한 기능은 그냥 예외들을 다시 던진다는 것뿐이다.
  • 동기화 문맥이 존재하는 경우 OnCompleted는 그것을 자동으로 갈무리해서 해당 문맥에 대한 연속용 콜백에 전달한다. 이는 리치 클라이언트 응용 프로그램에서 아주 유용한 기능이다. 이를 통해서 동기화 문맥을 UI 스레드에까지 전달할 수 있기 때문이다.
    • 그러나 라이브러리를 작성할 때는 이것이 별 도움이 되지 않는 경우가 많다. 비교적 비용이 많이 드는 ‘UI 스레드 문맥 전달’은 라이브러리를 벗어날 때 한 번만 수행되어야지, 메서드 호출마다 수행되어서는 부담이 크기 때문이다.
    • 그런 경우에는 다음처럼 ConfigureAwait 메서드를 이용해서 해당 기능을 비활성화하는 것이 좋다.
var awaiter = task.ConfigureAwait(false).GetAwaiter();
  • 동기화 문맥이 없다면 또는 ConfigureAwait(false)를 사용했다면 연속용 콜백은 (일반적으로) 선행 작업과 같은 스레드에서 실행되므로 문맥 전환 비용이 발생하지 않는다.
  • 작업 객체의 ContinueWith 메서드를 이용해서 연속용 콜백을 작업 객체에 부착할 수도 있다.
task.ContinueWith(antecedent => 
{
  int result = antecedent.Result;
  Console.WriteLine(result);  // 123을 출력한다.
});
  • ContinueWith 자체는 하나의 Task 객체를 돌려준다. 이 ‘연속 작업’ 객체는 또 다른 연속용 콜백을 부착하려는 경우에 유용하다. 그러나 이 작업 객체에서 장애가 발생할 여지가 있다면 AggregateException을 직접 처리하는 예외 처리부를 작성해 주어야 하며, UI 응용 프로그램에서는 연속 작업을 인도(마샬링)하는 코드로 추가로 작성해야 한다.
    • 그리고 UI 문맥에서는 만일 연속 작업이 같은 스레드에서 실행되게 하고 싶다면 반드시 TaskContinuationOptions.ExecuteSynchronously를 지정해야 한다. 그렇게 하지 않으면 연속용 콜백은 풀 스레드에서 실행된다.
    • ContinueWith는 병렬 프로그래밍 시나리오에서 특히나 유용하다.

TaskCompletionSource 클래스

  • 풀 스레드에서 대리자를 실행하는 작업 객체를 Task.Run 메서드를 이용해서 생성하는 방법을 앞서 보았다. 그 밖에 TaskCompletionSource 클래스를 이용해서 작업 객체를 생성할 수도 있다.
  • TaskCompletionSource를 이용하면 어떤 연산을 지금 바로가 아니라 잠시 후에 시작하는 작업 객체를 생성할 수 잇다. 그 작업 객체는 일종의 ‘생산자’에 해당하며 작업이 끝났거나 장애가 생겨서 더 이상 진행하지 못하게 되면 그 사실을 알려준다.
    • 이는 I/O 한정 연산에 이상적이다. 연산이 진행되는 동안 스레드를 차단하지 않고도 작업 객체의 모든 장점(특히 결과 반환, 예외 전파, 연속 기능)을 누릴 수 있다.
  • TaskCompletionSource를 사용하는 방법은 간단하다. 원하는 결과 형식을 지정해서 TaskCompletionSource 의 인스턴스를 생성하면 된다. 그 인스턴스의 Task 속성은 작업 객체를 하나 돌려주는데, 다른 작업 객체들과 마찬가지 방식으로 작업의 완료를 기다리거나 연속용 콜백을 부착할 수 있다.
    • 다른 작업 객체들과의 차이점은 TaskCompletionSource  인스턴스의 여러 메서드를 통해서 작업의 진행을 제어할 수 있다는 것이다. 그런 메서드들은 다음과 같다.
public class TaskCompletionSource<TResut>
{
  public void SetResult(TResult result);
  public void SetException(Exception exception);
  public void SetCanceled();

  public bool TrySetResult(TResult result);
  public bool TrySetException(Exception exception);
  public bool TrySetCanceled();
  public bool TrySetCanceled(CancellationToken cancellationToken);
  ...
}
  • 이 메서드 중 하나를 호출하면 작업 객체가 신호를 받으며, 그러면 작업 객체는 작업 완료 상태나 장애 상태, 취소 상태 중 하나로 귀결된다. 이 메서드들은 하나의 TaskCompletionSource 인스턴스에 대해 딱 한 번만 호출하도록 설계되어 있다.
    • SetResult와 SetException, SetCanceled는 두 번째 호출부터 예외를 던지고, Try* 메서드들은 두 번째 호출부터 항상 false를 돌려준다.
  • 다음 예제는 5초 대기 후 42를 출력한다.
var tcs = new TaskCompletionSource<int>();

new Thread(() => { Thread.Sleep(5000); tcs.SetResult(42); })
  { IsBackground = true }
  .Start();

Task<int> task = tcs.Task;  // 생산자 작업 객체
Console.WriteLine(task.Result);  // 42
  • 다음 예에서 보듯이 TaskCompletionSource에서는 Run 메서드를 직접 사용할 수 이다.
Task<TResult> Run<TResult> (Func<TResult> function)
{
  var tcs = new TaskCompletionSource<TResult>();
  new Thread(() =>
  {
    try { tcs.SetResult(function()); }
    catch (Exception ex) { tcs.SetException(ex); }
  }).Start();
  return tcs.Task;
}
...
Task<int> task = Run (() => { Thread.Sleep(5000); return 42; });
  • 이 메서드를 호출하는 것은 TaskCreationOptions.LongRunning 옵션을 지정해서 (풀 스레드가 아닌 보통 스레드를 사용하기 위해) Task.Factory.StartNew를 호출하는 것에 상응한다.
  • TaskCompletionSource의 진정한 위력은 스레드를 점유하지 않는 작업 객체를 생성하는데 있다.
    • 예컨대 5초 기다린 후 42라는 수치를 돌려주는 작업을 생각해 보자. Timer 클래스를 이용하면 스레드 없이도 그런 작업을 구현할 수 있다. Timer 객체는 CLR의 기능을 이용해서(그리고 CLR은 운영체제의 기능을 이용해서) x밀리초가 지나면 이벤트를 발동한다.
Task<int> GetAnswerToLife()
{
  var tcs = new TaskCompletionSource<int>();
  // 5000ms가 지나면 이벤트를 발동하는 타이머를 생성한다.
  var timer = new System.Timers.Timer(5000) { AutoReset = false };
  timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult(42); };
  timer.Start();
  return tcs.Task;
}
  • 이 메서드는 5초 후에 완료되며 42라는 결과를 반환하는 하나의 작업 객체를 돌려준다. 이 작업 객체에 연속용 콜백을 부착함으로써 그 어떤 스레드도 차단하지 않고도 원하는 결과가 출력되게 할 수 있다.
var awiter = GetAnswerToLife().GetAwaiter();
awaiter.OnCompleted(() => Console.WriteLine(awaiter.GetResult()));
  • 지연 시간을 매개변수로 지정하게 하고 반환값을 제거해서 이를 좀 더 유용한 범용 메서드로 만들어 보기로 하자. 메서드 이름은 Delay가 적당하겠다. 그러한 메서드는 Task<int> 대신 Task 객체를 돌려주어야 할 것이다. 그러나 TaskCompletionSource의 비제네릭 버전이 없으므로, 비제네릭 Task 인스턴스를 직접 생성할 수는 없다.
    • 우회책은 간단하다. Task<TResult>는 Task를 파생하므로 TaskCompletionSource<임의의_형식> 인스턴스를 생성한 후 그것을 Task<임의의_형식>으로 암묵적으로 변환해서 Task 객체를 얻으면 된다. 예컨대 다음과 같다.
var tcs = new TaskCompletionSource<object>();
Task task = tcs.Task;
  • 다음은 이상의 논의를 반영한 범용 Delay 메서드이다.
Task Delay(int milliseconds)
{
  var tcs = new TaskCompletionSource<object>();
  var timer = new System.Timers.Timer(milliseconds) { AutoReset = false };
  timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult(null); };
  timer.Start();
  return tcs.Task;
}
  • 이제 5초 후 42를 기록하는 연산을 다음과 같이 표현할 수 잇다.
Delay(5000).GetAwaiter().OnCompleted(() => Console.WriteLine(42));
  • 이처럼 TaskCompletionSource를 스레드 없이 사용하면 스레드는 오직 5초 후에 연속용 콜백이 시작될 때만 관여한다. 이 덕분에 다음처럼 그런 연산들을 10,000개 띄워도 오류나 과도한 자원 소비 문제가 발생하지 않는다.
for (int i = 0; i < 10000; i++)
  Delay(5000).GetAwaiter().OnCompleted(() => Console.WriteLine(42));
  • 타이머는 등록된 콜백을 풀 스레드에서 실행하므로 스레드 풀은 5초 후에 TaskCompletionSource에 대해 SetResult(null)을 호출하라는 요청을 10,000개나 받게 된다. 만일 그런 요청들이 자신의 처리 능력을 넘는 속도로 밀려오면, 스레드 풀은 그 요청들을 대기열에 집어넣고 CPU에 대한 최적의 병렬설 수준에 맞는 속도로 처리함으로써 상황에 대처한다. 스레드에 묶이는(스레드로 실행할) 연산들이 짧게 끝나는 경우에는 이런 방식이 이상적이다.
  • 지금 예가 그런 경우에 해당한다. 스레드에 묶이는 연산은 그냥 SetResult를 호출하고 연속용 콜백을 실행할 뿐이다.

Task.Delay 메서드

  • 방금 작성한 Delay 메서드는 아주 유용하다. 실제로 Task 클래스에는 그 Delay와 같은 일을 하는 정적 메서드 Delay가 있다. 이 메서드는 아래 2개와 같은 형식으로 사용한다.
    • Task.Delay는 Thread.Sleep의 비동기 버전이라 할 수 있다.
Task.Delay(5000).GetAwaiter().OnCompleted(() => Console.WriteLine(42));
Task.Delay(5000).ContinueWith(ant => Console.WriteLine(42));

비동기성의 원칙들

동기적 연산 대 비동기 연산

  • 동기적 연산(synchronous operation)은 자신이 할 일을 마친 후에 실행의 흐름을 호출자에게 반환하는 형식이다.
  • 비동기적 연산(asynchronous operation) 또는 비동기 연산은 자신이 할 일을 다 마치기 전에 실행의 흐름을 호출자에게 반환한다.
  • 비동기 메서드는 호출자와 병렬로 작업을 수행한다. 즉, 비동기 메서드는 동시성(concurrency)을 도입한다. 일반적으로 비동기 메서드는 호출자에게 빨리 반환된다. 그래서 비동기 메서드를 비차단 메서드(nonblocking method)라고 부르기도 한다.

비동기 프로그래밍이란 무엇인가?

  • 비동기 프로그래밍의 원칙은 오래 실행되는 (또는 오래 실행될 가능성이 있는) 함수는 비동기 연산의 형태로 작성하라는 것이다. 이는 오래 실행되는 함수를 그냥 동기적으로 작성하고 필요하다면 새 스레드나 작업 객체에서 그런 함수를 호출함으로써 동시성을 도입하는 전통적인 접근방식과 대조된다.
  • 그러한 전통적인 접근방식에 비한 비동기적 접근방식의 차이점은, 동시성이 함수 외부가 아니라 내부에서 시작한다는 것이다. 이로부터 다음 두 가지 장점이 비롯된다.
    • 스레드에 묶이지 않고도 I/O에 한정되는 동시성을 구현할 수 있다. 그러면 규모가변성(scalability)과 효율성이 향상된다.
    • 리치 클라이언트 응용 프로그램 작성 시 일꾼 스레드를 다루는 코드의 양을 줄일 수 있으며 그러면 스레드 안전성을 보장하기가 쉬워진다.
  • 이러한 장점들은 비동기 프로그래밍의 두드러진 용도 두 가지로 이어진다. 하나는 동시적 입출력 연산을 효율적으로 수행해야 하는 응용 프로그램(주로 서버쪽)을 작성하는 것이다. 이때 난제는 스레드 안전성이 아니라(공유 상태를 최소한으로만 사용하므로), 스레드 효율성이다. 특히 네트워크 요청 하나당 스레드 하나를 소비하는 방식을 피할 필요가 있다. 이 때문에 이런 문맥에서 비동기성의 혜택을 받는 것은 I/O 한정 연산들 뿐이다.
  • 둘째 용도는 리치 클라이언트에서 스레드 안전성 문제를 단순화 하는 것이다. 이는 프로그램의 덩치가 커짐에 따라 특히나 중요한 문제가 된다. 응용 프로그램이 커짐에 따라 증가하는 복잡도를 다스리기 위해 흔히 큰 메서드를 더 작은 메서드들로 리팩터링 하는데, 그러면 메서드들이 메서드들을 연쇄적으로 호출하는 메서드들의 사슬(chain of method)들이 생겨나기 때문이다. 그런 사슬들은 하나의 호출 그래프(call graph)를 형성한다.
    • 전통적인 동기적 접근방식에서는 호출 그래프에 오래 실행되는 연산이 끼어 있는 경우 UI의 반응성을 보장하려면 호출 그래프 전체를 스레드에서 실행해야 한다. 결과적으로 하나의 동시적 연산에 다수의 메서드들이 관여하게 되며(성긴 동시성(course-grained concurrency)), 프로그래머는 호출 그래프의 모든 메서드에 대해 스레드 안전성을 고려해야 한다.
    • 그러나 비동기적 접근방식에서는 호출 그래프 전체를 스레드에서 실행할 필요가 없다. 그럴 필요가 있는 메서드에 대해서만 스레드를 띄우면 된다.(일반적으로 스레드가 필요한 메서드는 그래프의 아래쪽에 있으며 I/O 한정 연산의 경우에는 그런 메서드가 아예 없을 수도 있다)
    • 다른 모든 메서드는 모두 UI 스레드에서 실행할 수 있으며, 따라서 스레드 안전성이 훨씬 간단해진다. 이런 접근 방식은 세밀한 동시성(fine-grained concurrency) 즉 작은 동시적 연산들로 이루어지며 그 실행이 UI 스레드와 개별 스레드를 오가는 형태의 동시성으로 이어진다.
      • 이런 동시성이 이득이 되려면 I/O 한정 연산들과 계산량 한정 연산들 모두를 비동기적으로 작성할 필요가 있다. 대략적인 원칙은 50ms 이상 걸리는 것은 모두 비동기적으로 작성하라는 것이다. (한편 과도하게 세밀한 비동기성은 성능에 해가 될 수 있다. 비동기 연산들이 추가 부담을 유발하기 때문이다)
      • Windows 스토어 .NET 프로파일들은 몇몇 오래 실행되는 메서드의 동기적 버전을 아예 노출하지 않을 정도로 비동기 프로그래밍을 권장한다. 그런 프로파일들에서는 작업 객체(또는 AsTask 확장 메서드를 통해서 작업 객체로 변환할 수 있는 객체)를 돌려주는 비동기 메서드들을 사용해야 한다.

비동기 프로그래밍과 연속 기능

  • 작업 객체는 비동기성에 필수인 연속 기능을 지원한다는 점에서 비동기 프로그래밍에 아주 적합하다. Delay 메서드가 그 점을 보여주는 예이다. Delay에서는 TaskCompletionSource를 이용해서 작업 객체를 생성했다. TaskCompletionSource는 최하 수준 I/O 한정 비동기 메서드를 작성하는 표준적인 수단이다.
  • 한편 계산량 한정 메서드에서는 Task.Run을 이용해서 스레드 기반 동시성을 실현한다. 만일 그 메서드가 호출자에게 일찍(연산을 다 마치지 않고) 작업 객체를 돌려준다면, 그 메서드는 비동기 메서드이다.
    • 비동기 프로그래밍의 특징은 호출 그래프의 더 낮은 수준에서 그런 식으로 실행의 흐름을 호출자에게 돌려주려 한다는 것이다. 그렇게 하면 예컨대 리치 클라이언트 응용 프로그램에서 상위 수준의 메서드들이 여전히 UI 스레드에 남아서 UI 컨트롤들과 공유 상태에 접근할 수 있으며, 그래도 스레드 안전성에 문제가 생기지 않게 만들 수 있다.
    • 이해를 돕기 위해 가용 CPU 코어들을 모두 활용해서 소수들의 개수를 세는 다음과 같은 메서드를 생각해 보자
int GetPrimesCount(int start, int count)
{
  return ParallelEnumerable.Range(start, count).Count(n =>
    Enumerable.Range(2, (int)Math.Sqrt(n)-1).All(i => n % i > 0));
}
  • 이 메서드의 구현 세부사항은 중요하지 않다. 중요한 것은 이 메서드의 연산이 완료되기까지 시간이 좀 걸린다는 점이다. 이제 다음 메서드로 이 메서드를 실행해 보자.
void DisplayPrimeCounts()
{
  for (int i = 0; i < 10; i++)
    Console.WriteLine(GetPrimesCount(i * 1000000 + 2, 1000000) + " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
  Console.WriteLine("Done");
}

// 결과
// 78498 primes between 0 and 999999
// 70435 primes between 1000000 and 1999999
// 67883 primes between 2000000 and 2999999
// 66330 primes between 3000000 and 3999999
...
  • 이상의 코드는 DisplayPrimeCounts가 GetPrimesCount를 호출하는 관계로 구성된 하나의 호출 그래프를 형성한다. 단순함을 위해 DisplayPrimeCounts는 그냥 Console.WriteLine을 호출하지만, 리치 클라이언트 응용 프로그램에서는 UI 컨트롤들을 갱신하는 코드를 수행하는 것이 더 현실적이다. 이런 호출 그래프를 다음처럼 실행하면 성긴 동시성이 실현된다.
Task.Run(() => DisplayPrimeCounts());
  • 세밀한 동시성을 위해서는 먼저 GetPrimesCount의 비동기 버전을 작성해야 한다.
Task<int> GetPrimesCountAsync(int start, int count)
{
  return Task.Run(() =>
     ParallelEnumerable.Range(start, count).Count(n =>
      Enumerable.Range(2, (int)Math.Sqrt(n)-1).All(i => n % i > 0)));
}

언어 차원의 지원이 중요한 이유

  • 다음으로 방금 작성한 GetPrimesCountAsync를 호출하도록 DisplayPrimeCounts를 수정해야 한다. 이때 C#의 새로운 키워드인 await와 async가 중요한 역할을 한다. 이 키워드들 없이 세밀한 동시성을 달성하기란 쉽지 않다. DisplayPrimeCounts의 루프를 다음과 같이 수정한다고 하자.
void DisplayPrimeCounts()
{
  for (int i = 0; i < 10; i++)
  {
    var awaiter = GetPrimesCountAsync(i * 1000000 + 2, 1000000).GetAwaiter();
    awaiter.OnCompleted(() => Console.WriteLine(awaiter.GetResult() + " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
  }
  Console.WriteLine("Done");
}
  • 그러면 이 루프는 빠르게(메서드들이 비차단이므로) 10번 회전하며, 10개의 비동기 연산들이 모두 병렬로 실행된다(그래서 소수 개수들보다 Done이 먼저 출력될 수도 있다.)
  • 지금 예에서 이 작업들을 병렬로 실행하는 것은 바람직하지 않다. 어차피 해당 연산의 내부 구현들이 이미 병렬화되어 있기 때문이다. 이런 식으로 하면 그냥 첫 결과가 더 늦게 나타날 뿐이다(게다가 결과들의 순서도 뒤죽박죽이 된다)
    • 그런데 작업들의 실행을 직렬화하는 것이 바람직한 좀 더 일반적인 상황이 존재한다. 바로 작업 객체 B가 작업 객체 A의 결과에 의존하는 경우이다. 예컨대 웹페이지를 내려 받으려면 DNS 조회를 HTTP 요청보다 먼저 실행해야 한다.
  • 이들을 직렬로, 즉 차례로 실행하려면 연속용 콜백 자체에서 다음번 루프 반복을 유발해야 한다. 그렇게 하려면 for 루프를 제거하고 연속용 콜백에서 재귀 호출을 이용해서 실행을 반복해야 하므로 코드가 좀 더 복잡해진다.
void DisplayPrimeCounts()
{
  DispalyPrimeCountsFrom(0);
}

void DisplayPrimeCountsFrom(int i)
{
    var awaiter = GetPrimesCountAsync(i * 1000000 + 2, 1000000).GetAwaiter();
    awaiter.OnCompleted(() => 
    {
      Console.WriteLine(awaiter.GetResult() + " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
      if (i++ < 10) DisplayPrimeCountsFrom(i);
      else Console.WriteLine("Done");
    });
}
  • 만일 DisplayPrimesCount 자체를 비동기화하려면, 즉 DisplayPrimesCount가 작업 완료 시 신호를 보내는 작업 객체를 돌려주게 하려면 상황이 더욱 복잡해진다. 이를 위해서는 다음처럼 TaskCompletionSource 인스턴스를 생성할 필요가 있다.
Task DisplayPrimeCountsAsync()
{
  var machine = new PrimesStateMachine();
  machine.DisplayPrimeCountsFrom(0);
  return machine.Task;
}

class PrimesStateMachine
{
  TaskCompletionSource<object> _tcs = new TaskCompletionSource<object>();
  public Task Task { get { return _tcs.Task; } }

  public void DisplayPrimeCountsFrom(int i)
  {
    var awaiter = GetPrimesCountAsync(i * 1000000 + 2, 1000000).GetAwaiter();
    awaiter.OnCompleted(() => 
    {
      Console.WriteLine(awaiter.GetResult() + " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
      if (i++ < 10) DisplayPrimeCountsFrom(i);
      else { Console.WriteLine("Done"); _tcs.SetResult(null); }
    });
  }
}
  • 다행히 C#의 비동기 함수 기능을 이용하면 이 모든 일이 자동으로 일어난다. 기존 메서드에 다음과 같이 async 키워드와 await 키워드를 추가하기만 하면 된다.
async Task DisplayPrimeCountsAsync()
{
  for (int i = 0; i < 10; i++)
    Console.WriteLine(await GetPrimesCountAsync(i * 1000000 + 2, 1000000) + " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
  Console.WriteLine("Done");
}
  • 이 예에서 보듯이 코드의 복잡도를 많이 증가하지 않고 비동기성을 구현하려면 async와 await가 필수이다.
  • 이 문제를 바라보는 또 다른 관점은 명령식(imperative) 루프 구조(for, foreach 등)가 연속 기능과는 잘 맞지 않는다는 것이다. 이는 기본적으로 루프 구조가 메서드의 현재 지역 상태(“루프를 몇 번 더 반복해야 하는가?”)에 의존하기 때문이다.
    • async와 await가 하나의 해결책을 제공하지만, 명령식 루프 구조를 함수적 구조로 바꾸는 것도 하나의 해결책이다. Reactive Framework(Rx)가 바로 그런 접근방식을 바탕으로 한다. Rx를 사용하지 않더라도 결과에 대해 질의 연산자들을 실행하려 하거나 여러 순차열을 결합하려는 경우에는 그런 접근 방식이 좋은 선택일 수 있다.
    • 한가지 단점은 Rx는 차단을 피하려고 밀어넣기(push) 기반 순차열을 사용하는데, 그러한 개념을 이해하기가 조금 어려울 수 있다는 것이다.

C#의 비동기 함수

  • C# 5.0에는 async와 await라는 키워드들이 도입되었다. 이 키워드들을 이용하면 동기적 코드와 같은 구조를 가진 비동기적 코드를 동기적 코드만큼이나 간단하게 작성할 수 있다. 따라서 비동기 프로그래밍에 필요한 배관 연결(plumbing) 부담이 사라진다.

await 키워드를 이용한 대기

  • await 키워드는 연속용 콜백의 부착을 단순화한다. 간단한 예로 컴파일러는 다음과 같은 코드를
var 결과 = await 표현식;
문장(들);
  • 기능상으로 다음에 해당하는 코드로 바꾸어서 컴파일한다.
    • 컴파일러는 또한 동기적 완료 시 연속용 콜백을 생략하는 코드와 이후의 절들에서 살펴볼 여러 미묘한 사항을 처리하는 코드도 생성한다.
var awaiter = 표현식.GetAwaiter();
awaiter.OnCompleted(() => {
  var 결과 = awaiter.GetResult();
  문장(들);
});
  • 이해를 돕기 위해 앞에서 소수들의 개수를 세는 비동게 메서드를 다시 살펴보자.
Task<int> GetPrimesCountAsync(int start, int count)
{
  return Task.Run(() =>
     ParallelEnumerable.Range(start, count).Count(n =>
      Enumerable.Range(2, (int)Math.Sqrt(n)-1).All(i => n % i > 0)));
}
  • 다음은 await 키워드를 이용해서 이 메서드를 호출하는 예이다.
int result = await GetPrimesCountAsync(2, 1000000);
Console.WriteLine(result);
  • 이 코드가 컴파일되려면 이 코드를 포함한 메서드에 async 수정자를 추가해야 한다.
async void DisplayPrimesCount()
{
  int result = await GetPrimesCountAsync(2, 1000000);
  Console.WriteLine(result);
}
  • 메서드 안에 있는 await에 중의성이 있는 경우 즉 이를 식별자로 해석할 수도 있고 키워드로 해석할 수도 있는 경우 만일 해당 메서드에 async 수정자가 지정되어 있으면 C# 컴파일러는 await를 키워드로 간주한다.(이는 C# 5 이전에 작성된 await를 식별자로 사용하는 메서드도 오류 없이 컴파일되게 하기 위한 것이다)
    • async 수정자는 반환 형식이 지금처럼 void이거나 Task, Task<TResult>인 메서드(그리고 람다식)에만 적용할 수 있다.
  • unsafe 수정자처럼 async 수정자는 메서드의 서명이나 공용 메타자료에 아무런 영향을 미치지 않는다. 이 수정자는 오직 메서드 안에서 일어나는 일에만 영향을 준다. 그래서 인터페이스에서 async를 사용하는 것은 별로 의미가 없다.
    • 그렇긴 하지만 예컨대 async가 아닌 가상 메서드를 재정의할 때 async를 도입하는 것은 적법하다. 물론 서명은 동일하게 유지해야 한다.
  • async 수정자가 지정된 메서드를 비동기 함수라고 부른다. 그런 메서드는 그 자체가 비동기적인 경우가 많기 때문이다. 왜 그런지는 비동기 함수를 거치는 실행의 흐름을 살펴보면 이해가 될 것이다.
  • await가 지정된 표현식을 만나면 실행의 흐름이 메서드를 떠나서 호출자로 반환된다. 이는 반복자의 yield return  문과 상당히 비슷하다.
    • 그런데 반환 전에 CLR은 대기 중인 작업 객체에 연속용 콜백을 부착한다. 이에 의해 작업이 완료되면 실행이 이전에 메서드를 떠났던 지점으로 돌아온다.
    • 만일 작업에 장애가 발생하면 해당 예외가 다시 던져지고 그렇지 않으면 반환값이 await 표현식에 배정된다. 앞의 비동기 메서드를 지금까지 설명한 과정에 맞게 확장하면 다음과 같은 모습이 된다.
void DisplayPrimeCounts()
{
  var awaiter = GetPrimesCountAsync(i * 1000000 + 2, 1000000).GetAwaiter();
  awaiter.OnCompleted(() => 
  {
    int result = awaiter.GetResult();
    Console.WriteLine(result);
  });
}
  • 일반적으로 await 키워드는 하나의 Task 작업 객체를 산출하는 표현식에 붙인다. 그러나 컴파일러는 대기 가능 객체(awaitable object; INotifyCompletion.OnCompleted를 구현하며 적절한 형식이 지정된 GetResult 메서드와 bool 형식의 IsCompleted 속성을 갖춘 클래스의 인스턴스)를 돌려주는 GetAwaiter 메서드를 갖춘 객체를 산출하는 표현식이라면 그 어떤 것이라도 await를 허용한다.
  • 지금 예에서 await 표현식이 최종적으로 하나의 int 값으로 평가된다는 점을 주목하기 바란다. 이는 대기하려는 표현식이 Task<int> 객체에 해당하며, 그 객체에 대한 GetAwaiter().GetResult()가 int를 돌려주기 때문이다.
  • 비제네릭 Task 객체를 기다리는 것도 적법하다. 이 경우 표현식은 void 표현식이다.
await Task.Delay(5000);
Console.WriteLine("5초가 지났음");

지역 상태 갈무리

  • await 표현식의 진정한 위력은 코드에서 await 표현식을 거의 아무 곳에서나 사용할 수 있다는 점이다. 구체적으로 await 표현식은 lock 표현식이나 unsafe 문맥 또는 실행 파일의 진입점(main 메서드 등) 내부를 제외할 때 비동기 함수 안에서 보통의 표현식을 둘 수 있는 곳이면 어떤 곳에도 둘 수 있다.
  • 다음은 루프 안에 await 표현싱르 둔 예이다.
async void DisplayPrimesCount()
{
  for (int i = 0; i < 10; i++)
    Console.WriteLine(await GetPrimesCountAsync(i*1000000+2, 1000000));
}
  • GetPrimesCountAsync가 처음 실행되면, await 표현식에 의해 실행의 흐름이 호출자로 돌아온다. GetPrimesCountAsync의 작업이 끝나면(또는 장애가 발생하면) 이전에 실행이 떠났던 곳에서 실행이 재개되는데, 그 문맥에는 지역 변수들과 루프 카운터들의 값들이 유지되어 있다.
  • 컴파일러는 await 표현식 이우헤 실행을 재개하기 위해 연속용 콜백(대기자 패턴을 통한)을 활용한다. 따라서 만일 리치 클라이언트 응용 프로그램의 UI 스레드에서 이런 비동기 함수를 실행한다면, 동기화 문맥에 의해 실행이 UI 스레드에서 재개된다. 그 외의 경우에는 실제로 연산을 완료한 스레드에서 실행이 재개된다.
    • 스레드 변경이 실행 순서에 영향을 미치지는 않으며, 스레드 친화도에 의존하는 어떤 특별한 논리를 사용하지 않는 한, 스레드가 바뀌지 않을 때와 다를바가 없다.
    • 이는 마치 어떤 도시로 여행을 가서 한 지점에서 다른 지점으로 택시를 타고 가는 것과 비슷하다. 동기화 문맥이 작용할 때는 항상 같은 택시를 타게 되고, 동기화 문맥이 없으면 매번 다른 택시를 탈 가능성이 크다. 어떤 경우이든 여정 자체는 동일하다.

UI 스레드 안에서의 대기

  • 비동기 함수의 좀 더 현실적인 용법을 보여주기 위해 계산량 한정 메서드를 실행하는 도중에도 여전히 반응성을 유지하는 간단한 UI의 예를 제시하겠다. 우선 동기적 해법부터 보자.
class TestUI : Window
{
  Button _button = new Button { Content = "Go" };
  TextBlock _results = new TextBlock();

  public TestUI()
  {
    var panel = new StackPanel();
    panel.Children.Add(_button);
    panel.Children.Add(_results);
    Content = panel;
    _buton.Click += (sender, args) => Go();
  }

  void Go()
  {
    for (int i = 1; i < 5; i++)
      _results.Text += GetPrimesCount(i * 1000000, 1000000) + " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) + Environment.NewLine;
  }

  int GetPrimesCount(int start, int count)
  {
    return ParallelEnumerable.Range(start, count).Count(n =>
      Enumerable.Ragne(2, (int)Math.Sqrt(n)-1).All(i => n % i > 0));
  }
}
  • Go 버튼을 클릭하면 계산량 한정 코드가 실행되며 계산이 끝날 때까지는 응용 프로그램이 반응을 멈춘다. 이를 비동기화하는 과정은 두 단계이다. 첫째는 GetPrimesCount를 이전 예제들에서 사용한 비동기 버전으로 대체하는 것이다.
Task<int> GetPrimesCountAsync(int start, int count)
{
  return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n =>
    Enumerable.Ragne(2, (int)Math.Sqrt(n)-1).All(i => n % i > 0)));
}
  • 둘째 단계는 이 GetPrimesCountAsync를 호출하도록 Go를 수정하는 것이다.
async void Go()
{
  _button.IsEnabled = false;
  for (int i = 1; i < 5; i++)
    _results.Text += await GetPrimesCountAsync(i * 1000000, 1000000) + " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) + Environment.NewLine;
  _button.IsEnabled = true;
}
  • 이 예는 비동기 함수를 이용한 프로그래밍이 얼마나 간단한지를 잘 보여준다. 그냥 동기적인 코드를 작성할 때처럼 코드를 짜되, 실행을 차단하는 함수를 비동기 함수로 대체하고 그 함수를 호출하는 표현식에 await를 적용하면 된다. 일꾼 스레드에서 실행되는 코드는 GetPrimesCountAsync의 한 본문뿐이다. Go의 코드는 UI 스레드상에서 시간을 ‘빌린다’.
    • Go가 메시지 루프와 ‘유사동시적으로(pseudoconcurrently)’ 실행된다고 말할 수도 있을 것이다. (Go의 연산들이 UI 스레드가 수행하는 다른 이벤트 처리들 사이사이에 끼어서 실행된다는 점에서). 이러한 유사동시성 상황에서 선점은 오직 await 도중에만 발생할 수 있다. 이 덕분에 스레드 안전성이 간단해진다.
    • 지금 예에서 문제가 될 만한 부분은 재진입(reentrancy) 즉 계산이 진행되는 도중에 사용자가 버튼을 다시 클릭해서 GetPrimesCountAsync에 다시 진입하는 상황뿐이다(이를 방지하는 한 방법은 계산 시작시 버튼을 비활성화 하는 것이다)
    • 진정한 동시성은 호출 스택의 아래쪽에서 Task.Run이 호출하는 코드 안에서 발생한다. 이 모형의 장점을 취하기 위해 그 코드(진정한 동시성이 일어나는)에서는 공유 상태나 UI 컨트롤에 접근하지 않는다.
  • 또 다른 예로 이번에는 소수들을 세는 대신 여러 웹 페이지를 내려 받아서 그 길이의 총합을 구한다고 하자.
    • .NET Framework 4.5부터는 작업 객체를 돌려주는 비동기 메서드들이 여럿 있는데, 그중 하나가 System.Net의 WebClient 클래스에 있는 DownloadDataTaskAsync 메서드이다. 이 메서드는 Task<byte[]> 객체를 돌려주며 주어진 URI에서 자료를 내려받아서 바이트 배열에 넣는 작업을 비동기적으로 진행한다. 즉, 이 메서드를 호출하는 표현식에 await를 적용하면 byte[]를 얻게 된다.
async void Go()
{
  _button.IsEnabled = false;
  string[] urls = "www.albahari.com www.oreilly.com www.linqpad.net".Split();
  int totlaLength = 0;

  try
  {
    foreach (string url in urls)
    {
      var uri = new Uri("http://" + url);
      byte[] data = await new WebClient().DownloadDataTaskAsync(uri)l
      _results.Text += "URL" + url + "의 길이는 " + data.Length + Environment.NewLine;
      totalLength += data.Length;
    }
    _results.Text += "총 길이: " + totalLength;
  }
  catch (WebException ex)
  {
    _results.Text += "오류: " + ex.Message;
  }
  finally { _button.IsEnabled = true; }
}
  • 이번에도 이 메서드는 동일한 작업을 동기적으로 진행하는 메서드를 작성할 때의 구조를 반영한다. 특히 동기적 버전에서도 이처럼 catch와 finally 블록을 사용했을 것이다.
    • 첫 await 이후에 실행이 호출자에게 돌아가지만, finally 블록은 메서드가 논리적으로 완료된 후에야(메서드의 모든 코드가 실행된 후 또는 이른 return 문을 만나거나 미처리 예외가 발생한 후에) 실행된다.
  • 내부적으로 정확히 어떤 일이 일어나는지 살펴보면 비동기 프로그래밍을 이해하는데 도움이 될 것이다. 우선 UI 스레드의 메시지 루프에서 어떤 일이 일어나는지부터 살펴보자.
이 스레드에 대한 동기화 문맥을 WPF 동기화 문맥으로 설정한다.
while(!thisApplication.Ended)
{
  메시지 대기열에 메시지가 들어오길 기다린다
  메시지가 들어오면, 그 종류에 따라 다음과 같이 처리한다.
    키보드/마우스 메시지 -> 해당 이벤트 처리부 발동
    사용자 BeginInvoke/Invoke 메시지 -> 대리자 실행
}
  • UI 요소들에 부착한 이벤트 처리부들은 바로 이러한 메시지 루프를 통해서 실행된다. Go 메서드가 호출되면 실행은 await 표현식까지만 나아간 후 메시지 루프로 돌아온다. (그러면 UI 스레드는 또 다른 이벤트들을 처리할 시간이 생긴다)
    • 그런데 컴파일러는 실행이 메시지 루프로 돌아가기 전에 작업이 끝나면 실행이 이 지점에서 재개되게 하는 연속용 콜백을 설정해 둔다. 그리고 지금은 UI 스레드 상에서 작업 완료를 기다리는 것이므로, 그 연속용 콜백은 메시지 루프가 실행되고 있는 동기화 문맥에 결과를 게시(post)한다.
    • 결과적으로 Go 메서드 전체는 UI 스레드와 ‘유사동시적으로’ 실행된다. 진정한 동시성(I/O에 한정되는)은 DownloadDataTaskAsync의 구현 안에서 발생한다.

성긴 동시성과의 비교

  • C# 5 이전에는 비동기 프로그래밍이 쉽지 않았다. 당시에는 언어 차원의 지원이 없었을 뿐만 아니라 .NET Framework가 비동기 기능성을 작업 개체를 돌려주는 메서드들이 아니라 EAP와 APM이라는 허술한 패턴들을 통해서 노출했기 때문이다.
  • 당시 인기 있던 우회책은 성긴 동시성을 사용하는 것이었다(실제로 이를 돕기 위한 BackgroundWorker라는 형식도 있었다). GetPrimesCount를 사용하는 동시적 구현의 예로 돌아가서, 그 예제에 성긴 동시성(coarse asynchrony) 해법을 적용해 보자. 우선 버튼의 이벤트 처리부를 다음과 같이 수정해야 한다.
...
_button.Click += (sender, args) => 
{
  _button.IsEnabled = false;
  Task.Run(() => Go());
};
  • BackgroundWorker 대신 Task.Run을 사용한 것은 지금 예제에서는 BackgroundWorker를 사용한다고해서 코드가 더 간단해지지 않기 때문이다. 어떤 것을 사용하든 결국에는 전체적인 동기적 호출 그래프(Go와 GetPrimesCount)가 일꾼 스레드에서 실행된다. 그리고 Go가 UI 요소들을 갱신하므로 다음처럼 Dispatcher.BeginInvoke 호출들로 UI 요소 갱신 코드를 감쌀 필요가 있다.
void Go()
{
  for (int i = 1; i < 5; i++)
  {
    int result = GetPrimesCount(i * 1000000, 1000000);
    Dispatcher.BeginInvoke(new Action (() =>
      _result.Text += result + " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) + Environment.NewLine));
  }
  Dispatcher.BeginInvoke(new Action(() => _button.IsEnabled = true));
}
  • 비동기 버전과는 달리 루프 자체가 일꾼 스레드에서 실행된다. 그것이 별문제가 아닐 것 같지만, 이처럼 간단한 예제에서도 다중 스레드 적용 때문에 경쟁 조건(race condition)이 발생할 여지가 존대한다.
  • 실행 취소와 진행 정도 보고 기능을 구현하려면 스레드 안전성 관련 오류가 발생할 가능성이 더욱 커진다. 사실 메서드에 그 어떤 코드를 추가하든 오류의 여지는 커진다. 예컨대 지금처럼 루프의 상한을 수치 리터럴로 두지 않고 어떤 메서드를 호출해서 얻은 값을 사용한다고 하자.
for (int i = 1; i < GetUpperBound(); i++)
  • 그리고 GetUpperBound()가 지연 적재(lazy load) 방식으로 구성 파일을 읽어서(이를테면 첫 호출시 비로소 디스크에서 파일을 읽어서) 어떤 값을 돌려준다고 하자. 그런 작업을 진행하는 코드는 스레드에 안전하지 않을 가능성이 크다. 그러나 그 모든 코드는 UI 스레드와는 다른 일꾼 스레드에 실행된다.
    • 이상의 예에서 보듯이 호출 그래프의 상위 수준에서 일꾼 스레드들을 시작하는 것은 위험한 일이다.

비동기 함수 작성

  • 반환 형식이 void 인 메서드가 있을 때 void를 Task로 바꾸고 async를 적용하면 메서드 자체가 await를 적용할 수 있는 유용한 비동기 버전으로 변한다. 다른 변경은 필요하지 않다.
async Task PrintAnswerToLife()  // void 대신 Task를 돌려준다.
{
  await Task.Delay(5000);
  int answer = 21 * 2;
  Console.WriteLine(answer);
}
  • 메서드 본문에서 명시적으로 Task 객체를 돌려주지는 않음을 주목하기 바란다. 컴파일러는 Task 객체를 만들고 메서드 실행이 완료되면(또는 미처리 예외가 발생하면) 그 작업 객체에 신호한다. 이 덕분에 비동기 호출 연쇄(비동기 함수 안에서 다른 비동기 함수를 호출하는)를 사용하기가 어렵지 않다.
async Task Go()
{
  await PrintAnswertToLife();
  Console.WriteLine("Done");
}
  • 그리고 Go의 반환 형식이 Task이므로 Go 자체도 대기 가능한 (await를 적용할 수 있는) 함수이다.
  • 컴파일러는 작업 객체를 돌려주는 비동기 함수를, TaskCompletionSource를 이용해서 작업 객체를 생성하고 완료 또는 장애 시 그 객체에 신호하는 코드로 확장한다.
  • 사실 TaskCompletionSource를 직접 인스턴스화 하지는 않는다. 컴파일러는 System.ComplierServices 이름공간에 있는 Async*MethodBuilder라는 이름의 형식들을 거친다. 이 형식들은 OperationCanceledException시 작업 객체를 취소된 상태로 설정한다거나 ‘비동기성과 동기화 문맥’에서 설명하는 사항들을 구현하는 등의 추가적인 안전장치를 갖추고 있다.
  • 개념적으로 컴파일러는 PrintAnswerToLife를 다음과 같은 형태의 코드로 확장한다.
async Task PrintAnswerToLife()
{
  var tcs = new TaskCompletionSource<object>();
  var awaiter = Task.Delay(5000).GetAwaiter();
  awaiter.OnCompleted(() =>
  {
    try
    {
      awaiter.GetResult();  // 발생한 모든 예외를 다시 던진다.
      int answer = 21 * 2;
      Console.WriteLine(answer);
      tcs.SetResult(null);
    }
    catch (Exception ex) { tcs.SetException(ex); }
  });
  return tcs.Task;
}
  • 따라서 작업 객체를 돌려주는 비동기 메서드가 완료되면 실행은 그 메서드의 완료를 기다리던 지점으로 돌아간다(연속용 콜백에 의해)
  • 리치 클라이언트 응용 프로그램의 시나리오에서 비동기 메서드 완료 시 실행은 UI 스레드로 돌아간다(이미 UI 스레드에 있었던 것이 아니라면) 그 외의 상황에서는 애초에 연속용 콜백을 호출했던 스레드로 돌아간다. 따라서 UI 스레드로의 첫 번째 복귀(동시성이 UI 스레드에서 시작된 경우에 필요한)를 제외하면 그 어떤 잠복지연 비용도 비동기 호출 그래프의 하위에서 상위로 전파되지 않는다.

Task<TResult>를 돌려주는 비동기 함수

  • 반환 형식이 void가 아닌 메서드를 비동기화할 떄는 원래의 반환 형식이 TResult라고 할 때 반환 형식을 Task<TResult>로 바꾸면 된다. 다음은 TResult가 int인 경우이다.
async Task<int> GetAnswerToLife()
{
  await Task.Delay(5000);
  int answer = 21 * 2;
  return answer;
}
  • 내부적으로 컴파일러는 TaskCompletionSource로 생성한 작업 객체에게 널이 아닌 값으로 신호한다. 그럼 PrintAnswerToLife에서 이 GetAnswerToLife를 호출해보자(PrintAnswerToLife 자체는 Go에서 호출한다)
async Task Go()
{
  await PrintAnswerToLife();
  ConsoleWriteLine("Done");
}

async Task PrintAnswerToLife()
{
  int answer = await GetAnswerToLife();
  Console.WriteLine(answer);
}

async Task<int> GetAnswerToLife()
{
  await Task.Delay(5000);
  int answer = 21 * 2;
  return answer;
}
  • 결과적으로 우리는 우너래의 PrintAnswerToLife를 두 개의 메서드로 리팩터링했다. 동기적 코드를 리팩터링할 때보다 더 어렵지는 않았음을 주목하기 바란다. 동기적 프로그래밍과의 유사성은 의도적인 것이다. 다음은 이상의 비동기 호출 그래프에 상응하는 동기적 호출 그래프를 형성하는 코드이다. Go()를 호출하면 5초 후에 비동기 버전과 같은 결과가 출력된다.
void Go()
{
  PrintAnswerToLife();
  ConsoleWriteLine("Done");
}

void PrintAnswerToLife()
{
  int answer = GetAnswerToLife();
  Console.WriteLine(answer);
}

int GetAnswerToLife()
{
  Thread.Sleep(5000);
  int answer = 21 * 2;
  return answer;
}
  • 이상의 예제들에는 C#에서 비동기 함수를 설계할 때 사용하는 기본 원리들이 반영되어 있다. 정리하자면 다음과 같다.
    1. 메서드를 동기적으로 작성한다.
    2. 동기적 메서드 호출을 비동기적 메서드 호출들로 바꾸고 await를 적용한다.
    3. 최상위 메서드(흔히 UI 컨트롤에 대한 이벤트 처리부)를 제외한 비동기 메서드들의 반환 형식을 Task에서 Task<TResult>로 바꾼다(await를 적용해서 대기할 수 있도록)
  • 비동기 함수를 위한 작업 객체를 컴파일러가 자동으로 만들어 주므로, 최하위 메서드에서 I/O 한정 동시성을 시작할 때를 제외하면 TaskCompletionSource를 독자가 직접 인스턴스화 하는 경우는 아주 드물다(그리고 계산량 한정 동시성을 시작하는 메서드의 경우에는 Task.Run으로 작업 객체를 생성한다)

비동기 호출 그래프의 실행

  • 비동기 호출 그래프가 실행되는 과정을 좀 더 쉽게 이해할 수 있도록 앞의 예제 코드를 다음과 같이 재배치해 보자.
async Task Go()
{
  var task = PrintAnswerToLife();
  await task;
  ConsoleWriteLine("Done");
}

async Task PrintAnswerToLife()
{
  var task = GetAnswerToLife();
  int answer = await task;
  Console.WriteLine(answer);
}

async Task<int> GetAnswerToLife()
{
  var task = Task.Delay(5000);
  await task;
  int answer = 21 * 2;
  return answer;
}
  • Go는 PrintAnswerToLife를 호출하고, PrintAnswerToLife는 GetAnswerToLife를 호출하고 GetAnswerToLife는 Delay를 호출한 후 완료를 기다린다.
    • await 때문에 실행은 다시 PrintAnswerToLife로 돌아가는데, 그 호출 역시 await로 대기 중이므로 실행은 Go로 돌아가며, 마찬가지 이유로 실행은 호출자에게 돌아간다.
    • Go를 호출하는 스레드에서 이 모든 일은 동기적으로 일어난다. 이는 비동기 호출 그래프에서 실행이 잠시 동기적으로 진행되는 단계에 해당한다.
  • 5초 후에 Delay에 대한 연속용 콜백이 발동해서 실행이 풀 스레드에 있는 GetAnswerToLife로 돌아간다(만일 전체 호출을 UI 스레드에서 시작했다면, 실행은 이제 UI 스레드로 돌아간다) 그 스레드에서 GetAnswerToLife의 나머지 문장들이 모두 실행되며, 그 후 이 메서드의 Task<int> 객체의 작업이 완료되어서 42 라는 결과가 산출된다.
    • 이제 PrintAnswerToLife의 연속용 콜백이 호출되어서 메서드의 나머지 문장들이 실행된다. 이런 과정이 반복되어서 Go의 작업 객체가 신호를 받으며, 해당 출력문이 실행되면 모든 과정이 끝난다.
  • 이런 실행 흐름은 앞에서 본 동기적 호출 그래프와 일치한다. 이는 우리가 모든 비동기 메서드 호출 직후에 await를 적용했기 때문이다. 이 때문에 호출 그래프 안에서 실행의 흐름들이 병렬로 진행되거나 중첩되는 경우는 없다. 각 await 표현식은 실행의 흐름에 ‘틈(실행이 잠시 떠났다가 다시 돌아오기까지의)’을 만들어 낸다.

병렬성

  • await 없이 비동기 메서드를 호출하면 실행이 병렬로 진행된다. 이전 예제들에서 어떤 버튼의 클릭 이벤트 처리부에서 Go를 호출했음을 기억할 것이다.
_button.Click += (sender, args) => Go();
  • Go는 비동기 메서드지만 이 이벤트 처리부는 Go의 완료를 기다리지 않는다. UI의 반응성을 유지하는데 필요한 동시성은 바로 이 부분 때문에 발생한다.
  • 같은 원리를 이용해서 두 비동기 연산을 병렬로 실행할 수 있다.
var task1 = PrintAnswerToLife();
var task2 = PrintAnswerToLife();
await task1; await task2;
  • 두 연산에 대한 await 표현식들은 그 지점에서 해당 병렬성을 ‘끝내는’ 역할을 한다. 나중에 이런 패턴을 좀 더 손쉽게 적용하는데 도움이 되는 WhenAll이라는 작업 조합기 메서드를 소개하겠다.
  • 이런 형태의 동시성은 연산들을 UI 스레드에서 시작하는지 아닌지와 무관하게 일어난다.
    • 그러나 구체적인 발생 방식은 다르다. 두 경우 모두 호출 그래프의 최하위 연산들(Task.Delay 또는 Task.Run 호출을 담은 코드 등)에서는 ‘진정한’ 동시성이 발생한다.
    • 호출 스택의 더 사우이에 있는 메서드들은 해당 연산을 동기화 문맥 없이 시작한 경우에만 진정한 동시성이 적용된다. 동기화 문맥이 존재하면 이전에 언급한 유사동시성(그리고 단순화된 스레드 안전성)이 적용된다. 이 경우 선점이 일어날 만한 지점은 await 문장 뿐이다.
    • 이 덕분에 이를테면 GetAnswerToLife에서 _x라는 공유 필드를 잠금 없이도 증가할 수 있다.
      • 단 await 이전과 이후에 _x가 같은 값이라고 가정할 수는 없다.
async Task<int> GetAnswerToLife()
{
  _x++;
  await Task.Delay(5000);
  return 21 * 2;
}

비동기 람다 표현식

  • 통상적인 메서드, 즉 이름이 붙은 메서드를 비동기화 하면 다음과 같은 형태가 된다.
async Task NameMethod()
{
  await Task.Delay(1000);
  Console.WriteLine("Foo");
}
  • 이와 비슷하게 이름이 없는 메서드(람다식과 익명 메서드)도 async 키워드를 붙여서 비동기화 할 수 있다.
Func<Task> unnamed = async () =>
{
  await Task.Delay(1000);
  Console.WriteLine("Foo");
}
  • 이를 비동기적으로 호출하는 구문은 동일하다.
await NamedMethod();
await unnamed();
  • 다음은 비동기 람다식을 이벤트 처리부로 등록하는 예이다.
myButton.CLick += = async (sender, args) =>
{
  await Task.Delay(1000);
  myButton.Content = "Done";
}
  • 비동기 람다식 역시 Task<TResult>를 돌려줄 수 있다.
Func<Task<int>> unnamed = async () =>
{
  await Task.Delay(1000);
  return 123;
}

int answer = await unnamed();

WinRT의 비동기 메서드

  • WinRT에서 Task에 해당하는 것은 IAsyncAction이고 Task<TResult>에 해당하는 것은 IAsyncOperation<TResult>이다.
    • 확장 메서드 AsTask를 이용해서 IAsyncAction 또는 IAsyncOperation<TResult> 객체를 Task 또는 Task<TResult> 객체로 변환할 수 있다.
    • 그 확장 메서드가 있는 System.Untime.WindowsRuntime.dll 어셈블리에는 GetAwaiter라는 메서드도 있다. 이 메서드를 IAsyncAction 이나 IAsyncOperation<TResult> 인스턴스에 대해 호출하면 해당 작업의 완료를 기다리는데 사용할 수 있는 대기자(TaskAwaiter 객체)가 반환된다.
    • 다음은 AsTask의 예이다.
Task<StorageFile> fileTask = KnownFolders.DocumentsLibrary.CreateFileAsync("test.txt").AsTask();
  • 다음은 IAsyncOperation<TResult>에 await를 직접 적용하는 예이다.
StorageFile file = await KnownFolders.DocumentsLibrary.CreateFileAsync("test.txt");
  • COM의 형식 체계(type system)에 있는 한계 때문에, 예상외로 IAsyncOperation<TResult>는 IAsyncAction을 상속하지 않는다. 대신 둘 다 IAsyncInfo라는 공통 기반 형식을 상속한다.

비동기성과 동기화 문맥

  • 앞의 논의에서 연속용 콜백의 전달에서 동기화 문맥의 존재 여부가 중요하다는 점ㅇ르 보았다. 그 외에도 동기화 문맥은 void를 돌려주는 비동기 함수들의 작동방식에 좀 더 미묘한 방식으로 영향을 미친다.
    • 이는 C# 컴파일러의 코드 확장에서 직접 비롯된 결과가 아니라, 그러한 확장에 쓰이는 System.CompilerServices 이름공간의 Async*MethodBuilder 형식들이 가진 좀 더 미묘한 다음의 두 기능 때문이다.

예외 전달

  • 리치 클라이언트 응용 프로그램에서는 UI 스레드에서 던진 미처리 예외들을 중앙 집중적 예외 처리 이벤트(WPF의 경우 Application.DispatcherUnhandledException)를 이용해서 처리하는 것이 흔한 관례이다. ASP.NET 응용 프로그램에서는 global.asax의 Application_Error가 비슷한 용도로 쓰인다.
    • 내부적으로 이들은 독자적인 try/catch 블록 안에서(ASP.NET에서는 페이지 처리 메서드들의 파이프라인에서) UI 이벤트들을 발동하는 식으로 작동한다.
  • 최상위 비동기 함수는 이러한 처리 과정에 방해가 될 수 있다. 버튼 클릭 사건에 대한 다음과 같은 이벤트 처리부를 생각해 보자.
async void ButtonClick(object sender, RoutedEventArgs args)
{
  await Task.Delay(1000);
  throw new Exception("이 예외가 무시될까?");
}
  • 사용자가 버튼을 클릭해서 이벤트 처리부가 실행되면 보통의 경우 실행은 await 문을 넘어간 후에 메시지 루프로 돌아온다. 따라서 클릭 후 1초가 지나서 발생한 예외는 메시지 루프의 catch 블록에 잡히지 않는다.
  • 이 문제를 완화하기 위해 동기화 문맥이 존재하는 경우 AsyncVoidMethodBuilder는 미처리 예외를 잡아서(void를 돌려주는 비동기 함수 안에서) 동기화 문맥에 전달한다. 따라서 동기화 문맥이 존재한다면 전역 예외 처리 이벤트들이 여전히 발생하게 된다.
  • 컴파일러는 이러한 논리를 void를 돌려주는 비동기 함수에만 적용한다. 따라서 ButtonClick이 void가 아니라 Task를 반환하도록 수정하면, 미처리 예외(해당 Task 작업이 장애를 일으키게 했을)는 아무데서도 잡히지 않는다(결과적으로 관찰되지 않는 예외가 된다)
  • 여기서 흥미로운 점 하나는 예외를 await 이전에 던지든 이후에 던지든 차이가 없다는 것이다. 예컨대 동기화 문맥이 존재하는 경우 다음 메서드가 던진 예외는 동기화 문맥으로 전달될 뿐 호출자에게는 절대 전달되지 않는다.
async void Foo() { throw null; await Task.Delay(1000); }
  • 동기화 문맥이 없으면 예외는 관찰되지 않는다. 예외가 호출자에게 도달하지 않는다는 점이 이상하겠지만, 사실 반복자에서도 이와 비슷한 일이 벌어진다.
IEnumerable<int> Foo() { throw null; yield return 123; }
  • 이 예에서 예외는 결코 호출자에게 직접 전달되지 않는다. 예외는 순차열을 열거해야 비로소 던져진다.

OperationStarted 메서드와 OperationCompleted 메서드

  • 동기화 문맥이 void를 돌려주는 비동기 함수에 미치는 영향이 또 있다. 동기화 문맥이 존재하면, 실행이 void 반환 비동기 함수에 진입할 때 OperationStarted 메서드가 호출되고 함수의 실행이 끝났을 때 OperationCompleted 메서드가 호출된다. ASP.NET의 동기화 문맥은 이 메서드들을 이용해서 페이지 처리 파이프라인의 순차적 실행을 보장한다.
  • void 반환 비동기 함수에 대해 단위 검사를 적용할 때는 커스텀 동기화 문맥을 작성해서 이 메서드를 재정의하는 것이 유용하다.

최적화

동기적 완료

  • 비동기 함수가 대기 이전에 반환될 수도 있다. 웹 페이지를 내려받는 연산에 캐싱을 적용하는 다음과 같은 메서드를 생각해 보자.
static Dictionary<string, string> _cache = new Dictionary<string, string>();

async Task<string> GetWebPageAsync(string uri)
{
  string html;
  if (_cache.TryGetValue(uri, out html)) return html;
  return _cache[uri] = await new WebClient().DownloadStringTaskAsync(uri);
}
  • 이 메서드는 주어진 URI가 캐시에 존재하면 대기를 수행하지 않고 즉시 결과를 반환한다. 이때 결과는 이미 신호된 작업 객체이다. 이러한 방식을 동기적 완료(synchronous completion)라고 부른다.
  • 동기적으로 완료된 작업에 await를 적용하면 실행은 연속용 콜백을 통해서 호출자로 복귀하는 것이 아니라 그냥 바로 그 다음 문장으로 넘어간다. 컴파일러는 대기자 객체의 IsCompleted 속성을 점검해서 이러한 최적화를 구현한다.
    • 좀 더 구체적으로 동기적 완료의 경우 컴파일러는 await 표현식이 있는 다음과 같은 문장을
Console.WriteLine(await GetWebPageAsync("http://oreilly.com"));
  • 다음과 같이 연속용 콜백을 건너뛰는 형태의 코드로 확장한다.
var awaiter = GetWebPageAsync().GetAwaiter();
if (awaiter.IsCompleted)
  Console.WriteLine(awaiter.GetResult());
else
  awaiter.OnCompleted(() => Console.WriteLine(awaiter.GetResult());
  • 동기적으로 반환되는 비동기 함수에 대한 대기가 추가부담을 전혀 유발하지 않는 것은 아니다. 2015년 기준 PC에서 약 50-100나노초 정도의 지연이 있을 수 있다.
    • 반면 실행이 스레드 풀로 돌아갈 때는 문맥 전환이 발생하며, 그러면 1-2마이크로초 정도의 지연이 생길 수 있다. 그리고 UI 메시지 루프로의 반환 시 지연은 문맥 전환 지연의 적어도 10배이다(UI 스레드가 바쁘다면 훨씬 더 길 수 있다)
  • 심지어 다음처럼 대기가 전혀 없는 비동기 메서드도 허용된다. 단, 이 경우 컴파일러는 경고 메시지를 발생한다.
async Task<string> Foo() { return "abc"; }
  • 이런 방식은 기반 클래스의 비동기적인 가상/추상 메서드를 비동기성이 필요하지 않은 구체 클래스에서 재정의할 때 유용할 수 있다. Task.FromResult로도 같은 결과를 얻을 수 있다. 이 메서드는 이미 신호된 작업 객체를 돌려준다.
async Task<string> Foo() { return Task.FromResult("abc"); }
  • 지금 예제의 GetWebPageAsync 메서드는 UI 스레드에서 호출하는 경우 암묵적으로 스레드에 안전하다. 다른 말로 하면 이 메서드를 여러 번 연달아 호출한다고 해도(따라서 다수의 다운로드 작업이 동시에 진행되어도) 캐시를 보호하기 위해 어떤 잠금 기법을 적용할 필요는 없다.
    • 그러나 같은 URI로 이 메서드를 여러 번 호출하면 다수의 다운로드 연산들이 결국은 캐시의 같은 항목을 갱신하게 된다(마지막으로 갱신된 내용이 남는다) 이것이 오류는 아니지만 만일 같은 URI에 대한 추가 호출들이 진행 중인 요청의 완료를 기다리게(비동기적으로) 한다면 좀 더 효율적일 것이다.
  • 그런 식의 대기를 잠금이나 신호 전달 수단을 사용하지 않고 손쉽게 구현하는 방법이 있다. 문자열들의 캐시 대신 ‘미래’ 객체(즉, Task<string> 인스턴스)들의 캐시를 만들면 된다.
static Dictionary<string, Task<string>> _cache = new Dictionary<string, Task<string>>();

Task<string> GetWebPageAsync(string uri)
{
  Task<string> downloadTask;
  if (_cache.TryGetValue(uri, out downloadTask)) return downloadTask;
  return _cache[uri] = new WebClient().DownloadStringTaskAsync(uri);
}
  • 이 메서드에 async를 지정하지 않았음을 주목하기 바란다. 이믄 이 메서드가 WebClient의 메서드를 호출해서 얻은 작업 객체를 직접 돌려주기 때문이다.
  • 이제는 같은 URI로 GetWebPageAsync 메서드를 거듭 호출해도 동일한 Task<string> 객체가 반환된다(쓰레기 수거기의 작업 부하를 최소화한다는 추가적인 장점도 있다) 그 작업 객체가 이미 완료된 상태라면, 방금 설명한 컴파일러의 최적화 덕분에 그 작업 객체에 대한 대기는 아주 적은 비용만 유발한다.
  • 이 예제를 좀 더 연장해서, 동기화 문맥을 보호하지 않고도 스레드에 안전하게 만들어 보자. 다음처럼 메서드 본문 전체를 잠그면 된다.
lock(_cache)
{
  Task<string> downloadTask;
  if (_cache.TryGetValue(uri, out downloadTask)) return downloadTask;
  return _cache[uri] = new WebClient().DownloadStringTaskAsync(uri);
}
  • 이것이 유효한 해법인 이유는 하나의 웹 페이지를 내려받는 내내 코드를 잠그는 것이 아니라 캐시를 점검하고 필요하다면 새 작업 객체를 시작한 후 그 작업 객체의 결과로 캐시를 갱신하는 짧은 기간만 잠그기 때문이다.

과도한 실행 복귀 피하기

  • 어떤 비동기 메서드를 루프 안에서 여러 번 호출하는 경우, UI 메시지 루프로의 거듭되는 복귀의 비용이 부담스러울 수 있다. 한 가지 해결책은 false를 인수로 해서 ConfigureAwait 메서드를 호출하는 것이다.
    • 그러면 작업 객체는 연속용 콜백을 동기화 문맥으로 되돌려보내지 않으며, 따라서 전체적인 부담이 한 번의 문맥 전환 비용에 가까워진다(대기하는 메서드가 동기적으로 완료되는 경우에는 비요잉 그보다 훨씬 적다). 다음 예를 보자.
async void A() { ... await B(); ... }

async Task B()
{
  for (int i = 0; i < 1000; i++)
    await C().ConfigureAwait(false);
}

async Task C() { ... }
  • ConfigureAwait(false) 호출 때문에, 메서드 B와 C에 대해서는 UI 응용 프로그램의 단순화된 스레드 안전성 모형(코드가 UI 스레드에서 실행되며 오직 await 문에서만 선점이 일어날 수 있다는)이 적용되지 않는다. 그러나 메서드 A는 이에 영향을 받지 않는다. A는 여전히 UI 스레드에서 실행된다(애초에 UI 스레드에서 호출했다면)
  • 이러한 최적화는 라이브러리를 작성할 때 특히나 중요하다. 라이브러리에서는 단순화된 스레드 모형의 장점이 필요하지 않다. 일반적으로 라이브러리의 코드는 호출자와 상태를 공유하지는 않으며, UI 컨트롤들에 접근하지도 않기 때문이다. (또한 지금 예제의 경우 만일 메서드 C의 연산이 짧게만 실행될 것임을 알고 있다면 C의 완료를 동기적으로 기다리는 것이 합당하다)

비동기 패턴

취소

  • 동시적 작업을 실행하는 능력만큼이나 실행 중인 작업을 취소할 수 있는(이를테면 사용자의 요청에 따라) 능력도 중요하다. 한 가지 간단한 방법은 취소 플래그를 두는 것이다. 다음은 그러한 기법을 캡슐화한 ‘취소 토큰’ 클래스이다.
class CancellationToken
{
  public bool IsCancellationRequested { get; private set; }
  public void Cancel() { IsCancellationRequested = true; }
  public void ThrowIfCancellationRequested()
  {  
    if (IsCancellationRequested)
      throw new OperationCanceledException();
  }
}
  • 다음은 이 클래스를 이용해서 취소 가능한 비동기 메서드를 작성한 예이다.
async Task Foo (CancellationToken cancellationToken)
{
  for (int i = 0; i < 10; i++)
  {
    Console.WriteLine(i);
    await Task.Delay(1000);
    cancellationToken.ThrowIfCancellationRequested();
  }
}
  • 작업의 취소를 원하는 호출자는 애초에 Foo에 전달했던 취소 토큰에 대해 Cancel을 호출한다. 그러면 IsCancellationRequested 속성이 true가 되며, 잠시 후 Foo는 그 사실을 알아채고 OperationCanceledException(이런 용도로 만들어진 예외로 System 이름공간이 미리 정의되어 있다)을 던진다.
  • 스레드 안전성을 위해서는 IsCancellationRequested 주변을 잠글 필요가 있지만, 그 점을 제외한다면 이 예제는 효과적인 비동기 패턴 하나를 보여준다. 실제로 CLR은 방금 이 예제의 것과 아주 비슷한 CancellationToken이라는 형식을 제공한다.
    • 단 이 형식에는 Cancel 메서드가 없다. 대신 CancellationTokenSource라는 또 다른 형식이 그 메서드를 제공한다. 이처럼 두 가지 형식을 둔 것은 일종의 보안 기능을 제공하기 위한 것이다.
    • CancellationToken 객체에만 접근할 수 있는 메서드는 취소 여부를 점검할 수는 있지만, 취소 과정을 시작할 수는 없다.
    • CLR에서 취소 토큰을 얻으려면 먼저 CancellationTokenSource를 인스턴스화 해야 한다.
var cancelSource = new CancellationTokenSource();
  • 이 인스턴스의 Token 속성은 CancellationToken 인스턴스를 돌려준다. 그것으로 Foo 메서드를 호출하면 된다.
var cancelSource = new CancellationTokenSource();
Task foo = Foo(cancelSource.Token);
...
... (시간이 좀 지나서)
cancelSource.Cancel();
  • CLR에 있는 비동기 메서드들은 대부분 이러한 취소 토큰을 지원한다. 특히 Delay가 그렇다. 만일 Foo를 다음처럼 수정해서 Delay 메서드 호출 시 취소 토큰을 지정하면, 취소 요청시 작업이 즉시 취소된다(1초까지 기다리는 대신)
async Task Foo (CancellationToken cancellationToken)
{
  for (int i = 0; i < 10; i++)
  {
    Console.WriteLine(i);
    await Task.Delay(1000, cancellationToken);
  }
}
  • 이제는 ThrowIfCancellationRequested를 호출할 필요가 없다는 점도 주목하기 바란다. Task.Delay가 대신 호출하기 때문이다. 취소 토큰은 호출 스택을 따라 아래로 잘 전파된다(마찬가지로 취소 요청들을 예외라는 수단을 통해서 호출 스택을 따라 위로 올라온다)
  • WinRT의 비동기 메서드들은 이보다 못한 프로토콜을 따라서 취소를 처리한다. WinRT에서는 메서드 호출 시 지정한 CancellationToken 인스턴스가 아니라 IAsyncInfo 형식이 제공하는 Cancel 메서드를 이용해서 취소를 요청한다. 그러나 AsTask 확장 메서드는 취소 토큰을 받도록 중복적재되어 있기 때문에 격차가 조금은 좁혀졌다고 할 수 있다.
  • 동기적 메서드들도 취소를 지원한다(이를테면 Task의 Wait가 그렇다) 이 경우 취소를 비동기적으로(즉 다른 작업 객체에서) 요청해야 한다. 예컨대 다음과 같다.
var cancelSource = new CancellationTokenSource();
Task.Delay(5000).ContinueWith(ant => cancelSource.Cancel());
...
  • 사실 .NET Framework 4.5부터는 CancellationTokenSource 인스턴스를 생성할 때 시간을 지정해서 그 시간이 지나면 취소가 시작되게 할 수도 있다. 다음이 그러한 예이다. 이런 기법은 시간 만료 기능(동기적이든 비동기적이든)을 구현할 때 유용하다.
var cancelSource = new CancellationTokenSource(5000);
try { await Foo (cancelSource.Token); }
catch (OperationCanceledException ex) { Console.WriteLine("취소됨"); }
  • CancellationToken 구조체는 Register라는 메서드를 제공한다. 이 메서드는 취소 시 발동될 콜백 대리자를 등록하며, 처분 시 등록을 철회할 수 있는 객체를 돌려준다.
  • 컴파일러의 비동기 함수들이 생성하는 작업 객체는 미처리 OperationCanceledException 발생 시 자동으로 ‘취소됨’ 상태가 된다(이 경우 IsCanceled는 true를 IsFaulted는 false를 돌려준다). Task.Run 호출 시 (같은) CancellationToken 인스턴스를 지정해서 얻은 작업 객체 역시 마찬가지다. 비동깆거 시나리오에서는 장애가 생긴 작업과 취소된 작업의 차이가 중요하지 않다. 어차피 둘 다 대기시 OperationCanceledException을 던진다. 둘의 차이는 고급 병렬 프로그래밍 시나리오에서(특히 조건부 실행 연속에서) 중요하다.

진행 정도 보고

  • 종종 실행 중인 비동기 연산의 진행 정도를 알아야 할 때가 있다. 한 가지 간단한 해법은 비동기 메서드에 Action 대리자를 전달하고 비동기 메서드에서 필요할 때마다 그 대리자를 실행하는 것이다.
Task Foo (Action<int> onProgressPercentChanged)
{
  return Task.Run(() =>
  {
    for (int i = 0; i < 1000; i++)
    {
      if (i % 10 == 0) onProgressPercentChanged(i/10);
      ...
    }
  }
}
  • 다음은 이 메서드를 호출하는 예이다.
Action<int> progress = i => Console.WriteLine(i + " %");
await Foo (progress);
  • 콘솔 응용 프로그램에서는 이 정도로 충분하겠지만, 리치 클라이언트 응용 프로그램에서는 그리 이상적이지 않다. 왜냐면 이 메서드는 일꾼 스레드에서 진행 정도를 보고하는데, 그러면 소비자쪽에서 잠재적으로 스레드 안전성 문제가 발생할 수 있기 때문이다(실제로 이 예제에서는 동시성의 부수 효과가 외부 세계로 ‘유출’ 될 수 있다. 그렇지만 않았다면 UI 스레드에서 호출했을 때 이 메서드가 외부 세계와 완전히 격리되었을 것이라는 점에서 이는 안타까운 일이다)

IProgress<T>와 Progress<T>

  • CLR은 이 문제를 해결해주는 한 쌍의 형식들을 제공한다. 바로, IProgress<T>라는 인터페이스와 그 인터페이스를 구현하는 Progress<T>라는 클래스이다. 본질적으로 이들은 UI 응용 프로그램이 동기화 문맥을 통해서 진행 정도를 안전하게 보고할 수 있도록 하나의 대리자를 ‘감싸는’ 역할을 한다.
  • IProgress<T> 클래스를 사용하는 방법은 간단하다.
Task Foo (IProgress<int> onProgressPercentChanged)
{
  return Task.Run(() =>
  {
    for (int i = 0; i < 1000; i++)
    {
      if (i % 10 == 0) onProgressPercentChanged.Report(i/10);
      ...
    }
  }
}
  • Progress<T>에는 Action<T> 형식의 대리자를 받는 생성자가 있다. 이 생성자는 주어진 대리자를 감싸는 Progress<T> 인스턴스를 돌려준다.
var progress = new Progress<int>(i => Console.WriteLine(i + " %"));
await Foo (progress);
  • Progress<T>에는 또한 ProgressChanged라는 이벤트가 있다. 생성자에 대리자를 지정하는 대신 이 이벤트에 적절한 진행 보고용 대리자를 등록해도 된다. 동기화 문맥이 존재하는 상황에서 Progress<int>를 인스턴스화 했다면 그 인스턴스에 해당 문맥이 갈무리된다. 이후 Foo에서 Report를 호출하면 그 문맥을 통해서 대리자가 호출된다.
  • 비동기 메서드에서 좀 더 정교한 진행 보고 기능을 구현하려면, int 대신 다양한 속성들을 가진 커스텀 형식을 사용해야 할 것이다.
  • Reactive Framework에 익숙한 독자라면 비동기 함수가 돌려준 작업 객체와 IProgress<T>의 조합이 IObserver<T>와 비슷한 기능성을 제공한다는 점을 알아챘을 것이다. 둘의 차이는 작업 객체에서는 IProgress<T>가 노출하는 값들뿐만 아니라 최종적인 반환값도 얻을 수 있다는 것이다.
    • 일반적으로 IProgress<T>가 노출하는 값들은 한번 쓰고 버릴 일회용 값들이다. 반면 IObserver<T>의 OnNext로 얻는 값들은 애초에 연산을 수행해서 얻고자 했던 결과 자체를 구성하는 값들인 경우가 많다.
  • WinRT의 비동기 메서드들도 진행 보고 기능을 제공하나 COM의 뒤처진 형식 체계 때문에 프로토콜이 좀 복잡하다. WinRT에서 진행 정도를 보고하는 기능을 가진 비동기 메서드들은 IProgress<T> 객체를 받지 않는다. 대신, IAsyncAction이나 IAsyncOperation<TResult> 가 아니라 다음 인터페이스 중 하나의 인스턴스를 돌려 준다.
IAsyncActionWithProgress<TProgress>
IAsyncOperationWithProgress<TResult, TProgress>
  • 흥미롭게도 두 인터페이스 모두 IAsyncInfo를 상속한다.
  • 한가지 다행인 점은 위의 인터페이스들을 위해 IProgress<T>를 받는 AsTask 확장 메서드 중복적재 버전이 있다는 점이다. 따라서 .NET 프로ㅡ래밍에서는 COM의 인터페이스들을 무시하고 그냥 다음과 같이 하면 된다.
var progeress = new Progress<int> (i => Console.WriteLine(i + " %"));
CancellationToken cancelToken = ...
var task = someWinRTobject.FooAsync().AsTask(cancelToken, progress));

Task 기반 비동기 패턴(TAP)

  • 버전 4.5 이상의 .NET Framework는 await로 대기할 수 있는 수백 개의 Task 반환 비동기 메서드들(주로 입출력 연산에 관련된)을 제공한다. 이 메서드들은 대부분 소위 Taks 기반 비동기 패턴(Task-based Asynchronous Pattern, TAP)을 따른다. TAP는 지금까지 설명한 내용을 패턴 형태로 적절히 형식화한 것이다.
  • TAP을 따르는 메서드는 다음과 같은 조건들을 만족한다.
    • 뜨어군(이미 실행이 시작된) Task 또는 Task<TResult> 인스턴스를 돌려준다.
    • 메서드 이름이 ‘Async’로 끝난다.(작업 조합기 같은 특별한 경우를 제외할 때)
    • 취소나 진행 보고를 지원하는 경웅네는 취소 토큰 또는 IProgress<T>(또는 둘다)를 받도록 중복적재된 버전도 제공한다.
    • 최대한 빨리 호출자에게 반환된다(초기 동기화 단계가 짧다)
    • I/O에 한정되는 경우 스레드를 점유하지 않는다.

작업 조합기

  • 비동기 함수들이 하나의 일관된 프로토콜(항상 작업 객체를 돌려준다는)을 따르는 덕분에 작업 조합기(task combinator)를 작성하고 사용하는 것이 가능하다.
    • 작업 조합기는 말 그대로 작업 객체들을 조합해서 또 다른 작업 객체를 산출하는 메서드로 주어진 각 작업 객체가 수행하는 구체적인 연산과는 무관하게 작업 객체들을 유용한 방식으로 조합해 준다.
  • CLR에는 두 개의 작업 조합기가 있다. 바로 Task.WhenAny와 Task.WhenAll이다. 이 둘의 설명을 위해 다음과 같은 메서드들이 정의되어 있다고 가정하겠다.
async Task<int> Delay1() { await Task.Delay(1000); return 1; }
async Task<int> Delay2() { await Task.Delay(2000); return 2; }
async Task<int> Delay3() { await Task.Delay(3000); return 3; }

WhenAny 메서드

  • Task.WhenAny는 주어진 작업 객체 중 하나라도 완료되면 완료되는 작업 객체를 돌려준다. 다음은 1초 후에 완료된다.
Task<int> winningTask = await Task.WhenAny(Delay1(), Delay2(), Delay3());
Console.WriteLine("Done");
Console.WriteLine(winningTask.Result);  // 1
  • Task.WhenAny가 돌려준 작업 객체에 대한 await 문 자체는 가장 먼저 완료된 작업 객체를 돌려준다. 지금 예제는 전적으로 비차단(nonblocking)이다. Result 속성에 접근하는 마지막 문장조차도 비차단이다(winningTask가 이미 완료되었으므로) 그렇긴 하지만 일반적으로 다음처럼 winningTask에 await를 적용하는 것이 더 낫다.
Console.WriteLine(await winningTask.Result);  // 1
  • 이렇게 하면 AggregateException으로 감싸지 않아도 미처리 예외들이 다시 던져지기 때문이다. 사실 두 await를 한 단계로 적용할 수도 있다.
int winningTask = awakt await Task.WhenAny(Delay1(), Delay2(), Delay3());
  • 경쟁에서 진 작업 객체에 장애가 발생한 경우 그런 작업 객체에 대해서도 await를 적용하지 않는 한(또는 Exception 속성을 조회하지 않는 한) 해당 예외는 관찰되지 않는다.
  • WhenAny는 시간 만료나 취소를 자체적으로 지원하지 않는 연산에 대해 시간 만료나 취소를 적용할 때 유용하다.
Task<string> task = SomeAsyncFunc();
Task winner = await (Task.WhenAny(task, Task.Delay(5000)));
if (winner != task) throw new TimeoutException();
string result = await task; // 결과를 복원하거나 예외를 다시 던진다
  • 이 예에서는 WhenAny에 서로 다른 형식의 작업 객체들을 지정했다. 이 경우 승자는 보통의 Task 객체로 (Task<string> 객체가 아니라) 반환된다.

WhenAll 메서드

  • Task.WhenAll 은 주어진 작업 객체들이 모두 완료되면 완료되는 작업 객체를 돌려준다. 다음은 3초 후에 완료된다(이 예제는 분기/합류(fork/join) 패턴을 보여준다)
await Task.WhenAll(Delay1(), Delay2(), Delay3());
  • WhenAll을 사용하는 대신, task1, task2, task3을 차례로 대기해도 비슷한 결과를 얻을 수 있다.
Task task1 = Delay1(), task2 = Delay2(), task3 = Delay3();
await task1; await task2; await task3;
  • 앞의 버전과 차이점은 만일 task1에서 장애가 발생하면 task2와 task3의 대기는 수행되지 않으며, 그 둘에서 발생한 예외들은 모두 관찰되지 않는다는 점이다.
    • 사실 이는 CLR 4.5에서 작업 객체의 미관찰 예외에 관한 규칙이 느슨해진 이유이다. 위의 코드 블록 전체를 하나의 예외 처리 블록으로 감쌌는데도 task2나 task3에서 발생한 예외 때문에 나중에 쓰레기 수거가 진행될 때 응용 프로그램이 강제로 종료된다면, 프로그래머로서는 좀 어리둥절할 수 밖에 없다.
  • 반면 Task.WhenAll은 모든 작업이 완료되기 전에는 완료되지 않는다. 심지어 일부 작업에 장애가 있어도 그렇다.
    • 여러 작업 객체에서 장애가 발생했다면, 해당 예외들이 조합 작업 객체(Task.WhenAll이 돌려준)의 AggregateException 속성으로 합쳐진다(이는 AggregateException이 실제로 유용하게 쓰이는 사례이다.) 그러나 조합 작업 객체를 대기하면 첫 예외만 던져지므로, 모든 예외를 봐야 한다면 다음과 같이 해야 한다.
Task task1 = Task.Run(() => { throw null; });
Task task2 = Task.Run(() => { throw null; });
Task all = Task.WhenAll(task1, task2);
try { await all; }
catch { Console.WriteLine(all.Exception.InnerExceptions.Count); } // 2
  • Task<TResult> 형식의 작업 객체들로 WhenAll을 호출하면 Task<TResult[]> 인스턴스가 반환된다. 이 작업 객체는 모든 작업 객체의 결과들을 담고 있으며, 이에 대한 await는 TResult[]를 돌려준다.
Task<int> task1 = Task.Run(() => 1);
Task<int> task2 = Task.Run(() => 2);
int[] results = await Task.WhenAll(task1, task2);  // { 1, 2 }
  • 좀 더 현실적인 예로 다음은 여러 URI를 동시에 내려받아서 그 내용의 길이를 모두 합하는 메서드이다.
async Task<int> GetTotalSize(string[] uris)
{
  IEnumerable<Task<byte[]>> downloadTasks = uris.Select(uri => new WebClient().DownloadDataTaskAsync(uri));

  byte[][] contents = await Task.WhenAll(downloadTasks);
  return contents.Sum(c => c.Length);
}
  • 그런데 모든 작업이 완료된 후에야 바이트 배열들의 길이를 계산한다는 것은 좀 비효율적이다. 그보다는 각 URI를 내려받은 직후에 바이트 배열의 길이를 구해서 그것을 해당 작업의 결과로 두는 것이 더 효율적일 것이다. 이때 비동기 람다식이 유용하다. 다음처럼 await 표현식을 LINQ의 Select 질의 연산자에 직접 지정하면 된다.
async Task<int> GetTotalSize(string[] uris)
{
  IEnumerable<Task<int>> downloadTasks = uris.Select(async uri => await new WebClient().DownloadDataTaskAsync(uri)).Length);

  int[] contentLengths = await Task.WhenAll(downloadTasks);
  return contentLengths.Sum();
}

커스텀 작업 조합기

  • 이런 작업 조합기를 독자의 필요에 맞게 직접 작성하면 더욱 유용할 것이다. 가장 단순한 ‘조합기’는 다음 예처럼 작업을 하나만 받는 형태이다. 이 작업 조합기는 임의의 작업에 시간만료 대기 기능을 추가해준다.
async static Task<TResult> WithTimeout<TResult>(this Task<TResult> task, TimeSpan timeout)
{
  Task winner = await (Task.WhenAny(task, Task.Delay(timeout)));
  if (winner != task) throw new TimeoutException();
  return await task; // 결과를 복원하거나 예외를 다시 던진다.
}
  • 또 다른 예로 다음은 CancellationToken을 이용해서 작업을 ‘폐기’ 할 수 있는 기능을 부여하는 작업 조합기이다.
static Task<TResult> WithCancellation<TResult>(this Task<TResult> task, CancellationToken cancelToken)
{
  var tcs = new TaskCompletionSource<TResult>();
  var reg = cancelToken.Register(() => tcs.TrySetCanceled());
  task.ContinueWith(ant =>
  {
    reg.Dispose();
    if (ant.IsCanceled)
      tcs.TrySetCanceled();
    else if (ant.IsFaulted)
      tcs.TrySetException(ant.Exception.InnerException);
    else
      tcs.TrySetResult(ant.Result);
  });
  return tcs.Task;
}
  • 물론 작업 조합기는 이보다 작성하기가 훨씬 복잡할 수 있다. 종종 신호 처리 기법들이 요구되기도 한다. 동시성 관련 복잡성을 프로그램의 업무 논리(business logic)에서 분리해서 개별적으로 검사할 수 있는 재사용 가능한 메서드에 집어넣는다는 점에서 이는 사실 좋은 ㅇ리이다.
  • 다음 작업 조합기는 WhenAll가 비슷하되, 작업 중 하나라도 장애가 있으면 조합 작업이 즉시 실패한다.
async Task<TResult[]> WhenAllOrError<TResult> (prams Task<TResult>[] tasks)
{
  var killJoy = new TaskCOmpletionSource<TResult[]>();
  foreach (var task in tasks)
    task.ContinueWith(ant =>
    {
      if (ant.IsCanceled)
        killJoy.TrySetCanceled();
      else
        killJoy.TrySetException(ant.Exception.InnerException);
    });
  return await await Task.WhenAny(killJoy.Task, Task.WhenAll(tasks));
}
  • 이 메서드는 우선 TaskCompletionSource 인스턴스를 생성한다. 이 인스턴스는 작업 객체 하나가 장애를 일으키면 전체 작업을 끝내는 역할만 담당한다. 따라서 인스턴스에 대해 SetResult 메서드를 호출하지는 않는다. 단지 TrySetCanceled와 TrySetException 메서드만 호출할 뿐이다.
    • 이런 용도에서는 GetAwaiter().OnCompleted 보다 ContinueWith가 더 유용하다. 작업들의 결과에 접근하려는 것이 아니고 그 지점에서 UI 스레드로 실행이 복귀하는 것도 바람직하지 않기 때문이다.

더 이상 필요 없는 패턴들

  • 작업 객체와 비동기 함수들이 도입되기 전에도 .NET Framework에는 비동기성을 위한 여러 패턴이 있었다. .NET Framework 4.5부터는 작업 기반 비동기성이 주된 패턴이 되었기 때문에, 그 패턴들은 이제 거의 필요 없다.

APM(비동기 프로그래밍 모형)

  • 가장 오래된 패턴은 APM(Asynchronous Programming Model; 비동기 프로그래밍 모형)이다. 이 모형은 Begin과 End로 시작하는 메서드 쌍들과 IAsyncResult라는 인터페이스를 사용한다.
    • (이하 생략)

EAP(이벤트 기반 비동기 패턴)

  • EAP(Event-based Asynchronous Pattern; 이벤트 기반 비동기 패턴)은 특히 UI 시나리오에서 APM 보다 좀 더 단순한 대안을 제공하기 위해 .NET Framework 2.0에 도입된 패턴이다. 이 패턴을 구현한 형식은 그리 많지 않은데, 가장 주목할 만한 것은 System.Net의 WebClient이다.
    • EAP는 단지 하나의 패턴일 뿐이며 이를 보조하기 위한 형식은 없다. 본질적으로 EAP는 내부적으로 동시성을 관리하는 일단의 멤ㅂ들을 가진 클래스에 관한 것이다.
    • (이하 생략)

BackgroundWorker 클래스

  • System.ComponentModel의 BackgroundWorker는 EAP의 한 범용 구현이다. 이 클래스를 이용하면 리치 클라이언트 앱에서 동기화 문맥을 명시적으로 갈무리하지 않고도 일꾼 스레드를 띄워서 완료 여부와 진행 정도(퍼센트)를 보고받을 수 있다.
    • (이하 생략)
[ssba]

The author

지성을 추구하는 디자이너/ suyeongpark@abyne.com

댓글 남기기

This site uses Akismet to reduce spam. Learn how your comment data is processed.