2016년 10월 23일 일요일

Threading in C# 1. 쓰레드의 기초 (1)

본 글은 Threading in C# - Getting Started 의 내용을 번역한 내용입니다.
쓰레드의 개념
C#에서는 멀티 쓰레드에 의한 병렬 처리가 지원되고 있습니다. 1개의 쓰레드는 다른 곳에서 독립된 실행 경로에 프로그램이 실행되어, 다른 쓰레드와 동시에 실행이 가능합니다. C#의 클라이언트 프로그램 (Console, Windows Form, WPF)에서는 OS 및 CLR에 의해 어플리케이션이 실행될 때 1개의 쓰레드가 만들어 집니다.

이 최초에 만들어진 쓰레드를 메인 쓰레드(Main Thread)라고 부릅니다. 또한 메인 쓰레드에서 쓰레드를 작성하는 것으로 멀티 쓰레드 어플리케이션을 만다는 것이 가능합니다. 아래는 쓰레드를 복수로 기동하는 샘플 코드입니다.

class ThreadTest
{
  static void Main()
  {


// 새로운 쓰레드의 작성 (OS 레벨에서는 아직 작성되지 않음)
    Thread t = new Thread (WriteY);   
    t.Start();  //쓰레드의 시작
 
    // 메인 쓰레드에서 실행
    for (int i = 0; i < 1000; i++) Console.Write ("x");
  }
 
  static void WriteY()
  {


// 작성한 쓰레드에서 X를 출력
    for (int i = 0; i < 1000; i++) Console.Write ("y");
  }
}

출력은 아래와 같이 나옵니다.



메인 쓰레드가 쓰레드 subThread를 생성하여 쓰레드를 실행합니다. subThread는 y라고 하는 문자를 콘솔에 출력합니다. 동시에 메인 쓰레드는 x 문자를 콘솔에 출력합니다.

Starting a new Thread

쓰레드가 한번 시작을 하면 쓰레드가 완료되기 까지 Thread 객체의 IsAlive 프로퍼티가 true로 됩니다. 쓰레드의 생성 시에 생성자에 인수로 넘겨진 메서드가 완료되면 그 쓰레드는 완료됩니다. 한 번 완료된 쓰레드는 두번 다시 재개할 수 없습니다. CLR은 개별 쓰레드에 대해서 스택 영역에 독립된 메모리를 할당합니다. 이 사양에 의해 지역 변수의 영역은 개별 쓰레드에서 분리되는 것을 보장받게 됩니다.

다음 샘플에서는 메서드 안에서 지역 변수를 정의하여 이 메서드를 메인 쓰레드와 새롭게 작성된 쓰레드 양쪽에서 실행해보기로 하겠습니다.

static void Main() 
{
  new Thread (Go).Start();      // Call Go() on a new thread
  Go();                         // Call Go() on the main thread
}
 
static void Go()
{
  // Declare and use a local variable - 'cycles'
  for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
}

??????????

개별 쓰레드에서 cycles 변수가 작성되어, 개별 쓰레드의 메모리 상에서 값이 가산되고 있습니다. 그런데 출력된 ? 의 갯수는 예상대로 10개가 되었습니다.
쓰레드는 같은 인스턴스로의 참조를 갖는 경우 데이터를 공유하게 됩니다. 아래는 그 예시가 되는 샘플 코드입니다.

class ThreadTest
{
  bool done;
 
  static void Main()
  {
    ThreadTest tt = new ThreadTest();   // Create a common instance
    new Thread (tt.Go).Start();
    tt.Go();
  }
 
  // Note that Go is now an instance method
  void Go() 
  {
     if (!done) { done = true; Console.WriteLine ("Done"); }
  }
}

Done

두 쓰레드가 Go 메서드를 호출하고 있지만, 같은 인스턴스를 참조하고 있습니다. 2개의 쓰레드에서 done 프로퍼티를 공유하고 있기 때문에 'Done' 이라고 출력되는 것은 2번 출력되는 것이 아닌 1번만 출력되었습니다. 다른 방법으로서 static 변수를 사용하여 쓰레드 간에 데이터 공유가 가능합니다. 같은 샘플을 다음 아래와 같이 수정합니다.

class ThreadTest 
{
  static bool done;    // Static fields are shared between all threads
 
  static void Main()
  {
    new Thread (Go).Start();
    Go();
  }
 
  static void Go()
  {
    if (!done) { done = true; Console.WriteLine ("Done"); }
  }
}

이 예제와 같이 쓰레드에서는 Thread-Safe 라고 하는 개념이 상당히 중요하다는 것을 나타냅니다. 바꿔 말하면 Thread-Safety의 개념이 부족한 샘플코드라고 말할 수 있습니다. 출력 결과는 예측이 불가능 합니다. 'Done'을 2회 출력시키는 것도 가능합니다. 놀랍게도 명령문의 순서를 반대로 하면 'Done' 이 2회 출력됩니다.


static void Go()
{
  if (!done) { Console.WriteLine ("Done"); done = true; }
}

Done
Done   (usually!)

문제의 본질은 다른 쓰레드가 WriteLine 메서드를 실행하여 'done'을 true로 변경하기 도 전에 또 하나의 쓰레드가 if문에 진입하는 것이 가능하다는 것입니다.
이 문제를 해결하기 위한 방법은 Exclusive Lock을 사용하여 명령문을 실행하는 것입니다. 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.WriteLine ("Done"); done = true; }
    }
  }
}

2개의 쓰레드가 동시에 Lock을 취득하는 경우 (이 예제에서는 locker) 1개의 쓰레드 만이 locker에 둘러 싸인 명령문을 실행하며, 남은 1개의 쓰레드는 실행 중인 쓰레드가 locker에서 먼저 진입한 쓰레드의 실행이 완료가 될 때까지 대기합니다. 이 locker로 둘러 싸인 부분을 Critical Section 이라고 합니다. Critical Section에서 실행되는 쓰레드는 오직 1개의 쓰레드에서만 가능하다는 것이 보장되어, 결과로서 Done이 출력되는 것은 1번 뿐입니다.
이와 같이 복수의 쓰레드가 실행되고 있는 환경에서도 처리 결과가 예측 가능한 프로그램을 가리켜 Thread Safe라고 합니다.

멀티 쓰레드에서의 복잡하고 알아 차리기 어려운 오류의 주된 원인은 쓰레드 간에 공유하는 데이터가 많다는 것입니다. 그 외 다양한 상황에서도 말씀드리지만 가능한 단순한 코드를 작성하는 것에 주의를 기울이는 것이 매우 중요합니다.

이 Lock 절에서 차단된 쓰레드는 CPU 리소스를 소비하지 않습니다.


Join과 Sleep
Join 메서드를 사용하면 다른 쓰레드가 완료될 때 까지 대기할 수 있습니다. 예제 코드는 다음과 같습니다.

static void Main()
{
  Thread t = new Thread (Go);
  t.Start();
  t.Join();
  Console.WriteLine ("Thread t has ended!");
}
 
static void Go()
{
  for (int i = 0; i < 1000; i++) Console.Write ("y");
}

이 코드를 실행하면 y가 1000회 출력한 후 "Thread t has ended!"가 출력됩니다. Join 메서드를 호출할 때 타임아웃을 지정하는 것도 가능합니다. 밀리 초를 Int32 타입으로 지정하거나, TimeSpan 타입으로 타임아웃을 지정합니다. Join 메서드의 반환 값이 true 일 경우는 쓰레드가 정상적으로 완료했다는 것을 가리키며, false의 경우는 쓰레드가 타임 아웃했다는 것을 가리킵니다.

Thread.Sleep 은 현재의 쓰레드를 지정한큼 정지됩니다.
Thread.Sleep (TimeSpan.FromHours (1));  // sleep for 1 hour
Thread.Sleep (500);                     // sleep for 500 milliseconds

SleepJoin에서 대기하고 있는 사이에 이 쓰레드는 블럭되어 있는 상태가 됩니다. 그래서 CPU의 리소스를 소비하지 않습니다.


Thread.Sleep(0) 은 남아 있는 현재의 쓰레드의 타임 슬라이스를 모두 포기하여, 자발적으로 다른 쓰레드에게 CPU 타임을 양보합니다. NET4.0에서는 새롭게 Thread.Yield() 메서드가 추가되어 같은 기능을 실행합니다. Thread.Sleep(0)과 Thread.Yield()의 유일한 차이는 Thread.Yield()의 경우는 같은 프로세서에서 실행되고 있는 쓰레드에 대해서만 CPU 타입을 양보한다는 것이 다릅니다.Thread.Sleep(0)과 Thread.Yield()는 제품 코드에서 고도의 성능 조정을 수행할 때 유용할 때가 있습니다. 또한 Thread-Safe가 아닌 미발견된 오류를 발견하기 위한 디버그용의 툴로서도 상당히 유용합니다. 만약 Thread.Yield()를 프로그램의 다양한 곳곳에 심어둔 프로그램을 실행할 때 프로그램이 충돌하게 되면 프로그램의 어딘 가에 오류가 있다는 것은 거의 틀림없다고 말할 수 있습니다.



쓰레드는 어떻게 동작하는가
멀티 쓰레드는 내부적으로는 쓰레드 스케쥴러에 의해 관리되고 있습니다. CRL은 이 기능을 OS의 기능을 이용하는 형태로 제공합니다. 쓰레드 스케쥴러는 모든 쓰레드가 옳바르게 실행시간을 할당될 수 있도록 각 쓰레드를 관리하며, 또한 대기상태와 블럭되어 있는 쓰레드(ExclusiveLock 과 사용자의 입력 대기 등등)가 쓸데없이 CPU를 소비하지 않도록 하고 있습니다.

프로세서가 1개 밖에 없는 컴퓨터의 경우 쓰레드 스케쥴러는 타임 슬라이스를 수행하게 됩니다. 타임 슬라이스라는 것은 고속으로 실행하는 쓰레드를 교체하여 마치 복수의 어플리케이션이 동시에 실행되고 있는 것처럼 보여주는 구조입니다. Windows에서는 이 타임 슬라이스는 통상 수십밀리 초 정도로 교체합니다. 쓰레드의 교체에 걸리는 시간(통상적으로는 수 밀리초 정도)보다도 큰 값으로 타임 슬라이스를 하게 됩니다.

복수의 프로세서를 탑재하고 있는 컴퓨터의 경우 멀티 쓰레드는 타임 슬라이스와 다른 쓰레드가 다른 CPU에서 각각 병렬로 실행되는 것으로 병렬처리로 처리가 됩니다. 하지만 타임 슬라이스가 실행되고 있는 것은 거의 확실합니다. 왜냐하면 다른 어플리케이션과 같이 OS 자신의 쓰레드도 실행될 필요가 있기 때문입니다.

타임 슬라이스등의 외부요인에 의한 쓰레드는 항상 실행시간을 다른 쓰레드에게 가로채였다고도 말합니다. 대부분의 상황에서는 가로채버리는 것을 방지하기 위한 수단이 없어 어떤 부분에서 실행이 막혀져 실행시간을 가로채버리게 되는 것을 제어할 수 없습니다.



쓰레드 VS 프로세스
쓰레드는 OS의 어플리케이션을 동작시키는 프로세스와 유사한 부분이 있습니다. 프로세스가 1개인 컴퓨터 상에서 병렬로 동작할 수 있는 것과 같이 쓰레드도 1개의 CPU 상에서 복수실행을 하는 것이 가능합니다.프로세스는 다른 프로세스와 완전히 분리되어 있지만, 쓰레드도 한정적이지만 어느 정도 분리가 되어 있습니다. 특히 쓰레드는
힙 메모리를 같은 어플리케이션에서 실행중인 다른 쓰레드와 공유하고 있습니다. 이 부분이 쓰레드를 유용하게 하는 부분입니다. 백그라운드 쓰레드에서 데이터를 취득하고 다른 쓰레드에서 그 데이터의 취득처리에 대한 진척상황을 화면에 표시하는 것이 가능합니다.



쓰레드의 유용한 사례와 잘못된 사례
멀티 쓰레드에는 상당히 다양한 사례가 있습니다.

UI가 얼어버리는 것(Freeze)를 방지

시간이 걸리는 처리를 백그라운드의 워커 쓰레드에서 실행하고, UI 쓰레드는 키보드나 마우스의 입력을 처리가능한 상태로 유지하여 UI가 반응하게끔 하는 것이 가능합니다.


CPU의 블럭을 회피한다.

다른 컴퓨터로부터 응답이나 디바이스등의 하드웨어의 응답을 기다릴 때 유용합니다. 쓰레드가 블럭되어 있는 사이에 다른 쓰레드는 비어있는 CPU 시간을 사용하도록 가능한 처리를 진행하는 것이 가능합니다.


병렬처리

어떤 계산처리 등이 쓰레드 마다 독립적으로 처리가능한 경우, 복수의 CPU를 사용하여 집중적으로 계산을 처리하는 것을 고속화하는 것이 가능합니다.


투기적실행(Speculative execution)

멀티 머신에서는 복수의 처리를 병렬로 실행하여 처리하는 방식을 이용하는 것으로 처리시간을 단축할 수 있는 경우가 있습니다. .NET FrameworkASP.NET, WCF, WebService, Remoting을 사용하는 경우에는 내부에서 자동적으로 쓰레드를 작성하여 클라이언트의 요청을 처리하고 있습니다. 병렬화에 의해 클라이언트의 대기 시간을 줄이는 것이 가능하여 상당히 유용합니다.


ASP.NETWCF 등에서는 개발자가 static 변수 등의 공유 변수를 이용하지 않는 다면 멀티 쓰레드에서 처리가 수행되고 있다는 것을 의식하지 못하며, 또한 lockingThread-Safety등을 인식할 필요도 없이 이것들의 기술을 이용할 수 있습니다.

하지만 쓰레드는 좋은 것만이 있는 것이 아닌 제약이 붙은 사항도 있습니다. 가장 큰 문제는 프로그램의 복잡성을 대폭으로 증대 시킨다는 것입니다. 복수의 쓰레드를 실행시킨다고 하는 것 보다 그 쓰레드 이외의 쓰레드 자신의 복잡성이 증대하게 됩니다. 복수의 쓰레드 사이에서의 데이터 주고 받기 (주로 공유 데이터)가 그 원인입니다. 그 데이터의 주고 받기가 의도적인지 아닌지, 멀티 쓰레드 프로그램은 가끔 발생하는 재현이 어려운 오류를 종종 발생시켜 개발기간의 장기화를 초래합니다. 이러한 이유 때문에 멀티 쓰레드 어플리케이션에서는 쓰레드 간의 데이터 주기 받기를 최저한으로 심플하게 하는 것이 중요하여, 신뢰된 디자인 패턴을 가능한 적용하여 프로그램을 작성하기를 권합니다. 이 기사에서는 이러한 복잡성을 어떻게 처리해 가는 지를 해설할 예정입니다.

해결책으로서는 멀티 쓰레드 처리를 재이용 클래스에 캡슐화를 통해 독립적으로 하여 실행 및 테스트가 가능한 클래스로 디자인하는 것이 중요합니다. .NET Framework에서는 높은 레벨에서 쓰레드의 구조를 추상화한 다양한 클래스를 제공하고 있습니다. 이것들의 클래스 등에 대해서는 다음 장에서 설명하겠습니다.

또한 멀티 쓰레드는 쓰레드의 스케쥴 관리와 변환 처리의 비용에 의한 CPU 소비량의 증대를 초래합니다. CPU의 수보다 쓰레드의 수가 더 많은 경우는 CPU 소비량이 증대하게 됩니다. 또한 쓰레드 자신의 작성관리와 폐기처리도 CPU를 소비합니다. 멀티 쓰레드는 어플리케이션을 고속화하는 것이 아닙니다. 오히려 잘못된 방식으로 사용되면 더욱 느려지게 될 가능성도 있습니다. 예를 들면 상당히 무거운 디스크의 I/O 처리가 있을 때는 10개의 쓰레드에서 동시에 처리하는 것 보다 워커 쓰레드를 만들어 1개씩 처리하는 쪽이 속도를 빠르게 합니다. (Signal의 구조의 장에서는 이 문제를 Producer/Consumer 큐브 패턴으로 해결하는 방법에 대해서 해설합니다.)



쓰레드의 작성과 개시
전의 장에서 소개했던 Thread 클래스의 생성자에 ThreadStart 대리자를 넘기는 것으로 쓰레드를 작성할 수 있었습니다. ThreadStart 대리자에는 다른 쓰레드에서 실행하는 실행 코드를 지정합니다. ThreadStart 대리자는 아래와 같이 정의하고 있습니다.

public delegate void ThreadStart();

Start 메서드를 호출하는것으로 실제로 쓰레드를 실행할 수 있습니다. 실행이 시작된 쓰레드는 메서드의 실행이 완료될 때 까지 실행이 진행됩니다. 아래에는 그러한 예제 코드입니다.

class ThreadTest
{
  static void Main() 
  {
    Thread t = new Thread (new ThreadStart (Go));
 
    t.Start();   // 새로운 쓰레드를 작성하여 Go() 메서드를 실행합니다.
    Go();        // 동시에 메인 쓰레드에서 Go() 메서드를 실행합니다.
  }
 
  static void Go()
  {
    Console.WriteLine ("hello!");
  }
}

이 예제에서는 쓰레드 tGo() 메서드를 실행합니다. 그리고 거의 동시에 메인 쓰레드에서도 Go() 메서드를 실행합니다. 결과로서는 거의 동시에 Hello! 라고 하는 메시지가 Console에 표시됩니다.

쓰레드는 메서드 그룹을 지정하여 더욱 간결하게 작성하는 것도 가능합니다. 지정한 메서드 그룹은 C#의 묵시적 형변환에 의해 ThreadStart 대리자로 변환됩니다.

Thread t = new Thread (Go);    // ThreadStart를 특별히 지정하지 않아도 괜찮습니다.

다른 방법으로서는 람다 식 또는 익명 메서드를 사용해서 쓰는 방식도 있습니다.

static void Main()
{
  Thread t = new Thread ( () => Console.WriteLine ("Hello!") );
  t.Start();
}



쓰레드에 데이터를 넘기기
쓰레드에 데이터를 넘기는 가장 간단한 방법은 실행하고 싶은 메서드를 람다 식으로 감싸 메서드의 파라미터로 자신이 넘기고 싶은 값을 지정하는 방법이 있습니다.

static void Main()
{
  Thread t = new Thread ( () => Print ("Hello from t!") );
  t.Start();
}
 
static void Print (string message) 
{
  Console.WriteLine (message);
}

이 방법에서는 여러가지 종류의 인수를 메서드에 넘기는 것이 가능합니다. 실행하고 싶은 코드 전체를 람다 식으로 랩핑하여 실행하는 것도 가능합니다.

new Thread (() =>
{
  Console.WriteLine ("I'm running on another thread!");
  Console.WriteLine ("This is so easy!");
}).Start();

같은 것을 C# 2.0의 익명 메서드를 사용하여 실행하느 ㄴ것도 가능합니다.

new Thread (delegate()
{
  ...
}).Start();

다른 방법으로서는 ThreadStart 메서드의 인수에 값을 지정하는 방법도 있습니다.
static void Main()
{
  Thread t = new Thread (Print);
  t.Start ("Hello from t!");
}
 
static void Print (object messageObj)
{
  string message = (string) messageObj;   // 캐스팅을 할 필요가 있습니다.
  Console.WriteLine (message);
}

이 코드가 어떻게 실행 가능한지 의문을 갖게 된다면 사실은 Thread의 생성자가 오버로드 되어 2종류의 대리자를 받아 들일 수 있기 때문입니다.

public delegate void ThreadStart();
public delegate void ParameterizedThreadStart (object obj);

ParameterizaedThreadStart 대리자의 제약으로는 파라메터가 1개밖에 지정할 수 없다는 것입니다. 또한 타입이 object 형이라서 캐스팅이 필요합니다.

람다 식을 이용하는 경우 변수의 캡쳐에 대한 문제가 있습니다. 자세한 것은 클로져(Closure)와 함수의 스코프 라는 글에서 설명하겠습니다.


이름붙는 쓰레드
디버그를 쉽게 하기 위해 쓰레드에 Name 프로퍼티가 있어 이름을 지정하는 것이 가능합니다. 이것은 특히 Visual Studio를 사용하고 있는 경우에 편리해서, 쓰레드의 이름은 쓰레드 윈도우와 툴바의 디버그 영역에 표시됩니다. 쓰레드 이름은 1개만 설정할 수 있습니다. 한번 설정한 후에 변경하게 되면 예외가 발생합니다.

staticThread.CurrentThread 프로퍼티를 사용하면 현재 실행중인 쓰레드를 취득할 수 있습니다. 아래의 예제에서는 메인 쓰레드의 이름을 설정합니다.

class ThreadNaming
{
  static void Main()
  {
    Thread.CurrentThread.Name = "main";
    Thread worker = new Thread (Go);
    worker.Name = "worker";
    worker.Start();
    Go();
  }
 
  static void Go()
  {
    Console.WriteLine ("Hello from " + Thread.CurrentThread.Name);
  }
}



포어그라운드 쓰레드(Foreground Thread)와 백그라운드 쓰레드(Background Thread)
기본적으로는 스스로 작성한 쓰레드는 전부 포어그라운드 쓰레드가 됩니다. 포어그라운드 쓰레드가 1개라도 실행중인 경우, 어플리케이션이 완료되지 않습니다. 반대로 백그라운드 쓰레드의 경우에는 그렇지 않습니다. 만약 포어그라운드 쓰레드의 실행이 전부 완료된 경우 어플리케이션은 종료하며, 백그라운드 쓰레드는 강제적으로 종료됩니다.

쓰레드의 포어그라운드백그라운드는 쓰레드의 우선 순위실행 시간의 할당과는 전혀 관계가 없습니다.

쓰레드가 백그라운드에서 실행되고 있는지 아닌지는 IsBackground 프로퍼티에서 취득 또는 설정할 수 있습니다.

class PriorityTest
{
  static void Main (string[] args)
  {
    Thread worker = new Thread ( () => Console.ReadLine() );
    if (args.Length > 0) worker.IsBackground = true;
    worker.Start();
  }
}

이 프로그램이 커맨드 라인 인수 (args) 없이 실행되는 경우 워커 쓰레드는 포어그라운드 쓰레드로 실행되며 사용자의 Enter 키의 입력을 기다리게 됩니다. 그것과 동시에 메인 쓰레드는 종료되지만 포어그라운드 쓰레드가 아직 존재하기 때문에 어플리케이션은 종료되지 않습니다.

반대로 말하면 인수가 있어서 실행되는 경우 워커 쓰레드는 백그라운드 쓰레드로서 실행되어 메인 쓰레드의 종료와 함께 프로그램은 즉석으로 종료하게 됩니다.

프로세스는 이 룰에 따라서 쓰레드를 파기하기 때문에 백그라운드 쓰레드에서 실행되는 코드의 finally 블럭은 실행되지 않고 쓰레드가 종료되는 경우가 있습니다. 만약 작성한 프로그램이 finally 블럭 (또는 using을 사용하고 있는 경우)에서 리소스의 파기와 일시적으로 파일의 제거를 실행하는 경우에 문제가 발생하는 경우가 있습니다. 이러한 문제를 회피하기 위해 백그라운드 쓰레드가 종료할 때까지 명시적으로 기다리는 것이 가능합니다. 구체적으로는 두 가지 방법이 있습니다.

    • 직접 작성한 쓰레드의 경우에는 Join 메서드로 쓰레드의 종료를 기다립니다.
    • 쓰레드 풀의 쓰레드인 경우에는 EventWaitHandle을 사용합니다.
어느쪽을 사용하더라도 타임아웃을 지정해야만 합니다. 당신의 예상이 빗나가 쓰레드가 완료되지 않는 경우가 있기 때문입니다.

만약 타임아웃을 지정하지 않는 경우 어플리케이션이 종료되지 않아 사용자는 작업 관리자에서 강제적으로 어플리케이션을 종료하게 됩니다.

다음 쓰레드의 개념(2)에서 계속하겠습니다.

테스트 글

테스트 글입니다