Programming with C#/C# 기초문법

[C# 기초문법] 11. 쓰레드(Thread)와 테스크(Task)

laboputer 2015. 2. 7. 12:51



 

프로세스(Process) 와 쓰레드 (Thread)

 프로세스는 실행 파일이 실행되어 메모리에 적재된 인스턴스입니다. 운영체제는 여러가지 프로세스를 동시에 실행할 수 있는 능력을 갖추고 있습니다. 즉 컴퓨터로 Youtube에서 노래를 들으면서 코딩을 할 수 있습니다.

그런데, 프로세스도 한번에 여러가지 작업을 수행할 수 있습니다. 쓰레드는 운영체제가 CPU 시간을 할당하는 기본 단위인데, 프로세스는 하나 이상의 쓰레드로 구성됩니다.

 

쓰레드의 장점

  • 사용자 대화형 프로그램에서 응답성을 높일 수 있다.
    (프로그램이 무슨 일을 하고 있을 때 대기 할 필요없이 다른 일을 진행할 수 있다)
  • 멀티 프로세스 방식에 비해 멀티 스레드 방식이 자원 공유가 쉽다.
    (프로세스끼리 데이터를 교환할 때 IPC;Inter Process Communication을 이용해야 하지만, 쓰레드는 코드 내의 변수를 같이 사용하기만 하면 된다)
  • 쓰레드를 사용하면 이미 프로세스에 할당된 메모리와 자원을 그대로 사용한다.
    (멀티 프로세스는 프로세스를 띄우기 위해 메모리와 자원을 할당하는 작업을 진행해야 한다)

쓰레드의 단점

  • 멀티 쓰레드에서 자식 쓰레드가 문제가 생기면 전체 프로세스가 영향을 받게 된다.
    (멀티 프로세스는 자식이 문제가 생기면 해당 프로세스만 죽습니다)
  • 멀티 쓰레드 구조의 소프트웨어는 구현하기가 까다롭다.
    (테스트가 어렵고 디버깅 또한 쉽지 않습니다)
  • 쓰레드가 CPU를 사용하기 위해서는 작업간 전환 (Context Switching) 을 해야 한다.
    (자주 작업 간 전환을 하기 되면 성능이 저하된다)

 

쓰레드의 상태

.NET Framework 의 ThreadState는 다음과 같습니다.

 

 상태 

 설명 

 Unstarted

 쓰레드 객체를 생성한 후 Thread.Start() 메소드가 호출 되기 전의 상태입니다.

 Running

 쓰레드가 시작하여 동작 중인 상태입니다.

 Unstarted 상태의 쓰레드를 Thread.Start() 메소드를 통해 이 상태로 만들 수 있습니다.

 Suspended

 쓰레드의 일시 중단 상태입니다.

 쓰레드를 Thread.Suspend() 메소드를 통해 이 상태로 만들 수 있으며, Suspended 상태인 쓰레드는

 Thread.Resume() 메소드를 통해 다시 Running 상태로 만들 수 있습니다.

 WaitSleepJoin

 쓰레드가 블록(Block)된 상태입니다.

 쓰레드에 대해 Monitor.Enter(), Thread.Sleep(), Thread.Join() 메소드를 호출하면 이 상태가 됩니다.

 Aborted

 쓰레드가 취소된 상태입니다.

 Thread.Abort() 메소드를 호출하면 이 상태가 됩니다. Aborted 상태가 된 쓰레드는 다시 Stopped 상태로 전

 환되어 완전히 중지됩니다.

 Stopped

 중지된 쓰레드의 상태입니다.

 Thread.Abort() 메소드를 호출하거나 쓰레드가 실행 중인 메소드가 종료되면 이 상태가 됩니다.

 Background

 쓰레드가 백그라운드로 동작되고 있음을 나타냅니다. Foreground 쓰레드는 하나라도 살아 있는 한 프로세스

 가 죽지 않지만, Background는 여러개가 살아 있어도 프로세스가 죽고 사는 것에는 영향을 미치지 않습니다

 하지만 프로세스가 죽으면 Background 쓰레드는 모두 죽습니다. Thread.IsBackground 속성에 true 값을

 입력하면 쓰레드를 이 상태로 바꿀 수 있습니다.

 

 

쓰레드의 라이프 사이클

 

 

                              이미지 참조: http://acroama.tistory.com/m/post/43

 

 

쓰레드 실행 예제를 보겠습니다.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using System.Threading;

namespace CsharpStudy
{
    class Program
    {
        static void DoSomething()
        {
            for (int i = 0; i < 5; i++)
            {
                Console.WriteLine("Thread : {0}", i);
                Thread.Sleep(250);
            }
        }

        static void ParametersDosomething(object num)
        {
            for(int i=0; i<(int)num; i++)
            {
                Console.WriteLine("ParametersThread : {0}", i);
                Thread.Sleep(250);
            }
        }

        static void Main(string[] args)
        {
            Thread thread = new Thread(new ThreadStart(DoSomething));
            thread.Start();
            thread.Join(); // thread 가 종료될 때 까지 대기.

            Thread thread2 = new Thread(new ParameterizedThreadStart(ParametersDosomething));
            thread2.Start(5); // 매개변수를 갖는 쓰레드 실행하는 방법 (object 매개변수만 넘길수 있다)

            for(int i=0; i<5; i++)
            {
                Console.WriteLine("Main : {0}", i);
                Thread.Sleep(500);
            }
        }
    }
}

 

 

 

 

쓰레드 종료하기

 쓰레드는 스스로 할일을 마치고 종료하는 것이 가장 좋겠지만, 쓰레드를 종료시켜야 할 경우가 있습니다.

Thread.Abort() 메소드로 가능하지만, 이는 쓰레드를 강제로 종료시켜버립니다. 즉, 도중에 작업이 강제로 종료되도 프로세스 자신이나 시스템에 전혀 영향이 없는 작업에 한해 사용하는 것이 좋습니다. 만약, 수행중인 작업이 시스템에 영향이 있을 거라 판단된다면 다음과 같이 쓰레드를 종료시켜야 합니다.

 

Thread.Interrupt() 메소드는 쓰레드가 Running State를 피해서 WaitJoinSleep State 에 들어갔을 때 ThreadInterruptedException 예외를 던져 쓰레드를 중지시킵니다. 따라서 절대로 중단되면 안되는 작업을 할 때 이렇게 안정성이 보장된 방법을 사용해야합니다.

 

쓰레드 종료 예제를 보겠습니다.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using System.Threading;

namespace CsharpStudy
{
    class Program
    {
        static void DoSomething()
        {
            try
            {
                for (int i = 0; i < 5; i++)
                {
                    Console.WriteLine("Thread : {0}", i);
                    Thread.Sleep(250);
                }
            }
            catch(ThreadInterruptedException e)
            {
                Console.WriteLine(e);
            }
            finally
            {
                Console.WriteLine("====Clearing Resource===");
            }
        }

        static void Main(string[] args)
        {
            Thread thread = new Thread(new ThreadStart(DoSomething));
            thread.Start();


            for(int i=0; i<5; i++)
            {
                Console.WriteLine("Main : {0}", i);
                Thread.Sleep(500);

                if (i == 0)
                    thread.Interrupt();
            }
        }
    }
}

 

 

 

 

쓰레드 간의 동기화하기

 각 쓰레드들은 여러가지 자원을 공유하는 경우가 많습니다. 쓰레드가 어떤 자원을 사용하고 있는데, 도중에 다른 쓰레드가 이 자원을 사용한다면 문제가 발생할 수 있습니다. 

예를 들면 은행에서 돈을 인출해주려고 할때, ATM 기기에서, 휴대폰에서, 인터넷뱅킹으로, 각각 비슷한 시간에 전재산을 인출해달라고 요청한다면 은행이 3번 모두 전재산을 인출시킨다면 문제가 있겠지요.

 

예제로 자원을 공유하는 상황을 만들어보겠습니다.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using System.Threading;

namespace CsharpStudy
{
    class Program
    {
        class Account
        {
            public int money = 1000;

            public void withdraw()
            {
                if (money <= 0)
                {
                    Console.WriteLine("잔액이 모자랍니다.");
                }
                else
                {
                    money -= 1000;
                }
            }
        }

        static void Main(string[] args)
        {
            Account account = new Account();
            Thread ATM = new Thread(new ThreadStart(account.withdraw));
            Thread Phone = new Thread(new ThreadStart(account.withdraw));
            Thread Internet = new Thread(new ThreadStart(account.withdraw));

            Console.WriteLine("ATM");
            ATM.Start();
            Console.WriteLine("Phone");
            Phone.Start();
            Console.WriteLine("Internet");
            Internet.Start();
        }
    }
}

 

위의 코드 결과가 잔액이 모자랍니다가 나올수도 있고 안나올수도 있습니다. 동시에 진행되어 세번 무사히 출금이 이루어 질수도 있는 것입니다.

 

따라서, 쓰레드들이 순서를 갖춰 자원을 사용할 수 있도록 동기화(Synchronization)을 해주어야 합니다. 자원을 한번에 하나의 쓰레드만 사용할 수 있도록 보장해야 합니다.

C# 에서는 쓰레드 간에 동기화하는 도구로 lock 키워드와 Monitor 클래스를 제공합니다.

 

-lock 키워드로 동기화하기

 

 한번에 한 쓰레드만 사용할 수 있는 크리티컬 섹션(Critical Section)인 코드영역을 만들어 주어야합니다.

C#에서는 lock 키워드로 감싸주기만 하면 크리티컬 섹션으로 바꿀 수 있습니다.

 

private readonly object thisLock= new object();

public void withdraw()
            {

                   lock(thisLock)   // 크리티컬 섹션영역이 됩니다. 한 쓰레드가 이 코드를 실행하면서

{                     // lock 블록이 끝나기 전까지 다른 쓰레드는 이 코드를 실행할 수 없습니다.
           if (money <= 0)
           {
                 Console.WriteLine("잔액이 모자랍니다.");
           }
           else
           {
                 money -= 1000;
           }
}

}

 

 

lock 키워드는 사용하는 것 자체는 쉽습니다. 하지만, 쓰레드들이 lock 키워드를 만나 크리티컬 섹션을 생성하려고 할 때 이미 하나의 쓰레드가 사용 중이면 락을 얻을 수가 없습니다. 즉 계속 대기하는 상황이 벌어집니다. 이렇게, 소프트웨어의 성능이 크게 떨어집니다. 따라서 쓰레드의 동기화를 설계할 때 크리티컬 섹션을 반드시 필요한 곳에만 사용하는 것이 중요합니다.

또, lock 키워드의 매개변수로 사용하는 객체는 참조형이면 어느 것이든 쓸수 있지만, public 키워드 등을 통해 외부 코드에서도 접근할 수 있는 다음 세가지는 절대 사용하지 않기를 권합니다.

 

  • this : 클래스의 인스턴스는 클래스 내부뿐만 아니라 외부에서도 자주 사용됩니다. lock (this)는 좋지 않습니다.
  • Type 형식 : typeof 연산자나 object 클래스로부터 물려받은 GetType() 메소드는 코드 어느 곳에서나 특정 형식에 대한 Type객체를 얻을 수 있습니다. lock(typeof(SomeClass)) , lock(obj.GetType()) 은 좋지 않습니다.
  • string 형식 : 절대 string 객체로 lock 하지마시기 바랍니다. lock("abc") 는 좋지 않습니다.

 

-Monitor 클래스로 동기화하기

 

public void withdraw()
{

       lock(thisLock)

       {
           if (money <= 0)
           {
                 Console.WriteLine("잔액이 모자랍니다.");
           }
           else
           {
                 money -= 1000;
           }

       }

}

public void withdraw()
{

      Monitor.Enter(thisLock); 

      try      

      {
           if (money <= 0)
           {
                 Console.WriteLine("잔액이 모자랍니다.");
           }
           else
           {
                 money -= 1000;
           }

       }

       finally

       {

            Monitor.Exit(thisLock);

       }

}

 

위 두가지 방식은 같은 방법입니다. lock 키워드는  Monitor 클래스의 Enter() 와 Exit() 메소드를 바탕으로 구현되어 있습니다.

그럼에도 불구하고 Monitor클래스 방식을 적는 이유는 Monitor.Wait() 메소드와 Monitor.Pulse() 메소드로 더욱 섬세하게 멀티 쓰레드간의 동기화를 가능하게 해줄 수 있습니다.

Wait() 와 Pulse() 메소드는 반드시 lock 블록 안에서 호출해야 합니다. (그렇지 않으면 CLR 이 SynchronizationLockException을 던집니다)

쓰레드가 WaitSleepJoin 상태가 되면, 동기화를 위해 갖고 있던 lock 을 놓고 Waiting Queue 에 입력되고, 다른 쓰레드가 lock을 얻어 작업을 수행하게 됩니다.

 

Wait() 와 Pulse() 메소드를 호출할 때 일어나는 일은 다음 그림과 같습니다.

 

                        원본이미지참조 : http://www.albahari.com/threading/  (편집하였음)

 

Thread.Sleep() 메소드도 쓰레드를 WaitSleepJoin State 가 될 수 있지만, Monitor.Pulse() 메소드에 의해 깨어날 수 없습니다. 다시 Running State 가 되려면 매개 변수로 입력된 시간이 경과되거나 Interrupt() 메소드 호출에 의해 깨어날 수 있습니다.

반면에 Monitor.Wait() 메소드는 Monitor.Pulse() 메소드가 호출되면 바로 깨어날 수 있습니다. 따라서 멀티 쓰레드 프로그램의 성능 향상을 위해서 Monitor.Wait() 와 Monitor.Pulse() 를 사용합니다.

 

사용방법은 다음과 같습니다.

1. 클래스 안에 동기화 객체 필드를 선언합니다.

2. 쓰레드를 WaitSleepJoin State로 바꿔 블록시킬 조건 (Wait()를 호출할 조건) 을 결정할 필드를 선언합니다.

3. 쓰레드를 블록시키고 싶은 곳에서는 lock 블록안에서 2번 과정에서 선언한 필드를 검사하여 Monitor.Wait()를 호출합니다.

4. 3번과정에서 선언한 코드는 lockedCount가 true면 해당 쓰레드를 블록시킵니다. 블록된 쓰레드가 깨어나면 lockedCount를 true로 변경합니다. 다른 쓰레드가 이 코드에 접근하면 3번 과정에서 선언했던 블로킹 코드에 걸려 같은 코드를 실행할 수 없습니다.

작업을 마치면 lockedCount의 값을 다시 false로 바꾼 뒤 Monitor.Pulse()를 호출합니다. 그럼 Waiting Queue에 대기하고 있던 다른 쓰레드가 깨어나서 false로 바뀐 lockedCount를 보고 작업을 수행합니다.

 

 

Wait()와 Pulse()를 사용한 예제를 보겠습니다.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using System.Threading;

namespace CsharpStudy
{
    class Program
    {
        class Account
        {
            public int money = 1000;
            
            private readonly object thisLock = new object();
            private bool lockedCount= false; // 다른 쓰레드가 공유된 자원을 사용하고 있는지 판별하기 위해 사용됨

            public void withdraw()
            {
                lock (thisLock)
                {
                    while (lockedCount == true) // 다른 쓰레드에 의해 true로 바뀌어있으면 현재 쓰레드를 블록시킵니다.
                        Monitor.Wait(thisLock);    // 다른 쓰레드가 Pulse()를 호출해 줄때 까지는 WaitSleepJoin State 에 남습니다.

                    lockedCount = true;

                    if (money <= 0)
                    {
                        Console.WriteLine("잔액이 모자랍니다.");
                    }
                    else
                    {
                        money -= 1000;
                    }

                    lockedCount = false; // 다른 쓰레드를 꺠웁니다. 
                                              // 깨어난 쓰레드들은 while의 조건검사를 통해 Wait()를 호출할지 코드를 실행할지 결정합니다.

                    Monitor.Pulse(thisLock);
                }
            }
        }

        static void Main(string[] args)
        {
            Account account = new Account();
            Thread ATM = new Thread(new ThreadStart(account.withdraw));
            Thread Phone = new Thread(new ThreadStart(account.withdraw));
            Thread Internet = new Thread(new ThreadStart(account.withdraw));

            Console.WriteLine("ATM");
            ATM.Start();
            Console.WriteLine("Phone");
            Phone.Start();
            Console.WriteLine("Internet");
            Internet.Start();
        }
    }
}

 

테스크(Task)

 CPU가 발전하면서 클럭을 높이는 방향에는 한계에 다다르자, 하나의 CPU안에 여러개의 코어를 집적하는 방향으로 제품을 향상시키기 시작했습니다. 이러한 하드웨어의 변화에 맞춰 소프트웨어도 변화를 최대로 활용할 수 있는 방법이 등장하고 있습니다. .NET Framework 에는 System.Threading.Tasks 에는 병행성 코드나 비동기 코드의 실행을 돕는 클래스들이 들어 있습니다. (Task 또한 내부적으로 Thread로 구현됩니다)

Task 클래스를 이용하여 비동기(Asynchronous) 코드를 작성할 수 있습니다.

 

Task<TResult> 클래스는 코드의 비동기 실행 결과를 얻을 수 있습니다.

Task 클래스는 비동기로 수행할 코드를 Action 델리게이트로 주는 반면 Task<TResult> 는 Func 델리게이트로 줍니다.

즉 Task<TResult> 비동기 작업이 끝나면 Task<>.Result  프로퍼티에 값을 반환하게 됩니다.

 

 

Task 예제를 보겠습니다.

using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Threading; namespace CsharpStudy { class Program { static void ActionMethod() { Thread.Sleep(1000); Console.WriteLine("ActionMethod Call"); } static int FuncMethod(object a) { Thread.Sleep(500); Console.WriteLine("FuncMethod Call"); return (int)a+5; } static void Main(string[] args) { Task task = new Task(ActionMethod); task.Start(); // task는 비동기 호출로 1초후 완료된다. Console.WriteLine("Main Logic"); //Main Logic 문구가 바로 출력된다. Task int task2 = new Task int (FuncMethod, (object)10); // 매개변수와 반환값을 가진 메소드 사용방법 == 태그문제 때문에 int 템플릿처리해줘야합니다 task2.Start(); task2.Wait(); // task2가 메소드가 완료될때 까지 대기 Console.WriteLine("{0}", task2.Result); // 반환값 출력 Console.WriteLine("Main Logic2"); task.Wait(); //task의 메소드가 완료될때 까지 대기 } } }

 

 

결과를 보면 아시겠지만 실행해보면 Task 클래스에 의해 비동기 호출이 이루어짐을 알 수 있습니다.

 

추가적으로 예제를 작성하면서 알게 된건데,

위와 같은 코드에서 Main의 Sleep() 가 없을 경우 TaskMethod()에서 Sleep() 다음 코드가 출력되지 않습니다.

즉, Main 함수가 종료되면서 비동기 호출했던 TaskMethod 도 미처 다 실행하지 못하고 종료됩니다.

Thread를 만들어 이용할 경우에는 정상적으로 쓰레드가 다 종료되어야 프로그램이 종료되었지만 말이에요.

메소드를 비동기 호출할 때 끝까지 실행하기를 원한다면 Wait() 메소드를 이용하면 됩니다.

여기서 알 수 있는 것은 프로세스가 생성했던 쓰레드가 다 종료된 후에야 프로세스가 정상종료 되지만, Task는 프로세스가 종료되면서 강제종료되는 것 같습니다.

 

 

Parallel

 Parallel 클래스는 좀더 쉽게 병렬처리를 하고 싶은 메소드를 처리할 수 있게 도와줍니다.

Parallel.For() 메소드는 주어진 델리게이트에 대하여 병렬로 호출합니다. 몇개의 쓰레드를 사용할 지는 내부적으로 판단하여 알아서 최적화하여 결정합니다.

 

Parallel 예제를 보겠습니다.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using System.Threading;

namespace CsharpStudy
{
    class Program
    {
        static void ActionMethod(int num)
        {
            Thread.Sleep(1000);
            Console.WriteLine("ActionMethod Call {0}", num);
        }

        static void Main(string[] args)
        {
            Parallel.For(0, 100, ActionMethod);
        }
    }
}

 

결과를 보면 아시겠지만, 함수를 100번 호출하면서 병렬처리를 하기 때문에 순서가 뒤죽박죽이 되었고,

직접 실행해보면 아시겠지만, 10번 호출할때, 100번호출할때 매개변수값을 바꿔보면 처리방식이 조금씩 달라짐이 보입니다.

그리고 함수호출 한번당 1초정도 소요되게 코드를 짰지만, 병렬처리를 하면서 훨씬 빠르게 해당 프로그램이 종료됨을 보실 수 있습니다.

 

 

이상으로 Thread 와 Task 에 대해 정리해보았습니다.

 

 


<참고문헌>

뇌를 자극하는 C# 4.0 프로그래밍 - 박상현 저