뇌를 자극하는 C# 4.0 프로그래밍/ 스레드와 태스크

프로세스와 스레드

  • 프로세스(Process)란 실행 파일이 실행되어 메모리에 적재된 인스턴스를 말한다. 예컨대 word.exe가 실행 파일이라면, 이 실행 파일을 실행한 것이 프로세스가 된다.
  • 프로세스는 반드시 1개 이상의 스레드(Thread)로 구성된다. 스레드는 운영체제가 CPU 시간을 할당하는 기본 단위인데, 프로세스가 밧줄이라면 스레드는 밧줄을 이루는 실이라고 할 수 있다. 위 그림은 프로세스와 프로세스를 구성하는 스레드의 관계를 표현하고 있다.

  • 멀티 스레드의 장점
    1. 사용자 대화형 프로그램에서 응답성을 높일 수 있다. 예컨대 프로그램 파일 복사를 하는데 파일 용량이 커서 소요 시간이 30분 정도 걸리는 경우, 단일 스레드에서는 취소 조차 할 수 없어서 –운영체제 단에서 강제 종료를 하지 않는 이상– 파일 복사가 완료될 때까지 기다리고 있어야 한다.
    2. 멀티 프로세스 방식에 비해 멀티 스레드 방식이 자원 공유가 쉽다. 멀티 스레드 방식에서는 스레드끼리 코드 내 변수를 같이 사용하는 것만으로도 데이터 교환을 할 수 있다.
    3. 멀티 프로세스 방식에 비해 경제성이 높다. 프로세스를 띄우기 위해 메모리와 자원을 할당하는 작업은 비용이 비싼데, 스레드를 띄울 때는 이미 프로세스에 할당된 메모리와 자원을 그대로 사용하므로 메모리와 자원을 할당하는 비용을 지불하지 않아도 된다.

  • 멀티 스레드 방식의 단점
    1. 구현이 어렵다.
    2. 테스트도 어렵다.
    3. 멀티 프로세스 방식에서는 자식 프로세스 중 하나에 문제가 생겨도 그 자식 프로세스 하나만 죽는 것 이상으로 영향이 확산되지 않지만, 멀티 스레드에서는 자식 스레드 중 하나만 문제가 생겨도 전체 프로세스에 영향이 미친다.
    4. 스레드를 너무 많이 사용하면 오히려 성능이 저하된다. 스레드가 CPU를 사용하기 위해서는 작업 간 전환(Context Switching)을 해야 하는데, 이 작업 간 전환 비용이 꽤 비싸다. 많은 스레드가 너무 자주 작업 간 전환을 수행하다 보면 응용 프로그램이 실제로 일을 하는 시간에 비해 작업 간 전환에 사용하는 시간이 커지기 때문에 성능이 저하된다.

스레드 시작하기

// 스레드 사용 예시
static void DoSomething ()
{
    for ( int i = 0 ; i < 5 ; i++ )
    {
        Console.WriteLine("DoSomething: {0}", i);
    }
}

static void Main(string[] args)
{
    Thread t1 = new Thread(new ThreadStart(DoSomething)); // Thread 인스턴스 생성

    t1.Start(); // 스레드를 시작한다.

    t1.Join(); // 스레드의 종료를 대기한다.
}
  • .NET 프레임워크에서는 System.Threading.Thread 클래스를 통해 스레드를 사용한다. 그 사용 방법은 다음과 같다.
    1. Thread의 인스턴스를 생성한다. 이때 생성자의 매개변수로 스레드가 실행할 메소드를 넘긴다.
    2. Thread.Start() 메소드를 호출하여 스레드를 시작한다.
    3. Thread.Join() 메소드를 호출하여 스레드가 끝날 때까지 기다린다.

스레드 임의로 종료 시키기

// 스레드 종료 예시
static void DoSomething ()
{
    try
    {
        for ( int i = 0 ; i < 5 ; i++ )
        {
            Console.WriteLine("DoSomething: {0}", i);
        }
    }
    catch ( ThreadAbortedException e )
    {
        // ...
    }
    finally
    {
        // ...
    }
}

static void Main(string[] args)
{
    Thread t1 = new Thread(new ThreadStart(DoSomething)); // Thread 인스턴스 생성

    t1.Start();

    t1.Abort(); // 스레드 취소

    t1.Join();
}
  • 살아 있는 스레드를 죽이려면 Thread 객체의 Abort() 메소드를 호출하면 된다.
  • 여기서 주의할 점은 Abort() 메소드를 호출한다고 동작하던 스레드가 즉시 종료된다는 보장이 없다는 것이다. Thread 객체의 Abort() 메소드를 호출하면 CLR은 해당 스레드가 실행 중이던 코드에 ThreadAbortException을 호출한다. 이 때 이 예외를 catch 하는 코드가 있으면 이 예외를 처리한 다음, finally 블록까지 실행한 후에야 해당 스레드는 완전히 종료가 된다.
  • (Thread.Abort()는 사용하지 않는 편이 좋다. 마치 goto와 같은 식)

스레드의 일생과 상태 변화

스레드 상태 설명
Unstarted 스레드 객체를 생성한 후 Thread.Start() 메소드가 호출되기 전의 상태
Running 스레드가 싲가하여 동작 중인 상태. Unstarted 상태의 스레드를 Thread.Start() 메소드를 통해 이 상태로 만들 수 있다.
Suspende 스레드의 일시 중단 상태. 스레드를 Thread.Suspend() 메소드를 통해 이 상태로 만들 수 있으며, Suspended 상태인 스레드는 Thread.Resume() 메소드를 통해 다시 Running 상태로 만들 수 있다.
WaitSleepJoin 스레드가 블록(block)된 상태
Aborted 스레드가 취소된 상태. Aborted 상태가 된 스레드는 다시 Stopped 상태로 전환되어 완전히 중지된다.
Stopped 중지된 스레드의 상태. Abort() 메소드를 호출하거나 스레드가 실행 중인 메소드가 종료되면 이 상태가 된다.
Background 스레드가 백그라운드로 동작하고 있는 상태. foreground 스레드는 하나라도 살아 있는 한 프로세스가 죽지 않지만, 백그라운드는 프로세스가 죽고 사는 것에 영향을 미치지 않는다. 다만 프로세스가 죽으면 백그라운드 스레드들도 모두 죽는다. Thread.IsBackground 속성에 true 값을 입력함으로써 이 상태를 바꿀 수 있다.
  • 스레드의 상태 변화에는 규칙이 있는데, 예컨대 Aborted 상태의 스레드는 절대 Running 상태로 옮겨가지 못하고, Running 상태의 스레드는 Unstarted 상태로 바뀔 수 없다. 자세한 내용은 아래 그림 참조.

  • ThreadState 열거형의 특징은 ThreadState가 Flags 애트리뷰트를 갖고 있다는 점이다. ThreadStage는 2가지 이상의 상태를 동시에 가질 수 있는 Flags를 통해 자신의 상태를 표현한다.
  • ThreadState의 열거형은 2n의 형태로 값을 갖는데 이유는 비트 연산을 통해 여러 상태를 동시에 가질 수 있는 ThreadState가 어떤 상태인지 쉽게 알 수 있게 하기 위함이다.
상태 10진수 2진수
Running 0 000000000
StopRequested 1 000000001
SuspendRequested 2 000000010
Background 4 000000100
Unstarted 8 000001000
Stopped 16 000010000
WaitSleepJoin 32 000100000
Suspended 64 001000000
AbortedRequest 128 010000000
Aborted 256 100000000
// Thread 객체의 ThreadState 필드의 값을 확인하는 예제
if ( t1.ThreadState & ThreadState.Aborted == ThreadState.Aborted )
{
    // ...
}
else if ( t1.ThreadState & ThreadState.Stopped == ThreadState.Stopped )
{
    // ...
}

스레드를 임의로 멈추는 또 하나의 방법: 인터럽트

// 스레드 중단 예시
static void DoSomething ()
{
    try
    {
        for ( int i = 0 ; i < 5 ; i++ )
        {
            Console.WriteLine("DoSomething: {0}", i);
        }
    }
    catch ( ThreadInterruptedException e )
    {
        // ...
    }
    finally
    {
        // ...
    }
}

static void Main(string[] args)
{
    Thread t1 = new Thread(new ThreadStart(DoSomething)); // Thread 인스턴스 생성

    t1.Start();

    t1.Interrupt(); // 스레드 중단

    t1.Join();
}
  • 불가피하게 스레드를 중단해야 한다면 Thread.Interrupt() 메소드를 이용하여 중단 시키는 것이 좋다. Thread.Interrupt() 메소드는 스레드가 한참 동작 중인 상태 –Running 상태– 를 피해서 WaitJoinSleep 상태에 들어갔을 때 ThreadInterruptedException 예외를 던져 스레드를 중지시킨다.
    • 이런 특징 때문에 프로그래머는 최소한 코드가 ‘절대로 중단 되면 안 되는’ 작업을 하고 있을 때는 중단 되지 않는다는 보장을 받을 수 있다.

스레드 간의 동기화

  • 스레드는 이기적이라 남의 상황 따위는 안중에 두지 않는다. 다른 스레드가 어떤 자원을 잡고 사용하고 있는 중인데 갑자기 끼어들어 자기 멋대로 사용해 버리는 경우가 다반사이다.
  • 스레드들이 순서를 갖춰 자원을 사용하게 하는 것을 일컬어 ‘동기화(Synchronization)’이라고 하는데, 이를 제대로 하는 것이 멀티 스레드 프로그래밍을 완벽하게 하는 길이라고 할 수 있다.
  • 스레드 동기화에서 가장 중요한 사명은 ‘자원을 한 번에 하나의 스레드가 사용하도록 보장’하는 것이다. 프로그래머가 이 사명을 달성할 수 있도록 .NET 프레임워크가 제공하는 도구로는 lock 키워드와 Monitor 클래스가 있다.
    • lock 키워드가 사용하기는 더 쉬운데 Monitor 클래스는 보다 섬세한 동기화 제어 기능을 제공한다.

lock 키워드로 동기화하기

// 스레드 동기화 예시 코드
class Counter
{
    public int count = 0;
    public void Increase()
    {
        count = count + 1;
    }
}

MyClass obj = new MyClass();
Thread t1 = new Thread(new ThreadStart(obj.DoSomething);
Thread t2 = new Thread(new ThreadStart(obj.DoSomething);
Thread t3 = new Thread(new ThreadStart(obj.DoSomething);

t1.Start();
t2.Start();
t3.Start();

t1.Join();
t2.Join();
t3.Join();

Console.WriteLine(obj.count);
  • 위 예시 코드에서 obj.count는 최소 1에서 최대 3까지의 결과를 가질 수 있는데, 이유는 아래 그림과 같다.
  • count + 1은 여러 단계의 하위 연산으로 나눠지는 코드로 CPU 입장에서는 count = count + 1이 꽤 긴 코드가 된다. 그리하여 프로그래머 입장에서는 위쪽 그림처럼 코드가 동작하길 바라지만, 실제로는 t1스레드가 연산을 마치기 전체 t2 스레드가 같은 코드를 실행하고, t3 스레드가 다시 같은 코드를 실행하여 최종적으로 1이 나올 수가 있다. 더 문제는 실행할 때마다 그 결과가 다를 수 있다는 것.
  • 이 문제를 해결학 위해 count = count + 1 코드를 한 스레드가 실행하고 있을 때 다른 스레드는 실행하지 못하도록 하는 장치가 필요하다. 그 장치가 바로 크리티컬 섹션
    • 크리티컬 섹션(Critical Section)이란 한 번에 한 스레드만 사용할 수 있는 코드 영역을 의미한다. C#에서는 lock 키워드로 감싸 주기만 해도 평범한 코드를 크리티컬 섹션으로 바꿀 수 있다.

// lock 키워드 사용 예시
public viod Increase ()
{
    // lock 키워드와 중괄호로 둘러싸인 이 부분은 크리티컬 섹션이 된다.
    // 한 스레드가 이 코드를 실행하다 lock 블록이 끝나는 괄호를 만나기 전까지는 다른 스레드는 절대 이 코드를 실행할 수 없다.
    lock ( thisLock )
    {
        count = count + 1;
    }
}
  • 스레드는 lock 을 얻어야 크리티컬 섹션을 생성하므로 누군가가 lock을 받아 크리티컬 섹션을 생성하였다면 다른 스레드들은 대기를 하는 상황이 발생한다. 이런 경우 소프트웨어의 성능이 크게 떨어지므로 동기화를 설계할 때는 반드시 필요한 곳에서만 크리티컬 섹션을 사용하도록 해야 한다.
  • 한편 lock 키워드의 매개 변수로 사용하는 객체는 참조형이면 어느 것이든 쓸 수 이지만 public 키워드 등을 통해 외부 코드에서도 접근할 수 있는 다음 3가지는 문법적으로는 문제가 없지만 절대 사용하지 않기를 권장된다.
    1. this: 클래스의 인스턴스는 클래스 내부 뿐만 아니라 외부에서도 자주 사용된다. lock(this)는 매우 나쁜 버릇이다.
    2. Type 형식: typeof 연산자나 object 클래스로부터 물려받은 GetType() 메소드는 Type 형식의 인스턴스를 반환한다. 즉 코드의 어느 곳에서나 특정 형식에 대한 Type 객체를 얻을 수 있다. lock( typeof(SomeClass) )나 lock( obj.GetType() )은 피할 것.
    3. string 형식: 절대 string 객체로 lock을 하지 말 것. “abc”는 어떤 코드에서든 읽을 수 있는 string 객체이다.

Monitor 클래스로 동기화하기

// Monitor.Enter()와 Monitor.Exit()를 이용한 크리티컬 섹션 생성과 삭제
public void Increase ()
{
    int loopCount = 1000;
    while (loopCount-- > 0)
    {
        Monitor.Enter(thisLock);

        try
        {
            count++;
        }
        finally
        {
            Monitor.Exit(thisLock);
        }
    }
}
  • Monitor.Enter()는 크리티컬 섹션을 생성하며 Monitor.Exit()은 크리티컬 섹션을 제거한다.
  • lock 키워드는 Monitor.Enter(), Monitor.Exit()를 바탕으로 구현되어 있기 때문에 이 둘은 완전히 동일한 기능을 갖는다. lock이 사용하기 더 간편하므로 lock을 사용하는 편이 낫다.

Monitor.Wait()와 Monitor.Pulse()로 하는 저수준 동기화

  • Monitor.Wait()와 Monitor.Pulse()는 lock 키워드보다 섬세한 멀티 스레드 동기화를 제공한다.
  • 이 두 메소드는 반드시 lock 블록 안에서 호출해야 하는데 만일 lock을 걸어 놓지 않은 상태에서 이 두 메소드를 호출하면 CLR이 Synchronization LockException 예외를 던지는 광경을 봐야 한다.
  • Wiat() 메소드는 스레드를 WaitSleepJoin 상태로 만든다. 이렇게 WaitSleepJoin 상태에 들어간 스레드는 동기화를 위해 갖고 있던 lock을 내려 놓은 뒤 Waiting Queue라고 하는 큐에 입력되고 다른 스레드가 lock을 얻어 작업을 수행한다.
  • 작업을 수행하던 스레드가 일을 마친 뒤 Pulse() 메소드를 호출하면 CLR은 Waiting Queue의 가장 첫 요소 스레드를 꺼낸 뒤 Ready Queue에 입력 시킨다. Ready Queue에 입력된 스레드는 입력된 차례에 따라 lock을 얻어 Running 상태에 들어간다.
    • 아래 그림은 이 과정을 설명한다.
  • 한편 Thread.Sleep() 메소드도 스레드를 WaitSleepJoin 상태로 만들기는 하지만 Monitor.Pulse() 메소드에 의해 깨어날 수는 없다. 다시 Running 상태로 돌아오려면 매개 변수로 입력된 시간이 경과되거나 인터럽트 예외를 받아야 깨어난다. 반면 Monitor.Wait() 메소드는 Monitor.Pulse() 메소드가 호출되면 바로 깨어날 수 있다. 이 때문에 멀티 스레드 응용 프로그램의 성능 향상을 위해 Monitor.Wait()와 Monitor.Pulse()를 사용한다.
  • (예시 코드 생략)

Task와 Task<TResult>, 그리고 Parallel

  • CPU의 발전이 클럭을 높이는 방향으로는 한계에 이르자, 인텔을 비롯한 CPU 업체들은 하나의 CPU 안에 여러 개의 코어를 집적하는 방향으로 제품을 향상시켰고 MS는 개발자들이 이러한 하드웨어 변화를 최대로 활용할 수 있는 방법을 연구하는데 많은 투자를 하였다.
  • .NET 프레임워크 4.0에 도입된 System.Threading.Tasks 네임 스페이스도 그 연구 성과의 일부로 이 네임스페이스는 병행성 코드나 비동기 코드를 개발자들이 손쉽게 작성할 수 있도록 돕는 클래스들이 들어 있다.
  • System.Threading.Task 네임스페이스의 클래스들은 하나의 작업을 쪼갠 뒤 쪼개진 작업들을 동시에 처리하는 코드와 비동기 코드를 위해 설계되었다.반면 Thread 클래스는 여러 개의 작업을 나누지 않고 각각 처리해야 하는 코드에 적합하다.

System.Threading.Tasks.Task 클래스

  • 동기(Synchronous) 코드는 검사에 비유할 수 있는데 상대를 찌른 후에 다시 뽑아야 다시 칼을 쓸 수 있는 것처럼 메소드를 호출한 뒤에 이 메소드의 실행이 완전히 종료되어야만 다음 메소드를 호출할 수 있다.
  • 반면 비동기 코드는 궁수에 비유할 수 있는데 궁수가 화살을 쏘고 바로 다음 화살을 쏠 수 있는 것 –Shoot and Forget– 처럼 메소드를 호출한 뒤에 메소드의 종료를 기다리지 않고 바로 다음 코드를 실행한다.
  • C#에는 어떤 메소드를 동기/ 비동기로 실행하도록 하는 키워드 같은 것은 없다. 대신 Task와 Task<TResult> 클래스를 제공해서 비동기 코드를 수행할 수 있도록 도울 뿐이다.
  • Task 클래스는 인스턴스를 생성할 때 Action 델리게이트를 넘겨 받는다. 다시 말해 반환형을 갖지 않는 메소드와 익명 메소드, 무명 함수 등을 넘겨 받는다는 것.

Task 사용 예시

// task의 인스턴스는 Start() 메소드를 통해 Action 델리게이트를 비동기로 실행한다.
Action> someAction  = () = >
{
    Thread.Sleep(1000);
    Console.WriteLine("Printed Asynchronously");
}

Task myTask = new Task(someAction);
myTask.Start();

Console.WriteLine("Printed Synchronously");

myTask.Wait();


// 보다 일반적인 Task 사용법. Task.Factory를 이용한다.
var myTask = Task.Factory.StartNew(
    () =>
    {
        Thread.Sleep(1000);
        Console.WriteLine("Printed Asynchronously");
    }
);

Console.WriteLine("Printed Synchronously");

myTask.Wait();

코드의 비동기 실행 결과를 주는 Task<TResult> 클래스

var myTask = Task<List<int>>.Factory.StartNew(
    () =>
    {
        Thread.Sleep(1000);

        List<int> list = new List<int>();
        list.Add(3);
        list.Add(4);
        list.Add(5);

        return list;
    }
);

var myList = new List<int>();
myList.Add(0);
myList.Add(1);
myList.Add(2);

myTask.Wait();
myList.AddRange(myTask.Result.ToArray());
  • Task<TResult>는 코드의 비동기 실행 결과를 손쉽게 취합할 수 있도록 도와준다.
  • Task가 비동기로 수행할 코드를 Action 델리게이트로 받는 대신 Func 델리게이트로 받아서 결과를 반환한다는 점이 다르다.

손쉬운 병렬 처리를 가능케 하는 Parallel 클래스

void SomeMethod(int i)
{
    Console.WriteLine(i);
}

// Parallel.For()는 SomeMethod를 병렬로 호출하면서 0부터 100사이의 정수를 메소드의 매개 변수로 넘긴다.
// SomeMethod를 병렬로 호출할 때 몇 개의 스레드를 사용할지는 Parallel 클래스가 내부적으로 판단하여 최적화 한다.
Parallel.For(0, 100, SomeMethod);
  • MS는 System.Threading.Tasks.Parallel 클래스를 제공하여 For(), Foreach() 등의 메소드를 이용하여 Task<TResult>를 이용해서 직접 구현했던 병렬처리를 더 쉽게 구현할 수 있게 해준다.
It's only fair to share...Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedIn

The author

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

댓글 남기기