[C# 기초] 12. 쓰레드(Thread)


프로그램이 순차적으로 실행되는 것이 아니라 여러 작업을 동시에 수행해야 한다면 어떻게 해야 하는지를 배웁니다.

쓰레드 (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 값을 입력하면 쓰레드를 이 상태로 바꿀 수 있습니다.

쓰레드 라이프사이클


예제 코드:

using System.Threading.Tasks;
using System.Threading;
 
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.Threading.Tasks;
using System.Threading;
 
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.Threading.Tasks;
using System.Threading;

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() 메소드를 호출할 때 일어나는 일은 다음 그림과 같습니다.

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.Threading.Tasks;
using System.Threading;
 
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();
    }
}

쓰레드를 사용하다보면 여러가지 문제에 부딪히게 됩니다. 하지만 쓰레드는 프로그램이 순차적으로 진행되지 않기 때문에 디버깅도 쉽지 않아 문제점을 찾기도 어렵습니다. 하지만 사용해야 하는 상황은 반드시 생길 것입니다. 다양한 자료를 찾아보면서 개념을 이해하는 것이 좋습니다.ㅉ